@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user