From c15bc6d4c2c0d43c0c2445deb72982f52c2cfd25 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:43:05 +0100 Subject: [PATCH] feat: subtitle delay config --- lib/screens/video_player_screen.dart | 10 + lib/services/settings_service.dart | 12 ++ .../sheets/video_settings_sheet.dart | 182 ++++++++---------- .../video_controls/video_controls.dart | 17 +- .../widgets/sync_offset_control.dart | 145 ++++++++++++++ 5 files changed, 261 insertions(+), 105 deletions(-) create mode 100644 lib/widgets/video_controls/widgets/sync_offset_control.dart diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 4d0c830f..55746fd4 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -140,6 +140,16 @@ class _VideoPlayerScreenState extends State { ); } + // Apply subtitle sync offset + final subtitleSyncOffset = settingsService.getSubtitleSyncOffset(); + if (subtitleSyncOffset != 0) { + final offsetSeconds = subtitleSyncOffset / 1000.0; + await (player!.platform as dynamic).setProperty( + 'sub-delay', + offsetSeconds.toString(), + ); + } + // Notify that player is ready if (mounted) { setState(() { diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index afe41b0d..34d97bd9 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -28,6 +28,7 @@ class SettingsService { static const String _keyShowHeroSection = 'show_hero_section'; static const String _keySleepTimerDuration = 'sleep_timer_duration'; static const String _keyAudioSyncOffset = 'audio_sync_offset'; + static const String _keySubtitleSyncOffset = 'subtitle_sync_offset'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -189,6 +190,16 @@ class SettingsService { return _prefs.getInt(_keyAudioSyncOffset) ?? 0; // Default: 0ms (no offset) } + // Subtitle Sync Offset (in milliseconds) + Future setSubtitleSyncOffset(int milliseconds) async { + await _prefs.setInt(_keySubtitleSyncOffset, milliseconds); + } + + int getSubtitleSyncOffset() { + return _prefs.getInt(_keySubtitleSyncOffset) ?? + 0; // Default: 0ms (no offset) + } + // Keyboard Shortcuts (Legacy String-based) Map getDefaultKeyboardShortcuts() { return { @@ -704,6 +715,7 @@ class SettingsService { _prefs.remove(_keyMediaVersionPreferences), _prefs.remove(_keySleepTimerDuration), _prefs.remove(_keyAudioSyncOffset), + _prefs.remove(_keySubtitleSyncOffset), ]); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 3c869b71..967fb37c 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -2,18 +2,21 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; +import '../widgets/sync_offset_control.dart'; -enum _SettingsView { menu, speed, sleep, audioSync } +enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync } /// Unified settings sheet for playback adjustments with in-sheet navigation class VideoSettingsSheet extends StatefulWidget { final Player player; final int audioSyncOffset; + final int subtitleSyncOffset; const VideoSettingsSheet({ super.key, required this.player, required this.audioSyncOffset, + required this.subtitleSyncOffset, }); static BoxConstraints getBottomSheetConstraints(BuildContext context) { @@ -27,14 +30,22 @@ class VideoSettingsSheet extends StatefulWidget { ); } - static void show(BuildContext context, Player player, int audioSyncOffset) { - showModalBottomSheet( + static Future show( + BuildContext context, + Player player, + int audioSyncOffset, + int subtitleSyncOffset, + ) { + return showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: getBottomSheetConstraints(context), - builder: (context) => - VideoSettingsSheet(player: player, audioSyncOffset: audioSyncOffset), + builder: (context) => VideoSettingsSheet( + player: player, + audioSyncOffset: audioSyncOffset, + subtitleSyncOffset: subtitleSyncOffset, + ), ); } @@ -45,13 +56,13 @@ class VideoSettingsSheet extends StatefulWidget { class _VideoSettingsSheetState extends State { _SettingsView _currentView = _SettingsView.menu; late int _audioSyncOffset; - late double _currentAudioOffset; + late int _subtitleSyncOffset; @override void initState() { super.initState(); _audioSyncOffset = widget.audioSyncOffset; - _currentAudioOffset = widget.audioSyncOffset.toDouble(); + _subtitleSyncOffset = widget.subtitleSyncOffset; } void _navigateTo(_SettingsView view) { @@ -76,6 +87,8 @@ class _VideoSettingsSheetState extends State { return 'Sleep Timer'; case _SettingsView.audioSync: return 'Audio Sync'; + case _SettingsView.subtitleSync: + return 'Subtitle Sync'; } } @@ -89,6 +102,8 @@ class _VideoSettingsSheetState extends State { return Icons.bedtime; case _SettingsView.audioSync: return Icons.sync; + case _SettingsView.subtitleSync: + return Icons.subtitles; } } @@ -136,7 +151,9 @@ class _VideoSettingsSheetState extends State { final sleepTimer = SleepTimerService(); final isIconActive = _currentView == _SettingsView.menu && - (sleepTimer.isActive || _audioSyncOffset != 0); + (sleepTimer.isActive || + _audioSyncOffset != 0 || + _subtitleSyncOffset != 0); return Padding( padding: const EdgeInsets.all(16), @@ -261,6 +278,35 @@ class _VideoSettingsSheetState extends State { ), onTap: () => _navigateTo(_SettingsView.audioSync), ), + + // Subtitle Sync + ListTile( + leading: Icon( + Icons.subtitles, + color: _subtitleSyncOffset != 0 ? Colors.amber : Colors.white70, + ), + title: const Text( + 'Subtitle Sync', + style: TextStyle(color: Colors.white), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatAudioSync(_subtitleSyncOffset), + style: TextStyle( + color: _subtitleSyncOffset != 0 + ? Colors.amber + : Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Colors.white70), + ], + ), + onTap: () => _navigateTo(_SettingsView.subtitleSync), + ), ], ); } @@ -421,98 +467,34 @@ class _VideoSettingsSheetState extends State { } Widget _buildAudioSyncView() { - void applyOffset(double offsetMs) async { - final offsetSeconds = offsetMs / 1000.0; - await (widget.player.platform as dynamic).setProperty( - 'audio-delay', - offsetSeconds.toString(), - ); + return SyncOffsetControl( + player: widget.player, + propertyName: 'audio-delay', + initialOffset: _audioSyncOffset, + labelText: 'Audio', + onOffsetChanged: (offset) async { + final settings = await SettingsService.getInstance(); + await settings.setAudioSyncOffset(offset); + setState(() { + _audioSyncOffset = offset; + }); + }, + ); + } - final settings = await SettingsService.getInstance(); - await settings.setAudioSyncOffset(offsetMs.round()); - - setState(() { - _audioSyncOffset = offsetMs.round(); - }); - } - - void resetOffset() { - setState(() { - _currentAudioOffset = 0; - }); - applyOffset(0); - } - - String formatOffset(double offsetMs) { - final sign = offsetMs >= 0 ? '+' : ''; - return '$sign${offsetMs.round()}ms'; - } - - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Current offset display - Text( - formatOffset(_currentAudioOffset), - style: const TextStyle( - color: Colors.white, - fontSize: 48, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - _currentAudioOffset > 0 - ? 'Audio plays later' - : _currentAudioOffset < 0 - ? 'Audio plays earlier' - : 'No offset', - style: const TextStyle(color: Colors.white70, fontSize: 16), - ), - const SizedBox(height: 48), - // Slider - Row( - children: [ - const Text('-2s', style: TextStyle(color: Colors.white70)), - Expanded( - child: Slider( - value: _currentAudioOffset, - min: -2000, - max: 2000, - divisions: 80, - activeColor: Colors.blue, - inactiveColor: Colors.white24, - onChanged: (value) { - setState(() { - _currentAudioOffset = value; - }); - }, - onChangeEnd: (value) { - applyOffset(value); - }, - ), - ), - const Text('+2s', style: TextStyle(color: Colors.white70)), - ], - ), - const SizedBox(height: 24), - // Reset button - ElevatedButton.icon( - onPressed: _currentAudioOffset != 0 ? resetOffset : null, - icon: const Icon(Icons.restart_alt), - label: const Text('Reset to 0ms'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[800], - foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey[850], - disabledForegroundColor: Colors.white38, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - ], - ), + Widget _buildSubtitleSyncView() { + return SyncOffsetControl( + player: widget.player, + propertyName: 'sub-delay', + initialOffset: _subtitleSyncOffset, + labelText: 'Subtitles', + onOffsetChanged: (offset) async { + final settings = await SettingsService.getInstance(); + await settings.setSubtitleSyncOffset(offset); + setState(() { + _subtitleSyncOffset = offset; + }); + }, ); } @@ -536,6 +518,8 @@ class _VideoSettingsSheetState extends State { return _buildSleepView(); case _SettingsView.audioSync: return _buildAudioSyncView(); + case _SettingsView.subtitleSync: + return _buildSubtitleSyncView(); } }(), ), diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 9f03d255..b73318ac 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -83,6 +83,7 @@ class _PlexVideoControlsState extends State KeyboardShortcutsService? _keyboardService; int _seekTimeSmall = 10; // Default, loaded from settings int _audioSyncOffset = 0; // Default, loaded from settings + int _subtitleSyncOffset = 0; // Default, loaded from settings // Double-tap feedback state bool _showDoubleTapFeedback = false; double _doubleTapFeedbackOpacity = 0.0; @@ -115,6 +116,7 @@ class _PlexVideoControlsState extends State setState(() { _seekTimeSmall = settingsService.getSeekTimeSmall(); _audioSyncOffset = settingsService.getAudioSyncOffset(); + _subtitleSyncOffset = settingsService.getSubtitleSyncOffset(); }); } } @@ -319,27 +321,30 @@ class _PlexVideoControlsState extends State return Row( mainAxisSize: MainAxisSize.min, children: [ - // Unified settings button (speed, sleep timer, audio sync) + // Unified settings button (speed, sleep timer, audio sync, subtitle sync) ListenableBuilder( listenable: SleepTimerService(), builder: (context, _) { final sleepTimer = SleepTimerService(); - final isActive = sleepTimer.isActive || _audioSyncOffset != 0; + final isActive = + sleepTimer.isActive || + _audioSyncOffset != 0 || + _subtitleSyncOffset != 0; return IconButton( icon: Icon( Icons.tune, color: isActive ? Colors.amber : Colors.white, ), onPressed: () async { - VideoSettingsSheet.show( + await VideoSettingsSheet.show( context, widget.player, _audioSyncOffset, + _subtitleSyncOffset, ); - // Reload offset after sheet closes (in case it changed) - await Future.delayed(const Duration(milliseconds: 300)); + // Sheet is now closed, reload immediately if (mounted) { - _loadSeekTimes(); + await _loadSeekTimes(); } }, ); diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart new file mode 100644 index 00000000..bb9382a5 --- /dev/null +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; + +/// Reusable widget for adjusting sync offsets (audio or subtitle) +class SyncOffsetControl extends StatefulWidget { + final Player player; + final String propertyName; // 'audio-delay' or 'sub-delay' + final int initialOffset; + final String labelText; // 'Audio' or 'Subtitles' + final Future Function(int offset) onOffsetChanged; + + const SyncOffsetControl({ + super.key, + required this.player, + required this.propertyName, + required this.initialOffset, + required this.labelText, + required this.onOffsetChanged, + }); + + @override + State createState() => _SyncOffsetControlState(); +} + +class _SyncOffsetControlState extends State { + late double _currentOffset; + + @override + void initState() { + super.initState(); + _currentOffset = widget.initialOffset.toDouble(); + } + + @override + void didUpdateWidget(SyncOffsetControl oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialOffset != oldWidget.initialOffset) { + setState(() { + _currentOffset = widget.initialOffset.toDouble(); + }); + } + } + + Future _applyOffset(double offsetMs) async { + // Convert milliseconds to seconds for media_kit + final offsetSeconds = offsetMs / 1000.0; + + // Apply to player using setProperty + await (widget.player.platform as dynamic).setProperty( + widget.propertyName, + offsetSeconds.toString(), + ); + + // Notify parent and save to settings + await widget.onOffsetChanged(offsetMs.round()); + } + + void _resetOffset() { + setState(() { + _currentOffset = 0; + }); + _applyOffset(0); + } + + String _formatOffset(double offsetMs) { + final sign = offsetMs >= 0 ? '+' : ''; + return '$sign${offsetMs.round()}ms'; + } + + String _getDescriptionText() { + if (_currentOffset > 0) { + return '${widget.labelText} plays later'; + } else if (_currentOffset < 0) { + return '${widget.labelText} plays earlier'; + } else { + return 'No offset'; + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Current offset display + Text( + _formatOffset(_currentOffset), + style: const TextStyle( + color: Colors.white, + fontSize: 48, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + _getDescriptionText(), + style: const TextStyle(color: Colors.white70, fontSize: 16), + ), + const SizedBox(height: 48), + // Slider + Row( + children: [ + const Text('-2s', style: TextStyle(color: Colors.white70)), + Expanded( + child: Slider( + value: _currentOffset, + min: -2000, + max: 2000, + divisions: 80, // 50ms steps + activeColor: Colors.blue, + inactiveColor: Colors.white24, + onChanged: (value) { + setState(() { + _currentOffset = value; + }); + }, + onChangeEnd: (value) { + _applyOffset(value); + }, + ), + ), + const Text('+2s', style: TextStyle(color: Colors.white70)), + ], + ), + const SizedBox(height: 24), + // Reset button + ElevatedButton.icon( + onPressed: _currentOffset != 0 ? _resetOffset : null, + icon: const Icon(Icons.restart_alt), + label: const Text('Reset to 0ms'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[800], + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey[850], + disabledForegroundColor: Colors.white38, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), + ); + } +}