diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 8426817b..b61ce5df 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -25,7 +25,8 @@ class PlaybackStateProvider with ChangeNotifier { /// 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) { + /// [loopQueue] - If true, restart from beginning when queue is exhausted + PlexMetadata? getNextEpisode(String currentEpisodeKey, {bool loopQueue = false}) { if (_shuffleQueue.isEmpty) return null; // Find current episode in queue @@ -42,6 +43,11 @@ class PlaybackStateProvider with ChangeNotifier { // Check if there's a next episode if (currentIndex + 1 >= _shuffleQueue.length) { // Queue exhausted + if (loopQueue && _shuffleQueue.isNotEmpty) { + // Loop back to beginning + _currentIndex = 0; + return _shuffleQueue[_currentIndex]; + } return null; } @@ -49,6 +55,31 @@ class PlaybackStateProvider with ChangeNotifier { return _shuffleQueue[_currentIndex]; } + /// Gets the previous episode in the shuffle queue. + /// Returns null if at the beginning of the queue or current episode is not in queue. + PlexMetadata? getPreviousEpisode(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 + return null; + } + + // Check if there's a previous episode + if (currentIndex <= 0) { + // At the beginning of queue + return null; + } + + _currentIndex = currentIndex - 1; + return _shuffleQueue[_currentIndex]; + } + /// Clears the shuffle queue and exits shuffle mode void clearShuffle() { _shuffleQueue = []; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index cd9ffa3c..61e98b6a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -7,6 +7,9 @@ class SettingsProvider extends ChangeNotifier { ViewMode _viewMode = ViewMode.grid; bool _useSeasonPoster = false; bool _showHeroSection = true; + bool _shuffleUnwatchedOnly = true; + bool _shuffleOrderNavigation = true; + bool _shuffleLoopQueue = false; SettingsProvider() { _initializeSettings(); @@ -18,6 +21,9 @@ class SettingsProvider extends ChangeNotifier { _viewMode = _settingsService.getViewMode(); _useSeasonPoster = _settingsService.getUseSeasonPoster(); _showHeroSection = _settingsService.getShowHeroSection(); + _shuffleUnwatchedOnly = _settingsService.getShuffleUnwatchedOnly(); + _shuffleOrderNavigation = _settingsService.getShuffleOrderNavigation(); + _shuffleLoopQueue = _settingsService.getShuffleLoopQueue(); notifyListeners(); } @@ -25,6 +31,9 @@ class SettingsProvider extends ChangeNotifier { ViewMode get viewMode => _viewMode; bool get useSeasonPoster => _useSeasonPoster; bool get showHeroSection => _showHeroSection; + bool get shuffleUnwatchedOnly => _shuffleUnwatchedOnly; + bool get shuffleOrderNavigation => _shuffleOrderNavigation; + bool get shuffleLoopQueue => _shuffleLoopQueue; Future setLibraryDensity(LibraryDensity density) async { if (_libraryDensity != density) { @@ -58,6 +67,30 @@ class SettingsProvider extends ChangeNotifier { } } + Future setShuffleUnwatchedOnly(bool value) async { + if (_shuffleUnwatchedOnly != value) { + _shuffleUnwatchedOnly = value; + await _settingsService.setShuffleUnwatchedOnly(value); + notifyListeners(); + } + } + + Future setShuffleOrderNavigation(bool value) async { + if (_shuffleOrderNavigation != value) { + _shuffleOrderNavigation = value; + await _settingsService.setShuffleOrderNavigation(value); + notifyListeners(); + } + } + + Future setShuffleLoopQueue(bool value) async { + if (_shuffleLoopQueue != value) { + _shuffleLoopQueue = value; + await _settingsService.setShuffleLoopQueue(value); + notifyListeners(); + } + } + String get libraryDensityDisplayName { switch (_libraryDensity) { case LibraryDensity.compact: diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9e885e35..9af5bca3 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -75,6 +75,8 @@ class _SettingsScreenState extends State { const SizedBox(height: 24), _buildVideoPlaybackSection(), const SizedBox(height: 24), + _buildShufflePlaySection(), + const SizedBox(height: 24), _buildKeyboardShortcutsSection(), const SizedBox(height: 24), _buildAdvancedSection(), @@ -248,6 +250,70 @@ class _SettingsScreenState extends State { ); } + Widget _buildShufflePlaySection() { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Shuffle Play', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const Icon(Icons.visibility_off), + title: const Text('Unwatched Only'), + subtitle: const Text( + 'Only include unwatched episodes in shuffle queue', + ), + value: settingsProvider.shuffleUnwatchedOnly, + onChanged: (value) async { + await settingsProvider.setShuffleUnwatchedOnly(value); + }, + ); + }, + ), + Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const Icon(Icons.shuffle), + title: const Text('Shuffle Order Navigation'), + subtitle: const Text( + 'Next/previous buttons follow shuffled order', + ), + value: settingsProvider.shuffleOrderNavigation, + onChanged: (value) async { + await settingsProvider.setShuffleOrderNavigation(value); + }, + ); + }, + ), + Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const Icon(Icons.loop), + title: const Text('Loop Shuffle Queue'), + subtitle: const Text( + 'Restart queue when reaching the end', + ), + value: settingsProvider.shuffleLoopQueue, + onChanged: (value) async { + await settingsProvider.setShuffleLoopQueue(value); + }, + ); + }, + ), + ], + ), + ); + } + Widget _buildKeyboardShortcutsSection() { return Card( child: Column( diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index a2ee3e75..e65609d8 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -9,6 +9,7 @@ import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../providers/plex_client_provider.dart'; import '../providers/playback_state_provider.dart'; +import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../widgets/video_controls/video_controls.dart'; import '../utils/language_codes.dart'; @@ -323,16 +324,29 @@ class VideoPlayerScreenState extends State { if (client == null) return; final playbackState = context.read(); + final settingsProvider = 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; + // Get settings + final shuffleOrderNavigation = settingsProvider.shuffleOrderNavigation; + final loopQueue = settingsProvider.shuffleLoopQueue; + + if (shuffleOrderNavigation) { + // Use shuffled order for next/previous + next = playbackState.getNextEpisode( + widget.metadata.ratingKey, + loopQueue: loopQueue, + ); + previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey); + } else { + // Use chronological order even in shuffle mode + next = await client.findAdjacentEpisode(widget.metadata, 1); + previous = await client.findAdjacentEpisode(widget.metadata, -1); + } } else { // Use normal sequential episode loading next = await client.findAdjacentEpisode(widget.metadata, 1); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index aefd06c4..c24ff73f 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -37,6 +37,9 @@ class SettingsService { static const String _keySubtitleBorderColor = 'subtitle_border_color'; static const String _keySubtitleBackgroundColor = 'subtitle_background_color'; static const String _keySubtitleBackgroundOpacity = 'subtitle_background_opacity'; + static const String _keyShuffleUnwatchedOnly = 'shuffle_unwatched_only'; + static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation'; + static const String _keyShuffleLoopQueue = 'shuffle_loop_queue'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -777,6 +780,35 @@ class SettingsService { } } + // Shuffle Play Settings + + /// Shuffle Unwatched Only - Filter shuffle queue to unwatched episodes only + Future setShuffleUnwatchedOnly(bool enabled) async { + await _prefs.setBool(_keyShuffleUnwatchedOnly, enabled); + } + + bool getShuffleUnwatchedOnly() { + return _prefs.getBool(_keyShuffleUnwatchedOnly) ?? true; // Default: true + } + + /// Shuffle Order Navigation - Next/previous buttons follow shuffled order + Future setShuffleOrderNavigation(bool enabled) async { + await _prefs.setBool(_keyShuffleOrderNavigation, enabled); + } + + bool getShuffleOrderNavigation() { + return _prefs.getBool(_keyShuffleOrderNavigation) ?? true; // Default: true + } + + /// Shuffle Loop Queue - Restart queue when reaching the end + Future setShuffleLoopQueue(bool enabled) async { + await _prefs.setBool(_keyShuffleLoopQueue, enabled); + } + + bool getShuffleLoopQueue() { + return _prefs.getBool(_keyShuffleLoopQueue) ?? false; // Default: false + } + // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ @@ -805,6 +837,9 @@ class SettingsService { _prefs.remove(_keySubtitleBorderColor), _prefs.remove(_keySubtitleBackgroundColor), _prefs.remove(_keySubtitleBackgroundOpacity), + _prefs.remove(_keyShuffleUnwatchedOnly), + _prefs.remove(_keyShuffleOrderNavigation), + _prefs.remove(_keyShuffleLoopQueue), ]); } diff --git a/lib/utils/shuffle_play_helper.dart b/lib/utils/shuffle_play_helper.dart index fca5eb04..8f3ab23e 100644 --- a/lib/utils/shuffle_play_helper.dart +++ b/lib/utils/shuffle_play_helper.dart @@ -2,14 +2,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/plex_metadata.dart'; import '../providers/playback_state_provider.dart'; +import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; /// Handle shuffle play action for shows and seasons /// -/// Fetches all unwatched episodes, shuffles them, and starts playback -/// from the first shuffled episode. The shuffle queue is stored in the -/// PlaybackStateProvider for continuous shuffle playback. +/// Fetches episodes based on user settings (unwatched only or including watched), +/// shuffles them, and starts playback from the first shuffled episode. +/// The shuffle queue is stored in the PlaybackStateProvider for continuous shuffle playback. Future handleShufflePlay( BuildContext context, PlexMetadata metadata, @@ -18,8 +19,12 @@ Future handleShufflePlay( if (client == null) return; final playbackState = context.read(); + final settingsProvider = context.read(); final itemType = metadata.type.toLowerCase(); + // Get shuffle setting + final unwatchedOnly = settingsProvider.shuffleUnwatchedOnly; + try { // Show loading indicator if (context.mounted) { @@ -31,17 +36,44 @@ Future handleShufflePlay( ); } - // Get unwatched episodes based on type + // Get episodes based on type and settings List episodes; if (itemType == 'show') { - episodes = await client.getAllUnwatchedEpisodes( - metadata.ratingKey, - ); + if (unwatchedOnly) { + // Get only unwatched episodes + episodes = await client.getAllUnwatchedEpisodes( + metadata.ratingKey, + ); + } else { + // Get all episodes from all seasons + final allEpisodes = []; + final seasons = await client.getChildren(metadata.ratingKey); + + for (final season in seasons) { + if (season.type == 'season') { + final seasonEpisodes = await client.getChildren(season.ratingKey); + final episodesOnly = seasonEpisodes + .where((ep) => ep.type == 'episode') + .toList(); + allEpisodes.addAll(episodesOnly); + } + } + episodes = allEpisodes; + } } else { // season - episodes = await client.getUnwatchedEpisodesInSeason( - metadata.ratingKey, - ); + if (unwatchedOnly) { + // Get only unwatched episodes + episodes = await client.getUnwatchedEpisodesInSeason( + metadata.ratingKey, + ); + } else { + // Get all episodes in season + final seasonEpisodes = await client.getChildren(metadata.ratingKey); + episodes = seasonEpisodes + .where((ep) => ep.type == 'episode') + .toList(); + } } // Close loading indicator @@ -52,7 +84,7 @@ Future handleShufflePlay( if (episodes.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No unwatched episodes found')), + const SnackBar(content: Text('No episodes found')), ); } return;