diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 28e6a01f..5f81a2d2 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -54,6 +54,7 @@ import '../utils/snackbar_helper.dart'; import '../utils/track_label_builder.dart' as tlb; import '../utils/plex_url_helper.dart'; import '../utils/video_player_navigation.dart'; +import '../widgets/overlay_sheet.dart'; import '../widgets/video_controls/video_controls.dart'; import '../focus/focusable_wrapper.dart'; import '../focus/input_mode_tracker.dart'; @@ -2178,23 +2179,21 @@ class VideoPlayerScreenState extends State with WidgetsBindin final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true; // Screen-level Focus wraps ALL phases (loading + initialized). // - autofocus: grabs focus when no deeper child claims it. - // - onKeyEvent: catch-all that consumes any event children didn't handle, - // preventing leaks to previous routes. + // - onKeyEvent: self-heals when this node has primary focus (no descendant + // focused). Nav keys are only consumed in that case; otherwise they pass + // through so DirectionalFocusAction can drive dpad nav in overlay sheets. return Focus( focusNode: _screenFocusNode, autofocus: isCurrentRoute, canRequestFocus: isCurrentRoute, onKeyEvent: (node, event) { if (!isCurrentRoute) return KeyEventResult.ignored; - // Safety net: if this screen-level node itself has primary focus - // (no descendant focused, e.g. after controls auto-hide), self-heal. - // BACK is excluded: on Android TV the BACK button fires both a key event - // and a system back gesture. Handling it here would double-pop because - // PopScope.onPopInvokedWithResult also processes the system back gesture. - // PopScope handles BACK navigation exclusively. - if (node.hasPrimaryFocus && !event.logicalKey.isBackKey) { - // Redirect focus to the first traversable descendant (video controls) - // and show controls immediately so the first key press isn't swallowed. + // Back keys always pass through — handled by PopScope (system back + // gesture) or overlay sheet's onKeyEvent. + if (event.logicalKey.isBackKey) return KeyEventResult.ignored; + // Self-heal: if this node itself has primary focus (no descendant + // focused, e.g. after controls auto-hide), redirect to first descendant. + if (node.hasPrimaryFocus) { if (event.isActionable) { _controlsVisible.value = true; final descendants = node.traversalDescendants; @@ -2202,10 +2201,19 @@ class VideoPlayerScreenState extends State with WidgetsBindin descendants.first.requestFocus(); } } + return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored; } - return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored; + // A descendant has focus — let events pass through so + // DirectionalFocusAction / ActivateAction can process them. + return KeyEventResult.ignored; }, - child: _isPlayerInitialized && player != null ? _buildVideoPlayer(context) : _buildLoadingSpinner(), + child: OverlaySheetHost( + child: Builder( + builder: (sheetContext) => _isPlayerInitialized && player != null + ? _buildVideoPlayer(sheetContext) + : _buildLoadingSpinner(), + ), + ), ); } @@ -2216,11 +2224,16 @@ class VideoPlayerScreenState extends State with WidgetsBindin return PopScope( canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing onPopInvokedWithResult: (didPop, result) { - // Only process system-initiated back gestures (didPop: false). - // Programmatic Navigator.pop() triggers didPop: true — ignore it here - // to avoid consuming the BackKeyCoordinator flag before the system back - // gesture arrives (which would cause a double-pop on Android TV). 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(); + return; + } if (BackKeyCoordinator.consumeIfHandled()) return; BackKeyCoordinator.markHandled(); _handleBackButton(); diff --git a/lib/widgets/bottom_sheet_header.dart b/lib/widgets/bottom_sheet_header.dart index 0493f756..1c1a48cb 100644 --- a/lib/widgets/bottom_sheet_header.dart +++ b/lib/widgets/bottom_sheet_header.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'overlay_sheet.dart'; + /// A reusable header widget for bottom sheets /// Provides consistent styling with title, optional leading widget, optional action, and close button class BottomSheetHeader extends StatelessWidget { @@ -68,9 +70,11 @@ class BottomSheetHeader extends StatelessWidget { if (leading != null) { resolvedLeading = leading; } else if (onBack != null) { - resolvedLeading = IconButton( - icon: AppIcon(Symbols.arrow_back_rounded, fill: 1, color: iconColor), - onPressed: onBack, + resolvedLeading = ExcludeFocusTraversal( + child: IconButton( + icon: AppIcon(Symbols.arrow_back_rounded, fill: 1, color: iconColor), + onPressed: onBack, + ), ); } else if (icon != null) { resolvedLeading = AppIcon(icon!, fill: 1, color: iconColor); @@ -91,10 +95,19 @@ class BottomSheetHeader extends StatelessWidget { if (resolvedLeading != null) ...[resolvedLeading, const SizedBox(width: 8)], Expanded(child: Text(title, style: effectiveTitleStyle)), ?action, - IconButton( - focusNode: closeFocusNode, - icon: AppIcon(Symbols.close_rounded, fill: 1, color: iconColor), - onPressed: onClose ?? () => Navigator.pop(context), + ExcludeFocusTraversal( + child: IconButton( + focusNode: closeFocusNode, + icon: AppIcon(Symbols.close_rounded, fill: 1, color: iconColor), + onPressed: onClose ?? () { + final sheetController = OverlaySheetController.maybeOf(context); + if (sheetController != null) { + sheetController.close(); + } else { + Navigator.pop(context); + } + }, + ), ), ], ), diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart new file mode 100644 index 00000000..70ee2a4d --- /dev/null +++ b/lib/widgets/overlay_sheet.dart @@ -0,0 +1,425 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../focus/dpad_navigator.dart'; +import '../focus/key_event_utils.dart'; +import '../utils/platform_detector.dart'; + +/// Entry in the sheet page stack. +class _OverlaySheetEntry { + final WidgetBuilder builder; + final Completer completer; + + _OverlaySheetEntry({required this.builder, required this.completer}); +} + +/// Provides [OverlaySheetController] to descendants via [of] / [maybeOf]. +class _OverlaySheetScope extends InheritedWidget { + final OverlaySheetController controller; + + const _OverlaySheetScope({required this.controller, required super.child}); + + @override + bool updateShouldNotify(_OverlaySheetScope oldWidget) => + controller != oldWidget.controller; +} + +/// Controller for the overlay-based bottom sheet system. +/// +/// Use [of] or [maybeOf] to access from descendants. +class OverlaySheetController { + final _OverlaySheetHostState _state; + + OverlaySheetController._(this._state); + + static OverlaySheetController of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_OverlaySheetScope>(); + assert(scope != null, 'No OverlaySheetHost found in context'); + return scope!.controller; + } + + static OverlaySheetController? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_OverlaySheetScope>()?.controller; + } + + /// Whether a sheet is currently showing (including while animating closed). + bool get isOpen => _state._isOpen; + + /// Show a bottom sheet with [builder] content. Returns a Future that completes + /// when the sheet is closed (with an optional result). + Future show({ + required WidgetBuilder builder, + BoxConstraints? constraints, + Color? backgroundColor, + bool barrierDismissible = true, + }) { + return _state._show( + builder: builder, + constraints: constraints, + backgroundColor: backgroundColor, + barrierDismissible: barrierDismissible, + ); + } + + /// Push a sub-page within the open sheet. + void push({required WidgetBuilder builder}) { + _state._push(builder: builder); + } + + /// Pop the top sub-page, or close the sheet if on the last page. + void pop([dynamic result]) { + _state._pop(result); + } + + /// Force close the sheet, completing all pending completers. + void close([dynamic result]) { + _state._close(result); + } + + /// Re-focus the first focusable descendant within the sheet. + /// Useful after internal page changes via setState. + void refocus() { + _state._refocus(); + } +} + +/// Host widget for the overlay-based bottom sheet system. +/// +/// Sheets are rendered as overlays within this widget's Stack instead of as +/// modal routes, eliminating the route-based back-button race condition on +/// Android TV and providing centralized focus management for keyboard/dpad +/// navigation on all platforms. +/// +/// Screens that contain a [PopScope] should check [OverlaySheetController.isOpen] +/// and skip their own back handling when a sheet is open. +class OverlaySheetHost extends StatefulWidget { + final Widget child; + + const OverlaySheetHost({super.key, required this.child}); + + @override + State createState() => _OverlaySheetHostState(); +} + +class _OverlaySheetHostState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + late final Animation _slideAnimation; + late final Animation _barrierAnimation; + late final OverlaySheetController _controller; + + final List<_OverlaySheetEntry> _pageStack = []; + final _sheetFocusScopeNode = FocusScopeNode(debugLabel: 'OverlaySheetScope'); + + bool _isOpen = false; + bool _isClosing = false; + bool _barrierDismissible = true; + BoxConstraints? _constraints; + Color _backgroundColor = Colors.grey[900]!; + + // Drag-to-dismiss state + double _dragOffset = 0; + bool _isDragging = false; + + @override + void initState() { + super.initState(); + _controller = OverlaySheetController._(this); + + _animationController = AnimationController( + duration: const Duration(milliseconds: 250), + vsync: this, + ); + + _slideAnimation = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )); + + _barrierAnimation = Tween(begin: 0, end: 0.5).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic), + ); + } + + @override + void dispose() { + for (final entry in _pageStack) { + if (!entry.completer.isCompleted) { + entry.completer.complete(null); + } + } + _sheetFocusScopeNode.dispose(); + _animationController.dispose(); + super.dispose(); + } + + Future _show({ + required WidgetBuilder builder, + BoxConstraints? constraints, + Color? backgroundColor, + bool barrierDismissible = true, + }) { + // If already open, close first (instant) + if (_isOpen) { + for (final entry in _pageStack) { + if (!entry.completer.isCompleted) { + entry.completer.complete(null); + } + } + _pageStack.clear(); + _isClosing = false; + } + + final completer = Completer(); + final entry = _OverlaySheetEntry(builder: builder, completer: completer); + + setState(() { + _pageStack.add(entry); + _isOpen = true; + _isClosing = false; + _barrierDismissible = barrierDismissible; + _constraints = constraints; + if (backgroundColor != null) _backgroundColor = backgroundColor; + _dragOffset = 0; + _isDragging = false; + }); + + BackKeyUpSuppressor.clearSuppression(); + _animationController.forward(from: 0); + _autoFocus(); + + return completer.future; + } + + void _push({required WidgetBuilder builder}) { + if (!_isOpen || _isClosing) return; + + final completer = Completer(); + final entry = _OverlaySheetEntry(builder: builder, completer: completer); + + setState(() { + _pageStack.add(entry); + }); + + _autoFocus(); + } + + void _pop([dynamic result]) { + if (!_isOpen || _isClosing || _pageStack.isEmpty) return; + + if (_pageStack.length == 1) { + _close(result); + return; + } + + final removed = _pageStack.removeLast(); + if (!removed.completer.isCompleted) { + removed.completer.complete(result); + } + + setState(() {}); + _autoFocus(); + } + + void _close([dynamic result]) { + if (!_isOpen || _isClosing) return; + _isClosing = true; + + _animationController.reverse().then((_) { + if (!mounted) return; + setState(() { + for (final entry in _pageStack) { + if (!entry.completer.isCompleted) { + entry.completer.complete(result); + } + } + _pageStack.clear(); + _isOpen = false; + _isClosing = false; + _dragOffset = 0; + _isDragging = false; + }); + }); + } + + void _autoFocus() { + // First post-frame: the FocusScope is now built and the node is attached. + // Grab scope focus immediately so key events (especially back) are trapped. + // Second post-frame: ListView.builder items are laid out and their + // FocusNodes are registered — focus the first descendant for dpad nav. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_isOpen) return; + _sheetFocusScopeNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_isOpen) return; + _focusFirstDescendant(); + }); + }); + } + + void _refocus() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_isOpen) return; + _sheetFocusScopeNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_isOpen) return; + _focusFirstDescendant(); + }); + }); + } + + void _focusFirstDescendant() { + final descendants = _sheetFocusScopeNode.traversalDescendants.toList(); + if (descendants.isNotEmpty) { + descendants.first.requestFocus(); + } else { + _sheetFocusScopeNode.requestFocus(); + } + } + + void _handleBack() { + if (_pageStack.length > 1) { + _pop(); + } else { + _close(); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + // Suppress stale select key-ups + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + + // Suppress stale back key-ups + if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + + // Back key: pop sub-page or close sheet + if (event.logicalKey.isBackKey) { + return handleBackKeyAction(event, _handleBack); + } + + // Let all other keys pass through. Directional keys need to reach + // Flutter's DirectionalFocusAction for dpad/arrow navigation, and + // select/enter keys need to reach ActivateAction for item taps. + // The FocusScope traps traversal within the sheet; the screen-level + // Focus catches any leaked nav keys. + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + // No PopScope here — the parent screen's PopScope should check + // OverlaySheetController.isOpen and delegate to us. This avoids + // the double-callback problem with nested PopScopes in one route. + return _OverlaySheetScope( + controller: _controller, + child: Stack( + children: [ + widget.child, + if (_isOpen) ...[ + // Barrier + AnimatedBuilder( + animation: _barrierAnimation, + builder: (context, child) { + return GestureDetector( + onTap: _barrierDismissible ? () => _close() : null, + child: Container( + color: Colors.black.withValues(alpha: _barrierAnimation.value), + ), + ); + }, + ), + // Sheet + _buildSheet(context), + ], + ], + ), + ); + } + + Widget _buildSheet(BuildContext context) { + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 600; + + final effectiveConstraints = _constraints ?? + BoxConstraints( + maxWidth: isDesktop ? 700 : double.infinity, + maxHeight: isDesktop ? 400 : size.height * 0.75, + minHeight: isDesktop ? 300 : size.height * 0.5, + ); + + Widget sheet = FocusScope( + node: _sheetFocusScopeNode, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _handleKeyEvent, + child: SlideTransition( + position: _slideAnimation, + child: Align( + alignment: Alignment.bottomCenter, + child: Transform.translate( + offset: Offset(0, _dragOffset.clamp(0, double.infinity)), + child: Material( + color: _backgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: ConstrainedBox( + constraints: effectiveConstraints, + child: _pageStack.isNotEmpty + ? _pageStack.last.builder(context) + : const SizedBox.shrink(), + ), + ), + ), + ), + ), + ), + ), + ); + + // Swipe-down-to-dismiss (skip on TV where there's no touchscreen) + if (!PlatformDetector.isTV()) { + sheet = GestureDetector( + onVerticalDragStart: (_) { + _isDragging = true; + _dragOffset = 0; + }, + onVerticalDragUpdate: (details) { + if (!_isDragging) return; + setState(() { + _dragOffset += details.delta.dy; + }); + }, + onVerticalDragEnd: (details) { + if (!_isDragging) return; + _isDragging = false; + + final sheetHeight = effectiveConstraints.minHeight; + final velocity = details.primaryVelocity ?? 0; + + if (_dragOffset > sheetHeight * 0.25 || velocity > 500) { + _close(); + } else { + setState(() { + _dragOffset = 0; + }); + } + }, + child: sheet, + ); + } + + return sheet; + } +} diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index 81669a53..d6b5204e 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -7,16 +7,19 @@ import '../../../utils/track_label_builder.dart'; import 'track_selection_sheet.dart'; /// Bottom sheet for selecting audio tracks -class AudioTrackSheet { - static void show( - BuildContext context, - Player player, { - Function(AudioTrack)? onTrackChanged, - VoidCallback? onOpen, - VoidCallback? onClose, - }) { - TrackSelectionSheet.show( - context: context, +class AudioTrackSheet extends StatelessWidget { + final Player player; + final Function(AudioTrack)? onTrackChanged; + + const AudioTrackSheet({ + super.key, + required this.player, + this.onTrackChanged, + }); + + @override + Widget build(BuildContext context) { + return TrackSelectionSheet( player: player, title: t.videoControls.audioLabel, icon: Symbols.audiotrack_rounded, @@ -31,8 +34,6 @@ class AudioTrackSheet { ), setTrack: (track) => player.selectAudioTrack(track), onTrackChanged: onTrackChanged, - onOpen: onOpen, - onClose: onClose, ); } } diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart index 44e58819..8f22866b 100644 --- a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; + +import '../../../focus/key_event_utils.dart'; +import '../../../focus/dpad_navigator.dart'; import 'video_sheet_header.dart'; /// Base class for video control bottom sheets providing common UI structure @@ -51,16 +54,34 @@ class BaseVideoControlSheet extends StatelessWidget { @override Widget build(BuildContext context) { + Widget content = Column( + children: [ + VideoSheetHeader(title: title, icon: icon, iconColor: iconColor, onBack: onBack), + const Divider(color: Colors.white24, height: 1), + Expanded(child: child), + ], + ); + + // Intercept back key at the sub-page level so it triggers onBack + // instead of bubbling up to OverlaySheetHost which would close the sheet. + if (onBack != null) { + content = Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (event.logicalKey.isBackKey) { + return handleBackKeyAction(event, onBack!); + } + return KeyEventResult.ignored; + }, + child: content, + ); + } + return SafeArea( child: SizedBox( height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - VideoSheetHeader(title: title, icon: icon, iconColor: iconColor, onBack: onBack), - const Divider(color: Colors.white24, height: 1), - Expanded(child: child), - ], - ), + child: content, ), ); } diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index a92cae1e..42a81ed9 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -9,10 +9,9 @@ import '../../../services/download_storage_service.dart'; import '../../../models/plex_media_info.dart'; import '../../../utils/formatters.dart'; import '../../../utils/provider_extensions.dart'; -import '../../../widgets/focusable_bottom_sheet.dart'; import '../../../widgets/focusable_list_tile.dart'; +import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; -import 'video_control_sheet_launcher.dart'; import '../../plex_optimized_image.dart'; /// Bottom sheet for selecting chapters @@ -30,42 +29,11 @@ class ChapterSheet extends StatefulWidget { this.serverId, }); - static void show( - BuildContext context, - Player player, - List chapters, - bool chaptersLoaded, { - String? serverId, - VoidCallback? onOpen, - VoidCallback? onClose, - }) { - VideoControlSheetLauncher.show( - context: context, - onOpen: onOpen, - onClose: onClose, - builder: (context) => - ChapterSheet(player: player, chapters: chapters, chaptersLoaded: chaptersLoaded, serverId: serverId), - ); - } - @override State createState() => _ChapterSheetState(); } class _ChapterSheetState extends State { - late final FocusNode _initialFocusNode; - - @override - void initState() { - super.initState(); - _initialFocusNode = FocusNode(debugLabel: 'ChapterSheetInitialFocus'); - } - - @override - void dispose() { - _initialFocusNode.dispose(); - super.dispose(); - } /// Get the PlexClient for chapters, or null if unavailable (offline mode) PlexClient? _tryGetClientForChapters(BuildContext context) { @@ -79,9 +47,7 @@ class _ChapterSheetState extends State { @override Widget build(BuildContext context) { - return FocusableBottomSheet( - initialFocusNode: _initialFocusNode, - child: StreamBuilder( + return StreamBuilder( stream: widget.player.streams.position, initialData: widget.player.state.position, builder: (context, positionSnapshot) { @@ -125,7 +91,6 @@ class _ChapterSheetState extends State { : null; return FocusableListTile( - focusNode: index == 0 ? _initialFocusNode : null, leading: chapter.thumb != null ? Stack( children: [ @@ -173,7 +138,7 @@ class _ChapterSheetState extends State { : null, onTap: () { widget.player.seek(chapter.startTime); - Navigator.pop(context); + OverlaySheetController.of(context).close(); }, ); }, @@ -186,7 +151,6 @@ class _ChapterSheetState extends State { child: content, ); }, - ), - ); + ); } } diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index 5b55495b..f08ecd0f 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -7,16 +7,19 @@ import '../../../utils/track_label_builder.dart'; import 'track_selection_sheet.dart'; /// Bottom sheet for selecting subtitle tracks -class SubtitleTrackSheet { - static void show( - BuildContext context, - Player player, { - Function(SubtitleTrack)? onTrackChanged, - VoidCallback? onOpen, - VoidCallback? onClose, - }) { - TrackSelectionSheet.show( - context: context, +class SubtitleTrackSheet extends StatelessWidget { + final Player player; + final Function(SubtitleTrack)? onTrackChanged; + + const SubtitleTrackSheet({ + super.key, + required this.player, + this.onTrackChanged, + }); + + @override + Widget build(BuildContext context) { + return TrackSelectionSheet( player: player, title: t.videoControls.subtitlesLabel, icon: Symbols.subtitles_rounded, @@ -33,8 +36,6 @@ class SubtitleTrackSheet { showOffOption: true, createOffTrack: () => SubtitleTrack.off, isOffTrack: (track) => track.id == 'no', - onOpen: onOpen, - onClose: onClose, ); } } diff --git a/lib/widgets/video_controls/sheets/track_selection_sheet.dart b/lib/widgets/video_controls/sheets/track_selection_sheet.dart index 35216994..dea6cb6f 100644 --- a/lib/widgets/video_controls/sheets/track_selection_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_selection_sheet.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import '../../../mpv/mpv.dart'; -import '../../../widgets/focusable_bottom_sheet.dart'; +import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; -import 'video_control_sheet_launcher.dart'; import '../helpers/track_filter_helper.dart'; import '../helpers/track_selection_helper.dart'; @@ -38,79 +37,26 @@ class TrackSelectionSheet extends StatefulWidget { this.isOffTrack, }); - static void show({ - required BuildContext context, - required Player player, - required String title, - required IconData icon, - required List Function(Tracks?) extractTracks, - required T? Function(TrackSelection) getCurrentTrack, - required String Function(T track, int index) buildLabel, - required void Function(T track) setTrack, - Function(T)? onTrackChanged, - bool showOffOption = false, - T Function()? createOffTrack, - bool Function(T track)? isOffTrack, - VoidCallback? onOpen, - VoidCallback? onClose, - }) { - VideoControlSheetLauncher.show( - context: context, - onOpen: onOpen, - onClose: onClose, - builder: (context) => TrackSelectionSheet( - player: player, - title: title, - icon: icon, - extractTracks: extractTracks, - getCurrentTrack: getCurrentTrack, - buildLabel: buildLabel, - setTrack: setTrack, - onTrackChanged: onTrackChanged, - showOffOption: showOffOption, - createOffTrack: createOffTrack, - isOffTrack: isOffTrack, - ), - ); - } - @override State> createState() => _TrackSelectionSheetState(); } class _TrackSelectionSheetState extends State> { - late final FocusNode _initialFocusNode; - - @override - void initState() { - super.initState(); - _initialFocusNode = FocusNode(debugLabel: 'TrackSelectionInitialFocus'); - } - - @override - void dispose() { - _initialFocusNode.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return FocusableBottomSheet( - initialFocusNode: _initialFocusNode, - child: StreamBuilder( - stream: widget.player.streams.tracks, - initialData: widget.player.state.tracks, - builder: (context, snapshot) { - final tracks = snapshot.data; - final availableTracks = TrackFilterHelper.extractAndFilterTracks(tracks, widget.extractTracks); + return StreamBuilder( + stream: widget.player.streams.tracks, + initialData: widget.player.state.tracks, + builder: (context, snapshot) { + final tracks = snapshot.data; + final availableTracks = TrackFilterHelper.extractAndFilterTracks(tracks, widget.extractTracks); - final sheetChild = availableTracks.isEmpty - ? TrackSelectionHelper.buildEmptyState() - : _buildTrackList(availableTracks); + final sheetChild = availableTracks.isEmpty + ? TrackSelectionHelper.buildEmptyState() + : _buildTrackList(availableTracks); - return BaseVideoControlSheet(title: widget.title, icon: widget.icon, child: sheetChild); - }, - ), + return BaseVideoControlSheet(title: widget.title, icon: widget.icon, child: sheetChild); + }, ); } @@ -135,16 +81,13 @@ class _TrackSelectionSheetState extends State> { final track = availableTracks[trackIndex]; final trackId = TrackSelectionHelper.getTrackId(track); final selectedId = selectedTrack == null ? '' : TrackSelectionHelper.getTrackId(selectedTrack); - final shouldFocus = !widget.showOffOption && index == 0; - return TrackSelectionHelper.buildTrackTile( label: widget.buildLabel(track, trackIndex), isSelected: trackId == selectedId, - focusNode: shouldFocus ? _initialFocusNode : null, onTap: () { widget.setTrack(track); widget.onTrackChanged?.call(track); - Navigator.pop(context); + OverlaySheetController.of(context).close(); }, ); }, @@ -156,14 +99,13 @@ class _TrackSelectionSheetState extends State> { Widget _buildOffTile(bool isOffSelected) { return TrackSelectionHelper.buildOffTile( isSelected: isOffSelected, - focusNode: _initialFocusNode, onTap: () { if (widget.createOffTrack != null) { final offTrack = widget.createOffTrack!(); widget.setTrack(offTrack); widget.onTrackChanged?.call(offTrack); } - Navigator.pop(context); + OverlaySheetController.of(context).close(); }, ); } diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart index d39f0680..99f7d502 100644 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_sheet.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../models/plex_media_version.dart'; -import '../../../widgets/focusable_bottom_sheet.dart'; import '../../../widgets/focusable_list_tile.dart'; +import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; -import 'video_control_sheet_launcher.dart'; /// Bottom sheet for selecting video version class VersionSheet extends StatefulWidget { @@ -20,69 +19,31 @@ class VersionSheet extends StatefulWidget { required this.onVersionSelected, }); - static void show( - BuildContext context, - List availableVersions, - int selectedMediaIndex, - Function(int) onVersionSelected, { - VoidCallback? onOpen, - VoidCallback? onClose, - }) { - VideoControlSheetLauncher.show( - context: context, - onOpen: onOpen, - onClose: onClose, - builder: (context) => VersionSheet( - availableVersions: availableVersions, - selectedMediaIndex: selectedMediaIndex, - onVersionSelected: onVersionSelected, - ), - ); - } - @override State createState() => _VersionSheetState(); } class _VersionSheetState extends State { - late final FocusNode _initialFocusNode; - - @override - void initState() { - super.initState(); - _initialFocusNode = FocusNode(debugLabel: 'VersionSheetInitialFocus'); - } - - @override - void dispose() { - _initialFocusNode.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return FocusableBottomSheet( - initialFocusNode: _initialFocusNode, - child: BaseVideoControlSheet( - title: 'Video Version', - icon: Symbols.video_file_rounded, - child: ListView.builder( - itemCount: widget.availableVersions.length, - itemBuilder: (context, index) { - final version = widget.availableVersions[index]; - final isSelected = index == widget.selectedMediaIndex; + return BaseVideoControlSheet( + title: 'Video Version', + icon: Symbols.video_file_rounded, + child: ListView.builder( + itemCount: widget.availableVersions.length, + itemBuilder: (context, index) { + final version = widget.availableVersions[index]; + final isSelected = index == widget.selectedMediaIndex; - return FocusableListTile( - focusNode: index == 0 ? _initialFocusNode : null, - title: Text(version.displayLabel, style: TextStyle(color: isSelected ? Colors.blue : Colors.white)), - trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null, - onTap: () { - Navigator.pop(context); - widget.onVersionSelected(index); - }, - ); - }, - ), + return FocusableListTile( + title: Text(version.displayLabel, style: TextStyle(color: isSelected ? Colors.blue : Colors.white)), + trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null, + onTap: () { + OverlaySheetController.of(context).close(); + widget.onVersionSelected(index); + }, + ); + }, ), ); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 05b35b19..6453dc5c 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -14,8 +14,8 @@ import '../../../services/shader_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../utils/formatters.dart'; import '../../../utils/platform_detector.dart'; -import '../../../widgets/focusable_bottom_sheet.dart'; import '../../../widgets/focusable_list_tile.dart'; +import '../../../widgets/overlay_sheet.dart'; import '../widgets/sync_offset_control.dart'; import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; @@ -31,7 +31,6 @@ class _SettingsMenuItem extends StatelessWidget { final VoidCallback onTap; final bool isHighlighted; final bool allowValueOverflow; - final FocusNode? focusNode; const _SettingsMenuItem({ required this.icon, @@ -40,7 +39,6 @@ class _SettingsMenuItem extends StatelessWidget { required this.onTap, this.isHighlighted = false, this.allowValueOverflow = false, - this.focusNode, }); @override @@ -52,7 +50,6 @@ class _SettingsMenuItem extends StatelessWidget { ); return FocusableListTile( - focusNode: focusNode, leading: AppIcon(icon, fill: 1, color: isHighlighted ? Colors.amber : Colors.white70), title: Text(title, style: const TextStyle(color: Colors.white)), trailing: Row( @@ -105,38 +102,6 @@ class VideoSettingsSheet extends StatefulWidget { this.onToggleAmbientLighting, }); - static Future show( - BuildContext context, - Player player, - int audioSyncOffset, - int subtitleSyncOffset, { - VoidCallback? onOpen, - VoidCallback? onClose, - bool canControl = true, - bool isLive = false, - ShaderService? shaderService, - VoidCallback? onShaderChanged, - bool isAmbientLightingEnabled = false, - VoidCallback? onToggleAmbientLighting, - }) { - return BaseVideoControlSheet.showSheet( - context: context, - onOpen: onOpen, - onClose: onClose, - builder: (context) => VideoSettingsSheet( - player: player, - audioSyncOffset: audioSyncOffset, - subtitleSyncOffset: subtitleSyncOffset, - canControl: canControl, - isLive: isLive, - shaderService: shaderService, - onShaderChanged: onShaderChanged, - isAmbientLightingEnabled: isAmbientLightingEnabled, - onToggleAmbientLighting: onToggleAmbientLighting, - ), - ); - } - @override State createState() => _VideoSettingsSheetState(); } @@ -148,23 +113,15 @@ class _VideoSettingsSheetState extends State { bool _enableHDR = true; bool _showPerformanceOverlay = false; bool _autoPlayNextEpisode = true; - late final FocusNode _initialFocusNode; @override void initState() { super.initState(); _audioSyncOffset = widget.audioSyncOffset; _subtitleSyncOffset = widget.subtitleSyncOffset; - _initialFocusNode = FocusNode(debugLabel: 'VideoSettingsInitialFocus'); _loadSettings(); } - @override - void dispose() { - _initialFocusNode.dispose(); - super.dispose(); - } - Future _loadSettings() async { final settings = await SettingsService.getInstance(); if (!mounted) return; @@ -211,12 +168,14 @@ class _VideoSettingsSheetState extends State { setState(() { _currentView = view; }); + OverlaySheetController.maybeOf(context)?.refocus(); } void _navigateBack() { setState(() { _currentView = _SettingsView.menu; }); + OverlaySheetController.maybeOf(context)?.refocus(); } String _getTitle() { @@ -283,7 +242,6 @@ class _VideoSettingsSheetState extends State { builder: (context, snapshot) { final currentRate = snapshot.data ?? 1.0; return _SettingsMenuItem( - focusNode: _initialFocusNode, icon: Symbols.speed_rounded, title: t.videoSettings.playbackSpeed, valueText: _formatSpeed(currentRate), @@ -394,13 +352,13 @@ class _VideoSettingsSheetState extends State { value: widget.isAmbientLightingEnabled, onChanged: (_) { widget.onToggleAmbientLighting?.call(); - Navigator.pop(context); + OverlaySheetController.of(context).close(); }, activeThumbColor: Colors.amber, ), onTap: () { widget.onToggleAmbientLighting?.call(); - Navigator.pop(context); + OverlaySheetController.of(context).close(); }, ), @@ -447,7 +405,7 @@ class _VideoSettingsSheetState extends State { final settings = await SettingsService.getInstance(); await settings.setDefaultPlaybackSpeed(speed); if (context.mounted) { - Navigator.pop(context); // Close sheet after selection + OverlaySheetController.of(context).close(); // Close sheet after selection } }, ); @@ -460,7 +418,7 @@ class _VideoSettingsSheetState extends State { Widget _buildSleepView() { final sleepTimer = SleepTimerService(); - return SleepTimerContent(player: widget.player, sleepTimer: sleepTimer, onCancel: () => Navigator.pop(context)); + return SleepTimerContent(player: widget.player, sleepTimer: sleepTimer, onCancel: () => OverlaySheetController.of(context).close()); } Widget _buildAudioSyncView() { @@ -592,7 +550,7 @@ class _VideoSettingsSheetState extends State { trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null, onTap: () { widget.player.setAudioDevice(device); - Navigator.pop(context); + OverlaySheetController.of(context).close(); }, ); } @@ -621,7 +579,7 @@ class _VideoSettingsSheetState extends State { await widget.shaderService!.applyPreset(preset); await shaderProvider.setPreset(preset); widget.onShaderChanged?.call(); - if (context.mounted) Navigator.pop(context); + if (context.mounted) OverlaySheetController.of(context).close(); }, ); }, @@ -656,36 +614,33 @@ class _VideoSettingsSheetState extends State { _currentView == _SettingsView.menu && (sleepTimer.isActive || _audioSyncOffset != 0 || _subtitleSyncOffset != 0 || isShaderActive); - return FocusableBottomSheet( - initialFocusNode: _initialFocusNode, - child: BaseVideoControlSheet( - title: _getTitle(), - icon: _getIcon(), - iconColor: () { - if (isIconActive) return Colors.amber; - if (_currentView == _SettingsView.shader && isShaderActive) return Colors.amber; - return Colors.white; - }(), - onBack: _currentView != _SettingsView.menu ? _navigateBack : null, - child: () { - switch (_currentView) { - case _SettingsView.menu: - return _buildMenuView(); - case _SettingsView.speed: - return _buildSpeedView(); - case _SettingsView.sleep: - return _buildSleepView(); - case _SettingsView.audioSync: - return _buildAudioSyncView(); - case _SettingsView.subtitleSync: - return _buildSubtitleSyncView(); - case _SettingsView.audioDevice: - return _buildAudioDeviceView(); - case _SettingsView.shader: - return _buildShaderView(); - } - }(), - ), + return BaseVideoControlSheet( + title: _getTitle(), + icon: _getIcon(), + iconColor: () { + if (isIconActive) return Colors.amber; + if (_currentView == _SettingsView.shader && isShaderActive) return Colors.amber; + return Colors.white; + }(), + onBack: _currentView != _SettingsView.menu ? _navigateBack : null, + child: () { + switch (_currentView) { + case _SettingsView.menu: + return _buildMenuView(); + case _SettingsView.speed: + return _buildSpeedView(); + case _SettingsView.sleep: + return _buildSleepView(); + case _SettingsView.audioSync: + return _buildAudioSyncView(); + case _SettingsView.subtitleSync: + return _buildSubtitleSyncView(); + case _SettingsView.audioDevice: + return _buildAudioDeviceView(); + case _SettingsView.shader: + return _buildShaderView(); + } + }(), ); } } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 86088773..461b830c 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -21,6 +21,7 @@ import '../../services/pip_service.dart'; import 'package:window_manager/window_manager.dart'; import '../../mpv/mpv.dart'; +import '../overlay_sheet.dart'; import '../../focus/dpad_navigator.dart'; import '../../focus/focusable_wrapper.dart'; @@ -1365,6 +1366,11 @@ class _PlexVideoControlsState extends State with WindowListen // TV back key fallback — Focus.onKeyEvent won't fire if _focusNode lost focus if (PlatformDetector.isTV() && event.logicalKey.isBackKey) { if (!_focusNode.hasFocus) { + // Skip if an overlay sheet is open — the sheet's FocusScope handles + // back keys via its own onKeyEvent. Without this check, this global + // handler would call Navigator.pop() alongside the sheet's handler. + final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; + if (sheetOpen) return false; final backResult = handleBackKeyAction(event, () { if (!_showControls) { _showControlsWithFocus(); diff --git a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart index de5d9203..badf6cf6 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart @@ -6,6 +6,7 @@ import '../../../mpv/mpv.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../utils/formatters.dart'; import '../../../utils/snackbar_helper.dart'; +import '../../../widgets/overlay_sheet.dart'; import '../../../i18n/strings.g.dart'; /// Widget displaying list of sleep timer durations for selection @@ -45,7 +46,12 @@ class SleepTimerDurationList extends StatelessWidget { // Pause playback when timer completes player.pause(); }); - Navigator.pop(context); + final sheetController = OverlaySheetController.maybeOf(context); + if (sheetController != null) { + sheetController.close(); + } else { + Navigator.pop(context); + } // Show confirmation snackbar showSuccessSnackBar(context, t.messages.sleepTimerSet(label: label)); diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index b13cd417..4ec91d92 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -11,6 +11,7 @@ import '../../../models/plex_media_version.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../utils/platform_detector.dart'; import '../../../i18n/strings.g.dart'; +import '../../../widgets/overlay_sheet.dart'; import '../sheets/audio_track_sheet.dart'; import '../sheets/chapter_sheet.dart'; import '../sheets/subtitle_track_sheet.dart'; @@ -197,22 +198,24 @@ class TrackChapterControls extends StatelessWidget { tracks: tracks, isMobile: isMobile, isDesktop: isDesktop, - onPressed: () async { - await VideoSettingsSheet.show( - context, - player, - audioSyncOffset, - subtitleSyncOffset, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - canControl: canControl, - isLive: isLive, - shaderService: shaderService, - onShaderChanged: onShaderChanged, - isAmbientLightingEnabled: isAmbientLightingEnabled, - onToggleAmbientLighting: onToggleAmbientLighting, - ); - onLoadSeekTimes?.call(); + onPressed: () { + onCancelAutoHide?.call(); + OverlaySheetController.of(context).show( + builder: (_) => VideoSettingsSheet( + player: player, + audioSyncOffset: audioSyncOffset, + subtitleSyncOffset: subtitleSyncOffset, + canControl: canControl, + isLive: isLive, + shaderService: shaderService, + onShaderChanged: onShaderChanged, + isAmbientLightingEnabled: isAmbientLightingEnabled, + onToggleAmbientLighting: onToggleAmbientLighting, + ), + ).whenComplete(() { + onStartAutoHide?.call(); + onLoadSeekTimes?.call(); + }); }, ); }, @@ -232,13 +235,15 @@ class TrackChapterControls extends StatelessWidget { tracks: tracks, isMobile: isMobile, isDesktop: isDesktop, - onPressed: () => AudioTrackSheet.show( - context, - player, - onTrackChanged: onAudioTrackChanged, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - ), + onPressed: () { + onCancelAutoHide?.call(); + OverlaySheetController.of(context).show( + builder: (_) => AudioTrackSheet( + player: player, + onTrackChanged: onAudioTrackChanged, + ), + ).whenComplete(() => onStartAutoHide?.call()); + }, ), ); buttonIndex++; @@ -256,13 +261,15 @@ class TrackChapterControls extends StatelessWidget { tracks: tracks, isMobile: isMobile, isDesktop: isDesktop, - onPressed: () => SubtitleTrackSheet.show( - context, - player, - onTrackChanged: onSubtitleTrackChanged, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - ), + onPressed: () { + onCancelAutoHide?.call(); + OverlaySheetController.of(context).show( + builder: (_) => SubtitleTrackSheet( + player: player, + onTrackChanged: onSubtitleTrackChanged, + ), + ).whenComplete(() => onStartAutoHide?.call()); + }, ), ); buttonIndex++; @@ -280,15 +287,17 @@ class TrackChapterControls extends StatelessWidget { tracks: tracks, isMobile: isMobile, isDesktop: isDesktop, - onPressed: () => ChapterSheet.show( - context, - player, - chapters, - chaptersLoaded, - serverId: serverId, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - ), + onPressed: () { + onCancelAutoHide?.call(); + OverlaySheetController.of(context).show( + builder: (_) => ChapterSheet( + player: player, + chapters: chapters, + chaptersLoaded: chaptersLoaded, + serverId: serverId, + ), + ).whenComplete(() => onStartAutoHide?.call()); + }, ), ); buttonIndex++; @@ -306,14 +315,16 @@ class TrackChapterControls extends StatelessWidget { tracks: tracks, isMobile: isMobile, isDesktop: isDesktop, - onPressed: () => VersionSheet.show( - context, - availableVersions, - selectedMediaIndex, - onSwitchVersion!, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - ), + onPressed: () { + onCancelAutoHide?.call(); + OverlaySheetController.of(context).show( + builder: (_) => VersionSheet( + availableVersions: availableVersions, + selectedMediaIndex: selectedMediaIndex, + onVersionSelected: onSwitchVersion!, + ), + ).whenComplete(() => onStartAutoHide?.call()); + }, ), ); buttonIndex++;