feat: shuffle play

This commit is contained in:
edde746
2025-11-05 07:28:23 +01:00
parent 8d8423d136
commit 6243195b21
5 changed files with 277 additions and 28 deletions
+38
View File
@@ -379,6 +379,44 @@ class PlexClient {
return _extractMetadataList(response);
}
/// Get all unwatched episodes for a TV show across all seasons
Future<List<PlexMetadata>> getAllUnwatchedEpisodes(
String showRatingKey,
) async {
final allEpisodes = <PlexMetadata>[];
// 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<List<PlexMetadata>> 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 '';
+2
View File
@@ -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<ThemeProvider>(
builder: (context, themeProvider, child) {
@@ -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<PlexMetadata> _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<PlexMetadata> 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;
}
+81 -24
View File
@@ -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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
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<PlaybackStateProvider>();
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<VideoPlayerScreen> {
// Build list of preferred languages
final preferredLanguages = <String>[];
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<VideoPlayerScreen> {
// 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<VideoPlayerScreen> {
// Build list of preferred languages
final preferredLanguages = <String>[];
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<VideoPlayerScreen> {
}
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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
// 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<VideoPlayerScreen> {
color: Colors.white,
),
const SizedBox(height: 24),
const Text(
'Up Next',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
Consumer<PlaybackStateProvider>(
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(
+91 -4
View File
@@ -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<MediaContextMenu> {
);
}
// 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<MediaContextMenu> {
case 'fileinfo':
await _showFileInfo(context);
break;
case 'shuffle_play':
await _handleShufflePlay(context);
break;
}
}
@@ -303,9 +321,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
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<MediaContextMenu> {
Navigator.pop(context);
}
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error loading file info: $e')));
}
}
}
/// Handle shuffle play action
Future<void> _handleShufflePlay(BuildContext context) async {
final client = context.client;
if (client == null) return;
final playbackState = context.read<PlaybackStateProvider>();
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<PlexMetadata> 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')),
);
}
}