refactor: overlay-based bottom sheets for video player
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user