1package coordinator
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/infrastructure/config"
15 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/dumber/internal/ui/adapter"
18 "github.com/bnema/dumber/internal/ui/component"
19 "github.com/bnema/dumber/internal/ui/input"
20 "github.com/bnema/dumber/internal/ui/layout"
21 "github.com/jwijenbergh/puregotk/v4/gdk"
22)
23
24// ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
25type ContentCoordinator struct {
26 pool *webkit.WebViewPool
27 widgetFactory layout.WidgetFactory
28 faviconAdapter *adapter.FaviconAdapter
29 zoomUC *usecase.ManageZoomUseCase
30
31 webViews map[entity.PaneID]*webkit.WebView
32 paneTitles map[entity.PaneID]string
33 titleMu sync.RWMutex
34
35 // Track original navigation URLs to handle cross-domain redirects
36 // e.g., google.fr → google.com: cache favicon under both domains
37 navOrigins map[entity.PaneID]string
38 navOriginMu sync.RWMutex
39
40 // Callback to get active workspace state (avoids circular dependency)
41 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
42
43 // Callback when title changes (for history persistence)
44 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
45
46 // Callback when page is committed (for history recording)
47 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
48
49 // Callback when pane URI changes (for session snapshots)
50 onPaneURIUpdated func(paneID entity.PaneID, url string)
51
52 // Callback when active pane title changes (for window title updates)
53 onWindowTitleChanged func(title string)
54
55 // Callback when the WebView becomes visible (first real commit)
56 onWebViewShown func(paneID entity.PaneID)
57
58 revealMu sync.Mutex
59 pendingReveal map[entity.PaneID]bool
60
61 // Gesture action handler for mouse button navigation
62 gestureActionHandler input.ActionHandler
63
64 // Popup handling
65 factory *webkit.WebViewFactory
66 popupConfig *config.PopupBehaviorConfig
67 pendingPopups map[port.WebViewID]*PendingPopup
68 popupMu sync.RWMutex
69
70 // Callback to insert popup into workspace (avoids circular dependency)
71 onInsertPopup func(ctx context.Context, input InsertPopupInput) error
72
73 // Callback to close a pane when popup closes
74 onClosePane func(ctx context.Context, paneID entity.PaneID) error
75
76 // ID generator for popup panes
77 generateID func() string
78
79 // Idle inhibitor for fullscreen video playback
80 idleInhibitor port.IdleInhibitor
81
82 // Callback when fullscreen state changes (for hiding/showing tab bar)
83 onFullscreenChanged func(entering bool)
84}
85
86// NewContentCoordinator creates a new ContentCoordinator.
87func NewContentCoordinator(
88 ctx context.Context,
89 pool *webkit.WebViewPool,
90 widgetFactory layout.WidgetFactory,
91 faviconAdapter *adapter.FaviconAdapter,
92 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
93 zoomUC *usecase.ManageZoomUseCase,
94) *ContentCoordinator {
95 log := logging.FromContext(ctx)
96 log.Debug().Msg("creating content coordinator")
97
98 return &ContentCoordinator{
99 pool: pool,
100 widgetFactory: widgetFactory,
101 faviconAdapter: faviconAdapter,
102 zoomUC: zoomUC,
103 webViews: make(map[entity.PaneID]*webkit.WebView),
104 paneTitles: make(map[entity.PaneID]string),
105 navOrigins: make(map[entity.PaneID]string),
106 pendingReveal: make(map[entity.PaneID]bool),
107 getActiveWS: getActiveWS,
108 pendingPopups: make(map[port.WebViewID]*PendingPopup),
109 }
110}
111
112// SetOnTitleUpdated sets the callback for title changes (for history persistence).
113func (c *ContentCoordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
114 c.onTitleUpdated = fn
115}
116
117// SetOnHistoryRecord sets the callback for recording history on page commit.
118func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
119 c.onHistoryRecord = fn
120}
121
122// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
123func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
124 c.onPaneURIUpdated = fn
125}
126
127// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
128func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) {
129 c.onWindowTitleChanged = fn
130}
131
132// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
133func (c *ContentCoordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
134 c.onWebViewShown = fn
135}
136
137// SetGestureActionHandler sets the callback for mouse button navigation gestures.
138func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
139 c.gestureActionHandler = handler
140}
141
142// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
143func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
144 c.idleInhibitor = inhibitor
145}
146
147// SetOnFullscreenChanged sets the callback for fullscreen state changes.
148func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
149 c.onFullscreenChanged = fn
150}
151
152// EnsureWebView acquires or reuses a WebView for the given pane.
153func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
154 log := logging.FromContext(ctx)
155
156 if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {
157 return wv, nil
158 }
159
160 if c.pool == nil {
161 return nil, fmt.Errorf("webview pool not configured")
162 }
163
164 wv, err := c.pool.Acquire(ctx)
165 if err != nil {
166 return nil, err
167 }
168
169 c.webViews[paneID] = wv
170
171 // Set up title change callback
172 wv.OnTitleChanged = func(title string) {
173 c.onTitleChanged(ctx, paneID, title)
174 }
175
176 // Set up favicon change callback
177 wv.OnFaviconChanged = func(favicon *gdk.Texture) {
178 c.onFaviconChanged(ctx, paneID, favicon)
179 }
180
181 // Set up load change callback to re-apply zoom on navigation and handle progress bar
182 wv.OnLoadChanged = func(event webkit.LoadEvent) {
183 switch event {
184 case webkit.LoadStarted:
185 c.onLoadStarted(paneID)
186 case webkit.LoadCommitted:
187 c.onLoadCommitted(ctx, paneID, wv)
188 case webkit.LoadFinished:
189 c.onLoadFinished(paneID)
190 }
191 }
192
193 // Set up progress callback for loading indicator
194 wv.OnProgressChanged = func(progress float64) {
195 c.onProgressChanged(paneID, progress)
196 }
197
198 // Set up URI change callback for SPA navigation (History API)
199 // This fires when URL changes via JavaScript without a full page load
200 wv.OnURIChanged = func(uri string) {
201 // Only record if not loading (SPA navigation via History API)
202 // Full page loads are handled by OnLoadCommitted
203 if !wv.IsLoading() && uri != "" {
204 c.onSPANavigation(ctx, paneID, uri)
205 }
206 }
207
208 // Set up middle-click / Ctrl+click handler for opening links in new pane
209 wv.OnLinkMiddleClick = func(uri string) bool {
210 return c.handleLinkMiddleClick(ctx, paneID, uri)
211 }
212
213 // Set up link hover callback for status overlay
214 wv.OnLinkHover = func(uri string) {
215 c.onLinkHover(paneID, uri)
216 }
217
218 // Set up fullscreen handlers for idle inhibition
219 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
220
221 // Set up popup handling for this WebView
222 c.SetupPopupHandling(ctx, paneID, wv)
223
224 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
225 return wv, nil
226}
227
228// ReleaseWebView returns the WebView for a pane to the pool.
229func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
230 log := logging.FromContext(ctx)
231
232 wv, ok := c.webViews[paneID]
233 if !ok || wv == nil {
234 return
235 }
236 delete(c.webViews, paneID)
237
238 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
239 // we must release the inhibition before destroying the webview.
240 // Otherwise the D-Bus inhibit request stays active forever.
241 if c.idleInhibitor != nil {
242 if wv.IsFullscreen() {
243 log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
244 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
245 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
246 }
247 }
248 if wv.IsPlayingAudio() {
249 log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
250 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
251 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
252 }
253 }
254 }
255
256 // Clean up title tracking
257 c.titleMu.Lock()
258 delete(c.paneTitles, paneID)
259 c.titleMu.Unlock()
260
261 // Clean up navigation origin tracking
262 c.navOriginMu.Lock()
263 delete(c.navOrigins, paneID)
264 c.navOriginMu.Unlock()
265
266 if c.pool != nil {
267 c.pool.Release(ctx, wv)
268 } else {
269 wv.Destroy()
270 }
271
272 log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
273}
274
275// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
276func (c *ContentCoordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
277 log := logging.FromContext(ctx)
278
279 if ws == nil || wsView == nil || c.widgetFactory == nil {
280 return
281 }
282
283 for _, pane := range ws.AllPanes() {
284 if pane == nil {
285 continue
286 }
287
288 wv, err := c.EnsureWebView(ctx, pane.ID)
289 if err != nil {
290 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
291 continue
292 }
293
294 // Load the pane's URI if set and different from current
295 if pane.URI != "" && pane.URI != wv.URI() {
296 if err := wv.LoadURI(ctx, pane.URI); err != nil {
297 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
298 }
299 }
300
301 widget := c.WrapWidget(ctx, wv)
302 if widget == nil {
303 continue
304 }
305
306 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
307 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
308 }
309 }
310}
311
312// WrapWidget converts a WebView to a layout.Widget for embedding.
313// It also attaches gesture handlers for mouse button navigation.
314func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
315 log := logging.FromContext(ctx)
316
317 if wv == nil || c.widgetFactory == nil {
318 log.Debug().Msg("cannot wrap nil webview or factory")
319 return nil
320 }
321
322 gtkView := wv.Widget()
323 if gtkView == nil {
324 return nil
325 }
326
327 widget := c.widgetFactory.WrapWidget(>kView.Widget)
328
329 // Attach gesture handler for mouse button 8/9 navigation
330 if widget != nil && c.gestureActionHandler != nil {
331 gestureHandler := input.NewGestureHandler(ctx)
332 gestureHandler.SetOnAction(c.gestureActionHandler)
333 gestureHandler.AttachTo(widget.GtkWidget())
334 log.Debug().Msg("gesture handler attached to webview")
335 }
336
337 return widget
338}
339
340// ActiveWebView returns the WebView for the active pane.
341func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
342 log := logging.FromContext(ctx)
343
344 ws, _ := c.getActiveWS()
345 if ws == nil {
346 log.Debug().Msg("no active workspace")
347 return nil
348 }
349
350 pane := ws.ActivePane()
351 if pane == nil || pane.Pane == nil {
352 log.Debug().Msg("no active pane")
353 return nil
354 }
355
356 return c.webViews[pane.Pane.ID]
357}
358
359// GetWebView returns the WebView for a specific pane.
360func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
361 return c.webViews[paneID]
362}
363
364// RegisterPopupWebView registers a popup WebView that was created externally.
365// This is used when popup tabs are created and the WebView needs to be tracked.
366func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
367 if wv != nil && paneID != "" {
368 c.webViews[paneID] = wv
369 }
370}
371
372// ApplyFiltersToAll applies content filters to all active webviews.
373// Called when filters become available after webviews were already created.
374func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
375 log := logging.FromContext(ctx)
376
377 for paneID, wv := range c.webViews {
378 if wv != nil && !wv.IsDestroyed() {
379 applier.ApplyTo(ctx, wv.UserContentManager())
380 log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
381 }
382 }
383}
384
385// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
386func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
387 log := logging.FromContext(ctx)
388 if sm == nil {
389 return
390 }
391
392 for paneID, wv := range c.webViews {
393 if wv == nil || wv.IsDestroyed() {
394 continue
395 }
396 sm.ApplyToWebView(ctx, wv.Widget())
397 log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
398 }
399}
400
401// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
402//
403// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
404// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
405// the scripts so future navigations pick up the latest values.
406func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
407 log := logging.FromContext(ctx)
408 if injector == nil {
409 return
410 }
411
412 for paneID, wv := range c.webViews {
413 if wv == nil || wv.IsDestroyed() {
414 continue
415 }
416 ucm := wv.UserContentManager()
417 if ucm == nil {
418 continue
419 }
420
421 ucm.RemoveAllScripts()
422 injector.InjectScripts(ctx, ucm, wv.ID())
423 log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
424 }
425}
426
427// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
428// This is necessary because user scripts only run on navigation.
429func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
430 log := logging.FromContext(ctx)
431 if cssText == "" {
432 return
433 }
434
435 // Safely embed CSS in JS
436 cssJSON, err := json.Marshal(cssText)
437 if err != nil {
438 log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")
439 return
440 }
441
442 script := fmt.Sprintf(`(function(){
443 try {
444 var cssText = %s;
445 var prefersDark = %t;
446
447 // Keep the global flag in sync
448 window.__dumber_gtk_prefers_dark = prefersDark;
449
450 // Update dark/light class
451 if (prefersDark) {
452 document.documentElement.classList.add('dark');
453 document.documentElement.classList.remove('light');
454 } else {
455 document.documentElement.classList.add('light');
456 document.documentElement.classList.remove('dark');
457 }
458
459 // Update or insert theme style
460 var style = document.querySelector('style[data-dumber-theme-vars]');
461 if (!style) {
462 style = document.createElement('style');
463 style.setAttribute('data-dumber-theme-vars', '');
464 (document.head || document.documentElement).appendChild(style);
465 }
466 style.textContent = cssText;
467
468 // Notify any running WebUI that theme changed
469 try {
470 window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
471 detail: { prefersDark: prefersDark }
472 }));
473 } catch (e) {
474 // ignore
475 }
476
477 // Keep color-scheme consistent
478 var meta = document.querySelector('meta[name="color-scheme"]');
479 if (!meta) {
480 meta = document.createElement('meta');
481 meta.name = 'color-scheme';
482 document.documentElement.appendChild(meta);
483 }
484 meta.content = prefersDark ? 'dark light' : 'light dark';
485 } catch (e) {
486 console.error('[dumber] failed to apply theme', e);
487 }
488})();`, string(cssJSON), prefersDark)
489
490 for paneID, wv := range c.webViews {
491 if wv == nil || wv.IsDestroyed() {
492 continue
493 }
494 uri := wv.URI()
495 if !strings.HasPrefix(uri, "dumb://") {
496 continue
497 }
498 wv.RunJavaScript(ctx, script, "")
499 log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
500 }
501}
502
503// GetTitle returns the current title for a pane.
504func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
505 c.titleMu.RLock()
506 defer c.titleMu.RUnlock()
507 return c.paneTitles[paneID]
508}
509
510// onTitleChanged updates title tracking when a WebView's title changes.
511func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
512 log := logging.FromContext(ctx)
513
514 // Update title map
515 c.titleMu.Lock()
516 c.paneTitles[paneID] = title
517 c.titleMu.Unlock()
518
519 // Update domain model and check if this is the active pane
520 isActivePaneTitle := false
521 ws, wsView := c.getActiveWS()
522 if ws != nil {
523 paneNode := ws.FindPane(paneID)
524 if paneNode != nil && paneNode.Pane != nil {
525 paneNode.Pane.Title = title
526 }
527 // Check if this pane is the active one
528 if ws.ActivePaneID == paneID {
529 isActivePaneTitle = true
530 }
531 }
532
533 // Update StackedView title bar if this pane is in a stack
534 if wsView != nil {
535 tr := wsView.TreeRenderer()
536 if tr != nil {
537 stackedView := tr.GetStackedViewForPane(string(paneID))
538 if stackedView != nil {
539 c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
540 }
541 }
542 }
543
544 // Notify history persistence (get URL from WebView)
545 if c.onTitleUpdated != nil {
546 if wv := c.webViews[paneID]; wv != nil {
547 url := wv.URI()
548 if url != "" && title != "" {
549 c.onTitleUpdated(ctx, paneID, url, title)
550 }
551 }
552 }
553
554 // Notify window title update if this is the active pane
555 if isActivePaneTitle && c.onWindowTitleChanged != nil {
556 c.onWindowTitleChanged(title)
557 }
558
559 log.Debug().
560 Str("pane_id", string(paneID)).
561 Str("title", title).
562 Msg("pane title updated")
563}
564
565// updateStackedPaneTitle updates the title of a pane in a StackedView.
566func (c *ContentCoordinator) updateStackedPaneTitle(
567 ctx context.Context,
568 sv *layout.StackedView,
569 paneID entity.PaneID,
570 title string,
571) {
572 log := logging.FromContext(ctx)
573
574 // Find the pane's index directly in the StackedView
575 index := sv.FindPaneIndex(string(paneID))
576 if index < 0 {
577 log.Debug().
578 Str("pane_id", string(paneID)).
579 Msg("pane not found in StackedView for title update")
580 return
581 }
582
583 if err := sv.UpdateTitle(index, title); err != nil {
584 log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
585 }
586}
587
588// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
589func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {
590 log := logging.FromContext(ctx)
591
592 // Get current URI to extract domain for caching
593 wv := c.webViews[paneID]
594 if wv == nil {
595 return
596 }
597 uri := wv.URI()
598
599 // Update favicon cache with domain key (handles cross-domain redirects)
600 if c.faviconAdapter != nil && favicon != nil && uri != "" {
601 c.navOriginMu.RLock()
602 originURL := c.navOrigins[paneID]
603 c.navOriginMu.RUnlock()
604 c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
605 }
606
607 // Update StackedView favicon if this pane is in a stack
608 _, wsView := c.getActiveWS()
609 if wsView != nil {
610 tr := wsView.TreeRenderer()
611 if tr != nil {
612 stackedView := tr.GetStackedViewForPane(string(paneID))
613 if stackedView != nil {
614 c.updateStackedPaneFavicon(ctx, stackedView, paneID, favicon)
615 }
616 }
617 }
618
619 log.Debug().
620 Str("pane_id", string(paneID)).
621 Str("uri", uri).
622 Bool("has_favicon", favicon != nil).
623 Msg("pane favicon updated")
624}
625
626// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
627func (c *ContentCoordinator) updateStackedPaneFavicon(
628 ctx context.Context,
629 sv *layout.StackedView,
630 paneID entity.PaneID,
631 favicon *gdk.Texture,
632) {
633 log := logging.FromContext(ctx)
634
635 // Find the pane's index directly in the StackedView
636 index := sv.FindPaneIndex(string(paneID))
637 if index < 0 {
638 log.Debug().
639 Str("pane_id", string(paneID)).
640 Msg("pane not found in StackedView for favicon update")
641 return
642 }
643
644 if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
645 log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
646 }
647}
648
649// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
650func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
651 return c.faviconAdapter
652}
653
654// SetNavigationOrigin records the original URL before navigation starts.
655// This allows caching favicons under both original and final domains
656// when cross-domain redirects occur (e.g., google.fr → google.com).
657func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) {
658 c.navOriginMu.Lock()
659 c.navOrigins[paneID] = url
660 c.navOriginMu.Unlock()
661}
662
663// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
664// title bar immediately if a cached favicon exists for the URL.
665// This provides instant favicon display without waiting for WebKit.
666func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {
667 if c.faviconAdapter == nil || url == "" {
668 return
669 }
670
671 // Check memory and disk cache (no external fetch)
672 texture := c.faviconAdapter.PreloadFromCache(url)
673 if texture == nil {
674 return
675 }
676
677 // Update stacked pane favicon if applicable
678 _, wsView := c.getActiveWS()
679 if wsView != nil {
680 tr := wsView.TreeRenderer()
681 if tr != nil {
682 stackedView := tr.GetStackedViewForPane(string(paneID))
683 if stackedView != nil {
684 c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
685 }
686 }
687 }
688}
689
690// onLoadCommitted re-applies zoom when page content starts loading and records history.
691// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
692// History is recorded here because the URI is guaranteed to be correct after commit.
693// Also shows the WebView widget (it's hidden during creation to avoid white flash).
694func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
695 log := logging.FromContext(ctx)
696
697 url := wv.URI()
698 if url == "" {
699 return
700 }
701
702 // Show the WebView now that content is being painted
703 // (WebViews are hidden on creation to avoid white flash)
704 // Skip showing if this is about:blank but the pane is loading a different URL
705 // This prevents the brief flash of about:blank during initial navigation
706 shouldShow := true
707 if url == "about:blank" {
708 // Get the pane's intended URI from the workspace
709 ws, _ := c.getActiveWS()
710 if ws != nil {
711 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
712 // Don't show about:blank if the pane is supposed to load a different URL
713 if paneNode.Pane.URI != "" && paneNode.Pane.URI != "about:blank" {
714 shouldShow = false
715 log.Debug().
716 Str("pane_id", string(paneID)).
717 Str("pane_uri", paneNode.Pane.URI).
718 Msg("skipping webview show for about:blank (pane loading different URL)")
719 }
720 }
721 }
722 }
723
724 if !shouldShow {
725 // Avoid updating UI/domain state to about:blank when we know the pane is
726 // navigating to a different URL. This prevents the omnibox/window title from
727 // briefly showing about:blank on cold start.
728 c.clearPendingReveal(paneID)
729 return
730 }
731
732 c.markPendingReveal(paneID)
733 if wv.EstimatedProgress() > 0 {
734 c.revealIfPending(ctx, paneID, url, "progress-after-commit")
735 }
736
737 // Update domain model with current URI for session snapshots
738 c.updatePaneURI(paneID, url)
739
740 // Record history - URI is guaranteed to be correct at LoadCommitted
741 if c.onHistoryRecord != nil {
742 c.onHistoryRecord(ctx, paneID, url)
743 }
744
745 // Apply zoom
746 if c.zoomUC == nil {
747 return
748 }
749
750 domain, err := usecase.ExtractDomain(url)
751 if err != nil {
752 return
753 }
754
755 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
756}
757
758// onSPANavigation records history when URL changes via JavaScript (History API).
759// This handles SPA navigation like YouTube search, where the URL changes without a page load.
760func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
761 log := logging.FromContext(ctx)
762 log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
763
764 // Update domain model with current URI for session snapshots
765 c.updatePaneURI(paneID, url)
766
767 // Record history for SPA navigation
768 if c.onHistoryRecord != nil {
769 c.onHistoryRecord(ctx, paneID, url)
770 }
771}
772
773// updatePaneURI updates the pane's URI in the domain model.
774// This is called on navigation so that session snapshots capture the current URL.
775func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {
776 if c.onPaneURIUpdated != nil {
777 c.onPaneURIUpdated(paneID, url)
778 }
779}
780
781// onLoadStarted shows the progress bar when page loading begins.
782func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
783 _, wsView := c.getActiveWS()
784 if wsView == nil {
785 return
786 }
787
788 paneView := wsView.GetPaneView(paneID)
789 if paneView != nil {
790 paneView.SetLoading(true)
791 }
792}
793
794// onLoadFinished hides the progress bar when page loading completes.
795func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
796 _, wsView := c.getActiveWS()
797 if wsView == nil {
798 return
799 }
800
801 paneView := wsView.GetPaneView(paneID)
802 if paneView != nil {
803 paneView.SetLoading(false)
804 }
805
806 c.revealIfPending(context.Background(), paneID, "", "load-finished")
807}
808
809// onProgressChanged updates the progress bar with current load progress.
810func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
811 if progress > 0 {
812 c.revealIfPending(context.Background(), paneID, "", "progress")
813 }
814
815 _, wsView := c.getActiveWS()
816 if wsView == nil {
817 return
818 }
819
820 paneView := wsView.GetPaneView(paneID)
821 if paneView != nil {
822 paneView.SetLoadProgress(progress)
823 }
824}
825
826func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) {
827 c.revealMu.Lock()
828 c.pendingReveal[paneID] = true
829 c.revealMu.Unlock()
830}
831
832func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
833 c.revealMu.Lock()
834 delete(c.pendingReveal, paneID)
835 c.revealMu.Unlock()
836}
837
838func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) {
839 c.revealMu.Lock()
840 pending := c.pendingReveal[paneID]
841 if pending {
842 delete(c.pendingReveal, paneID)
843 }
844 c.revealMu.Unlock()
845
846 if !pending {
847 return
848 }
849
850 wv := c.webViews[paneID]
851 if wv == nil || wv.IsDestroyed() {
852 return
853 }
854
855 if inner := wv.Widget(); inner != nil {
856 inner.SetVisible(true)
857 logging.FromContext(ctx).
858 Debug().
859 Str("pane_id", string(paneID)).
860 Str("url", url).
861 Str("reason", reason).
862 Msg("webview revealed")
863 }
864 if c.onWebViewShown != nil {
865 c.onWebViewShown(paneID)
866 }
867}
868
869// onLinkHover updates the link status overlay when hovering over links.
870func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
871 _, wsView := c.getActiveWS()
872 if wsView == nil {
873 return
874 }
875
876 paneView := wsView.GetPaneView(paneID)
877 if paneView == nil {
878 return
879 }
880
881 if uri != "" {
882 paneView.ShowLinkStatus(uri)
883 } else {
884 paneView.HideLinkStatus()
885 }
886}
887
888// --- Popup Handling ---
889
890// SetPopupConfig configures popup handling.
891func (c *ContentCoordinator) SetPopupConfig(
892 factory *webkit.WebViewFactory,
893 popupConfig *config.PopupBehaviorConfig,
894 generateID func() string,
895) {
896 c.factory = factory
897 c.popupConfig = popupConfig
898 c.generateID = generateID
899}
900
901// SetOnInsertPopup sets the callback to insert popups into the workspace.
902func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
903 c.onInsertPopup = fn
904}
905
906// SetOnClosePane sets the callback to close a pane when its popup closes.
907func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
908 c.onClosePane = fn
909}
910
911// SetupPopupHandling wires the popup create signal for a WebView.
912// This should be called after acquiring a WebView for a pane.
913func (c *ContentCoordinator) SetupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
914 log := logging.FromContext(ctx)
915
916 if wv == nil {
917 return
918 }
919
920 // Wire the OnCreate callback for popup requests
921 wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
922 return c.handlePopupCreate(ctx, paneID, wv, req)
923 }
924
925 log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
926}
927
928// handlePopupCreate handles the WebKit "create" signal for popup windows.
929// Returns a new related WebView if popup is allowed, nil to block.
930func (c *ContentCoordinator) handlePopupCreate(
931 ctx context.Context,
932 parentPaneID entity.PaneID,
933 parentWV *webkit.WebView,
934 req webkit.PopupRequest,
935) *webkit.WebView {
936 log := logging.FromContext(ctx)
937
938 log.Debug().
939 Str("parent_pane", string(parentPaneID)).
940 Str("target_uri", req.TargetURI).
941 Str("frame_name", req.FrameName).
942 Bool("user_gesture", req.IsUserGesture).
943 Msg("popup create request")
944
945 // Check if popups are enabled
946 if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
947 log.Debug().Msg("popups disabled by config, blocking")
948 return nil
949 }
950
951 // Check if factory is available
952 if c.factory == nil {
953 log.Warn().Msg("no webview factory, cannot create popup")
954 return nil
955 }
956
957 // Create related WebView for session sharing
958 parentID := parentWV.ID()
959 popupWV, err := c.factory.CreateRelated(ctx, parentID)
960 if err != nil {
961 log.Error().Err(err).Msg("failed to create related webview for popup")
962 return nil
963 }
964
965 // Detect popup type
966 popupType := DetectPopupType(req.FrameName)
967
968 // Store pending popup for ready-to-show handling
969 popupID := popupWV.ID()
970 pending := &PendingPopup{
971 WebView: popupWV,
972 ParentPaneID: parentPaneID,
973 ParentWebViewID: parentID,
974 TargetURI: req.TargetURI,
975 FrameName: req.FrameName,
976 IsUserGesture: req.IsUserGesture,
977 PopupType: popupType,
978 CreatedAt: time.Now(),
979 }
980
981 c.popupMu.Lock()
982 c.pendingPopups[popupID] = pending
983 c.popupMu.Unlock()
984
985 // Wire ready-to-show and close signals
986 popupWV.OnReadyToShow = func() {
987 c.handlePopupReadyToShow(ctx, popupID)
988 }
989 popupWV.OnClose = func() {
990 c.handlePopupClose(ctx, popupID)
991 }
992
993 log.Info().
994 Uint64("popup_id", uint64(popupID)).
995 Str("popup_type", popupType.String()).
996 Str("target_uri", req.TargetURI).
997 Msg("popup created, awaiting ready-to-show")
998
999 return popupWV
1000}
1001
1002// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
1003// This is called when the popup is configured and ready to be displayed.
1004func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
1005 log := logging.FromContext(ctx)
1006
1007 // Get pending popup
1008 c.popupMu.Lock()
1009 pending, ok := c.pendingPopups[popupID]
1010 if ok {
1011 delete(c.pendingPopups, popupID)
1012 }
1013 c.popupMu.Unlock()
1014
1015 if !ok || pending == nil {
1016 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
1017 return
1018 }
1019
1020 log.Debug().
1021 Uint64("popup_id", uint64(popupID)).
1022 Str("popup_type", pending.PopupType.String()).
1023 Msg("popup ready to show")
1024
1025 // Determine behavior from config
1026 behavior := GetBehavior(pending.PopupType, c.popupConfig)
1027 placement := "right"
1028 if c.popupConfig != nil {
1029 placement = c.popupConfig.Placement
1030 }
1031
1032 // Create popup pane entity
1033 var paneID entity.PaneID
1034 if c.generateID != nil {
1035 paneID = entity.PaneID(c.generateID())
1036 } else {
1037 paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
1038 }
1039
1040 popupPane := entity.NewPane(paneID)
1041 popupPane.WindowType = entity.WindowPopup
1042 popupPane.IsRelated = true
1043 popupPane.ParentPaneID = &pending.ParentPaneID
1044 popupPane.URI = pending.TargetURI
1045
1046 // Enable OAuth auto-close if configured
1047 if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(pending.TargetURI) {
1048 popupPane.AutoClose = true
1049 c.setupOAuthAutoClose(ctx, paneID, pending.WebView)
1050 }
1051
1052 // Register WebView in our map
1053 c.webViews[paneID] = pending.WebView
1054
1055 // Setup standard callbacks
1056 c.setupWebViewCallbacks(ctx, paneID, pending.WebView)
1057
1058 // Request insertion into workspace
1059 if c.onInsertPopup != nil {
1060 popupInput := InsertPopupInput{
1061 ParentPaneID: pending.ParentPaneID,
1062 PopupPane: popupPane,
1063 WebView: pending.WebView,
1064 Behavior: behavior,
1065 Placement: placement,
1066 PopupType: pending.PopupType,
1067 TargetURI: pending.TargetURI,
1068 }
1069
1070 if err := c.onInsertPopup(ctx, popupInput); err != nil {
1071 log.Error().Err(err).Msg("failed to insert popup into workspace")
1072 // Clean up on failure
1073 delete(c.webViews, paneID)
1074 pending.WebView.Destroy()
1075 return
1076 }
1077 }
1078
1079 log.Info().
1080 Str("pane_id", string(paneID)).
1081 Str("behavior", string(behavior)).
1082 Str("placement", placement).
1083 Msg("popup inserted into workspace")
1084}
1085
1086// handlePopupClose handles the WebKit "close" signal for popup windows.
1087func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
1088 log := logging.FromContext(ctx)
1089 log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
1090
1091 // Check if still pending (never shown)
1092 c.popupMu.Lock()
1093 pending, wasPending := c.pendingPopups[popupID]
1094 if wasPending {
1095 delete(c.pendingPopups, popupID)
1096 }
1097 c.popupMu.Unlock()
1098
1099 if wasPending && pending != nil {
1100 // Was never shown, just destroy
1101 pending.WebView.Destroy()
1102 log.Debug().Msg("destroyed pending popup that was never shown")
1103 return
1104 }
1105
1106 // Find pane by WebView ID
1107 var paneID entity.PaneID
1108 for pid, wv := range c.webViews {
1109 if wv != nil && wv.ID() == popupID {
1110 paneID = pid
1111 break
1112 }
1113 }
1114
1115 if paneID == "" {
1116 log.Warn().Msg("popup close: could not find pane for webview")
1117 return
1118 }
1119
1120 // Close the pane in workspace (this removes the UI element)
1121 if c.onClosePane != nil {
1122 if err := c.onClosePane(ctx, paneID); err != nil {
1123 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
1124 }
1125 }
1126
1127 // Release the WebView (this will clean up tracking)
1128 c.ReleaseWebView(ctx, paneID)
1129
1130 log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
1131}
1132
1133// setupWebViewCallbacks configures standard callbacks for a WebView.
1134func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1135 // Title changes
1136 wv.OnTitleChanged = func(title string) {
1137 c.onTitleChanged(ctx, paneID, title)
1138 }
1139
1140 // Favicon changes
1141 wv.OnFaviconChanged = func(favicon *gdk.Texture) {
1142 c.onFaviconChanged(ctx, paneID, favicon)
1143 }
1144
1145 // Load events
1146 wv.OnLoadChanged = func(event webkit.LoadEvent) {
1147 switch event {
1148 case webkit.LoadStarted:
1149 c.onLoadStarted(paneID)
1150 case webkit.LoadCommitted:
1151 c.onLoadCommitted(ctx, paneID, wv)
1152 case webkit.LoadFinished:
1153 c.onLoadFinished(paneID)
1154 }
1155 }
1156
1157 // Progress
1158 wv.OnProgressChanged = func(progress float64) {
1159 c.onProgressChanged(paneID, progress)
1160 }
1161
1162 // SPA navigation
1163 wv.OnURIChanged = func(uri string) {
1164 if !wv.IsLoading() && uri != "" {
1165 c.onSPANavigation(ctx, paneID, uri)
1166 }
1167 }
1168
1169 // Middle-click / Ctrl+click handler for opening links in new pane
1170 wv.OnLinkMiddleClick = func(uri string) bool {
1171 return c.handleLinkMiddleClick(ctx, paneID, uri)
1172 }
1173
1174 // Link hover callback for status overlay
1175 wv.OnLinkHover = func(uri string) {
1176 c.onLinkHover(paneID, uri)
1177 }
1178
1179 // Fullscreen handlers for idle inhibition
1180 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
1181}
1182
1183// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
1184func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1185 log := logging.FromContext(ctx)
1186
1187 // Wrap the existing OnURIChanged to also check for OAuth callbacks
1188 originalURIChanged := wv.OnURIChanged
1189 wv.OnURIChanged = func(uri string) {
1190 // Call original handler first
1191 if originalURIChanged != nil {
1192 originalURIChanged(uri)
1193 }
1194
1195 // Check for OAuth callback
1196 if ShouldAutoClose(uri) {
1197 log.Info().
1198 Str("pane_id", string(paneID)).
1199 Str("uri", uri).
1200 Msg("OAuth callback detected, auto-closing popup")
1201
1202 // Trigger close after a short delay to let the page process
1203 go func() {
1204 time.Sleep(500 * time.Millisecond)
1205 if wv != nil && !wv.IsDestroyed() {
1206 // This will trigger handlePopupClose
1207 wv.Close()
1208 }
1209 }()
1210 }
1211 }
1212
1213 // Also check on load committed for immediate redirects
1214 originalLoadChanged := wv.OnLoadChanged
1215 wv.OnLoadChanged = func(event webkit.LoadEvent) {
1216 if originalLoadChanged != nil {
1217 originalLoadChanged(event)
1218 }
1219
1220 if event == webkit.LoadCommitted {
1221 uri := wv.URI()
1222 if ShouldAutoClose(uri) {
1223 log.Info().
1224 Str("pane_id", string(paneID)).
1225 Str("uri", uri).
1226 Msg("OAuth callback detected on load, auto-closing popup")
1227
1228 go func() {
1229 time.Sleep(500 * time.Millisecond)
1230 if wv != nil && !wv.IsDestroyed() {
1231 wv.Close()
1232 }
1233 }()
1234 }
1235 }
1236 }
1237}
1238
1239// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
1240// Opens the link in a new pane using blank_target_behavior config.
1241func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
1242 log := logging.FromContext(ctx)
1243
1244 log.Info().
1245 Str("parent_pane", string(parentPaneID)).
1246 Str("uri", uri).
1247 Msg("middle-click/ctrl+click on link")
1248
1249 // Check if popups are enabled
1250 if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
1251 log.Debug().Msg("popups disabled by config, ignoring middle-click")
1252 return false
1253 }
1254
1255 // Check if factory is available
1256 if c.factory == nil {
1257 log.Warn().Msg("no webview factory, cannot handle middle-click")
1258 return false
1259 }
1260
1261 // Create a new WebView (regular, not related - just opening a link)
1262 newWV, err := c.factory.Create(ctx)
1263 if err != nil {
1264 log.Error().Err(err).Msg("failed to create webview for middle-click")
1265 return false
1266 }
1267
1268 // Generate pane ID
1269 var paneID entity.PaneID
1270 if c.generateID != nil {
1271 paneID = entity.PaneID(c.generateID())
1272 } else {
1273 paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
1274 }
1275
1276 // Create pane entity - uses blank_target_behavior
1277 newPane := entity.NewPane(paneID)
1278 newPane.WindowType = entity.WindowPopup
1279 newPane.URI = uri
1280
1281 // Register WebView
1282 c.webViews[paneID] = newWV
1283
1284 // Setup callbacks
1285 c.setupWebViewCallbacks(ctx, paneID, newWV)
1286
1287 // Get behavior from config (same as _blank links)
1288 behavior := config.PopupBehaviorStacked // default
1289 if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
1290 behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
1291 }
1292 placement := "right"
1293 if c.popupConfig != nil {
1294 placement = c.popupConfig.Placement
1295 }
1296
1297 // Request insertion into workspace
1298 if c.onInsertPopup != nil {
1299 popupInput := InsertPopupInput{
1300 ParentPaneID: parentPaneID,
1301 PopupPane: newPane,
1302 WebView: newWV,
1303 Behavior: behavior,
1304 Placement: placement,
1305 PopupType: PopupTypeTab, // Treat like _blank
1306 TargetURI: uri,
1307 }
1308
1309 if err := c.onInsertPopup(ctx, popupInput); err != nil {
1310 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
1311 // Clean up on failure
1312 delete(c.webViews, paneID)
1313 newWV.Destroy()
1314 return false
1315 }
1316 }
1317
1318 // Load the URI after insertion
1319 if err := newWV.LoadURI(ctx, uri); err != nil {
1320 log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
1321 }
1322
1323 log.Info().
1324 Str("pane_id", string(paneID)).
1325 Str("behavior", string(behavior)).
1326 Str("uri", uri).
1327 Msg("middle-click link opened in new pane")
1328
1329 return true
1330}
1331
1332// setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
1333// Idle is inhibited when:
1334// - The webview enters fullscreen mode (e.g., fullscreen video)
1335// - The webview is playing audio (e.g., video/music playback)
1336// The inhibitor uses refcounting, so both can be active simultaneously.
1337func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1338 log := logging.FromContext(ctx)
1339
1340 if wv == nil {
1341 return
1342 }
1343
1344 // Fullscreen handling
1345 wv.OnEnterFullscreen = func() bool {
1346 if c.idleInhibitor != nil {
1347 if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
1348 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
1349 }
1350 }
1351 if c.onFullscreenChanged != nil {
1352 c.onFullscreenChanged(true)
1353 }
1354 return false // Allow fullscreen
1355 }
1356
1357 wv.OnLeaveFullscreen = func() bool {
1358 if c.idleInhibitor != nil {
1359 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1360 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
1361 }
1362 }
1363 if c.onFullscreenChanged != nil {
1364 c.onFullscreenChanged(false)
1365 }
1366 return false // Allow leaving fullscreen
1367 }
1368
1369 // Audio playback handling
1370 wv.OnAudioStateChanged = func(playing bool) {
1371 if c.idleInhibitor == nil {
1372 return
1373 }
1374 if playing {
1375 if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
1376 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
1377 }
1378 } else {
1379 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1380 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
1381 }
1382 }
1383 }
1384}