diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 0b901286..4d0c830f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -130,6 +130,16 @@ class _VideoPlayerScreenState extends State { ), ); + // Apply audio sync offset + final audioSyncOffset = settingsService.getAudioSyncOffset(); + if (audioSyncOffset != 0) { + final offsetSeconds = audioSyncOffset / 1000.0; + await (player!.platform as dynamic).setProperty( + 'audio-delay', + offsetSeconds.toString(), + ); + } + // Notify that player is ready if (mounted) { setState(() { @@ -296,7 +306,11 @@ class _VideoPlayerScreenState extends State { setState(() { _boxFitMode = (_boxFitMode + 1) % 3; }); - final modes = ['contain (letterbox)', 'cover (fill screen)', 'fill (stretch)']; + final modes = [ + 'contain (letterbox)', + 'cover (fill screen)', + 'fill (stretch)', + ]; appLogger.d('BoxFit mode: ${modes[_boxFitMode]}'); } @@ -1059,7 +1073,8 @@ class _VideoPlayerScreenState extends State { child: Scaffold( backgroundColor: Colors.black, body: GestureDetector( - behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls + behavior: HitTestBehavior + .translucent, // Allow taps to pass through to controls onScaleStart: (details) { // Initialize pinch gesture tracking (mobile only) if (!PlatformDetector.isMobile(context)) return; @@ -1099,117 +1114,117 @@ class _VideoPlayerScreenState extends State { ), ), ), - // Play Next Dialog - if (_showPlayNextDialog && _nextEpisode != null) - Positioned.fill( - child: Container( - color: Colors.black.withValues(alpha: 0.8), - child: Center( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 32), - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: Colors.grey[900], - borderRadius: BorderRadius.circular(16), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.play_circle_outline, - size: 64, - color: Colors.white, - ), - const SizedBox(height: 24), - Consumer( - builder: (context, playbackState, child) { - final isShuffleActive = - playbackState.isShuffleActive; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Up Next', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - if (isShuffleActive) ...[ - const SizedBox(width: 8), - const Icon( - Icons.shuffle, - size: 20, - color: Colors.white70, - ), - ], - ], - ); - }, - ), - const SizedBox(height: 16), - Text( - _nextEpisode!.grandparentTitle ?? - _nextEpisode!.title, - style: const TextStyle( + // Play Next Dialog + if (_showPlayNextDialog && _nextEpisode != null) + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.8), + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 32), + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_circle_outline, + size: 64, color: Colors.white, - fontSize: 18, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - if (_nextEpisode!.parentIndex != null && - _nextEpisode!.index != null) + const SizedBox(height: 24), + Consumer( + builder: (context, playbackState, child) { + final isShuffleActive = + playbackState.isShuffleActive; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Up Next', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if (isShuffleActive) ...[ + const SizedBox(width: 8), + const Icon( + Icons.shuffle, + size: 20, + color: Colors.white70, + ), + ], + ], + ); + }, + ), + const SizedBox(height: 16), Text( - 'S${_nextEpisode!.parentIndex} · E${_nextEpisode!.index} · ${_nextEpisode!.title}', + _nextEpisode!.grandparentTitle ?? + _nextEpisode!.title, style: const TextStyle( - color: Colors.white70, - fontSize: 16, + color: Colors.white, + fontSize: 18, ), textAlign: TextAlign.center, ), - const SizedBox(height: 32), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton( - onPressed: () { - setState(() { - _showPlayNextDialog = false; - }); - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white), - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), + const SizedBox(height: 8), + if (_nextEpisode!.parentIndex != null && + _nextEpisode!.index != null) + Text( + 'S${_nextEpisode!.parentIndex} · E${_nextEpisode!.index} · ${_nextEpisode!.title}', + style: const TextStyle( + color: Colors.white70, + fontSize: 16, ), - child: const Text('Cancel'), + textAlign: TextAlign.center, ), - const SizedBox(width: 16), - FilledButton( - onPressed: _playNext, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton( + onPressed: () { + setState(() { + _showPlayNextDialog = false; + }); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), ), + child: const Text('Cancel'), ), - child: const Text('Play Now'), - ), - ], - ), - ], + const SizedBox(width: 16), + FilledButton( + onPressed: _playNext, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + child: const Text('Play Now'), + ), + ], + ), + ], + ), ), ), ), ), - ), ], ), ), diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 5e144fa1..afe41b0d 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -27,6 +27,7 @@ class SettingsService { static const String _keyMediaVersionPreferences = 'media_version_preferences'; static const String _keyShowHeroSection = 'show_hero_section'; static const String _keySleepTimerDuration = 'sleep_timer_duration'; + static const String _keyAudioSyncOffset = 'audio_sync_offset'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -179,6 +180,15 @@ class SettingsService { return _prefs.getInt(_keySleepTimerDuration) ?? 30; // Default: 30 minutes } + // Audio Sync Offset (in milliseconds) + Future setAudioSyncOffset(int milliseconds) async { + await _prefs.setInt(_keyAudioSyncOffset, milliseconds); + } + + int getAudioSyncOffset() { + return _prefs.getInt(_keyAudioSyncOffset) ?? 0; // Default: 0ms (no offset) + } + // Keyboard Shortcuts (Legacy String-based) Map getDefaultKeyboardShortcuts() { return { @@ -693,6 +703,7 @@ class SettingsService { _prefs.remove(_keySeekTimeLarge), _prefs.remove(_keyMediaVersionPreferences), _prefs.remove(_keySleepTimerDuration), + _prefs.remove(_keyAudioSyncOffset), ]); } diff --git a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart new file mode 100644 index 00000000..8726945b --- /dev/null +++ b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:plezy/services/settings_service.dart'; + +/// Bottom sheet for adjusting audio sync offset +class AudioSyncSheet extends StatefulWidget { + final Player player; + final int initialOffset; + + const AudioSyncSheet({ + super.key, + required this.player, + required this.initialOffset, + }); + + static BoxConstraints getBottomSheetConstraints(BuildContext context) { + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 600; + + return BoxConstraints( + maxWidth: isDesktop ? 700 : double.infinity, + maxHeight: isDesktop ? 400 : size.height * 0.75, + minHeight: isDesktop ? 300 : size.height * 0.5, + ); + } + + static void show(BuildContext context, Player player, int initialOffset) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: getBottomSheetConstraints(context), + builder: (context) => + AudioSyncSheet(player: player, initialOffset: initialOffset), + ); + } + + @override + State createState() => _AudioSyncSheetState(); +} + +class _AudioSyncSheetState extends State { + late double _currentOffset; + + @override + void initState() { + super.initState(); + _currentOffset = widget.initialOffset.toDouble(); + } + + void _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( + 'audio-delay', + offsetSeconds.toString(), + ); + + // Save to settings + final settings = await SettingsService.getInstance(); + await settings.setAudioSyncOffset(offsetMs.round()); + } + + void _resetOffset() { + setState(() { + _currentOffset = 0; + }); + _applyOffset(0); + } + + String _formatOffset(double offsetMs) { + final sign = offsetMs >= 0 ? '+' : ''; + return '$sign${offsetMs.round()}ms'; + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.sync, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Audio Sync', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + Expanded( + child: 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( + _currentOffset > 0 + ? 'Audio plays later' + : _currentOffset < 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: _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, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 589cf1c6..cb7cab8f 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -17,6 +17,7 @@ import '../../utils/provider_extensions.dart'; import '../../screens/video_player_screen.dart'; import '../app_bar_back_button.dart'; import 'painters/chapter_marker_painter.dart'; +import 'sheets/audio_sync_sheet.dart'; import 'sheets/audio_track_sheet.dart'; import 'sheets/chapter_sheet.dart'; import 'sheets/playback_speed_sheet.dart'; @@ -83,6 +84,7 @@ class _PlexVideoControlsState extends State late final FocusNode _focusNode; KeyboardShortcutsService? _keyboardService; int _seekTimeSmall = 10; // Default, loaded from settings + int _audioSyncOffset = 0; // Default, loaded from settings // Double-tap feedback state bool _showDoubleTapFeedback = false; double _doubleTapFeedbackOpacity = 0.0; @@ -114,6 +116,7 @@ class _PlexVideoControlsState extends State if (mounted) { setState(() { _seekTimeSmall = settingsService.getSeekTimeSmall(); + _audioSyncOffset = settingsService.getAudioSyncOffset(); }); } } @@ -343,6 +346,21 @@ class _PlexVideoControlsState extends State icon: const Icon(Icons.audiotrack, color: Colors.white), onPressed: () => AudioTrackSheet.show(context, widget.player), ), + // Audio sync offset button + IconButton( + icon: Icon( + Icons.sync, + color: _audioSyncOffset != 0 ? Colors.amber : Colors.white, + ), + onPressed: () async { + AudioSyncSheet.show(context, widget.player, _audioSyncOffset); + // Reload offset after sheet closes (in case it changed) + await Future.delayed(const Duration(milliseconds: 300)); + if (mounted) { + _loadSeekTimes(); + } + }, + ), if (_hasSubtitles(tracks)) IconButton( icon: const Icon(Icons.subtitles, color: Colors.white),