feat: shuffle settings

This commit is contained in:
edde746
2025-11-07 20:50:09 +01:00
parent b6234120dd
commit fac7aed1e7
6 changed files with 227 additions and 16 deletions
+32 -1
View File
@@ -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 = [];
+33
View File
@@ -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<void> setLibraryDensity(LibraryDensity density) async {
if (_libraryDensity != density) {
@@ -58,6 +67,30 @@ class SettingsProvider extends ChangeNotifier {
}
}
Future<void> setShuffleUnwatchedOnly(bool value) async {
if (_shuffleUnwatchedOnly != value) {
_shuffleUnwatchedOnly = value;
await _settingsService.setShuffleUnwatchedOnly(value);
notifyListeners();
}
}
Future<void> setShuffleOrderNavigation(bool value) async {
if (_shuffleOrderNavigation != value) {
_shuffleOrderNavigation = value;
await _settingsService.setShuffleOrderNavigation(value);
notifyListeners();
}
}
Future<void> setShuffleLoopQueue(bool value) async {
if (_shuffleLoopQueue != value) {
_shuffleLoopQueue = value;
await _settingsService.setShuffleLoopQueue(value);
notifyListeners();
}
}
String get libraryDensityDisplayName {
switch (_libraryDensity) {
case LibraryDensity.compact:
+66
View File
@@ -75,6 +75,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
);
}
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<SettingsProvider>(
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<SettingsProvider>(
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<SettingsProvider>(
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(
+18 -4
View File
@@ -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<VideoPlayerScreen> {
if (client == null) return;
final playbackState = context.read<PlaybackStateProvider>();
final settingsProvider = context.read<SettingsProvider>();
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);
+35
View File
@@ -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<void> 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<void> 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<void> setShuffleLoopQueue(bool enabled) async {
await _prefs.setBool(_keyShuffleLoopQueue, enabled);
}
bool getShuffleLoopQueue() {
return _prefs.getBool(_keyShuffleLoopQueue) ?? false; // Default: false
}
// Reset all settings to defaults
Future<void> 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),
]);
}
+43 -11
View File
@@ -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<void> handleShufflePlay(
BuildContext context,
PlexMetadata metadata,
@@ -18,8 +19,12 @@ Future<void> handleShufflePlay(
if (client == null) return;
final playbackState = context.read<PlaybackStateProvider>();
final settingsProvider = context.read<SettingsProvider>();
final itemType = metadata.type.toLowerCase();
// Get shuffle setting
final unwatchedOnly = settingsProvider.shuffleUnwatchedOnly;
try {
// Show loading indicator
if (context.mounted) {
@@ -31,17 +36,44 @@ Future<void> handleShufflePlay(
);
}
// Get unwatched episodes based on type
// Get episodes based on type and settings
List<PlexMetadata> 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 = <PlexMetadata>[];
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<void> 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;