Merge pull request #48 from bnema/fix/fullscreen-tabbar-progress-timeout
fix(ui): fullscreen tab bar visibility and progress bar timeout
4 files · +75 -1
M CHANGELOG.md +2 -0
| @@ -35,6 +35,8 @@ All notable changes to this project will be documented in this file. | |||
| 35 | 35 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | |
| 36 | 36 | ||
| 37 | 37 | ### Fixed | |
| 38 | + | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | |
| 39 | + | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | |
| 38 | 40 | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | |
| 39 | 41 | - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency. | |
| 40 | 42 | - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag). | |
| @@ -35,6 +35,8 @@ All notable changes to this project will be documented in this file. | |||||
| 35 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | 35 | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | ||
| 36 | 36 | ||||
| 37 | ### Fixed | 37 | ### Fixed | ||
| 38 | + | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | |||
| 39 | + | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | |||
| 38 | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | 40 | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | ||
| 39 | - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency. | 41 | - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency. | ||
| 40 | - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag). | 42 | - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag). | ||
M internal/ui/app.go +14 -0
| @@ -1035,6 +1035,20 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1035 | 1035 | }) | |
| 1036 | 1036 | } | |
| 1037 | 1037 | ||
| 1038 | + | // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized) | |
| 1039 | + | a.contentCoord.SetOnFullscreenChanged(func(entering bool) { | |
| 1040 | + | if a.mainWindow == nil || a.mainWindow.TabBar() == nil { | |
| 1041 | + | return | |
| 1042 | + | } | |
| 1043 | + | if entering { | |
| 1044 | + | // Hide tab bar when entering fullscreen video | |
| 1045 | + | a.mainWindow.TabBar().SetVisible(false) | |
| 1046 | + | } else { | |
| 1047 | + | // Restore tab bar visibility based on normal logic | |
| 1048 | + | a.tabCoord.UpdateBarVisibility(ctx) | |
| 1049 | + | } | |
| 1050 | + | }) | |
| 1051 | + | ||
| 1038 | 1052 | // 3. Workspace Coordinator | |
| 1039 | 1053 | a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{ | |
| 1040 | 1054 | PanesUC: a.panesUC, | |
| @@ -1035,6 +1035,20 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1035 | }) | 1035 | }) | ||
| 1036 | } | 1036 | } | ||
| 1037 | 1037 | ||||
| 1038 | + | // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized) | |||
| 1039 | + | a.contentCoord.SetOnFullscreenChanged(func(entering bool) { | |||
| 1040 | + | if a.mainWindow == nil || a.mainWindow.TabBar() == nil { | |||
| 1041 | + | return | |||
| 1042 | + | } | |||
| 1043 | + | if entering { | |||
| 1044 | + | // Hide tab bar when entering fullscreen video | |||
| 1045 | + | a.mainWindow.TabBar().SetVisible(false) | |||
| 1046 | + | } else { | |||
| 1047 | + | // Restore tab bar visibility based on normal logic | |||
| 1048 | + | a.tabCoord.UpdateBarVisibility(ctx) | |||
| 1049 | + | } | |||
| 1050 | + | }) | |||
| 1051 | + | ||||
| 1038 | // 3. Workspace Coordinator | 1052 | // 3. Workspace Coordinator | ||
| 1039 | a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{ | 1053 | a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{ | ||
| 1040 | PanesUC: a.panesUC, | 1054 | PanesUC: a.panesUC, | ||
M internal/ui/component/progress_bar.go +45 -1
| @@ -15,11 +15,14 @@ const ( | |||
| 15 | 15 | progressStep = 0.02 | |
| 16 | 16 | // Animation interval in milliseconds | |
| 17 | 17 | progressIntervalMs = 16 // ~60fps | |
| 18 | + | // Timeout to auto-hide progress bar if stuck (30 seconds) | |
| 19 | + | progressTimeoutMs = 30000 | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 20 | 22 | // ProgressBar displays a slim loading progress indicator at the bottom of a pane. | |
| 21 | 23 | // Uses native GtkProgressBar with "osd" styling for overlay appearance. | |
| 22 | 24 | // Implements smooth animation by incrementing towards the target value. | |
| 25 | + | // Includes a 30-second timeout to auto-hide if the page load stalls. | |
| 23 | 26 | type ProgressBar struct { | |
| 24 | 27 | progressBar layout.ProgressBarWidget | |
| 25 | 28 | ||
| @@ -27,6 +30,7 @@ type ProgressBar struct { | |||
| 27 | 30 | currentValue float64 // Current displayed value | |
| 28 | 31 | targetValue float64 // Target value to animate towards | |
| 29 | 32 | animationTimer uint // Timer source ID for animation | |
| 33 | + | timeoutTimer uint // Timer source ID for auto-hide timeout | |
| 30 | 34 | ||
| 31 | 35 | mu sync.Mutex | |
| 32 | 36 | } | |
| @@ -127,7 +131,7 @@ func (pb *ProgressBar) startAnimation() { | |||
| 127 | 131 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | |
| 128 | 132 | } | |
| 129 | 133 | ||
| 130 | - | // Show makes the progress bar visible. | |
| 134 | + | // Show makes the progress bar visible and starts the auto-hide timeout. | |
| 131 | 135 | func (pb *ProgressBar) Show() { | |
| 132 | 136 | pb.mu.Lock() | |
| 133 | 137 | defer pb.mu.Unlock() | |
| @@ -136,6 +140,34 @@ func (pb *ProgressBar) Show() { | |||
| 136 | 140 | pb.visible = true | |
| 137 | 141 | pb.progressBar.SetVisible(true) | |
| 138 | 142 | } | |
| 143 | + | ||
| 144 | + | // Reset timeout timer on every Show call | |
| 145 | + | pb.resetTimeout() | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // resetTimeout cancels any existing timeout and starts a new one. | |
| 149 | + | // Must be called with lock held. | |
| 150 | + | func (pb *ProgressBar) resetTimeout() { | |
| 151 | + | // Cancel existing timeout | |
| 152 | + | if pb.timeoutTimer != 0 { | |
| 153 | + | glib.SourceRemove(pb.timeoutTimer) | |
| 154 | + | pb.timeoutTimer = 0 | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Start new timeout | |
| 158 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 159 | + | pb.mu.Lock() | |
| 160 | + | defer pb.mu.Unlock() | |
| 161 | + | ||
| 162 | + | // Clear timer ID (timer is being removed) | |
| 163 | + | pb.timeoutTimer = 0 | |
| 164 | + | ||
| 165 | + | // Hide the progress bar inline to avoid race condition | |
| 166 | + | pb.hideInternal() | |
| 167 | + | return false // Don't repeat | |
| 168 | + | }) | |
| 169 | + | ||
| 170 | + | pb.timeoutTimer = glib.TimeoutAdd(progressTimeoutMs, &cb, 0) | |
| 139 | 171 | } | |
| 140 | 172 | ||
| 141 | 173 | // Hide makes the progress bar invisible and resets state. | |
| @@ -143,6 +175,12 @@ func (pb *ProgressBar) Hide() { | |||
| 143 | 175 | pb.mu.Lock() | |
| 144 | 176 | defer pb.mu.Unlock() | |
| 145 | 177 | ||
| 178 | + | pb.hideInternal() | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // hideInternal performs the actual hide operation. | |
| 182 | + | // Must be called with lock held. | |
| 183 | + | func (pb *ProgressBar) hideInternal() { | |
| 146 | 184 | if pb.visible { | |
| 147 | 185 | pb.visible = false | |
| 148 | 186 | pb.progressBar.SetVisible(false) | |
| @@ -153,6 +191,12 @@ func (pb *ProgressBar) Hide() { | |||
| 153 | 191 | pb.animationTimer = 0 | |
| 154 | 192 | } | |
| 155 | 193 | ||
| 194 | + | // Stop timeout timer | |
| 195 | + | if pb.timeoutTimer != 0 { | |
| 196 | + | glib.SourceRemove(pb.timeoutTimer) | |
| 197 | + | pb.timeoutTimer = 0 | |
| 198 | + | } | |
| 199 | + | ||
| 156 | 200 | // Reset values | |
| 157 | 201 | pb.currentValue = 0 | |
| 158 | 202 | pb.targetValue = 0 | |
| @@ -15,11 +15,14 @@ const ( | |||||
| 15 | progressStep = 0.02 | 15 | progressStep = 0.02 | ||
| 16 | // Animation interval in milliseconds | 16 | // Animation interval in milliseconds | ||
| 17 | progressIntervalMs = 16 // ~60fps | 17 | progressIntervalMs = 16 // ~60fps | ||
| 18 | + | // Timeout to auto-hide progress bar if stuck (30 seconds) | |||
| 19 | + | progressTimeoutMs = 30000 | |||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| 20 | // ProgressBar displays a slim loading progress indicator at the bottom of a pane. | 22 | // ProgressBar displays a slim loading progress indicator at the bottom of a pane. | ||
| 21 | // Uses native GtkProgressBar with "osd" styling for overlay appearance. | 23 | // Uses native GtkProgressBar with "osd" styling for overlay appearance. | ||
| 22 | // Implements smooth animation by incrementing towards the target value. | 24 | // Implements smooth animation by incrementing towards the target value. | ||
| 25 | + | // Includes a 30-second timeout to auto-hide if the page load stalls. | |||
| 23 | type ProgressBar struct { | 26 | type ProgressBar struct { | ||
| 24 | progressBar layout.ProgressBarWidget | 27 | progressBar layout.ProgressBarWidget | ||
| 25 | 28 | ||||
| @@ -27,6 +30,7 @@ type ProgressBar struct { | |||||
| 27 | currentValue float64 // Current displayed value | 30 | currentValue float64 // Current displayed value | ||
| 28 | targetValue float64 // Target value to animate towards | 31 | targetValue float64 // Target value to animate towards | ||
| 29 | animationTimer uint // Timer source ID for animation | 32 | animationTimer uint // Timer source ID for animation | ||
| 33 | + | timeoutTimer uint // Timer source ID for auto-hide timeout | |||
| 30 | 34 | ||||
| 31 | mu sync.Mutex | 35 | mu sync.Mutex | ||
| 32 | } | 36 | } | ||
| @@ -127,7 +131,7 @@ func (pb *ProgressBar) startAnimation() { | |||||
| 127 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | 131 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | ||
| 128 | } | 132 | } | ||
| 129 | 133 | ||||
| 130 | - | // Show makes the progress bar visible. | 134 | + | // Show makes the progress bar visible and starts the auto-hide timeout. |
| 131 | func (pb *ProgressBar) Show() { | 135 | func (pb *ProgressBar) Show() { | ||
| 132 | pb.mu.Lock() | 136 | pb.mu.Lock() | ||
| 133 | defer pb.mu.Unlock() | 137 | defer pb.mu.Unlock() | ||
| @@ -136,6 +140,34 @@ func (pb *ProgressBar) Show() { | |||||
| 136 | pb.visible = true | 140 | pb.visible = true | ||
| 137 | pb.progressBar.SetVisible(true) | 141 | pb.progressBar.SetVisible(true) | ||
| 138 | } | 142 | } | ||
| 143 | + | ||||
| 144 | + | // Reset timeout timer on every Show call | |||
| 145 | + | pb.resetTimeout() | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // resetTimeout cancels any existing timeout and starts a new one. | |||
| 149 | + | // Must be called with lock held. | |||
| 150 | + | func (pb *ProgressBar) resetTimeout() { | |||
| 151 | + | // Cancel existing timeout | |||
| 152 | + | if pb.timeoutTimer != 0 { | |||
| 153 | + | glib.SourceRemove(pb.timeoutTimer) | |||
| 154 | + | pb.timeoutTimer = 0 | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // Start new timeout | |||
| 158 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 159 | + | pb.mu.Lock() | |||
| 160 | + | defer pb.mu.Unlock() | |||
| 161 | + | ||||
| 162 | + | // Clear timer ID (timer is being removed) | |||
| 163 | + | pb.timeoutTimer = 0 | |||
| 164 | + | ||||
| 165 | + | // Hide the progress bar inline to avoid race condition | |||
| 166 | + | pb.hideInternal() | |||
| 167 | + | return false // Don't repeat | |||
| 168 | + | }) | |||
| 169 | + | ||||
| 170 | + | pb.timeoutTimer = glib.TimeoutAdd(progressTimeoutMs, &cb, 0) | |||
| 139 | } | 171 | } | ||
| 140 | 172 | ||||
| 141 | // Hide makes the progress bar invisible and resets state. | 173 | // Hide makes the progress bar invisible and resets state. | ||
| @@ -143,6 +175,12 @@ func (pb *ProgressBar) Hide() { | |||||
| 143 | pb.mu.Lock() | 175 | pb.mu.Lock() | ||
| 144 | defer pb.mu.Unlock() | 176 | defer pb.mu.Unlock() | ||
| 145 | 177 | ||||
| 178 | + | pb.hideInternal() | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // hideInternal performs the actual hide operation. | |||
| 182 | + | // Must be called with lock held. | |||
| 183 | + | func (pb *ProgressBar) hideInternal() { | |||
| 146 | if pb.visible { | 184 | if pb.visible { | ||
| 147 | pb.visible = false | 185 | pb.visible = false | ||
| 148 | pb.progressBar.SetVisible(false) | 186 | pb.progressBar.SetVisible(false) | ||
| @@ -153,6 +191,12 @@ func (pb *ProgressBar) Hide() { | |||||
| 153 | pb.animationTimer = 0 | 191 | pb.animationTimer = 0 | ||
| 154 | } | 192 | } | ||
| 155 | 193 | ||||
| 194 | + | // Stop timeout timer | |||
| 195 | + | if pb.timeoutTimer != 0 { | |||
| 196 | + | glib.SourceRemove(pb.timeoutTimer) | |||
| 197 | + | pb.timeoutTimer = 0 | |||
| 198 | + | } | |||
| 199 | + | ||||
| 156 | // Reset values | 200 | // Reset values | ||
| 157 | pb.currentValue = 0 | 201 | pb.currentValue = 0 | ||
| 158 | pb.targetValue = 0 | 202 | pb.targetValue = 0 | ||
M internal/ui/coordinator/content.go +14 -0
| @@ -75,6 +75,9 @@ type ContentCoordinator struct { | |||
| 75 | 75 | ||
| 76 | 76 | // Idle inhibitor for fullscreen video playback | |
| 77 | 77 | idleInhibitor port.IdleInhibitor | |
| 78 | + | ||
| 79 | + | // Callback when fullscreen state changes (for hiding/showing tab bar) | |
| 80 | + | onFullscreenChanged func(entering bool) | |
| 78 | 81 | } | |
| 79 | 82 | ||
| 80 | 83 | // NewContentCoordinator creates a new ContentCoordinator. | |
| @@ -137,6 +140,11 @@ func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { | |||
| 137 | 140 | c.idleInhibitor = inhibitor | |
| 138 | 141 | } | |
| 139 | 142 | ||
| 143 | + | // SetOnFullscreenChanged sets the callback for fullscreen state changes. | |
| 144 | + | func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) { | |
| 145 | + | c.onFullscreenChanged = fn | |
| 146 | + | } | |
| 147 | + | ||
| 140 | 148 | // EnsureWebView acquires or reuses a WebView for the given pane. | |
| 141 | 149 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | |
| 142 | 150 | log := logging.FromContext(ctx) | |
| @@ -1289,6 +1297,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa | |||
| 1289 | 1297 | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle") | |
| 1290 | 1298 | } | |
| 1291 | 1299 | } | |
| 1300 | + | if c.onFullscreenChanged != nil { | |
| 1301 | + | c.onFullscreenChanged(true) | |
| 1302 | + | } | |
| 1292 | 1303 | return false // Allow fullscreen | |
| 1293 | 1304 | } | |
| 1294 | 1305 | ||
| @@ -1298,6 +1309,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa | |||
| 1298 | 1309 | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle") | |
| 1299 | 1310 | } | |
| 1300 | 1311 | } | |
| 1312 | + | if c.onFullscreenChanged != nil { | |
| 1313 | + | c.onFullscreenChanged(false) | |
| 1314 | + | } | |
| 1301 | 1315 | return false // Allow leaving fullscreen | |
| 1302 | 1316 | } | |
| 1303 | 1317 | ||
| @@ -75,6 +75,9 @@ type ContentCoordinator struct { | |||||
| 75 | 75 | ||||
| 76 | // Idle inhibitor for fullscreen video playback | 76 | // Idle inhibitor for fullscreen video playback | ||
| 77 | idleInhibitor port.IdleInhibitor | 77 | idleInhibitor port.IdleInhibitor | ||
| 78 | + | ||||
| 79 | + | // Callback when fullscreen state changes (for hiding/showing tab bar) | |||
| 80 | + | onFullscreenChanged func(entering bool) | |||
| 78 | } | 81 | } | ||
| 79 | 82 | ||||
| 80 | // NewContentCoordinator creates a new ContentCoordinator. | 83 | // NewContentCoordinator creates a new ContentCoordinator. | ||
| @@ -137,6 +140,11 @@ func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { | |||||
| 137 | c.idleInhibitor = inhibitor | 140 | c.idleInhibitor = inhibitor | ||
| 138 | } | 141 | } | ||
| 139 | 142 | ||||
| 143 | + | // SetOnFullscreenChanged sets the callback for fullscreen state changes. | |||
| 144 | + | func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) { | |||
| 145 | + | c.onFullscreenChanged = fn | |||
| 146 | + | } | |||
| 147 | + | ||||
| 140 | // EnsureWebView acquires or reuses a WebView for the given pane. | 148 | // EnsureWebView acquires or reuses a WebView for the given pane. | ||
| 141 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | 149 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | ||
| 142 | log := logging.FromContext(ctx) | 150 | log := logging.FromContext(ctx) | ||
| @@ -1289,6 +1297,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa | |||||
| 1289 | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle") | 1297 | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle") | ||
| 1290 | } | 1298 | } | ||
| 1291 | } | 1299 | } | ||
| 1300 | + | if c.onFullscreenChanged != nil { | |||
| 1301 | + | c.onFullscreenChanged(true) | |||
| 1302 | + | } | |||
| 1292 | return false // Allow fullscreen | 1303 | return false // Allow fullscreen | ||
| 1293 | } | 1304 | } | ||
| 1294 | 1305 | ||||
| @@ -1298,6 +1309,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa | |||||
| 1298 | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle") | 1309 | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle") | ||
| 1299 | } | 1310 | } | ||
| 1300 | } | 1311 | } | ||
| 1312 | + | if c.onFullscreenChanged != nil { | |||
| 1313 | + | c.onFullscreenChanged(false) | |||
| 1314 | + | } | |||
| 1301 | return false // Allow leaving fullscreen | 1315 | return false // Allow leaving fullscreen | ||
| 1302 | } | 1316 | } | ||
| 1303 | 1317 | ||||