fix: add idle inhibition for fullscreen video playback (fixes #18)
- commit
- 1628a6ba103bd74e656e931155dec6dec58b6688
- author
- brice · · 2025-12-24 06:26 UTC
- committer
- brice · · 2025-12-24 16:11 UTC
- parent
- 5dc745a
- signature
- unsigned
10 files · +308 -1
M CHANGELOG.md +1 -0
| @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. | |||
| 6 | 6 | ||
| 7 | 7 | ### Fixed | |
| 8 | 8 | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | |
| 9 | + | - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.). | |
| 9 | 10 | ||
| 10 | 11 | ### Added | |
| 11 | 12 | - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers. | |
| @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. | |||||
| 6 | 6 | ||||
| 7 | ### Fixed | 7 | ### Fixed | ||
| 8 | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | 8 | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | ||
| 9 | + | - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.). | |||
| 9 | 10 | ||||
| 10 | ### Added | 11 | ### Added | ||
| 11 | - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers. | 12 | - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers. | ||
M cmd/dumber/main.go +10 -1
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 21 | 21 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 22 | 22 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 23 | + | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 23 | 24 | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 24 | 25 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 25 | 26 | "github.com/bnema/dumber/internal/logging" | |
| @@ -109,7 +110,13 @@ func runGUI() int { | |||
| 109 | 110 | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger) | |
| 110 | 111 | repos := createRepositories(db) | |
| 111 | 112 | useCases := createUseCases(repos, cfg.DefaultWebpageZoom) | |
| 112 | - | uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases) | |
| 113 | + | idleInhibitor := idle.NewPortalInhibitor(ctx) | |
| 114 | + | defer func() { | |
| 115 | + | if idleInhibitor != nil { | |
| 116 | + | _ = idleInhibitor.Close() | |
| 117 | + | } | |
| 118 | + | }() | |
| 119 | + | uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor) | |
| 113 | 120 | ||
| 114 | 121 | app, err := ui.New(uiDeps) | |
| 115 | 122 | if err != nil { | |
| @@ -278,6 +285,7 @@ func buildUIDependencies( | |||
| 278 | 285 | stack *bootstrap.WebKitStack, | |
| 279 | 286 | repos *repositories, | |
| 280 | 287 | uc *useCases, | |
| 288 | + | idleInhibitor port.IdleInhibitor, | |
| 281 | 289 | ) *ui.Dependencies { | |
| 282 | 290 | return &ui.Dependencies{ | |
| 283 | 291 | Ctx: ctx, | |
| @@ -302,5 +310,6 @@ func buildUIDependencies( | |||
| 302 | 310 | CopyURLUC: uc.copyURL, | |
| 303 | 311 | Clipboard: uc.clipboard, | |
| 304 | 312 | FaviconService: uc.favicon, | |
| 313 | + | IdleInhibitor: idleInhibitor, | |
| 305 | 314 | } | |
| 306 | 315 | } | |
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | "github.com/bnema/dumber/internal/infrastructure/config" | 20 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 21 | "github.com/bnema/dumber/internal/infrastructure/deps" | 21 | "github.com/bnema/dumber/internal/infrastructure/deps" | ||
| 22 | "github.com/bnema/dumber/internal/infrastructure/favicon" | 22 | "github.com/bnema/dumber/internal/infrastructure/favicon" | ||
| 23 | + | "github.com/bnema/dumber/internal/infrastructure/idle" | |||
| 23 | "github.com/bnema/dumber/internal/infrastructure/media" | 24 | "github.com/bnema/dumber/internal/infrastructure/media" | ||
| 24 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 25 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 25 | "github.com/bnema/dumber/internal/logging" | 26 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -109,7 +110,13 @@ func runGUI() int { | |||||
| 109 | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger) | 110 | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger) | ||
| 110 | repos := createRepositories(db) | 111 | repos := createRepositories(db) | ||
| 111 | useCases := createUseCases(repos, cfg.DefaultWebpageZoom) | 112 | useCases := createUseCases(repos, cfg.DefaultWebpageZoom) | ||
| 112 | - | uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases) | 113 | + | idleInhibitor := idle.NewPortalInhibitor(ctx) |
| 114 | + | defer func() { | |||
| 115 | + | if idleInhibitor != nil { | |||
| 116 | + | _ = idleInhibitor.Close() | |||
| 117 | + | } | |||
| 118 | + | }() | |||
| 119 | + | uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor) | |||
| 113 | 120 | ||||
| 114 | app, err := ui.New(uiDeps) | 121 | app, err := ui.New(uiDeps) | ||
| 115 | if err != nil { | 122 | if err != nil { | ||
| @@ -278,6 +285,7 @@ func buildUIDependencies( | |||||
| 278 | stack *bootstrap.WebKitStack, | 285 | stack *bootstrap.WebKitStack, | ||
| 279 | repos *repositories, | 286 | repos *repositories, | ||
| 280 | uc *useCases, | 287 | uc *useCases, | ||
| 288 | + | idleInhibitor port.IdleInhibitor, | |||
| 281 | ) *ui.Dependencies { | 289 | ) *ui.Dependencies { | ||
| 282 | return &ui.Dependencies{ | 290 | return &ui.Dependencies{ | ||
| 283 | Ctx: ctx, | 291 | Ctx: ctx, | ||
| @@ -302,5 +310,6 @@ func buildUIDependencies( | |||||
| 302 | CopyURLUC: uc.copyURL, | 310 | CopyURLUC: uc.copyURL, | ||
| 303 | Clipboard: uc.clipboard, | 311 | Clipboard: uc.clipboard, | ||
| 304 | FaviconService: uc.favicon, | 312 | FaviconService: uc.favicon, | ||
| 313 | + | IdleInhibitor: idleInhibitor, | |||
| 305 | } | 314 | } | ||
| 306 | } | 315 | } | ||
M go.mod +1 -0
| @@ -30,6 +30,7 @@ require ( | |||
| 30 | 30 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 31 | 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 32 | 32 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 33 | + | github.com/godbus/dbus/v5 v5.2.1 // indirect | |
| 33 | 34 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 34 | 35 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | |
| 35 | 36 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |
| @@ -30,6 +30,7 @@ require ( | |||||
| 30 | github.com/davecgh/go-spew v1.1.1 // indirect | 30 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
| 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 32 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | 32 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | ||
| 33 | + | github.com/godbus/dbus/v5 v5.2.1 // indirect | |||
| 33 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 34 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 34 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | 35 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | ||
| 35 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | 36 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | ||
M go.sum +2 -0
| @@ -39,6 +39,8 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 | |||
| 39 | 39 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= | |
| 40 | 40 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= | |
| 41 | 41 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | |
| 42 | + | github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk= | |
| 43 | + | github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= | |
| 42 | 44 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | |
| 43 | 45 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | |
| 44 | 46 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| @@ -39,6 +39,8 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 | |||||
| 39 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= | 39 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= | ||
| 40 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= | 40 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= | ||
| 41 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | 41 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | ||
| 42 | + | github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk= | |||
| 43 | + | github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= | |||
| 42 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | 44 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | ||
| 43 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | 45 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | ||
| 44 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | 46 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||
A internal/application/port/idle_inhibit.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // IdleInhibitor prevents system idle/screensaver during fullscreen video playback. | |
| 6 | + | // Implementations use refcounting internally - multiple Inhibit calls require | |
| 7 | + | // matching Uninhibit calls before inhibition is released. | |
| 8 | + | type IdleInhibitor interface { | |
| 9 | + | // Inhibit increments the inhibit refcount. First call activates inhibition. | |
| 10 | + | // Safe to call multiple times (refcounted). | |
| 11 | + | Inhibit(ctx context.Context, reason string) error | |
| 12 | + | ||
| 13 | + | // Uninhibit decrements the refcount. When zero, releases inhibition. | |
| 14 | + | // Safe to call even if not currently inhibited (no-op). | |
| 15 | + | Uninhibit(ctx context.Context) error | |
| 16 | + | ||
| 17 | + | // IsInhibited returns true if currently inhibiting idle. | |
| 18 | + | IsInhibited() bool | |
| 19 | + | ||
| 20 | + | // Close releases any held resources. Should be called on application shutdown. | |
| 21 | + | Close() error | |
| 22 | + | } | |
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // IdleInhibitor prevents system idle/screensaver during fullscreen video playback. | |||
| 6 | + | // Implementations use refcounting internally - multiple Inhibit calls require | |||
| 7 | + | // matching Uninhibit calls before inhibition is released. | |||
| 8 | + | type IdleInhibitor interface { | |||
| 9 | + | // Inhibit increments the inhibit refcount. First call activates inhibition. | |||
| 10 | + | // Safe to call multiple times (refcounted). | |||
| 11 | + | Inhibit(ctx context.Context, reason string) error | |||
| 12 | + | ||||
| 13 | + | // Uninhibit decrements the refcount. When zero, releases inhibition. | |||
| 14 | + | // Safe to call even if not currently inhibited (no-op). | |||
| 15 | + | Uninhibit(ctx context.Context) error | |||
| 16 | + | ||||
| 17 | + | // IsInhibited returns true if currently inhibiting idle. | |||
| 18 | + | IsInhibited() bool | |||
| 19 | + | ||||
| 20 | + | // Close releases any held resources. Should be called on application shutdown. | |||
| 21 | + | Close() error | |||
| 22 | + | } | |||
A internal/infrastructure/idle/portal_inhibitor.go +191 -0
| @@ -0,0 +1,191 @@ | |||
| 1 | + | // Package idle provides system idle/screensaver inhibition via XDG Desktop Portal. | |
| 2 | + | package idle | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | "github.com/godbus/dbus/v5" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | portalDest = "org.freedesktop.portal.Desktop" | |
| 16 | + | portalPath = "/org/freedesktop/portal/desktop" | |
| 17 | + | portalInterface = "org.freedesktop.portal.Inhibit" | |
| 18 | + | requestIface = "org.freedesktop.portal.Request" | |
| 19 | + | ||
| 20 | + | // Inhibit flags from portal spec | |
| 21 | + | flagLogout = 1 | |
| 22 | + | flagUserSwitch = 2 | |
| 23 | + | flagSuspend = 4 | |
| 24 | + | flagIdle = 8 | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // Compile-time interface check. | |
| 28 | + | var _ port.IdleInhibitor = (*PortalInhibitor)(nil) | |
| 29 | + | ||
| 30 | + | // PortalInhibitor implements idle inhibition using XDG Desktop Portal. | |
| 31 | + | // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.). | |
| 32 | + | type PortalInhibitor struct { | |
| 33 | + | conn *dbus.Conn | |
| 34 | + | requestPath dbus.ObjectPath // Active inhibit request handle | |
| 35 | + | refcount int | |
| 36 | + | supported bool | |
| 37 | + | mu sync.Mutex | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // NewPortalInhibitor creates a new portal-based idle inhibitor. | |
| 41 | + | // Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation). | |
| 42 | + | func NewPortalInhibitor(ctx context.Context) *PortalInhibitor { | |
| 43 | + | log := logging.FromContext(ctx) | |
| 44 | + | ||
| 45 | + | inhibitor := &PortalInhibitor{ | |
| 46 | + | supported: false, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Connect to session bus | |
| 50 | + | conn, err := dbus.ConnectSessionBus() | |
| 51 | + | if err != nil { | |
| 52 | + | log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus") | |
| 53 | + | return inhibitor | |
| 54 | + | } | |
| 55 | + | inhibitor.conn = conn | |
| 56 | + | ||
| 57 | + | // Check if portal is available | |
| 58 | + | obj := conn.Object(portalDest, portalPath) | |
| 59 | + | var version uint32 | |
| 60 | + | err = obj.Call("org.freedesktop.DBus.Properties.Get", 0, | |
| 61 | + | portalInterface, "version").Store(&version) | |
| 62 | + | if err != nil { | |
| 63 | + | log.Debug().Err(err).Msg("idle inhibitor: portal not available") | |
| 64 | + | return inhibitor | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | inhibitor.supported = true | |
| 68 | + | log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available") | |
| 69 | + | ||
| 70 | + | return inhibitor | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Inhibit increments the inhibit refcount. First call activates inhibition. | |
| 74 | + | func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error { | |
| 75 | + | log := logging.FromContext(ctx) | |
| 76 | + | ||
| 77 | + | p.mu.Lock() | |
| 78 | + | defer p.mu.Unlock() | |
| 79 | + | ||
| 80 | + | p.refcount++ | |
| 81 | + | log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called") | |
| 82 | + | ||
| 83 | + | // Already inhibiting | |
| 84 | + | if p.refcount > 1 { | |
| 85 | + | return nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // First inhibit request | |
| 89 | + | if !p.supported || p.conn == nil { | |
| 90 | + | log.Debug().Msg("idle inhibitor: not supported, skipping") | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Call portal Inhibit method | |
| 95 | + | // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o | |
| 96 | + | obj := p.conn.Object(portalDest, portalPath) | |
| 97 | + | ||
| 98 | + | options := map[string]dbus.Variant{ | |
| 99 | + | "reason": dbus.MakeVariant(reason), | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | var handlePath dbus.ObjectPath | |
| 103 | + | err := obj.Call(portalInterface+".Inhibit", 0, | |
| 104 | + | "", // window identifier (empty for non-sandboxed) | |
| 105 | + | uint32(flagIdle|flagSuspend), // inhibit idle and suspend | |
| 106 | + | options, | |
| 107 | + | ).Store(&handlePath) | |
| 108 | + | ||
| 109 | + | if err != nil { | |
| 110 | + | p.refcount-- | |
| 111 | + | log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit") | |
| 112 | + | return fmt.Errorf("portal inhibit: %w", err) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | p.requestPath = handlePath | |
| 116 | + | log.Info(). | |
| 117 | + | Str("handle", string(handlePath)). | |
| 118 | + | Str("reason", reason). | |
| 119 | + | Msg("idle inhibitor: activated") | |
| 120 | + | ||
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Uninhibit decrements the refcount. When zero, releases inhibition. | |
| 125 | + | func (p *PortalInhibitor) Uninhibit(ctx context.Context) error { | |
| 126 | + | log := logging.FromContext(ctx) | |
| 127 | + | ||
| 128 | + | p.mu.Lock() | |
| 129 | + | defer p.mu.Unlock() | |
| 130 | + | ||
| 131 | + | if p.refcount <= 0 { | |
| 132 | + | return nil // Nothing to uninhibit | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | p.refcount-- | |
| 136 | + | log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called") | |
| 137 | + | ||
| 138 | + | // Still have active inhibitors | |
| 139 | + | if p.refcount > 0 { | |
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Release the inhibition | |
| 144 | + | if !p.supported || p.conn == nil || p.requestPath == "" { | |
| 145 | + | return nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Close the request to release inhibition | |
| 149 | + | obj := p.conn.Object(portalDest, p.requestPath) | |
| 150 | + | err := obj.Call(requestIface+".Close", 0).Err | |
| 151 | + | ||
| 152 | + | if err != nil { | |
| 153 | + | log.Warn().Err(err).Msg("idle inhibitor: failed to close request") | |
| 154 | + | // Don't return error - the request may have already been closed | |
| 155 | + | } else { | |
| 156 | + | log.Info().Msg("idle inhibitor: deactivated") | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | p.requestPath = "" | |
| 160 | + | return nil | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // IsInhibited returns true if currently inhibiting idle. | |
| 164 | + | func (p *PortalInhibitor) IsInhibited() bool { | |
| 165 | + | p.mu.Lock() | |
| 166 | + | defer p.mu.Unlock() | |
| 167 | + | return p.refcount > 0 | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Close releases D-Bus resources and any active inhibition. | |
| 171 | + | func (p *PortalInhibitor) Close() error { | |
| 172 | + | p.mu.Lock() | |
| 173 | + | defer p.mu.Unlock() | |
| 174 | + | ||
| 175 | + | // Release any active inhibition | |
| 176 | + | if p.conn != nil && p.requestPath != "" { | |
| 177 | + | obj := p.conn.Object(portalDest, p.requestPath) | |
| 178 | + | _ = obj.Call(requestIface+".Close", 0).Err | |
| 179 | + | p.requestPath = "" | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | p.refcount = 0 | |
| 183 | + | ||
| 184 | + | if p.conn != nil { | |
| 185 | + | err := p.conn.Close() | |
| 186 | + | p.conn = nil | |
| 187 | + | return err | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | return nil | |
| 191 | + | } | |
| @@ -0,0 +1,191 @@ | |||||
| 1 | + | // Package idle provides system idle/screensaver inhibition via XDG Desktop Portal. | |||
| 2 | + | package idle | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "sync" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | + | "github.com/godbus/dbus/v5" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | portalDest = "org.freedesktop.portal.Desktop" | |||
| 16 | + | portalPath = "/org/freedesktop/portal/desktop" | |||
| 17 | + | portalInterface = "org.freedesktop.portal.Inhibit" | |||
| 18 | + | requestIface = "org.freedesktop.portal.Request" | |||
| 19 | + | ||||
| 20 | + | // Inhibit flags from portal spec | |||
| 21 | + | flagLogout = 1 | |||
| 22 | + | flagUserSwitch = 2 | |||
| 23 | + | flagSuspend = 4 | |||
| 24 | + | flagIdle = 8 | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // Compile-time interface check. | |||
| 28 | + | var _ port.IdleInhibitor = (*PortalInhibitor)(nil) | |||
| 29 | + | ||||
| 30 | + | // PortalInhibitor implements idle inhibition using XDG Desktop Portal. | |||
| 31 | + | // This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.). | |||
| 32 | + | type PortalInhibitor struct { | |||
| 33 | + | conn *dbus.Conn | |||
| 34 | + | requestPath dbus.ObjectPath // Active inhibit request handle | |||
| 35 | + | refcount int | |||
| 36 | + | supported bool | |||
| 37 | + | mu sync.Mutex | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // NewPortalInhibitor creates a new portal-based idle inhibitor. | |||
| 41 | + | // Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation). | |||
| 42 | + | func NewPortalInhibitor(ctx context.Context) *PortalInhibitor { | |||
| 43 | + | log := logging.FromContext(ctx) | |||
| 44 | + | ||||
| 45 | + | inhibitor := &PortalInhibitor{ | |||
| 46 | + | supported: false, | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Connect to session bus | |||
| 50 | + | conn, err := dbus.ConnectSessionBus() | |||
| 51 | + | if err != nil { | |||
| 52 | + | log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus") | |||
| 53 | + | return inhibitor | |||
| 54 | + | } | |||
| 55 | + | inhibitor.conn = conn | |||
| 56 | + | ||||
| 57 | + | // Check if portal is available | |||
| 58 | + | obj := conn.Object(portalDest, portalPath) | |||
| 59 | + | var version uint32 | |||
| 60 | + | err = obj.Call("org.freedesktop.DBus.Properties.Get", 0, | |||
| 61 | + | portalInterface, "version").Store(&version) | |||
| 62 | + | if err != nil { | |||
| 63 | + | log.Debug().Err(err).Msg("idle inhibitor: portal not available") | |||
| 64 | + | return inhibitor | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | inhibitor.supported = true | |||
| 68 | + | log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available") | |||
| 69 | + | ||||
| 70 | + | return inhibitor | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Inhibit increments the inhibit refcount. First call activates inhibition. | |||
| 74 | + | func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error { | |||
| 75 | + | log := logging.FromContext(ctx) | |||
| 76 | + | ||||
| 77 | + | p.mu.Lock() | |||
| 78 | + | defer p.mu.Unlock() | |||
| 79 | + | ||||
| 80 | + | p.refcount++ | |||
| 81 | + | log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called") | |||
| 82 | + | ||||
| 83 | + | // Already inhibiting | |||
| 84 | + | if p.refcount > 1 { | |||
| 85 | + | return nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // First inhibit request | |||
| 89 | + | if !p.supported || p.conn == nil { | |||
| 90 | + | log.Debug().Msg("idle inhibitor: not supported, skipping") | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // Call portal Inhibit method | |||
| 95 | + | // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o | |||
| 96 | + | obj := p.conn.Object(portalDest, portalPath) | |||
| 97 | + | ||||
| 98 | + | options := map[string]dbus.Variant{ | |||
| 99 | + | "reason": dbus.MakeVariant(reason), | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | var handlePath dbus.ObjectPath | |||
| 103 | + | err := obj.Call(portalInterface+".Inhibit", 0, | |||
| 104 | + | "", // window identifier (empty for non-sandboxed) | |||
| 105 | + | uint32(flagIdle|flagSuspend), // inhibit idle and suspend | |||
| 106 | + | options, | |||
| 107 | + | ).Store(&handlePath) | |||
| 108 | + | ||||
| 109 | + | if err != nil { | |||
| 110 | + | p.refcount-- | |||
| 111 | + | log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit") | |||
| 112 | + | return fmt.Errorf("portal inhibit: %w", err) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | p.requestPath = handlePath | |||
| 116 | + | log.Info(). | |||
| 117 | + | Str("handle", string(handlePath)). | |||
| 118 | + | Str("reason", reason). | |||
| 119 | + | Msg("idle inhibitor: activated") | |||
| 120 | + | ||||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Uninhibit decrements the refcount. When zero, releases inhibition. | |||
| 125 | + | func (p *PortalInhibitor) Uninhibit(ctx context.Context) error { | |||
| 126 | + | log := logging.FromContext(ctx) | |||
| 127 | + | ||||
| 128 | + | p.mu.Lock() | |||
| 129 | + | defer p.mu.Unlock() | |||
| 130 | + | ||||
| 131 | + | if p.refcount <= 0 { | |||
| 132 | + | return nil // Nothing to uninhibit | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | p.refcount-- | |||
| 136 | + | log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called") | |||
| 137 | + | ||||
| 138 | + | // Still have active inhibitors | |||
| 139 | + | if p.refcount > 0 { | |||
| 140 | + | return nil | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // Release the inhibition | |||
| 144 | + | if !p.supported || p.conn == nil || p.requestPath == "" { | |||
| 145 | + | return nil | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // Close the request to release inhibition | |||
| 149 | + | obj := p.conn.Object(portalDest, p.requestPath) | |||
| 150 | + | err := obj.Call(requestIface+".Close", 0).Err | |||
| 151 | + | ||||
| 152 | + | if err != nil { | |||
| 153 | + | log.Warn().Err(err).Msg("idle inhibitor: failed to close request") | |||
| 154 | + | // Don't return error - the request may have already been closed | |||
| 155 | + | } else { | |||
| 156 | + | log.Info().Msg("idle inhibitor: deactivated") | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | p.requestPath = "" | |||
| 160 | + | return nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // IsInhibited returns true if currently inhibiting idle. | |||
| 164 | + | func (p *PortalInhibitor) IsInhibited() bool { | |||
| 165 | + | p.mu.Lock() | |||
| 166 | + | defer p.mu.Unlock() | |||
| 167 | + | return p.refcount > 0 | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // Close releases D-Bus resources and any active inhibition. | |||
| 171 | + | func (p *PortalInhibitor) Close() error { | |||
| 172 | + | p.mu.Lock() | |||
| 173 | + | defer p.mu.Unlock() | |||
| 174 | + | ||||
| 175 | + | // Release any active inhibition | |||
| 176 | + | if p.conn != nil && p.requestPath != "" { | |||
| 177 | + | obj := p.conn.Object(portalDest, p.requestPath) | |||
| 178 | + | _ = obj.Call(requestIface+".Close", 0).Err | |||
| 179 | + | p.requestPath = "" | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | p.refcount = 0 | |||
| 183 | + | ||||
| 184 | + | if p.conn != nil { | |||
| 185 | + | err := p.conn.Close() | |||
| 186 | + | p.conn = nil | |||
| 187 | + | return err | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | return nil | |||
| 191 | + | } | |||
M internal/infrastructure/webkit/webview.go +28 -0
| @@ -107,6 +107,8 @@ type WebView struct { | |||
| 107 | 107 | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | |
| 108 | 108 | OnReadyToShow func() // Called when popup is ready to display | |
| 109 | 109 | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) | |
| 110 | + | OnEnterFullscreen func() bool // Return true to prevent fullscreen | |
| 111 | + | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | |
| 110 | 112 | ||
| 111 | 113 | logger zerolog.Logger | |
| 112 | 114 | mu sync.RWMutex | |
| @@ -314,6 +316,8 @@ func (wv *WebView) connectSignals() { | |||
| 314 | 316 | wv.connectFaviconSignal() | |
| 315 | 317 | wv.connectProgressSignal() | |
| 316 | 318 | wv.connectDecidePolicySignal() | |
| 319 | + | wv.connectEnterFullscreenSignal() | |
| 320 | + | wv.connectLeaveFullscreenSignal() | |
| 317 | 321 | } | |
| 318 | 322 | ||
| 319 | 323 | func (wv *WebView) connectLoadChangedSignal() { | |
| @@ -520,6 +524,30 @@ func (wv *WebView) connectDecidePolicySignal() { | |||
| 520 | 524 | wv.signalIDs = append(wv.signalIDs, sigID) | |
| 521 | 525 | } | |
| 522 | 526 | ||
| 527 | + | func (wv *WebView) connectEnterFullscreenSignal() { | |
| 528 | + | enterFullscreenCb := func(_ webkit.WebView) bool { | |
| 529 | + | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen") | |
| 530 | + | if wv.OnEnterFullscreen != nil { | |
| 531 | + | return wv.OnEnterFullscreen() | |
| 532 | + | } | |
| 533 | + | return false // Allow fullscreen | |
| 534 | + | } | |
| 535 | + | sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb) | |
| 536 | + | wv.signalIDs = append(wv.signalIDs, sigID) | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | func (wv *WebView) connectLeaveFullscreenSignal() { | |
| 540 | + | leaveFullscreenCb := func(_ webkit.WebView) bool { | |
| 541 | + | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen") | |
| 542 | + | if wv.OnLeaveFullscreen != nil { | |
| 543 | + | return wv.OnLeaveFullscreen() | |
| 544 | + | } | |
| 545 | + | return false // Allow leaving fullscreen | |
| 546 | + | } | |
| 547 | + | sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb) | |
| 548 | + | wv.signalIDs = append(wv.signalIDs, sigID) | |
| 549 | + | } | |
| 550 | + | ||
| 523 | 551 | // ID returns the unique identifier for this WebView. | |
| 524 | 552 | func (wv *WebView) ID() WebViewID { | |
| 525 | 553 | return wv.id | |
| @@ -107,6 +107,8 @@ type WebView struct { | |||||
| 107 | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | 107 | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | ||
| 108 | OnReadyToShow func() // Called when popup is ready to display | 108 | OnReadyToShow func() // Called when popup is ready to display | ||
| 109 | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) | 109 | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) | ||
| 110 | + | OnEnterFullscreen func() bool // Return true to prevent fullscreen | |||
| 111 | + | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | |||
| 110 | 112 | ||||
| 111 | logger zerolog.Logger | 113 | logger zerolog.Logger | ||
| 112 | mu sync.RWMutex | 114 | mu sync.RWMutex | ||
| @@ -314,6 +316,8 @@ func (wv *WebView) connectSignals() { | |||||
| 314 | wv.connectFaviconSignal() | 316 | wv.connectFaviconSignal() | ||
| 315 | wv.connectProgressSignal() | 317 | wv.connectProgressSignal() | ||
| 316 | wv.connectDecidePolicySignal() | 318 | wv.connectDecidePolicySignal() | ||
| 319 | + | wv.connectEnterFullscreenSignal() | |||
| 320 | + | wv.connectLeaveFullscreenSignal() | |||
| 317 | } | 321 | } | ||
| 318 | 322 | ||||
| 319 | func (wv *WebView) connectLoadChangedSignal() { | 323 | func (wv *WebView) connectLoadChangedSignal() { | ||
| @@ -520,6 +524,30 @@ func (wv *WebView) connectDecidePolicySignal() { | |||||
| 520 | wv.signalIDs = append(wv.signalIDs, sigID) | 524 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 521 | } | 525 | } | ||
| 522 | 526 | ||||
| 527 | + | func (wv *WebView) connectEnterFullscreenSignal() { | |||
| 528 | + | enterFullscreenCb := func(_ webkit.WebView) bool { | |||
| 529 | + | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen") | |||
| 530 | + | if wv.OnEnterFullscreen != nil { | |||
| 531 | + | return wv.OnEnterFullscreen() | |||
| 532 | + | } | |||
| 533 | + | return false // Allow fullscreen | |||
| 534 | + | } | |||
| 535 | + | sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb) | |||
| 536 | + | wv.signalIDs = append(wv.signalIDs, sigID) | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | func (wv *WebView) connectLeaveFullscreenSignal() { | |||
| 540 | + | leaveFullscreenCb := func(_ webkit.WebView) bool { | |||
| 541 | + | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen") | |||
| 542 | + | if wv.OnLeaveFullscreen != nil { | |||
| 543 | + | return wv.OnLeaveFullscreen() | |||
| 544 | + | } | |||
| 545 | + | return false // Allow leaving fullscreen | |||
| 546 | + | } | |||
| 547 | + | sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb) | |||
| 548 | + | wv.signalIDs = append(wv.signalIDs, sigID) | |||
| 549 | + | } | |||
| 550 | + | ||||
| 523 | // ID returns the unique identifier for this WebView. | 551 | // ID returns the unique identifier for this WebView. | ||
| 524 | func (wv *WebView) ID() WebViewID { | 552 | func (wv *WebView) ID() WebViewID { | ||
| 525 | return wv.id | 553 | return wv.id | ||
M internal/ui/app.go +11 -0
| @@ -412,6 +412,12 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 412 | 412 | if a.deps.Pool != nil { | |
| 413 | 413 | a.deps.Pool.Close(ctx) | |
| 414 | 414 | } | |
| 415 | + | // Close idle inhibitor to release D-Bus connection | |
| 416 | + | if a.deps.IdleInhibitor != nil { | |
| 417 | + | if err := a.deps.IdleInhibitor.Close(); err != nil { | |
| 418 | + | log.Warn().Err(err).Msg("failed to close idle inhibitor") | |
| 419 | + | } | |
| 420 | + | } | |
| 415 | 421 | ||
| 416 | 422 | log.Info().Msg("application shutdown complete") | |
| 417 | 423 | } | |
| @@ -443,6 +449,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 443 | 449 | a.deps.ZoomUC, | |
| 444 | 450 | ) | |
| 445 | 451 | ||
| 452 | + | // Set idle inhibitor for fullscreen video playback | |
| 453 | + | if a.deps.IdleInhibitor != nil { | |
| 454 | + | a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor) | |
| 455 | + | } | |
| 456 | + | ||
| 446 | 457 | // 2. Tab Coordinator | |
| 447 | 458 | a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | |
| 448 | 459 | TabsUC: a.tabsUC, | |
| @@ -412,6 +412,12 @@ func (a *App) onShutdown(ctx context.Context) { | |||||
| 412 | if a.deps.Pool != nil { | 412 | if a.deps.Pool != nil { | ||
| 413 | a.deps.Pool.Close(ctx) | 413 | a.deps.Pool.Close(ctx) | ||
| 414 | } | 414 | } | ||
| 415 | + | // Close idle inhibitor to release D-Bus connection | |||
| 416 | + | if a.deps.IdleInhibitor != nil { | |||
| 417 | + | if err := a.deps.IdleInhibitor.Close(); err != nil { | |||
| 418 | + | log.Warn().Err(err).Msg("failed to close idle inhibitor") | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 415 | 421 | ||||
| 416 | log.Info().Msg("application shutdown complete") | 422 | log.Info().Msg("application shutdown complete") | ||
| 417 | } | 423 | } | ||
| @@ -443,6 +449,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 443 | a.deps.ZoomUC, | 449 | a.deps.ZoomUC, | ||
| 444 | ) | 450 | ) | ||
| 445 | 451 | ||||
| 452 | + | // Set idle inhibitor for fullscreen video playback | |||
| 453 | + | if a.deps.IdleInhibitor != nil { | |||
| 454 | + | a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor) | |||
| 455 | + | } | |||
| 456 | + | ||||
| 446 | // 2. Tab Coordinator | 457 | // 2. Tab Coordinator | ||
| 447 | a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | 458 | a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | ||
| 448 | TabsUC: a.tabsUC, | 459 | TabsUC: a.tabsUC, | ||
M internal/ui/coordinator/content.go +41 -0
| @@ -63,6 +63,9 @@ type ContentCoordinator struct { | |||
| 63 | 63 | ||
| 64 | 64 | // ID generator for popup panes | |
| 65 | 65 | generateID func() string | |
| 66 | + | ||
| 67 | + | // Idle inhibitor for fullscreen video playback | |
| 68 | + | idleInhibitor port.IdleInhibitor | |
| 66 | 69 | } | |
| 67 | 70 | ||
| 68 | 71 | // NewContentCoordinator creates a new ContentCoordinator. | |
| @@ -105,6 +108,11 @@ func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler | |||
| 105 | 108 | c.gestureActionHandler = handler | |
| 106 | 109 | } | |
| 107 | 110 | ||
| 111 | + | // SetIdleInhibitor sets the idle inhibitor for fullscreen video playback. | |
| 112 | + | func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { | |
| 113 | + | c.idleInhibitor = inhibitor | |
| 114 | + | } | |
| 115 | + | ||
| 108 | 116 | // EnsureWebView acquires or reuses a WebView for the given pane. | |
| 109 | 117 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | |
| 110 | 118 | log := logging.FromContext(ctx) | |
| @@ -166,6 +174,9 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||
| 166 | 174 | return c.handleLinkMiddleClick(ctx, paneID, uri) | |
| 167 | 175 | } | |
| 168 | 176 | ||
| 177 | + | // Set up fullscreen handlers for idle inhibition | |
| 178 | + | c.setupFullscreenHandlers(ctx, paneID, wv) | |
| 179 | + | ||
| 169 | 180 | // Set up popup handling for this WebView | |
| 170 | 181 | c.SetupPopupHandling(ctx, paneID, wv) | |
| 171 | 182 | ||
| @@ -988,6 +999,9 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||
| 988 | 999 | wv.OnLinkMiddleClick = func(uri string) bool { | |
| 989 | 1000 | return c.handleLinkMiddleClick(ctx, paneID, uri) | |
| 990 | 1001 | } | |
| 1002 | + | ||
| 1003 | + | // Fullscreen handlers for idle inhibition | |
| 1004 | + | c.setupFullscreenHandlers(ctx, paneID, wv) | |
| 991 | 1005 | } | |
| 992 | 1006 | ||
| 993 | 1007 | // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes. | |
| @@ -1138,3 +1152,30 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa | |||
| 1138 | 1152 | ||
| 1139 | 1153 | return true | |
| 1140 | 1154 | } | |
| 1155 | + | ||
| 1156 | + | // setupFullscreenHandlers configures fullscreen callbacks for idle inhibition. | |
| 1157 | + | func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |
| 1158 | + | log := logging.FromContext(ctx) | |
| 1159 | + | ||
| 1160 | + | if wv == nil { | |
| 1161 | + | return | |
| 1162 | + | } | |
| 1163 | + | ||
| 1164 | + | wv.OnEnterFullscreen = func() bool { | |
| 1165 | + | if c.idleInhibitor != nil { | |
| 1166 | + | if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil { | |
| 1167 | + | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle") | |
| 1168 | + | } | |
| 1169 | + | } | |
| 1170 | + | return false // Allow fullscreen | |
| 1171 | + | } | |
| 1172 | + | ||
| 1173 | + | wv.OnLeaveFullscreen = func() bool { | |
| 1174 | + | if c.idleInhibitor != nil { | |
| 1175 | + | if err := c.idleInhibitor.Uninhibit(ctx); err != nil { | |
| 1176 | + | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle") | |
| 1177 | + | } | |
| 1178 | + | } | |
| 1179 | + | return false // Allow leaving fullscreen | |
| 1180 | + | } | |
| 1181 | + | } | |
| @@ -63,6 +63,9 @@ type ContentCoordinator struct { | |||||
| 63 | 63 | ||||
| 64 | // ID generator for popup panes | 64 | // ID generator for popup panes | ||
| 65 | generateID func() string | 65 | generateID func() string | ||
| 66 | + | ||||
| 67 | + | // Idle inhibitor for fullscreen video playback | |||
| 68 | + | idleInhibitor port.IdleInhibitor | |||
| 66 | } | 69 | } | ||
| 67 | 70 | ||||
| 68 | // NewContentCoordinator creates a new ContentCoordinator. | 71 | // NewContentCoordinator creates a new ContentCoordinator. | ||
| @@ -105,6 +108,11 @@ func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler | |||||
| 105 | c.gestureActionHandler = handler | 108 | c.gestureActionHandler = handler | ||
| 106 | } | 109 | } | ||
| 107 | 110 | ||||
| 111 | + | // SetIdleInhibitor sets the idle inhibitor for fullscreen video playback. | |||
| 112 | + | func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { | |||
| 113 | + | c.idleInhibitor = inhibitor | |||
| 114 | + | } | |||
| 115 | + | ||||
| 108 | // EnsureWebView acquires or reuses a WebView for the given pane. | 116 | // EnsureWebView acquires or reuses a WebView for the given pane. | ||
| 109 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | 117 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | ||
| 110 | log := logging.FromContext(ctx) | 118 | log := logging.FromContext(ctx) | ||
| @@ -166,6 +174,9 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||||
| 166 | return c.handleLinkMiddleClick(ctx, paneID, uri) | 174 | return c.handleLinkMiddleClick(ctx, paneID, uri) | ||
| 167 | } | 175 | } | ||
| 168 | 176 | ||||
| 177 | + | // Set up fullscreen handlers for idle inhibition | |||
| 178 | + | c.setupFullscreenHandlers(ctx, paneID, wv) | |||
| 179 | + | ||||
| 169 | // Set up popup handling for this WebView | 180 | // Set up popup handling for this WebView | ||
| 170 | c.SetupPopupHandling(ctx, paneID, wv) | 181 | c.SetupPopupHandling(ctx, paneID, wv) | ||
| 171 | 182 | ||||
| @@ -988,6 +999,9 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||||
| 988 | wv.OnLinkMiddleClick = func(uri string) bool { | 999 | wv.OnLinkMiddleClick = func(uri string) bool { | ||
| 989 | return c.handleLinkMiddleClick(ctx, paneID, uri) | 1000 | return c.handleLinkMiddleClick(ctx, paneID, uri) | ||
| 990 | } | 1001 | } | ||
| 1002 | + | ||||
| 1003 | + | // Fullscreen handlers for idle inhibition | |||
| 1004 | + | c.setupFullscreenHandlers(ctx, paneID, wv) | |||
| 991 | } | 1005 | } | ||
| 992 | 1006 | ||||
| 993 | // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes. | 1007 | // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes. | ||
| @@ -1138,3 +1152,30 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa | |||||
| 1138 | 1152 | ||||
| 1139 | return true | 1153 | return true | ||
| 1140 | } | 1154 | } | ||
| 1155 | + | ||||
| 1156 | + | // setupFullscreenHandlers configures fullscreen callbacks for idle inhibition. | |||
| 1157 | + | func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |||
| 1158 | + | log := logging.FromContext(ctx) | |||
| 1159 | + | ||||
| 1160 | + | if wv == nil { | |||
| 1161 | + | return | |||
| 1162 | + | } | |||
| 1163 | + | ||||
| 1164 | + | wv.OnEnterFullscreen = func() bool { | |||
| 1165 | + | if c.idleInhibitor != nil { | |||
| 1166 | + | if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil { | |||
| 1167 | + | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle") | |||
| 1168 | + | } | |||
| 1169 | + | } | |||
| 1170 | + | return false // Allow fullscreen | |||
| 1171 | + | } | |||
| 1172 | + | ||||
| 1173 | + | wv.OnLeaveFullscreen = func() bool { | |||
| 1174 | + | if c.idleInhibitor != nil { | |||
| 1175 | + | if err := c.idleInhibitor.Uninhibit(ctx); err != nil { | |||
| 1176 | + | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle") | |||
| 1177 | + | } | |||
| 1178 | + | } | |||
| 1179 | + | return false // Allow leaving fullscreen | |||
| 1180 | + | } | |||
| 1181 | + | } | |||
M internal/ui/deps.go +1 -0
| @@ -52,6 +52,7 @@ type Dependencies struct { | |||
| 52 | 52 | Clipboard port.Clipboard | |
| 53 | 53 | FaviconService *favicon.Service | |
| 54 | 54 | FilterManager *filtering.Manager | |
| 55 | + | IdleInhibitor port.IdleInhibitor | |
| 55 | 56 | } | |
| 56 | 57 | ||
| 57 | 58 | // Validate checks that all required dependencies are set. | |
| @@ -52,6 +52,7 @@ type Dependencies struct { | |||||
| 52 | Clipboard port.Clipboard | 52 | Clipboard port.Clipboard | ||
| 53 | FaviconService *favicon.Service | 53 | FaviconService *favicon.Service | ||
| 54 | FilterManager *filtering.Manager | 54 | FilterManager *filtering.Manager | ||
| 55 | + | IdleInhibitor port.IdleInhibitor | |||
| 55 | } | 56 | } | ||
| 56 | 57 | ||||
| 57 | // Validate checks that all required dependencies are set. | 58 | // Validate checks that all required dependencies are set. | ||