From 248b5fd91ac3e39648b55dc613b6c30105ac6b99 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:13:49 +0200 Subject: [PATCH] refactor(back): adopt host-owned sheet back in watch-together + player watch_together, video_player, mobile_remote and live_tv_show_schedule now pass canPop/onSystemBack to OverlaySheetHost instead of hand-rolling a PopScope. Restores the iOS swipe-back on watch_together and centralizes the sheet-close-on-back logic. --- .../mobile_remote_screen.dart | 2 + .../livetv/live_tv_show_schedule_screen.dart | 2 + lib/screens/video_player/parts/build.dart | 357 +++++++++--------- lib/screens/video_player_screen.dart | 9 + .../screens/watch_together_screen.dart | 46 +-- 5 files changed, 194 insertions(+), 222 deletions(-) diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 2da3b38b..748a099d 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -29,6 +29,8 @@ class _MobileRemoteScreenState extends State { @override Widget build(BuildContext context) { return OverlaySheetHost( + // Close an open sheet on system back instead of popping the screen. + canPop: true, child: Scaffold( appBar: AppBar( title: Text(t.companionRemote.title), diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index bdf45435..fdebbd65 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -114,6 +114,8 @@ class _LiveTvShowScheduleScreenState extends State Widget build(BuildContext context) { final showRecord = _canRecord && _programs.any((p) => p.guid != null && p.guid!.isNotEmpty); return OverlaySheetHost( + // Close an open sheet on system back instead of popping the screen. + canPop: true, child: FocusedScrollScaffold( title: Text(widget.showTitle), actions: showRecord diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index dbdd4893..95011090 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -160,199 +160,182 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { final isMobile = PlatformDetector.isMobile(context); final hideChromeOnMouseExit = !(isMobile && !PlatformDetector.isTV()); - return PopScope( - canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing - onPopInvokedWithResult: (didPop, result) { - if (!didPop) { - // If an overlay sheet is open, delegate back to it instead of - // exiting the player. This prevents the double-pop on Android TV - // where the system back gesture would otherwise reach both the - // sheet and the player's PopScope. - final sheetController = OverlaySheetController.maybeOf(context); - if (sheetController != null && sheetController.isOpen) { - sheetController.pop(); + // Back handling (sheet-close + player exit) is owned by the OverlaySheetHost + // that wraps this widget — see video_player_screen.dart (canPop/onSystemBack). + return Scaffold( + // Use transparent background on macOS when native video layer is active + backgroundColor: Colors.transparent, + body: GestureDetector( + behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls + onScaleStart: (details) { + if (!isMobile) return; + if (details.pointerCount >= 2) _startMobileZoomGesture(); + }, + onScaleUpdate: (details) { + if (!isMobile) return; + if (details.pointerCount < 2) return; + if (!_isPinchZooming) _startMobileZoomGesture(); + + final startZoom = _pinchStartZoomScale; + final filterManager = _videoFilterManager; + if (!_isPinchZooming || startZoom == null || filterManager == null) return; + if ((details.scale - 1.0).abs() <= _pinchZoomActivationThreshold && !_pinchZoomChanged) return; + + _pinchZoomChanged = true; + filterManager.setZoomScale(startZoom * details.scale); + }, + onScaleEnd: (details) { + if (!isMobile) return; + if (!_isPinchZooming) return; + if (!_pinchZoomChanged) { + _clearMobileZoomGesture(); return; } - if (BackKeyCoordinator.consumeIfHandled()) return; - BackKeyCoordinator.markHandled(); - _handleBackButton(); - } - }, - child: Scaffold( - // Use transparent background on macOS when native video layer is active - backgroundColor: Colors.transparent, - body: GestureDetector( - behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls - onScaleStart: (details) { - if (!isMobile) return; - if (details.pointerCount >= 2) _startMobileZoomGesture(); - }, - onScaleUpdate: (details) { - if (!isMobile) return; - if (details.pointerCount < 2) return; - if (!_isPinchZooming) _startMobileZoomGesture(); - final startZoom = _pinchStartZoomScale; - final filterManager = _videoFilterManager; - if (!_isPinchZooming || startZoom == null || filterManager == null) return; - if ((details.scale - 1.0).abs() <= _pinchZoomActivationThreshold && !_pinchZoomChanged) return; + final zoomScale = _videoFilterManager?.zoomScale ?? 1.0; + _showZoomToast(zoomScale); + _clearMobileZoomGesture(); + _setPlayerState(() {}); + }, + child: PlayerChromeInteractionRegion( + controller: _chromeController, + hideOnExit: hideChromeOnMouseExit, + child: Stack( + children: [ + // macOS PiP placeholder — video is in PiP window, show background with icon + // Placed before Video so controls render on top + if (Platform.isMacOS) const VideoPlayerMacPipPlaceholder(), + Center( + child: LayoutBuilder( + builder: (context, constraints) { + final newSize = Size(constraints.maxWidth, constraints.maxHeight); + _scheduleVideoLayoutUpdate(newSize); - _pinchZoomChanged = true; - filterManager.setZoomScale(startZoom * details.scale); - }, - onScaleEnd: (details) { - if (!isMobile) return; - if (!_isPinchZooming) return; - if (!_pinchZoomChanged) { - _clearMobileZoomGesture(); - return; - } - - final zoomScale = _videoFilterManager?.zoomScale ?? 1.0; - _showZoomToast(zoomScale); - _clearMobileZoomGesture(); - _setPlayerState(() {}); - }, - child: PlayerChromeInteractionRegion( - controller: _chromeController, - hideOnExit: hideChromeOnMouseExit, - child: Stack( - children: [ - // macOS PiP placeholder — video is in PiP window, show background with icon - // Placed before Video so controls render on top - if (Platform.isMacOS) const VideoPlayerMacPipPlaceholder(), - Center( - child: LayoutBuilder( - builder: (context, constraints) { - final newSize = Size(constraints.maxWidth, constraints.maxHeight); - _scheduleVideoLayoutUpdate(newSize); - - // Compute canControl from Watch Together provider (reactive) - bool canControl = true; - try { - canControl = context.select( - (wt) => wt.isInSession ? wt.canControl() : true, - ); - } catch (e) { - // Watch Together not available, default to can control - } - - VoidCallback? onNext; - if (widget.isLive) { - onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null; - } else { - onNext = (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null; - } - - VoidCallback? onPrevious; - if (widget.isLive) { - onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null; - } else { - final canRestartOrPrevious = _currentMetadata.isEpisode || _previousEpisode != null; - onPrevious = (canRestartOrPrevious && _canNavigateEpisodes()) ? _restartOrPlayPrevious : null; - } - - final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const []; - final sourceSubtitleTracks = _sourceSubtitleTracksForControls(); - - return Video( - player: player!, - controls: (context) => PlexVideoControls( - player: player!, - metadata: _currentMetadata, - onNext: onNext, - onPrevious: onPrevious, - availableVersions: _availableVersions, - selectedMediaIndex: _effectiveSelectedMediaIndex, - selectedMediaSourceId: widget.selectedMediaSourceId, - selectedQualityPreset: _selectedQualityPreset, - serverSupportsTranscoding: _serverSupportsTranscoding, - isTranscoding: _isTranscoding, - isOfflinePlayback: _isOfflinePlayback, - sourceAudioTracks: sourceAudioTracks, - selectedAudioStreamId: _selectedAudioStreamId, - sourceSubtitleTracks: sourceSubtitleTracks, - selectedSubtitleStreamId: _selectedSourceSubtitleStreamId(sourceSubtitleTracks), - sourcePartId: _currentMediaInfo?.partId, - onTogglePIPMode: _togglePIPMode, - boxFitMode: _videoFilterManager?.boxFitMode ?? 0, - videoZoomScale: _videoFilterManager?.zoomScale ?? 1.0, - onCycleBoxFitMode: _cycleBoxFitMode, - onVideoZoomChanged: _setVideoZoom, - onZoomIn: _zoomVideoIn, - onZoomOut: _zoomVideoOut, - onResetVideoZoom: _resetVideoZoom, - onCycleAudioTrack: _cycleAudioTrack, - onCycleSubtitleTrack: _cycleSubtitleTrack, - onAudioTrackChanged: _onAudioTrackChanged, - onSubtitleTrackChanged: _onSubtitleTrackChanged, - onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, - onSeekRequested: _seekPlayback, - onSeekCompleted: _notifyWatchTogetherSeek, - onBack: _handleBackButton, - onReachedEnd: ({skipAutoPlayCountdown = false}) => - _onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown), - canControl: canControl, - hasFirstFrame: _hasFirstFrame, - playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null, - chromeController: _chromeController, - shaderService: _shaderService, - // ignore: no-empty-block - state update triggers rebuild to reflect shader change - onShaderChanged: () => _setPlayerState(() {}), - thumbnailDataBuilder: _scrubPreviewSource?.isAvailable == true ? _getThumbnailData : null, - isLive: widget.isLive, - liveChannelName: _liveChannelName, - captureBuffer: _captureBuffer, - isAtLiveEdge: _isAtLiveEdge, - streamStartEpoch: _streamStartEpoch, - currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null, - onLiveSeek: _captureBuffer != null ? _seekLiveToEpoch : null, - onLiveSeekBy: _captureBuffer != null ? _liveSeek.seekBy : null, - onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null, - isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false, - onToggleAmbientLighting: _ambientLightingService?.isSupported == true - ? _toggleAmbientLighting - : null, - toastController: _toastController, - ), + // Compute canControl from Watch Together provider (reactive) + bool canControl = true; + try { + canControl = context.select( + (wt) => wt.isInSession ? wt.canControl() : true, ); - }, - ), + } catch (e) { + // Watch Together not available, default to can control + } + + VoidCallback? onNext; + if (widget.isLive) { + onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null; + } else { + onNext = (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null; + } + + VoidCallback? onPrevious; + if (widget.isLive) { + onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null; + } else { + final canRestartOrPrevious = _currentMetadata.isEpisode || _previousEpisode != null; + onPrevious = (canRestartOrPrevious && _canNavigateEpisodes()) ? _restartOrPlayPrevious : null; + } + + final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const []; + final sourceSubtitleTracks = _sourceSubtitleTracksForControls(); + + return Video( + player: player!, + controls: (context) => PlexVideoControls( + player: player!, + metadata: _currentMetadata, + onNext: onNext, + onPrevious: onPrevious, + availableVersions: _availableVersions, + selectedMediaIndex: _effectiveSelectedMediaIndex, + selectedMediaSourceId: widget.selectedMediaSourceId, + selectedQualityPreset: _selectedQualityPreset, + serverSupportsTranscoding: _serverSupportsTranscoding, + isTranscoding: _isTranscoding, + isOfflinePlayback: _isOfflinePlayback, + sourceAudioTracks: sourceAudioTracks, + selectedAudioStreamId: _selectedAudioStreamId, + sourceSubtitleTracks: sourceSubtitleTracks, + selectedSubtitleStreamId: _selectedSourceSubtitleStreamId(sourceSubtitleTracks), + sourcePartId: _currentMediaInfo?.partId, + onTogglePIPMode: _togglePIPMode, + boxFitMode: _videoFilterManager?.boxFitMode ?? 0, + videoZoomScale: _videoFilterManager?.zoomScale ?? 1.0, + onCycleBoxFitMode: _cycleBoxFitMode, + onVideoZoomChanged: _setVideoZoom, + onZoomIn: _zoomVideoIn, + onZoomOut: _zoomVideoOut, + onResetVideoZoom: _resetVideoZoom, + onCycleAudioTrack: _cycleAudioTrack, + onCycleSubtitleTrack: _cycleSubtitleTrack, + onAudioTrackChanged: _onAudioTrackChanged, + onSubtitleTrackChanged: _onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, + onSeekRequested: _seekPlayback, + onSeekCompleted: _notifyWatchTogetherSeek, + onBack: _handleBackButton, + onReachedEnd: ({skipAutoPlayCountdown = false}) => + _onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown), + canControl: canControl, + hasFirstFrame: _hasFirstFrame, + playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null, + chromeController: _chromeController, + shaderService: _shaderService, + // ignore: no-empty-block - state update triggers rebuild to reflect shader change + onShaderChanged: () => _setPlayerState(() {}), + thumbnailDataBuilder: _scrubPreviewSource?.isAvailable == true ? _getThumbnailData : null, + isLive: widget.isLive, + liveChannelName: _liveChannelName, + captureBuffer: _captureBuffer, + isAtLiveEdge: _isAtLiveEdge, + streamStartEpoch: _streamStartEpoch, + currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null, + onLiveSeek: _captureBuffer != null ? _seekLiveToEpoch : null, + onLiveSeekBy: _captureBuffer != null ? _liveSeek.seekBy : null, + onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null, + isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false, + onToggleAmbientLighting: _ambientLightingService?.isSupported == true + ? _toggleAmbientLighting + : null, + toastController: _toastController, + ), + ); + }, ), - // Netflix-style auto-play overlay (hidden in PiP mode) - VideoPlayerPlayNextOverlay( - visible: _showPlayNextDialog, - nextEpisode: _nextEpisode, - autoPlayCountdown: _autoPlayCountdown, - cancelFocusNode: _playNextCancelFocusNode, - confirmFocusNode: _playNextConfirmFocusNode, - chromeController: _chromeController, - onCancel: _cancelAutoPlay, - onPlayNext: _playNext, - ), - // "Still watching?" overlay (hidden in PiP mode) - VideoPlayerStillWatchingOverlay( - visible: _showStillWatchingPrompt, - countdown: _stillWatchingCountdown, - pauseFocusNode: _stillWatchingPauseFocusNode, - continueFocusNode: _stillWatchingContinueFocusNode, - chromeController: _chromeController, - onPause: _onStillWatchingPause, - onContinue: _onStillWatchingContinue, - ), - // Buffering indicator (also shows during initial load, but not when exiting) - // Hidden in PiP mode - VideoPlayerBufferingOverlay( - isBuffering: _isBuffering, - hasFirstFrame: _hasFirstFrame, - isExiting: _isExiting, - ), - // Watch Together overlays (isolated from video surface repaints) - const VideoPlayerWatchTogetherOverlays(), - // Black overlay during exit (no spinner - just covers transparency) - VideoPlayerExitOverlay(isExiting: _isExiting), - ], - ), + ), + // Netflix-style auto-play overlay (hidden in PiP mode) + VideoPlayerPlayNextOverlay( + visible: _showPlayNextDialog, + nextEpisode: _nextEpisode, + autoPlayCountdown: _autoPlayCountdown, + cancelFocusNode: _playNextCancelFocusNode, + confirmFocusNode: _playNextConfirmFocusNode, + chromeController: _chromeController, + onCancel: _cancelAutoPlay, + onPlayNext: _playNext, + ), + // "Still watching?" overlay (hidden in PiP mode) + VideoPlayerStillWatchingOverlay( + visible: _showStillWatchingPrompt, + countdown: _stillWatchingCountdown, + pauseFocusNode: _stillWatchingPauseFocusNode, + continueFocusNode: _stillWatchingContinueFocusNode, + chromeController: _chromeController, + onPause: _onStillWatchingPause, + onContinue: _onStillWatchingContinue, + ), + // Buffering indicator (also shows during initial load, but not when exiting) + // Hidden in PiP mode + VideoPlayerBufferingOverlay( + isBuffering: _isBuffering, + hasFirstFrame: _hasFirstFrame, + isExiting: _isExiting, + ), + // Watch Together overlays (isolated from video surface repaints) + const VideoPlayerWatchTogetherOverlays(), + // Black overlay during exit (no spinner - just covers transparency) + VideoPlayerExitOverlay(isExiting: _isExiting), + ], ), ), ), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 8f337f04..a17b1b06 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1452,6 +1452,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin return KeyEventResult.ignored; }, child: OverlaySheetHost( + // Host owns sheet + system back: a back with a sheet open closes it; + // with no sheet, exit the player. canPop:false keeps swipe-back disabled + // so it doesn't fight timeline scrubbing. + canPop: false, + onSystemBack: () { + if (BackKeyCoordinator.consumeIfHandled()) return; + BackKeyCoordinator.markHandled(); + _handleBackButton(); + }, child: Builder( builder: (sheetContext) => _isPlayerInitialized && player != null ? _buildVideoPlayer(sheetContext) diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 5d472139..a565cb15 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -12,7 +12,6 @@ import '../../mixins/mounted_set_state_mixin.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_wrapper.dart'; -import '../../focus/key_event_utils.dart'; import '../../profiles/active_profile_provider.dart'; import '../../services/settings_service.dart'; import '../../utils/app_logger.dart'; @@ -38,41 +37,18 @@ class WatchTogetherScreen extends StatelessWidget { return Consumer( builder: (context, watchTogether, child) { final canGoBack = watchTogether.isHost || !watchTogether.isInSession; - // Host the actions sheet so it uses the overlay system (focus + back - // handling) instead of the showModalBottomSheet fallback, which on - // TV/dpad leaks the select-key suppressor. The PopScope sits *below* the - // host (via Builder) so that on a system/gesture back it can see the open - // sheet and close it instead of popping the screen — mirrors the - // video_player_screen pattern and the OverlaySheetHost contract. + // The host owns sheet + system back: a back with the actions sheet open + // closes it; otherwise the route pops only when [canGoBack] (a guest in + // an active session can't leave). canGoBack==true also preserves the iOS + // interactive swipe-back. return OverlaySheetHost( - child: Builder( - builder: (context) => PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - if (didPop) return; - final sheet = OverlaySheetController.maybeOf(context); - if (sheet != null && sheet.isOpen) { - sheet.pop(); - return; - } - if (BackKeyCoordinator.consumeIfHandled()) return; - if (!canGoBack) return; - BackKeyCoordinator.markHandled(); - Navigator.pop(context); - }, - child: FocusedScrollScaffold( - title: Text(t.watchTogether.title), - automaticallyImplyLeading: canGoBack, - slivers: watchTogether.isInSession - ? _buildActiveSessionSlivers(watchTogether) - : [ - SliverFillRemaining( - hasScrollBody: false, - child: _NotInSessionView(watchTogether: watchTogether), - ), - ], - ), - ), + canPop: canGoBack, + child: FocusedScrollScaffold( + title: Text(t.watchTogether.title), + automaticallyImplyLeading: canGoBack, + slivers: watchTogether.isInSession + ? _buildActiveSessionSlivers(watchTogether) + : [SliverFillRemaining(hasScrollBody: false, child: _NotInSessionView(watchTogether: watchTogether))], ), ); },