From b3696035b506f70b3c76aba9e2cfb4bd03ef5644 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 28 Oct 2025 07:50:51 +0100 Subject: [PATCH] feat: playback speed controls --- lib/screens/video_player_screen.dart | 14 +++++ lib/widgets/plex_video_controls.dart | 89 ++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 7189ae429..4028da041 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -15,6 +15,7 @@ class VideoPlayerScreen extends StatefulWidget { final PlexMetadata metadata; final AudioTrack? preferredAudioTrack; final SubtitleTrack? preferredSubtitleTrack; + final double? preferredPlaybackRate; final PlexUserProfile? userProfile; const VideoPlayerScreen({ @@ -23,6 +24,7 @@ class VideoPlayerScreen extends StatefulWidget { required this.metadata, this.preferredAudioTrack, this.preferredSubtitleTrack, + this.preferredPlaybackRate, this.userProfile, }); @@ -545,6 +547,14 @@ class _VideoPlayerScreenState extends State { appLogger.i('Final subtitle selection: $finalSubtitle'); player.setSubtitleTrack(subtitleToSelect); + // Set playback rate if preferred rate was provided + if (widget.preferredPlaybackRate != null) { + appLogger.d( + 'Setting preferred playback rate: ${widget.preferredPlaybackRate}x', + ); + player.setRate(widget.preferredPlaybackRate!); + } + appLogger.d('Track selection complete'); } @@ -621,6 +631,7 @@ class _VideoPlayerScreenState extends State { // Navigate to the next episode using pushReplacement to destroy current player if (mounted) { + final currentRate = player.state.rate; Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => VideoPlayerScreen( @@ -628,6 +639,7 @@ class _VideoPlayerScreenState extends State { metadata: _nextEpisode!, preferredAudioTrack: currentAudioTrack, preferredSubtitleTrack: currentSubtitleTrack, + preferredPlaybackRate: currentRate, userProfile: widget.userProfile, ), ), @@ -649,6 +661,7 @@ class _VideoPlayerScreenState extends State { // Navigate to the previous episode using pushReplacement to destroy current player if (mounted) { + final currentRate = player.state.rate; Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => VideoPlayerScreen( @@ -656,6 +669,7 @@ class _VideoPlayerScreenState extends State { metadata: _previousEpisode!, preferredAudioTrack: currentAudioTrack, preferredSubtitleTrack: currentSubtitleTrack, + preferredPlaybackRate: currentRate, userProfile: widget.userProfile, ), ), diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index feca01ebf..b8246d467 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -205,6 +205,10 @@ class _PlexVideoControlsState extends State return Row( mainAxisSize: MainAxisSize.min, children: [ + IconButton( + icon: const Icon(Icons.speed, color: Colors.white), + onPressed: _showPlaybackSpeedBottomSheet, + ), if (_hasMultipleAudioTracks(tracks)) IconButton( icon: const Icon(Icons.audiotrack, color: Colors.white), @@ -1578,6 +1582,91 @@ class _PlexVideoControlsState extends State ); } + void _showPlaybackSpeedBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: _getBottomSheetConstraints(), + builder: (context) => StreamBuilder( + stream: widget.player.stream.rate, + initialData: widget.player.state.rate, + builder: (context, snapshot) { + final currentRate = snapshot.data ?? 1.0; + + // Define available playback speeds + final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + + 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.speed, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Playback Speed', + 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: ListView.builder( + itemCount: speeds.length, + itemBuilder: (context, index) { + final speed = speeds[index]; + final isSelected = (currentRate - speed).abs() < 0.01; + + // Format speed label + 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); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + String _formatDuration(Duration duration) { final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60);