From 6243195b2189fd4a72598e4fbfa2f9d1c986c5da Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 5 Nov 2025 07:28:23 +0100 Subject: [PATCH] feat: shuffle play --- lib/client/plex_client.dart | 38 ++++++++ lib/main.dart | 2 + lib/providers/playback_state_provider.dart | 65 +++++++++++++ lib/screens/video_player_screen.dart | 105 ++++++++++++++++----- lib/widgets/media_context_menu.dart | 95 ++++++++++++++++++- 5 files changed, 277 insertions(+), 28 deletions(-) create mode 100644 lib/providers/playback_state_provider.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 86e4f7eb..83907304 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -379,6 +379,44 @@ class PlexClient { return _extractMetadataList(response); } + /// Get all unwatched episodes for a TV show across all seasons + Future> getAllUnwatchedEpisodes( + String showRatingKey, + ) async { + final allEpisodes = []; + + // Get all seasons for the show + final seasons = await getChildren(showRatingKey); + + // Get episodes from each season + for (final season in seasons) { + if (season.type == 'season') { + final episodes = await getChildren(season.ratingKey); + + // Filter for unwatched episodes + final unwatchedEpisodes = episodes + .where((ep) => ep.type == 'episode' && (ep.viewCount ?? 0) == 0) + .toList(); + + allEpisodes.addAll(unwatchedEpisodes); + } + } + + return allEpisodes; + } + + /// Get all unwatched episodes in a specific season + Future> getUnwatchedEpisodesInSeason( + String seasonRatingKey, + ) async { + final episodes = await getChildren(seasonRatingKey); + + // Filter for unwatched episodes + return episodes + .where((ep) => ep.type == 'episode' && (ep.viewCount ?? 0) == 0) + .toList(); + } + /// Get thumbnail URL String getThumbnailUrl(String? thumbPath) { if (thumbPath == null || thumbPath.isEmpty) return ''; diff --git a/lib/main.dart b/lib/main.dart index 5c501469..8d704f7f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'providers/plex_client_provider.dart'; import 'providers/theme_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/hidden_libraries_provider.dart'; +import 'providers/playback_state_provider.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/provider_extensions.dart'; @@ -72,6 +73,7 @@ class MainApp extends StatelessWidget { ChangeNotifierProvider(create: (context) => ThemeProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider()), ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider()), + ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), ], child: Consumer( builder: (context, themeProvider, child) { diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart new file mode 100644 index 00000000..8426817b --- /dev/null +++ b/lib/providers/playback_state_provider.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import '../models/plex_metadata.dart'; + +/// Manages shuffle playback state for TV shows and seasons. +/// This provider is session-only and does not persist across app restarts. +class PlaybackStateProvider with ChangeNotifier { + List _shuffleQueue = []; + String? + _shuffleContextKey; // The show/season ratingKey for this shuffle session + int _currentIndex = 0; + + /// Whether shuffle mode is currently active + bool get isShuffleActive => _shuffleQueue.isNotEmpty; + + /// The context key (show or season ratingKey) for the current shuffle session + String? get shuffleContextKey => _shuffleContextKey; + + /// Sets a new shuffle queue and starts shuffle mode + void setShuffleQueue(List episodes, String contextKey) { + _shuffleQueue = List.from(episodes); + _shuffleContextKey = contextKey; + _currentIndex = 0; + notifyListeners(); + } + + /// Gets the next episode in the shuffle queue. + /// Returns null if queue is exhausted or current episode is not in queue. + PlexMetadata? getNextEpisode(String currentEpisodeKey) { + if (_shuffleQueue.isEmpty) return null; + + // Find current episode in queue + final currentIndex = _shuffleQueue.indexWhere( + (ep) => ep.ratingKey == currentEpisodeKey, + ); + + if (currentIndex == -1) { + // Current episode not in queue, clear shuffle + clearShuffle(); + return null; + } + + // Check if there's a next episode + if (currentIndex + 1 >= _shuffleQueue.length) { + // Queue exhausted + return null; + } + + _currentIndex = currentIndex + 1; + return _shuffleQueue[_currentIndex]; + } + + /// Clears the shuffle queue and exits shuffle mode + void clearShuffle() { + _shuffleQueue = []; + _shuffleContextKey = null; + _currentIndex = 0; + notifyListeners(); + } + + /// Gets the total number of episodes in the current shuffle queue + int get queueLength => _shuffleQueue.length; + + /// Gets the current position in the queue (1-indexed) + int get currentPosition => _currentIndex + 1; +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 87e23d61..939d5b31 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -3,9 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; +import 'package:provider/provider.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/playback_state_provider.dart'; import '../utils/provider_extensions.dart'; import '../widgets/plex_video_controls.dart'; import '../utils/language_codes.dart'; @@ -101,7 +103,8 @@ class _VideoPlayerScreenState extends State { final settingsService = await SettingsService.getInstance(); final bufferSizeMB = settingsService.getBufferSize(); final bufferSizeBytes = bufferSizeMB * 1024 * 1024; - final enableHardwareDecoding = settingsService.getEnableHardwareDecoding(); + final enableHardwareDecoding = settingsService + .getEnableHardwareDecoding(); // Create player with configuration player = Player( @@ -173,8 +176,22 @@ class _VideoPlayerScreenState extends State { final client = clientProvider.client; if (client == null) return; - final next = await client.findAdjacentEpisode(widget.metadata, 1); - final previous = await client.findAdjacentEpisode(widget.metadata, -1); + final playbackState = context.read(); + + PlexMetadata? next; + PlexMetadata? previous; + + // Check if shuffle mode is active + if (playbackState.isShuffleActive) { + // Get next episode from shuffle queue + next = playbackState.getNextEpisode(widget.metadata.ratingKey); + // No previous episode in shuffle mode + previous = null; + } else { + // Use normal sequential episode loading + next = await client.findAdjacentEpisode(widget.metadata, 1); + previous = await client.findAdjacentEpisode(widget.metadata, -1); + } if (mounted) { setState(() { @@ -387,7 +404,8 @@ class _VideoPlayerScreenState extends State { // Build list of preferred languages final preferredLanguages = []; - if (profile.defaultAudioLanguage != null && profile.defaultAudioLanguage!.isNotEmpty) { + if (profile.defaultAudioLanguage != null && + profile.defaultAudioLanguage!.isNotEmpty) { preferredLanguages.add(profile.defaultAudioLanguage!); } if (profile.defaultAudioLanguages != null) { @@ -469,15 +487,20 @@ class _VideoPlayerScreenState extends State { // Mode 1: Shown with foreign audio if (profile.autoSelectSubtitle == 1) { - appLogger.d('Profile specifies foreign audio mode (autoSelectSubtitle=1)'); + appLogger.d( + 'Profile specifies foreign audio mode (autoSelectSubtitle=1)', + ); // Check if audio language matches user's preferred subtitle language - if (selectedAudioTrack != null && profile.defaultSubtitleLanguage != null) { + if (selectedAudioTrack != null && + profile.defaultSubtitleLanguage != null) { final audioLang = selectedAudioTrack.language?.toLowerCase(); final prefLang = profile.defaultSubtitleLanguage!.toLowerCase(); final languageVariations = LanguageCodes.getVariations(prefLang); - appLogger.d('Checking if audio is foreign - audio: $audioLang, preferred subtitle lang: $prefLang'); + appLogger.d( + 'Checking if audio is foreign - audio: $audioLang, preferred subtitle lang: $prefLang', + ); // If audio matches preferred language, no subtitles needed if (audioLang != null && languageVariations.contains(audioLang)) { @@ -494,7 +517,8 @@ class _VideoPlayerScreenState extends State { // Build list of preferred languages final preferredLanguages = []; - if (profile.defaultSubtitleLanguage != null && profile.defaultSubtitleLanguage!.isNotEmpty) { + if (profile.defaultSubtitleLanguage != null && + profile.defaultSubtitleLanguage!.isNotEmpty) { preferredLanguages.add(profile.defaultSubtitleLanguage!); } if (profile.defaultSubtitleLanguages != null) { @@ -502,7 +526,9 @@ class _VideoPlayerScreenState extends State { } if (preferredLanguages.isEmpty) { - appLogger.d('Cannot use profile: No defaultSubtitleLanguage(s) specified'); + appLogger.d( + 'Cannot use profile: No defaultSubtitleLanguage(s) specified', + ); return null; } @@ -512,10 +538,16 @@ class _VideoPlayerScreenState extends State { var candidateTracks = availableTracks; // Filter by SDH (defaultSubtitleAccessibility: 0-3) - candidateTracks = _filterSubtitlesBySDH(candidateTracks, profile.defaultSubtitleAccessibility); + candidateTracks = _filterSubtitlesBySDH( + candidateTracks, + profile.defaultSubtitleAccessibility, + ); // Filter by forced subtitle preference (defaultSubtitleForced: 0-3) - candidateTracks = _filterSubtitlesByForced(candidateTracks, profile.defaultSubtitleForced); + candidateTracks = _filterSubtitlesByForced( + candidateTracks, + profile.defaultSubtitleForced, + ); // If no candidates after filtering, relax filters if (candidateTracks.isEmpty) { @@ -563,7 +595,9 @@ class _VideoPlayerScreenState extends State { final preferSDH = preference == 1; final preferred = tracks.where((t) => _isSDH(t) == preferSDH).toList(); if (preferred.isNotEmpty) { - appLogger.d('Applying SDH preference: ${preferSDH ? "prefer SDH" : "prefer non-SDH"} (${preferred.length} tracks)'); + appLogger.d( + 'Applying SDH preference: ${preferSDH ? "prefer SDH" : "prefer non-SDH"} (${preferred.length} tracks)', + ); return preferred; } appLogger.d('No tracks match SDH preference, using all tracks'); @@ -596,9 +630,13 @@ class _VideoPlayerScreenState extends State { if (preference == 0 || preference == 1) { // Prefer but don't require final preferForced = preference == 1; - final preferred = tracks.where((t) => _isForced(t) == preferForced).toList(); + final preferred = tracks + .where((t) => _isForced(t) == preferForced) + .toList(); if (preferred.isNotEmpty) { - appLogger.d('Applying forced preference: ${preferForced ? "prefer forced" : "prefer non-forced"} (${preferred.length} tracks)'); + appLogger.d( + 'Applying forced preference: ${preferForced ? "prefer forced" : "prefer non-forced"} (${preferred.length} tracks)', + ); return preferred; } appLogger.d('No tracks match forced preference, using all tracks'); @@ -625,9 +663,9 @@ class _VideoPlayerScreenState extends State { // Look for common SDH indicators return title.contains('sdh') || - title.contains('cc') || - title.contains('hearing impaired') || - title.contains('deaf'); + title.contains('cc') || + title.contains('hearing impaired') || + title.contains('deaf'); } /// Checks if a subtitle track is forced @@ -981,13 +1019,32 @@ class _VideoPlayerScreenState extends State { color: Colors.white, ), const SizedBox(height: 24), - const Text( - 'Up Next', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), + 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( diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 93ddbc01..be94a4f7 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,10 +1,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../models/plex_metadata.dart'; import '../utils/provider_extensions.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../widgets/file_info_bottom_sheet.dart'; +import '../providers/playback_state_provider.dart'; +import '../utils/video_player_navigation.dart'; /// Helper class to store menu action data class _MenuAction { @@ -100,6 +103,17 @@ class _MediaContextMenuState extends State { ); } + // Shuffle Play (for shows and seasons) + if (itemType == 'show' || itemType == 'season') { + menuActions.add( + _MenuAction( + value: 'shuffle_play', + icon: Icons.shuffle, + label: 'Shuffle Play', + ), + ); + } + // File Info (for episodes and movies) if (itemType == 'episode' || itemType == 'movie') { menuActions.add( @@ -235,6 +249,10 @@ class _MediaContextMenuState extends State { case 'fileinfo': await _showFileInfo(context); break; + + case 'shuffle_play': + await _handleShufflePlay(context); + break; } } @@ -303,9 +321,8 @@ class _MediaContextMenuState extends State { showDialog( context: context, barrierDismissible: false, - builder: (context) => const Center( - child: CircularProgressIndicator(), - ), + builder: (context) => + const Center(child: CircularProgressIndicator()), ); } @@ -339,9 +356,79 @@ class _MediaContextMenuState extends State { Navigator.pop(context); } + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error loading file info: $e'))); + } + } + } + + /// Handle shuffle play action + Future _handleShufflePlay(BuildContext context) async { + final client = context.client; + if (client == null) return; + + final playbackState = context.read(); + final itemType = widget.metadata.type.toLowerCase(); + + try { + // Show loading indicator + if (context.mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + const Center(child: CircularProgressIndicator()), + ); + } + + // Get unwatched episodes based on type + List episodes; + if (itemType == 'show') { + episodes = await client.getAllUnwatchedEpisodes( + widget.metadata.ratingKey, + ); + } else { + // season + episodes = await client.getUnwatchedEpisodesInSeason( + widget.metadata.ratingKey, + ); + } + + // Close loading indicator + if (context.mounted) { + Navigator.pop(context); + } + + if (episodes.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No unwatched episodes found')), + ); + } + return; + } + + // Shuffle the episodes + episodes.shuffle(); + + // Store shuffle queue in provider + playbackState.setShuffleQueue(episodes, widget.metadata.ratingKey); + + // Navigate to first episode + if (context.mounted) { + await navigateToVideoPlayer(context, metadata: episodes.first); + } + } catch (e) { + // Close loading indicator if it's still open + if (context.mounted && Navigator.canPop(context)) { + Navigator.pop(context); + } + if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error loading file info: $e')), + SnackBar(content: Text('Error starting shuffle play: $e')), ); } }