feat: shuffle settings
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user