From 98776b40f5ba18b369ff989b373b347de491cc88 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:40:52 +0100 Subject: [PATCH] fix: show sync controls as compact top bar close #548 --- lib/widgets/overlay_sheet.dart | 50 +++-- .../desktop_video_controls.dart | 3 + .../sheets/video_settings_sheet.dart | 184 ++++++++++++++---- .../video_controls/video_controls.dart | 38 +++- .../widgets/sync_offset_control.dart | 142 +++++++++++++- .../widgets/track_chapter_controls.dart | 5 + 6 files changed, 365 insertions(+), 57 deletions(-) diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 5c4abb48..b0f718c5 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -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 show({ required WidgetBuilder builder, BoxConstraints? constraints, Color? backgroundColor, bool barrierDismissible = true, FocusNode? initialFocusNode, + Alignment alignment = Alignment.bottomCenter, }) { return _state._show( 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( @@ -176,7 +183,7 @@ class OverlaySheetHost extends StatefulWidget { class _OverlaySheetHostState extends State with SingleTickerProviderStateMixin { late final AnimationController _animationController; - late final Animation _slideAnimation; + late final CurvedAnimation _slideCurve; late final Animation _barrierAnimation; late final OverlaySheetController _controller; @@ -188,6 +195,7 @@ class _OverlaySheetHostState extends State 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 with SingleTickerPr _animationController = AnimationController(duration: const Duration(milliseconds: 250), vsync: this); - _slideAnimation = Tween(begin: const Offset(0, 1), end: Offset.zero).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic), + _slideCurve = CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, ); _barrierAnimation = Tween( @@ -218,6 +228,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr } } _sheetFocusScopeNode.dispose(); + _slideCurve.dispose(); _animationController.dispose(); super.dispose(); } @@ -228,6 +239,7 @@ class _OverlaySheetHostState extends State 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 with SingleTickerPr _barrierDismissible = barrierDismissible; _constraints = constraints; _explicitBackgroundColor = backgroundColor; + _alignment = alignment; _dragOffset = 0; _isDragging = false; }); @@ -448,29 +461,44 @@ class _OverlaySheetHostState extends State 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 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; diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 9f306562..74880b43 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -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 { onLoadSeekTimes: widget.onLoadSeekTimes, onCancelAutoHide: widget.onCancelAutoHide, onStartAutoHide: widget.onStartAutoHide, + onSyncOffsetChanged: widget.onSyncOffsetChanged, focusNodes: _trackControlFocusNodes, onFocusChange: _onFocusChange, onNavigateLeft: navigateFromTrackToVolume, diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 2c94a438..af2140c9 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -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 { } 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 { 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 { 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 { ); } } + +/// 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 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), + ], + ); + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index a46016a5..d4a0623f 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -657,16 +657,20 @@ class _PlexVideoControlsState extends State 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 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 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, diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index f18ab5fa..7940d60b 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -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 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 { 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 { 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); diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index dfd00c65..9cab3bee 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -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();