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