fix: show sync controls as compact top bar

close #548
This commit is contained in:
edde746
2026-02-26 03:41:19 +01:00
parent 3418241c04
commit 98776b40f5
6 changed files with 365 additions and 57 deletions
+39 -11
View File
@@ -48,14 +48,18 @@ class OverlaySheetController {
/// 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
/// Show a sheet with [builder] content. Returns a Future that completes
/// when the sheet is closed (with an optional result).
///
/// [alignment] controls where the sheet appears. Defaults to
/// [Alignment.bottomCenter]. Use [Alignment.topCenter] to anchor at the top.
Future<T?> show<T>({
required WidgetBuilder builder,
BoxConstraints? constraints,
Color? backgroundColor,
bool barrierDismissible = true,
FocusNode? initialFocusNode,
Alignment alignment = Alignment.bottomCenter,
}) {
return _state._show<T>(
builder: builder,
@@ -63,6 +67,7 @@ class OverlaySheetController {
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
initialFocusNode: initialFocusNode,
alignment: alignment,
);
}
@@ -98,6 +103,7 @@ class OverlaySheetController {
bool barrierDismissible = true,
bool isScrollControlled = false,
FocusNode? initialFocusNode,
Alignment alignment = Alignment.bottomCenter,
}) {
final controller = maybeOf(context);
if (controller != null) {
@@ -107,6 +113,7 @@ class OverlaySheetController {
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
initialFocusNode: initialFocusNode,
alignment: alignment,
);
}
return showModalBottomSheet<T>(
@@ -176,7 +183,7 @@ class OverlaySheetHost extends StatefulWidget {
class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<Offset> _slideAnimation;
late final CurvedAnimation _slideCurve;
late final Animation<double> _barrierAnimation;
late final OverlaySheetController _controller;
@@ -188,6 +195,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
bool _barrierDismissible = true;
BoxConstraints? _constraints;
Color? _explicitBackgroundColor;
Alignment _alignment = Alignment.bottomCenter;
// Drag-to-dismiss state
double _dragOffset = 0;
@@ -200,8 +208,10 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
_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),
_slideCurve = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_barrierAnimation = Tween<double>(
@@ -218,6 +228,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
}
}
_sheetFocusScopeNode.dispose();
_slideCurve.dispose();
_animationController.dispose();
super.dispose();
}
@@ -228,6 +239,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
Color? backgroundColor,
bool barrierDismissible = true,
FocusNode? initialFocusNode,
Alignment alignment = Alignment.bottomCenter,
}) {
// If already open, close first (instant)
if (_isOpen) {
@@ -250,6 +262,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
_barrierDismissible = barrierDismissible;
_constraints = constraints;
_explicitBackgroundColor = backgroundColor;
_alignment = alignment;
_dragOffset = 0;
_isDragging = false;
});
@@ -448,29 +461,44 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
Widget _buildSheet(BuildContext context) {
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 600;
final isTop = _alignment.y < 0;
final effectiveConstraints =
_constraints ??
BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: isDesktop ? 400 : size.height * 0.75);
// Slide direction depends on alignment: bottom sheets slide up, top sheets slide down.
final slideBegin = isTop ? const Offset(0, -1) : const Offset(0, 1);
final borderRadius = isTop
? const BorderRadius.vertical(bottom: Radius.circular(16))
: const BorderRadius.vertical(top: Radius.circular(16));
Widget sheet = FocusScope(
node: _sheetFocusScopeNode,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: _handleKeyEvent,
child: SlideTransition(
position: _slideAnimation,
child: AnimatedBuilder(
animation: _slideCurve,
builder: (context, child) {
final slideOffset = Offset.lerp(slideBegin, Offset.zero, _slideCurve.value)!;
return FractionalTranslation(
translation: slideOffset,
child: child,
);
},
child: Align(
alignment: Alignment.bottomCenter,
alignment: _alignment,
child: Transform.translate(
offset: Offset(0, _dragOffset.clamp(0, double.infinity)),
child: Material(
color: _explicitBackgroundColor ?? Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
top: !isTop,
bottom: isTop,
child: ConstrainedBox(
constraints: effectiveConstraints,
child: _pageStack.isNotEmpty ? _pageStack.last.builder(context) : const SizedBox.shrink(),
@@ -483,8 +511,8 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
),
);
// Swipe-down-to-dismiss (skip on TV where there's no touchscreen)
if (!PlatformDetector.isTV()) {
// Swipe-down-to-dismiss (skip on TV and for top-aligned sheets)
if (!PlatformDetector.isTV() && !isTop) {
sheet = GestureDetector(
onVerticalDragStart: (_) {
_isDragging = true;
@@ -68,6 +68,7 @@ class DesktopVideoControls extends StatefulWidget {
final VoidCallback? onLoadSeekTimes;
final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide;
final void Function(String propertyName, int offset)? onSyncOffsetChanged;
final String serverId;
final VoidCallback? onBack;
@@ -138,6 +139,7 @@ class DesktopVideoControls extends StatefulWidget {
this.onLoadSeekTimes,
this.onCancelAutoHide,
this.onStartAutoHide,
this.onSyncOffsetChanged,
this.serverId = '',
this.onBack,
this.canControl = true,
@@ -689,6 +691,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
onLoadSeekTimes: widget.onLoadSeekTimes,
onCancelAutoHide: widget.onCancelAutoHide,
onStartAutoHide: widget.onStartAutoHide,
onSyncOffsetChanged: widget.onSyncOffsetChanged,
focusNodes: _trackControlFocusNodes,
onFocusChange: _onFocusChange,
onNavigateLeft: navigateFromTrackToVolume,
@@ -12,6 +12,7 @@ import '../../../providers/shader_provider.dart';
import '../../../services/settings_service.dart';
import '../../../services/shader_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../focus/focusable_wrapper.dart';
import '../../../utils/formatters.dart';
import '../../../utils/platform_detector.dart';
import '../../../theme/mono_tokens.dart';
@@ -91,6 +92,15 @@ class VideoSettingsSheet extends StatefulWidget {
/// Called to toggle ambient lighting on/off (null if unsupported)
final VoidCallback? onToggleAmbientLighting;
/// Called to cancel the video controls auto-hide timer.
final VoidCallback? onCancelAutoHide;
/// Called to restart the video controls auto-hide timer.
final VoidCallback? onStartAutoHide;
/// Called when a sync offset changes (so the parent can update its state).
final void Function(String propertyName, int offset)? onSyncOffsetChanged;
const VideoSettingsSheet({
super.key,
required this.player,
@@ -102,6 +112,9 @@ class VideoSettingsSheet extends StatefulWidget {
this.onShaderChanged,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
this.onCancelAutoHide,
this.onStartAutoHide,
this.onSyncOffsetChanged,
});
@override
@@ -180,12 +193,65 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
}
void _navigateTo(_SettingsView view) {
// Sync views open as a compact top bar instead of a sub-view
if (view == _SettingsView.audioSync || view == _SettingsView.subtitleSync) {
_openSyncBar(view);
return;
}
setState(() {
_currentView = view;
});
OverlaySheetController.maybeOf(context)?.refocus();
}
void _openSyncBar(_SettingsView view) {
final controller = OverlaySheetController.maybeOf(context);
if (controller == null) return;
final isSubtitle = view == _SettingsView.subtitleSync;
final title = isSubtitle ? t.videoSettings.subtitleSync : t.videoSettings.audioSync;
final icon = isSubtitle ? Symbols.subtitles_rounded : Symbols.sync_rounded;
final propertyName = isSubtitle ? 'sub-delay' : 'audio-delay';
final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset;
// Created here so we can pass it as initialFocusNode to the overlay sheet,
// ensuring the slider gets focus when the bar opens. Disposed by _CompactSyncBar.
final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider');
// show() with new alignment replaces the current sheet (completing the
// settings sheet future, which restarts the auto-hide timer via
// whenComplete in track_chapter_controls). Cancel it again here.
controller.show(
alignment: Alignment.topCenter,
constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900),
initialFocusNode: sliderFocusNode,
builder: (_) => _CompactSyncBar(
title: title,
icon: icon,
player: widget.player,
propertyName: propertyName,
initialOffset: initialOffset,
sliderFocusNode: sliderFocusNode,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
if (isSubtitle) {
await settings.setSubtitleSyncOffset(offset);
} else {
await settings.setAudioSyncOffset(offset);
}
widget.onSyncOffsetChanged?.call(propertyName, offset);
},
),
).whenComplete(() {
widget.onStartAutoHide?.call();
});
// Cancel auto-hide after show() — the previous sheet's whenComplete
// fires as a microtask and restarts the timer, so schedule our cancel
// to run after that microtask.
Future.microtask(() => widget.onCancelAutoHide?.call());
}
void _navigateBack() {
setState(() {
_currentView = _SettingsView.menu;
@@ -453,39 +519,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return SleepTimerContent(player: widget.player, sleepTimer: sleepTimer, onCancel: () => OverlaySheetController.of(context).close());
}
Widget _buildAudioSyncView() {
return SyncOffsetControl(
player: widget.player,
propertyName: 'audio-delay',
initialOffset: _audioSyncOffset,
labelText: t.videoControls.audioLabel,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setAudioSyncOffset(offset);
if (!mounted) return;
setState(() {
_audioSyncOffset = offset;
});
},
);
}
Widget _buildSubtitleSyncView() {
return SyncOffsetControl(
player: widget.player,
propertyName: 'sub-delay',
initialOffset: _subtitleSyncOffset,
labelText: t.videoControls.subtitlesLabel,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setSubtitleSyncOffset(offset);
if (!mounted) return;
setState(() {
_subtitleSyncOffset = offset;
});
},
);
}
// Audio/subtitle sync views are now opened as compact top bars via _openSyncBar()
/// Extract the audio backend name from a device name (e.g. "coreaudio" from "coreaudio/BuiltIn").
static String _audioBackend(String name) {
@@ -668,9 +702,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
case _SettingsView.sleep:
return _buildSleepView();
case _SettingsView.audioSync:
return _buildAudioSyncView();
case _SettingsView.subtitleSync:
return _buildSubtitleSyncView();
return _buildMenuView(); // Sync views open as top bars, fallback to menu
case _SettingsView.audioDevice:
return _buildAudioDeviceView();
case _SettingsView.shader:
@@ -680,3 +713,84 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
);
}
}
/// Compact sync bar shown at the top of the screen so subtitles remain visible.
class _CompactSyncBar extends StatefulWidget {
final String title;
final IconData icon;
final Player player;
final String propertyName;
final int initialOffset;
final Future<void> Function(int offset) onOffsetChanged;
final FocusNode sliderFocusNode;
const _CompactSyncBar({
required this.title,
required this.icon,
required this.player,
required this.propertyName,
required this.initialOffset,
required this.onOffsetChanged,
required this.sliderFocusNode,
});
@override
State<_CompactSyncBar> createState() => _CompactSyncBarState();
}
class _CompactSyncBarState extends State<_CompactSyncBar> {
final _resetFocusNode = FocusNode(debugLabel: 'SyncResetButton');
final _closeFocusNode = FocusNode(debugLabel: 'SyncCloseButton');
@override
void dispose() {
widget.sliderFocusNode.dispose();
_resetFocusNode.dispose();
_closeFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
const SizedBox(width: 16),
AppIcon(widget.icon, fill: 1, color: tokens(context).textMuted, size: 20),
const SizedBox(width: 8),
Text(widget.title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
Expanded(
child: SyncOffsetControl(
player: widget.player,
propertyName: widget.propertyName,
initialOffset: widget.initialOffset,
labelText: widget.title,
onOffsetChanged: widget.onOffsetChanged,
compact: true,
sliderFocusNode: widget.sliderFocusNode,
resetFocusNode: _resetFocusNode,
closeFocusNode: _closeFocusNode,
),
),
const SizedBox(width: 8),
FocusableWrapper(
focusNode: _closeFocusNode,
onSelect: () => OverlaySheetController.of(context).close(),
onNavigateLeft: () => _resetFocusNode.requestFocus(),
borderRadius: 18,
autoScroll: false,
useBackgroundFocus: true,
child: GestureDetector(
onTap: () => OverlaySheetController.of(context).close(),
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
child: AppIcon(Symbols.close_rounded, fill: 1, color: tokens(context).textMuted, size: 22),
),
),
),
const SizedBox(width: 12),
],
);
}
}
+30 -8
View File
@@ -657,16 +657,20 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
// Immediately try to reclaim focus (important for TV where global handler
// won't fire if _focusNode lost focus)
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasFocus) {
// Reclaim focus so the global key handler stays active for TV dpad,
// but skip if an overlay sheet owns focus — stealing it would break
// sheet navigation (e.g. the compact sync bar).
final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false;
if (!sheetOpen) {
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
}
}
void _startHideTimer() {
@@ -922,6 +926,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
},
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
onSyncOffsetChanged: (propertyName, offset) {
setState(() {
if (propertyName == 'sub-delay') {
_subtitleSyncOffset = offset;
} else {
_audioSyncOffset = offset;
}
});
},
serverId: widget.metadata.serverId ?? '',
canControl: widget.canControl,
isLive: widget.isLive,
@@ -1947,6 +1960,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
},
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
onSyncOffsetChanged: (propertyName, offset) {
setState(() {
if (propertyName == 'sub-delay') {
_subtitleSyncOffset = offset;
} else {
_audioSyncOffset = offset;
}
});
},
serverId: widget.metadata.serverId ?? '',
onBack: widget.onBack,
canControl: widget.canControl,
@@ -1,9 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/focusable_wrapper.dart';
import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
import '../../../theme/mono_tokens.dart';
@@ -17,6 +20,21 @@ class SyncOffsetControl extends StatefulWidget {
final String labelText; // 'Audio' or 'Subtitles'
final Future<void> Function(int offset) onOffsetChanged;
/// When true, renders as a compact single-row layout for use in a top bar.
final bool compact;
/// Focus node for the reset button (compact mode). When provided from the
/// parent, allows the close button's left-press to focus the reset button.
final FocusNode? resetFocusNode;
/// Focus node for the close button (compact mode). When provided, pressing
/// select/enter on the slider moves focus here.
final FocusNode? closeFocusNode;
/// Focus node for the slider (compact mode). When provided, allows the
/// parent to auto-focus the slider when the bar opens.
final FocusNode? sliderFocusNode;
const SyncOffsetControl({
super.key,
required this.player,
@@ -24,6 +42,10 @@ class SyncOffsetControl extends StatefulWidget {
required this.initialOffset,
required this.labelText,
required this.onOffsetChanged,
this.compact = false,
this.resetFocusNode,
this.closeFocusNode,
this.sliderFocusNode,
});
@override
@@ -138,6 +160,8 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
required IconData icon,
required VoidCallback onTap,
required VoidCallback onLongPressStart,
double size = 48,
double iconSize = 28,
}) {
return GestureDetector(
onTap: onTap,
@@ -145,16 +169,128 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: _stopLongPress,
child: Container(
width: 48,
height: 48,
width: size,
height: size,
decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.all(Radius.circular(8))),
child: Icon(icon, color: tokens(context).text, size: 28),
child: Icon(icon, color: tokens(context).text, size: iconSize),
),
);
}
@override
Widget build(BuildContext context) {
return widget.compact ? _buildCompact(context) : _buildFull(context);
}
Widget _buildCompactStepButton({
required IconData icon,
required VoidCallback onTap,
required VoidCallback onLongPressStart,
}) {
return FocusableWrapper(
onSelect: onTap,
borderRadius: 18,
autoScroll: false,
useBackgroundFocus: true,
child: GestureDetector(
onTap: onTap,
onLongPressStart: (_) => onLongPressStart(),
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: _stopLongPress,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.all(Radius.circular(8))),
child: Icon(icon, color: tokens(context).text, size: 22),
),
),
);
}
Widget _buildCompact(BuildContext context) {
final sliderValue = _currentOffset.clamp(_sliderMin, _sliderMax);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_buildCompactStepButton(
icon: Symbols.remove_rounded,
onTap: _decrementOffset,
onLongPressStart: _startLongPressDecrement,
),
Expanded(
child: Focus(
onKeyEvent: (node, event) {
// Select/enter on the slider jumps focus to the close button
if (event.logicalKey.isSelectKey && event is KeyDownEvent) {
widget.closeFocusNode?.requestFocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
canRequestFocus: false,
child: Slider(
focusNode: widget.sliderFocusNode,
value: sliderValue,
min: _sliderMin,
max: _sliderMax,
divisions: _sliderDivisions,
activeColor: Colors.blue,
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
onChanged: (value) {
setState(() {
_currentOffset = value;
});
},
onChangeEnd: (value) {
_applyOffset(value);
},
),
),
),
_buildCompactStepButton(
icon: Symbols.add_rounded,
onTap: _incrementOffset,
onLongPressStart: _startLongPressIncrement,
),
const SizedBox(width: 12),
SizedBox(
width: 80,
child: Text(
formatSyncOffset(_currentOffset),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 8),
FocusableWrapper(
focusNode: widget.resetFocusNode,
onSelect: _currentOffset != 0 ? _resetOffset : null,
borderRadius: 18,
autoScroll: false,
useBackgroundFocus: true,
child: GestureDetector(
onTap: _currentOffset != 0 ? _resetOffset : null,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
child: AppIcon(
Symbols.restart_alt_rounded,
fill: 1,
color: _currentOffset != 0 ? tokens(context).text : tokens(context).textMuted,
size: 22,
),
),
),
),
],
),
);
}
Widget _buildFull(BuildContext context) {
// Clamp the slider value to its range, but display the actual offset
final sliderValue = _currentOffset.clamp(_sliderMin, _sliderMax);
@@ -47,6 +47,7 @@ class TrackChapterControls extends StatelessWidget {
final VoidCallback? onLoadSeekTimes;
final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide;
final void Function(String propertyName, int offset)? onSyncOffsetChanged;
final String serverId;
final ShaderService? shaderService;
final VoidCallback? onShaderChanged;
@@ -103,6 +104,7 @@ class TrackChapterControls extends StatelessWidget {
this.onLoadSeekTimes,
this.onCancelAutoHide,
this.onStartAutoHide,
this.onSyncOffsetChanged,
this.focusNodes,
this.onFocusChange,
this.onNavigateLeft,
@@ -221,6 +223,9 @@ class TrackChapterControls extends StatelessWidget {
onShaderChanged: onShaderChanged,
isAmbientLightingEnabled: isAmbientLightingEnabled,
onToggleAmbientLighting: onToggleAmbientLighting,
onCancelAutoHide: onCancelAutoHide,
onStartAutoHide: onStartAutoHide,
onSyncOffsetChanged: onSyncOffsetChanged,
),
).whenComplete(() {
onStartAutoHide?.call();