refactor: overlay-based bottom sheets for video player

This commit is contained in:
edde746
2026-02-18 23:06:44 +01:00
parent 7c651eb61f
commit 63d711ae43
13 changed files with 672 additions and 353 deletions
+30 -17
View File
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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();
+20 -7
View File
@@ -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);
}
},
),
),
],
),
+425
View File
@@ -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<dynamic> 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<T?> show<T>({
required WidgetBuilder builder,
BoxConstraints? constraints,
Color? backgroundColor,
bool barrierDismissible = true,
}) {
return _state._show<T>(
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<OverlaySheetHost> createState() => _OverlaySheetHostState();
}
class _OverlaySheetHostState extends State<OverlaySheetHost>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<Offset> _slideAnimation;
late final Animation<double> _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<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
));
_barrierAnimation = Tween<double>(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<T?> _show<T>({
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<T?>();
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<dynamic>();
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;
}
}
@@ -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<AudioTrack>(
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<AudioTrack>(
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,
);
}
}
@@ -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,
),
);
}
@@ -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<PlexChapter> 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<ChapterSheet> createState() => _ChapterSheetState();
}
class _ChapterSheetState extends State<ChapterSheet> {
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<ChapterSheet> {
@override
Widget build(BuildContext context) {
return FocusableBottomSheet(
initialFocusNode: _initialFocusNode,
child: StreamBuilder<Duration>(
return StreamBuilder<Duration>(
stream: widget.player.streams.position,
initialData: widget.player.state.position,
builder: (context, positionSnapshot) {
@@ -125,7 +91,6 @@ class _ChapterSheetState extends State<ChapterSheet> {
: null;
return FocusableListTile(
focusNode: index == 0 ? _initialFocusNode : null,
leading: chapter.thumb != null
? Stack(
children: [
@@ -173,7 +138,7 @@ class _ChapterSheetState extends State<ChapterSheet> {
: null,
onTap: () {
widget.player.seek(chapter.startTime);
Navigator.pop(context);
OverlaySheetController.of(context).close();
},
);
},
@@ -186,7 +151,6 @@ class _ChapterSheetState extends State<ChapterSheet> {
child: content,
);
},
),
);
);
}
}
@@ -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<SubtitleTrack>(
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<SubtitleTrack>(
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,
);
}
}
@@ -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<T> extends StatefulWidget {
this.isOffTrack,
});
static void show<T>({
required BuildContext context,
required Player player,
required String title,
required IconData icon,
required List<T> 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<T>(
player: player,
title: title,
icon: icon,
extractTracks: extractTracks,
getCurrentTrack: getCurrentTrack,
buildLabel: buildLabel,
setTrack: setTrack,
onTrackChanged: onTrackChanged,
showOffOption: showOffOption,
createOffTrack: createOffTrack,
isOffTrack: isOffTrack,
),
);
}
@override
State<TrackSelectionSheet<T>> createState() => _TrackSelectionSheetState<T>();
}
class _TrackSelectionSheetState<T> extends State<TrackSelectionSheet<T>> {
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<Tracks>(
stream: widget.player.streams.tracks,
initialData: widget.player.state.tracks,
builder: (context, snapshot) {
final tracks = snapshot.data;
final availableTracks = TrackFilterHelper.extractAndFilterTracks<T>(tracks, widget.extractTracks);
return StreamBuilder<Tracks>(
stream: widget.player.streams.tracks,
initialData: widget.player.state.tracks,
builder: (context, snapshot) {
final tracks = snapshot.data;
final availableTracks = TrackFilterHelper.extractAndFilterTracks<T>(tracks, widget.extractTracks);
final sheetChild = availableTracks.isEmpty
? TrackSelectionHelper.buildEmptyState<T>()
: _buildTrackList(availableTracks);
final sheetChild = availableTracks.isEmpty
? TrackSelectionHelper.buildEmptyState<T>()
: _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<T> extends State<TrackSelectionSheet<T>> {
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<T>(
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<T> extends State<TrackSelectionSheet<T>> {
Widget _buildOffTile(bool isOffSelected) {
return TrackSelectionHelper.buildOffTile<T>(
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();
},
);
}
@@ -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<PlexMediaVersion> 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<VersionSheet> createState() => _VersionSheetState();
}
class _VersionSheetState extends State<VersionSheet> {
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);
},
);
},
),
);
}
@@ -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<void> 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<VideoSettingsSheet> createState() => _VideoSettingsSheetState();
}
@@ -148,23 +113,15 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
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<void> _loadSettings() async {
final settings = await SettingsService.getInstance();
if (!mounted) return;
@@ -211,12 +168,14 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
_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();
}
}(),
);
}
}
@@ -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<PlexVideoControls> 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();
@@ -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));
@@ -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++;