diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart new file mode 100644 index 00000000..3c869b71 --- /dev/null +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -0,0 +1,547 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import '../../../services/settings_service.dart'; +import '../../../services/sleep_timer_service.dart'; + +enum _SettingsView { menu, speed, sleep, audioSync } + +/// Unified settings sheet for playback adjustments with in-sheet navigation +class VideoSettingsSheet extends StatefulWidget { + final Player player; + final int audioSyncOffset; + + const VideoSettingsSheet({ + super.key, + required this.player, + required this.audioSyncOffset, + }); + + 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 audioSyncOffset) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: getBottomSheetConstraints(context), + builder: (context) => + VideoSettingsSheet(player: player, audioSyncOffset: audioSyncOffset), + ); + } + + @override + State createState() => _VideoSettingsSheetState(); +} + +class _VideoSettingsSheetState extends State { + _SettingsView _currentView = _SettingsView.menu; + late int _audioSyncOffset; + late double _currentAudioOffset; + + @override + void initState() { + super.initState(); + _audioSyncOffset = widget.audioSyncOffset; + _currentAudioOffset = widget.audioSyncOffset.toDouble(); + } + + void _navigateTo(_SettingsView view) { + setState(() { + _currentView = view; + }); + } + + void _navigateBack() { + setState(() { + _currentView = _SettingsView.menu; + }); + } + + String _getTitle() { + switch (_currentView) { + case _SettingsView.menu: + return 'Playback Settings'; + case _SettingsView.speed: + return 'Playback Speed'; + case _SettingsView.sleep: + return 'Sleep Timer'; + case _SettingsView.audioSync: + return 'Audio Sync'; + } + } + + IconData _getIcon() { + switch (_currentView) { + case _SettingsView.menu: + return Icons.tune; + case _SettingsView.speed: + return Icons.speed; + case _SettingsView.sleep: + return Icons.bedtime; + case _SettingsView.audioSync: + return Icons.sync; + } + } + + String _formatSpeed(double speed) { + if (speed == 1.0) return 'Normal'; + return '${speed.toStringAsFixed(2)}x'; + } + + String _formatAudioSync(int offsetMs) { + if (offsetMs == 0) return '0ms'; + final sign = offsetMs >= 0 ? '+' : ''; + return '$sign${offsetMs}ms'; + } + + String _formatSleepTimer(SleepTimerService sleepTimer) { + if (!sleepTimer.isActive) return 'Off'; + final remaining = sleepTimer.remainingTime; + if (remaining == null) return 'Off'; + + final minutes = remaining.inMinutes; + final seconds = remaining.inSeconds.remainder(60); + + if (minutes > 0) { + return 'Active (${minutes}m ${seconds}s)'; + } else { + return 'Active (${seconds}s)'; + } + } + + String _formatSleepTimerDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m ${seconds}s'; + } else if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } else { + return '${seconds}s'; + } + } + + Widget _buildHeader() { + final sleepTimer = SleepTimerService(); + final isIconActive = + _currentView == _SettingsView.menu && + (sleepTimer.isActive || _audioSyncOffset != 0); + + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Back button or icon + if (_currentView != _SettingsView.menu) + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: _navigateBack, + ) + else + Icon(_getIcon(), color: isIconActive ? Colors.amber : Colors.white), + const SizedBox(width: 12), + Text( + _getTitle(), + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } + + Widget _buildMenuView() { + final sleepTimer = SleepTimerService(); + + return ListView( + children: [ + // Playback Speed + StreamBuilder( + stream: widget.player.stream.rate, + initialData: widget.player.state.rate, + builder: (context, snapshot) { + final currentRate = snapshot.data ?? 1.0; + return ListTile( + leading: const Icon(Icons.speed, color: Colors.white70), + title: const Text( + 'Playback Speed', + style: TextStyle(color: Colors.white), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatSpeed(currentRate), + style: const TextStyle(color: Colors.white70, fontSize: 14), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Colors.white70), + ], + ), + onTap: () => _navigateTo(_SettingsView.speed), + ); + }, + ), + + // Sleep Timer + ListenableBuilder( + listenable: sleepTimer, + builder: (context, _) { + final isActive = sleepTimer.isActive; + return ListTile( + leading: Icon( + isActive ? Icons.bedtime : Icons.bedtime_outlined, + color: isActive ? Colors.amber : Colors.white70, + ), + title: const Text( + 'Sleep Timer', + style: TextStyle(color: Colors.white), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatSleepTimer(sleepTimer), + style: TextStyle( + color: isActive ? Colors.amber : Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Colors.white70), + ], + ), + onTap: () => _navigateTo(_SettingsView.sleep), + ); + }, + ), + + // Audio Sync + ListTile( + leading: Icon( + Icons.sync, + color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70, + ), + title: const Text( + 'Audio Sync', + style: TextStyle(color: Colors.white), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatAudioSync(_audioSyncOffset), + style: TextStyle( + color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Colors.white70), + ], + ), + onTap: () => _navigateTo(_SettingsView.audioSync), + ), + ], + ); + } + + Widget _buildSpeedView() { + return StreamBuilder( + stream: widget.player.stream.rate, + initialData: widget.player.state.rate, + builder: (context, snapshot) { + final currentRate = snapshot.data ?? 1.0; + final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; + + return ListView.builder( + itemCount: speeds.length, + itemBuilder: (context, index) { + final speed = speeds[index]; + final isSelected = (currentRate - speed).abs() < 0.01; + final label = speed == 1.0 + ? 'Normal' + : '${speed.toStringAsFixed(2)}x'; + + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, + ), + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + widget.player.setRate(speed); + Navigator.pop(context); // Close sheet after selection + }, + ); + }, + ); + }, + ); + } + + Widget _buildSleepView() { + final sleepTimer = SleepTimerService(); + + return ListenableBuilder( + listenable: sleepTimer, + builder: (context, _) { + final durations = [5, 10, 15, 30, 45, 60, 90, 120]; + final remainingTime = sleepTimer.remainingTime; + + return Column( + children: [ + // Active timer status + if (sleepTimer.isActive && remainingTime != null) ...[ + Container( + padding: const EdgeInsets.all(16), + color: Colors.amber.withValues(alpha: 0.1), + child: Column( + children: [ + const Text( + 'Timer Active', + style: TextStyle( + color: Colors.amber, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.add), + label: const Text('+15 min'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white54), + ), + onPressed: () { + sleepTimer.extendTimer(const Duration(minutes: 15)); + }, + ), + const SizedBox(width: 12), + FilledButton.icon( + icon: const Icon(Icons.cancel), + label: const Text('Cancel'), + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + ), + onPressed: () { + sleepTimer.cancelTimer(); + Navigator.pop(context); // Close after cancel + }, + ), + ], + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + ], + + // Duration list + Expanded( + child: ListView.builder( + itemCount: durations.length, + itemBuilder: (context, index) { + final minutes = durations[index]; + final label = minutes < 60 + ? '$minutes minutes' + : '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; + + return ListTile( + leading: const Icon(Icons.timer, color: Colors.white70), + title: Text( + label, + style: const TextStyle(color: Colors.white), + ), + onTap: () { + sleepTimer.startTimer(Duration(minutes: minutes), () { + widget.player.pause(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Sleep timer completed - playback paused', + ), + duration: Duration(seconds: 3), + ), + ); + } + }); + Navigator.pop(context); // Close after selection + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Sleep timer set for $label'), + duration: const Duration(seconds: 2), + ), + ); + }, + ); + }, + ), + ), + ], + ); + }, + ); + } + + Widget _buildAudioSyncView() { + void applyOffset(double offsetMs) async { + final offsetSeconds = offsetMs / 1000.0; + await (widget.player.platform as dynamic).setProperty( + 'audio-delay', + offsetSeconds.toString(), + ); + + 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), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + _buildHeader(), + const Divider(color: Colors.white24, height: 1), + Expanded( + child: () { + switch (_currentView) { + case _SettingsView.menu: + return _buildMenuView(); + case _SettingsView.speed: + return _buildSpeedView(); + case _SettingsView.sleep: + return _buildSleepView(); + case _SettingsView.audioSync: + return _buildAudioSyncView(); + } + }(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index cb7cab8f..9f03d255 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -17,13 +17,11 @@ 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'; -import 'sheets/sleep_timer_sheet.dart'; import 'sheets/subtitle_track_sheet.dart'; import 'sheets/version_sheet.dart'; +import 'sheets/video_settings_sheet.dart'; /// Custom video controls builder for Plex with chapter, audio, and subtitle support Widget plexVideoControlsBuilder( @@ -321,46 +319,37 @@ class _PlexVideoControlsState extends State return Row( mainAxisSize: MainAxisSize.min, children: [ - // Sleep Timer button + // Unified settings button (speed, sleep timer, audio sync) ListenableBuilder( listenable: SleepTimerService(), builder: (context, _) { final sleepTimer = SleepTimerService(); + final isActive = sleepTimer.isActive || _audioSyncOffset != 0; return IconButton( icon: Icon( - sleepTimer.isActive - ? Icons.bedtime - : Icons.bedtime_outlined, - color: sleepTimer.isActive ? Colors.amber : Colors.white, + Icons.tune, + color: isActive ? Colors.amber : Colors.white, ), - onPressed: () => SleepTimerSheet.show(context, widget.player), + onPressed: () async { + VideoSettingsSheet.show( + context, + widget.player, + _audioSyncOffset, + ); + // Reload offset after sheet closes (in case it changed) + await Future.delayed(const Duration(milliseconds: 300)); + if (mounted) { + _loadSeekTimes(); + } + }, ); }, ), - IconButton( - icon: const Icon(Icons.speed, color: Colors.white), - onPressed: () => PlaybackSpeedSheet.show(context, widget.player), - ), if (_hasMultipleAudioTracks(tracks)) IconButton( 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),