detached 351fdaf

Merge pull request #21 from bnema/fix/idle-inhibition-cleanup

fix: prevent idle inhibition leak on pane close and add audio-based detection
commit
351fdaf8aed7a8a4ca6e3637417bb70b7771e752
author
bnema · · 2025-12-25 05:17 UTC
committer
GitHub · · 2025-12-25 05:17 UTC
merge of
d9a391f + 4530593 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +193 -17
M CHANGELOG.md +6 -2
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Fixed
8+- **Idle inhibition leak on pane close**: Fixed D-Bus idle inhibit request not being released when closing a pane/tab while in fullscreen or playing audio. The inhibition would remain active until the app exited. Now properly tracks fullscreen and audio state and releases inhibition before destroying WebView.
9+- **Audio-based idle inhibition**: Extended idle inhibition to also activate when a page is playing audio (e.g., video/music playback), not just in fullscreen mode. Uses WebKit's `notify::is-playing-audio` signal. The inhibitor uses refcounting, so both fullscreen and audio can be active simultaneously.
10+
11+## [0.20.1] - 2025-12-25
12+
713 ### Fixed
814 - **Stacked pane favicon and title updates**: Fixed favicon and title not updating in stacked pane title bars by tracking pane IDs in StackedView and properly routing update events to the correct pane.
915 - **WebKit process leak on pane close**: Fixed zombie WebKit web processes not being terminated when panes were closed. `WebView.Destroy()` now properly calls `TerminateWebProcess()` and unparents GTK widgets, freeing GPU resources (VA-API, DMA-BUF, GL contexts). This should significantly improve video playback stability and reduce memory usage when opening/closing many panes.
@@ -29,8 +35,6 @@ All notable changes to this project will be documented in this file.
2935 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
3036 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.
3137 - **Find-in-page**: New find UI and shortcut handling.
32-
33-### Changed
3438 - Migrated GUI stack to `puregotk` + `puregotk-webkit` and disabled CGO for pure Go compilation.
3539 - **Omnibox rewrite**: Replaced injected Svelte/JS omnibox with a native GTK4 widget overlay.
3640 - Startup defers non-critical work, adds DNS prefetching and a warmed WebView pool for faster tab/pane creation.
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Fixed
8+- **Idle inhibition leak on pane close**: Fixed D-Bus idle inhibit request not being released when closing a pane/tab while in fullscreen or playing audio. The inhibition would remain active until the app exited. Now properly tracks fullscreen and audio state and releases inhibition before destroying WebView.
9+- **Audio-based idle inhibition**: Extended idle inhibition to also activate when a page is playing audio (e.g., video/music playback), not just in fullscreen mode. Uses WebKit's `notify::is-playing-audio` signal. The inhibitor uses refcounting, so both fullscreen and audio can be active simultaneously.
10+
11+## [0.20.1] - 2025-12-25
12+
7 ### Fixed13 ### Fixed
8 - **Stacked pane favicon and title updates**: Fixed favicon and title not updating in stacked pane title bars by tracking pane IDs in StackedView and properly routing update events to the correct pane.14 - **Stacked pane favicon and title updates**: Fixed favicon and title not updating in stacked pane title bars by tracking pane IDs in StackedView and properly routing update events to the correct pane.
9 - **WebKit process leak on pane close**: Fixed zombie WebKit web processes not being terminated when panes were closed. `WebView.Destroy()` now properly calls `TerminateWebProcess()` and unparents GTK widgets, freeing GPU resources (VA-API, DMA-BUF, GL contexts). This should significantly improve video playback stability and reduce memory usage when opening/closing many panes.15 - **WebKit process leak on pane close**: Fixed zombie WebKit web processes not being terminated when panes were closed. `WebView.Destroy()` now properly calls `TerminateWebProcess()` and unparents GTK widgets, freeing GPU resources (VA-API, DMA-BUF, GL contexts). This should significantly improve video playback stability and reduce memory usage when opening/closing many panes.
@@ -29,8 +35,6 @@ All notable changes to this project will be documented in this file.
29 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.35 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
30 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.36 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.
31 - **Find-in-page**: New find UI and shortcut handling.37 - **Find-in-page**: New find UI and shortcut handling.
32-
33-### Changed
34 - Migrated GUI stack to `puregotk` + `puregotk-webkit` and disabled CGO for pure Go compilation.38 - Migrated GUI stack to `puregotk` + `puregotk-webkit` and disabled CGO for pure Go compilation.
35 - **Omnibox rewrite**: Replaced injected Svelte/JS omnibox with a native GTK4 widget overlay.39 - **Omnibox rewrite**: Replaced injected Svelte/JS omnibox with a native GTK4 widget overlay.
36 - Startup defers non-critical work, adds DNS prefetching and a warmed WebView pool for faster tab/pane creation.40 - Startup defers non-critical work, adds DNS prefetching and a warmed WebView pool for faster tab/pane creation.
M internal/application/port/mocks/mocks.go +88 -0
@@ -592,6 +592,50 @@ func (_c *MockWebView_IsDestroyed_Call) RunAndReturn(run func() bool) *MockWebVi
592592 return _c
593593 }
594594
595+// IsFullscreen provides a mock function for the type MockWebView
596+func (_mock *MockWebView) IsFullscreen() bool {
597+ ret := _mock.Called()
598+
599+ if len(ret) == 0 {
600+ panic("no return value specified for IsFullscreen")
601+ }
602+
603+ var r0 bool
604+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
605+ r0 = returnFunc()
606+ } else {
607+ r0 = ret.Get(0).(bool)
608+ }
609+ return r0
610+}
611+
612+// MockWebView_IsFullscreen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsFullscreen'
613+type MockWebView_IsFullscreen_Call struct {
614+ *mock.Call
615+}
616+
617+// IsFullscreen is a helper method to define mock.On call
618+func (_e *MockWebView_Expecter) IsFullscreen() *MockWebView_IsFullscreen_Call {
619+ return &MockWebView_IsFullscreen_Call{Call: _e.mock.On("IsFullscreen")}
620+}
621+
622+func (_c *MockWebView_IsFullscreen_Call) Run(run func()) *MockWebView_IsFullscreen_Call {
623+ _c.Call.Run(func(args mock.Arguments) {
624+ run()
625+ })
626+ return _c
627+}
628+
629+func (_c *MockWebView_IsFullscreen_Call) Return(b bool) *MockWebView_IsFullscreen_Call {
630+ _c.Call.Return(b)
631+ return _c
632+}
633+
634+func (_c *MockWebView_IsFullscreen_Call) RunAndReturn(run func() bool) *MockWebView_IsFullscreen_Call {
635+ _c.Call.Return(run)
636+ return _c
637+}
638+
595639 // IsLoading provides a mock function for the type MockWebView
596640 func (_mock *MockWebView) IsLoading() bool {
597641 ret := _mock.Called()
@@ -636,6 +680,50 @@ func (_c *MockWebView_IsLoading_Call) RunAndReturn(run func() bool) *MockWebView
636680 return _c
637681 }
638682
683+// IsPlayingAudio provides a mock function for the type MockWebView
684+func (_mock *MockWebView) IsPlayingAudio() bool {
685+ ret := _mock.Called()
686+
687+ if len(ret) == 0 {
688+ panic("no return value specified for IsPlayingAudio")
689+ }
690+
691+ var r0 bool
692+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
693+ r0 = returnFunc()
694+ } else {
695+ r0 = ret.Get(0).(bool)
696+ }
697+ return r0
698+}
699+
700+// MockWebView_IsPlayingAudio_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsPlayingAudio'
701+type MockWebView_IsPlayingAudio_Call struct {
702+ *mock.Call
703+}
704+
705+// IsPlayingAudio is a helper method to define mock.On call
706+func (_e *MockWebView_Expecter) IsPlayingAudio() *MockWebView_IsPlayingAudio_Call {
707+ return &MockWebView_IsPlayingAudio_Call{Call: _e.mock.On("IsPlayingAudio")}
708+}
709+
710+func (_c *MockWebView_IsPlayingAudio_Call) Run(run func()) *MockWebView_IsPlayingAudio_Call {
711+ _c.Call.Run(func(args mock.Arguments) {
712+ run()
713+ })
714+ return _c
715+}
716+
717+func (_c *MockWebView_IsPlayingAudio_Call) Return(b bool) *MockWebView_IsPlayingAudio_Call {
718+ _c.Call.Return(b)
719+ return _c
720+}
721+
722+func (_c *MockWebView_IsPlayingAudio_Call) RunAndReturn(run func() bool) *MockWebView_IsPlayingAudio_Call {
723+ _c.Call.Return(run)
724+ return _c
725+}
726+
639727 // LoadHTML provides a mock function for the type MockWebView
640728 func (_mock *MockWebView) LoadHTML(ctx context.Context, content string, baseURI string) error {
641729 ret := _mock.Called(ctx, content, baseURI)
@@ -592,6 +592,50 @@ func (_c *MockWebView_IsDestroyed_Call) RunAndReturn(run func() bool) *MockWebVi
592 return _c592 return _c
593 }593 }
594 594
595+// IsFullscreen provides a mock function for the type MockWebView
596+func (_mock *MockWebView) IsFullscreen() bool {
597+ ret := _mock.Called()
598+
599+ if len(ret) == 0 {
600+ panic("no return value specified for IsFullscreen")
601+ }
602+
603+ var r0 bool
604+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
605+ r0 = returnFunc()
606+ } else {
607+ r0 = ret.Get(0).(bool)
608+ }
609+ return r0
610+}
611+
612+// MockWebView_IsFullscreen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsFullscreen'
613+type MockWebView_IsFullscreen_Call struct {
614+ *mock.Call
615+}
616+
617+// IsFullscreen is a helper method to define mock.On call
618+func (_e *MockWebView_Expecter) IsFullscreen() *MockWebView_IsFullscreen_Call {
619+ return &MockWebView_IsFullscreen_Call{Call: _e.mock.On("IsFullscreen")}
620+}
621+
622+func (_c *MockWebView_IsFullscreen_Call) Run(run func()) *MockWebView_IsFullscreen_Call {
623+ _c.Call.Run(func(args mock.Arguments) {
624+ run()
625+ })
626+ return _c
627+}
628+
629+func (_c *MockWebView_IsFullscreen_Call) Return(b bool) *MockWebView_IsFullscreen_Call {
630+ _c.Call.Return(b)
631+ return _c
632+}
633+
634+func (_c *MockWebView_IsFullscreen_Call) RunAndReturn(run func() bool) *MockWebView_IsFullscreen_Call {
635+ _c.Call.Return(run)
636+ return _c
637+}
638+
595 // IsLoading provides a mock function for the type MockWebView639 // IsLoading provides a mock function for the type MockWebView
596 func (_mock *MockWebView) IsLoading() bool {640 func (_mock *MockWebView) IsLoading() bool {
597 ret := _mock.Called()641 ret := _mock.Called()
@@ -636,6 +680,50 @@ func (_c *MockWebView_IsLoading_Call) RunAndReturn(run func() bool) *MockWebView
636 return _c680 return _c
637 }681 }
638 682
683+// IsPlayingAudio provides a mock function for the type MockWebView
684+func (_mock *MockWebView) IsPlayingAudio() bool {
685+ ret := _mock.Called()
686+
687+ if len(ret) == 0 {
688+ panic("no return value specified for IsPlayingAudio")
689+ }
690+
691+ var r0 bool
692+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
693+ r0 = returnFunc()
694+ } else {
695+ r0 = ret.Get(0).(bool)
696+ }
697+ return r0
698+}
699+
700+// MockWebView_IsPlayingAudio_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsPlayingAudio'
701+type MockWebView_IsPlayingAudio_Call struct {
702+ *mock.Call
703+}
704+
705+// IsPlayingAudio is a helper method to define mock.On call
706+func (_e *MockWebView_Expecter) IsPlayingAudio() *MockWebView_IsPlayingAudio_Call {
707+ return &MockWebView_IsPlayingAudio_Call{Call: _e.mock.On("IsPlayingAudio")}
708+}
709+
710+func (_c *MockWebView_IsPlayingAudio_Call) Run(run func()) *MockWebView_IsPlayingAudio_Call {
711+ _c.Call.Run(func(args mock.Arguments) {
712+ run()
713+ })
714+ return _c
715+}
716+
717+func (_c *MockWebView_IsPlayingAudio_Call) Return(b bool) *MockWebView_IsPlayingAudio_Call {
718+ _c.Call.Return(b)
719+ return _c
720+}
721+
722+func (_c *MockWebView_IsPlayingAudio_Call) RunAndReturn(run func() bool) *MockWebView_IsPlayingAudio_Call {
723+ _c.Call.Return(run)
724+ return _c
725+}
726+
639 // LoadHTML provides a mock function for the type MockWebView727 // LoadHTML provides a mock function for the type MockWebView
640 func (_mock *MockWebView) LoadHTML(ctx context.Context, content string, baseURI string) error {728 func (_mock *MockWebView) LoadHTML(ctx context.Context, content string, baseURI string) error {
641 ret := _mock.Called(ctx, content, baseURI)729 ret := _mock.Called(ctx, content, baseURI)
M internal/application/port/webview.go +8 -0
@@ -190,6 +190,14 @@ type WebView interface {
190190 // Pass nil to clear all callbacks.
191191 SetCallbacks(callbacks *WebViewCallbacks)
192192
193+ // --- Media/Fullscreen State ---
194+
195+ // IsFullscreen returns true if the WebView is currently in fullscreen mode.
196+ IsFullscreen() bool
197+
198+ // IsPlayingAudio returns true if the WebView is currently playing audio.
199+ IsPlayingAudio() bool
200+
193201 // --- Lifecycle ---
194202
195203 // IsDestroyed returns true if the WebView has been destroyed.
@@ -190,6 +190,14 @@ type WebView interface {
190 // Pass nil to clear all callbacks.190 // Pass nil to clear all callbacks.
191 SetCallbacks(callbacks *WebViewCallbacks)191 SetCallbacks(callbacks *WebViewCallbacks)
192 192
193+ // --- Media/Fullscreen State ---
194+
195+ // IsFullscreen returns true if the WebView is currently in fullscreen mode.
196+ IsFullscreen() bool
197+
198+ // IsPlayingAudio returns true if the WebView is currently playing audio.
199+ IsPlayingAudio() bool
200+
193 // --- Lifecycle ---201 // --- Lifecycle ---
194 202
195 // IsDestroyed returns true if the WebView has been destroyed.203 // IsDestroyed returns true if the WebView has been destroyed.
M internal/infrastructure/webkit/webview.go +48 -11
@@ -95,6 +95,10 @@ type WebView struct {
9595 canGoFwd bool
9696 isLoading bool
9797
98+ // Media/fullscreen state for idle inhibition cleanup
99+ isFullscreen atomic.Bool
100+ isPlayingAudio atomic.Bool
101+
98102 // Progress throttling (~60fps)
99103 lastProgressUpdate atomic.Int64 // Unix nanoseconds
100104
@@ -102,17 +106,18 @@ type WebView struct {
102106 signalIDs []uint32
103107
104108 // Callbacks (set by UI layer)
105- OnLoadChanged func(LoadEvent)
106- OnTitleChanged func(string)
107- OnURIChanged func(string)
108- OnProgressChanged func(float64)
109- OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
110- OnClose func()
111- OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
112- OnReadyToShow func() // Called when popup is ready to display
113- OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
114- OnEnterFullscreen func() bool // Return true to prevent fullscreen
115- OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
109+ OnLoadChanged func(LoadEvent)
110+ OnTitleChanged func(string)
111+ OnURIChanged func(string)
112+ OnProgressChanged func(float64)
113+ OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
114+ OnClose func()
115+ OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
116+ OnReadyToShow func() // Called when popup is ready to display
117+ OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
118+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
119+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
120+ OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
116121
117122 logger zerolog.Logger
118123 mu sync.RWMutex
@@ -322,6 +327,7 @@ func (wv *WebView) connectSignals() {
322327 wv.connectDecidePolicySignal()
323328 wv.connectEnterFullscreenSignal()
324329 wv.connectLeaveFullscreenSignal()
330+ wv.connectAudioStateSignal()
325331 }
326332
327333 func (wv *WebView) connectLoadChangedSignal() {
@@ -545,6 +551,7 @@ func (wv *WebView) connectDecidePolicySignal() {
545551
546552 func (wv *WebView) connectEnterFullscreenSignal() {
547553 enterFullscreenCb := func(_ webkit.WebView) bool {
554+ wv.isFullscreen.Store(true)
548555 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")
549556 if wv.OnEnterFullscreen != nil {
550557 return wv.OnEnterFullscreen()
@@ -557,6 +564,7 @@ func (wv *WebView) connectEnterFullscreenSignal() {
557564
558565 func (wv *WebView) connectLeaveFullscreenSignal() {
559566 leaveFullscreenCb := func(_ webkit.WebView) bool {
567+ wv.isFullscreen.Store(false)
560568 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")
561569 if wv.OnLeaveFullscreen != nil {
562570 return wv.OnLeaveFullscreen()
@@ -567,6 +575,24 @@ func (wv *WebView) connectLeaveFullscreenSignal() {
567575 wv.signalIDs = append(wv.signalIDs, sigID)
568576 }
569577
578+func (wv *WebView) connectAudioStateSignal() {
579+ audioCb := func() {
580+ playing := wv.inner.IsPlayingAudio()
581+ oldState := wv.isPlayingAudio.Swap(playing)
582+ if oldState != playing {
583+ wv.logger.Debug().
584+ Uint64("id", uint64(wv.id)).
585+ Bool("playing", playing).
586+ Msg("audio state changed")
587+ if wv.OnAudioStateChanged != nil {
588+ wv.OnAudioStateChanged(playing)
589+ }
590+ }
591+ }
592+ sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))
593+ wv.signalIDs = append(wv.signalIDs, sigID)
594+}
595+
570596 // ID returns the unique identifier for this WebView.
571597 func (wv *WebView) ID() WebViewID {
572598 return wv.id
@@ -582,6 +608,16 @@ func (wv *WebView) Widget() *webkit.WebView {
582608 return wv.inner
583609 }
584610
611+// IsFullscreen returns true if the WebView is currently in fullscreen mode.
612+func (wv *WebView) IsFullscreen() bool {
613+ return wv.isFullscreen.Load()
614+}
615+
616+// IsPlayingAudio returns true if the WebView is currently playing audio.
617+func (wv *WebView) IsPlayingAudio() bool {
618+ return wv.isPlayingAudio.Load()
619+}
620+
585621 // GetFindController returns the WebKit FindController wrapped in the port interface.
586622 // The adapter is cached to prevent the Go wrapper from being garbage collected.
587623 func (wv *WebView) GetFindController() port.FindController {
@@ -898,6 +934,7 @@ func (wv *WebView) Destroy() {
898934 wv.OnLinkMiddleClick = nil
899935 wv.OnEnterFullscreen = nil
900936 wv.OnLeaveFullscreen = nil
937+ wv.OnAudioStateChanged = nil
901938
902939 // 3. Clear async callback references
903940 wv.mu.Lock()
@@ -95,6 +95,10 @@ type WebView struct {
95 canGoFwd bool95 canGoFwd bool
96 isLoading bool96 isLoading bool
97 97
98+ // Media/fullscreen state for idle inhibition cleanup
99+ isFullscreen atomic.Bool
100+ isPlayingAudio atomic.Bool
101+
98 // Progress throttling (~60fps)102 // Progress throttling (~60fps)
99 lastProgressUpdate atomic.Int64 // Unix nanoseconds103 lastProgressUpdate atomic.Int64 // Unix nanoseconds
100 104
@@ -102,17 +106,18 @@ type WebView struct {
102 signalIDs []uint32106 signalIDs []uint32
103 107
104 // Callbacks (set by UI layer)108 // Callbacks (set by UI layer)
105- OnLoadChanged func(LoadEvent)109+ OnLoadChanged func(LoadEvent)
106- OnTitleChanged func(string)110+ OnTitleChanged func(string)
107- OnURIChanged func(string)111+ OnURIChanged func(string)
108- OnProgressChanged func(float64)112+ OnProgressChanged func(float64)
109- OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes113+ OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
110- OnClose func()114+ OnClose func()
111- OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup115+ OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
112- OnReadyToShow func() // Called when popup is ready to display116+ OnReadyToShow func() // Called when popup is ready to display
113- OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)117+ OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
114- OnEnterFullscreen func() bool // Return true to prevent fullscreen118+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
115- OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen119+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
120+ OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
116 121
117 logger zerolog.Logger122 logger zerolog.Logger
118 mu sync.RWMutex123 mu sync.RWMutex
@@ -322,6 +327,7 @@ func (wv *WebView) connectSignals() {
322 wv.connectDecidePolicySignal()327 wv.connectDecidePolicySignal()
323 wv.connectEnterFullscreenSignal()328 wv.connectEnterFullscreenSignal()
324 wv.connectLeaveFullscreenSignal()329 wv.connectLeaveFullscreenSignal()
330+ wv.connectAudioStateSignal()
325 }331 }
326 332
327 func (wv *WebView) connectLoadChangedSignal() {333 func (wv *WebView) connectLoadChangedSignal() {
@@ -545,6 +551,7 @@ func (wv *WebView) connectDecidePolicySignal() {
545 551
546 func (wv *WebView) connectEnterFullscreenSignal() {552 func (wv *WebView) connectEnterFullscreenSignal() {
547 enterFullscreenCb := func(_ webkit.WebView) bool {553 enterFullscreenCb := func(_ webkit.WebView) bool {
554+ wv.isFullscreen.Store(true)
548 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")555 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")
549 if wv.OnEnterFullscreen != nil {556 if wv.OnEnterFullscreen != nil {
550 return wv.OnEnterFullscreen()557 return wv.OnEnterFullscreen()
@@ -557,6 +564,7 @@ func (wv *WebView) connectEnterFullscreenSignal() {
557 564
558 func (wv *WebView) connectLeaveFullscreenSignal() {565 func (wv *WebView) connectLeaveFullscreenSignal() {
559 leaveFullscreenCb := func(_ webkit.WebView) bool {566 leaveFullscreenCb := func(_ webkit.WebView) bool {
567+ wv.isFullscreen.Store(false)
560 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")568 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")
561 if wv.OnLeaveFullscreen != nil {569 if wv.OnLeaveFullscreen != nil {
562 return wv.OnLeaveFullscreen()570 return wv.OnLeaveFullscreen()
@@ -567,6 +575,24 @@ func (wv *WebView) connectLeaveFullscreenSignal() {
567 wv.signalIDs = append(wv.signalIDs, sigID)575 wv.signalIDs = append(wv.signalIDs, sigID)
568 }576 }
569 577
578+func (wv *WebView) connectAudioStateSignal() {
579+ audioCb := func() {
580+ playing := wv.inner.IsPlayingAudio()
581+ oldState := wv.isPlayingAudio.Swap(playing)
582+ if oldState != playing {
583+ wv.logger.Debug().
584+ Uint64("id", uint64(wv.id)).
585+ Bool("playing", playing).
586+ Msg("audio state changed")
587+ if wv.OnAudioStateChanged != nil {
588+ wv.OnAudioStateChanged(playing)
589+ }
590+ }
591+ }
592+ sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))
593+ wv.signalIDs = append(wv.signalIDs, sigID)
594+}
595+
570 // ID returns the unique identifier for this WebView.596 // ID returns the unique identifier for this WebView.
571 func (wv *WebView) ID() WebViewID {597 func (wv *WebView) ID() WebViewID {
572 return wv.id598 return wv.id
@@ -582,6 +608,16 @@ func (wv *WebView) Widget() *webkit.WebView {
582 return wv.inner608 return wv.inner
583 }609 }
584 610
611+// IsFullscreen returns true if the WebView is currently in fullscreen mode.
612+func (wv *WebView) IsFullscreen() bool {
613+ return wv.isFullscreen.Load()
614+}
615+
616+// IsPlayingAudio returns true if the WebView is currently playing audio.
617+func (wv *WebView) IsPlayingAudio() bool {
618+ return wv.isPlayingAudio.Load()
619+}
620+
585 // GetFindController returns the WebKit FindController wrapped in the port interface.621 // GetFindController returns the WebKit FindController wrapped in the port interface.
586 // The adapter is cached to prevent the Go wrapper from being garbage collected.622 // The adapter is cached to prevent the Go wrapper from being garbage collected.
587 func (wv *WebView) GetFindController() port.FindController {623 func (wv *WebView) GetFindController() port.FindController {
@@ -898,6 +934,7 @@ func (wv *WebView) Destroy() {
898 wv.OnLinkMiddleClick = nil934 wv.OnLinkMiddleClick = nil
899 wv.OnEnterFullscreen = nil935 wv.OnEnterFullscreen = nil
900 wv.OnLeaveFullscreen = nil936 wv.OnLeaveFullscreen = nil
937+ wv.OnAudioStateChanged = nil
901 938
902 // 3. Clear async callback references939 // 3. Clear async callback references
903 wv.mu.Lock()940 wv.mu.Lock()
M internal/ui/coordinator/content.go +43 -4
@@ -175,7 +175,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
175175 }
176176
177177 // Set up fullscreen handlers for idle inhibition
178- c.setupFullscreenHandlers(ctx, paneID, wv)
178+ c.setupIdleInhibitionHandlers(ctx, paneID, wv)
179179
180180 // Set up popup handling for this WebView
181181 c.SetupPopupHandling(ctx, paneID, wv)
@@ -194,6 +194,24 @@ func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.P
194194 }
195195 delete(c.webViews, paneID)
196196
197+ // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
198+ // we must release the inhibition before destroying the webview.
199+ // Otherwise the D-Bus inhibit request stays active forever.
200+ if c.idleInhibitor != nil {
201+ if wv.IsFullscreen() {
202+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
203+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
204+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
205+ }
206+ }
207+ if wv.IsPlayingAudio() {
208+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
209+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
210+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
211+ }
212+ }
213+ }
214+
197215 // Clean up title tracking
198216 c.titleMu.Lock()
199217 delete(c.paneTitles, paneID)
@@ -993,7 +1011,7 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
9931011 }
9941012
9951013 // Fullscreen handlers for idle inhibition
996- c.setupFullscreenHandlers(ctx, paneID, wv)
1014+ c.setupIdleInhibitionHandlers(ctx, paneID, wv)
9971015 }
9981016
9991017 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
@@ -1145,14 +1163,19 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
11451163 return true
11461164 }
11471165
1148-// setupFullscreenHandlers configures fullscreen callbacks for idle inhibition.
1149-func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1166+// setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
1167+// Idle is inhibited when:
1168+// - The webview enters fullscreen mode (e.g., fullscreen video)
1169+// - The webview is playing audio (e.g., video/music playback)
1170+// The inhibitor uses refcounting, so both can be active simultaneously.
1171+func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
11501172 log := logging.FromContext(ctx)
11511173
11521174 if wv == nil {
11531175 return
11541176 }
11551177
1178+ // Fullscreen handling
11561179 wv.OnEnterFullscreen = func() bool {
11571180 if c.idleInhibitor != nil {
11581181 if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
@@ -1170,4 +1193,20 @@ func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID
11701193 }
11711194 return false // Allow leaving fullscreen
11721195 }
1196+
1197+ // Audio playback handling
1198+ wv.OnAudioStateChanged = func(playing bool) {
1199+ if c.idleInhibitor == nil {
1200+ return
1201+ }
1202+ if playing {
1203+ if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
1204+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
1205+ }
1206+ } else {
1207+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1208+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
1209+ }
1210+ }
1211+ }
11731212 }
@@ -175,7 +175,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
175 }175 }
176 176
177 // Set up fullscreen handlers for idle inhibition177 // Set up fullscreen handlers for idle inhibition
178- c.setupFullscreenHandlers(ctx, paneID, wv)178+ c.setupIdleInhibitionHandlers(ctx, paneID, wv)
179 179
180 // Set up popup handling for this WebView180 // Set up popup handling for this WebView
181 c.SetupPopupHandling(ctx, paneID, wv)181 c.SetupPopupHandling(ctx, paneID, wv)
@@ -194,6 +194,24 @@ func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.P
194 }194 }
195 delete(c.webViews, paneID)195 delete(c.webViews, paneID)
196 196
197+ // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
198+ // we must release the inhibition before destroying the webview.
199+ // Otherwise the D-Bus inhibit request stays active forever.
200+ if c.idleInhibitor != nil {
201+ if wv.IsFullscreen() {
202+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
203+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
204+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
205+ }
206+ }
207+ if wv.IsPlayingAudio() {
208+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
209+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
210+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
211+ }
212+ }
213+ }
214+
197 // Clean up title tracking215 // Clean up title tracking
198 c.titleMu.Lock()216 c.titleMu.Lock()
199 delete(c.paneTitles, paneID)217 delete(c.paneTitles, paneID)
@@ -993,7 +1011,7 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
993 }1011 }
994 1012
995 // Fullscreen handlers for idle inhibition1013 // Fullscreen handlers for idle inhibition
996- c.setupFullscreenHandlers(ctx, paneID, wv)1014+ c.setupIdleInhibitionHandlers(ctx, paneID, wv)
997 }1015 }
998 1016
999 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.1017 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
@@ -1145,14 +1163,19 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
1145 return true1163 return true
1146 }1164 }
1147 1165
1148-// setupFullscreenHandlers configures fullscreen callbacks for idle inhibition.1166+// setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
1149-func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {1167+// Idle is inhibited when:
1168+// - The webview enters fullscreen mode (e.g., fullscreen video)
1169+// - The webview is playing audio (e.g., video/music playback)
1170+// The inhibitor uses refcounting, so both can be active simultaneously.
1171+func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1150 log := logging.FromContext(ctx)1172 log := logging.FromContext(ctx)
1151 1173
1152 if wv == nil {1174 if wv == nil {
1153 return1175 return
1154 }1176 }
1155 1177
1178+ // Fullscreen handling
1156 wv.OnEnterFullscreen = func() bool {1179 wv.OnEnterFullscreen = func() bool {
1157 if c.idleInhibitor != nil {1180 if c.idleInhibitor != nil {
1158 if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {1181 if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
@@ -1170,4 +1193,20 @@ func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID
1170 }1193 }
1171 return false // Allow leaving fullscreen1194 return false // Allow leaving fullscreen
1172 }1195 }
1196+
1197+ // Audio playback handling
1198+ wv.OnAudioStateChanged = func(playing bool) {
1199+ if c.idleInhibitor == nil {
1200+ return
1201+ }
1202+ if playing {
1203+ if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
1204+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
1205+ }
1206+ } else {
1207+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1208+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
1209+ }
1210+ }
1211+ }
1173 }1212 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close