Tapping Instant Mix on Plex often did nothing: the /playQueues POST used no endpoint failover, createPlayQueue swallowed every error into null, fetchInstantMix mapped that to an empty list, playInstantMix returned silently on empty tracks, and the errors stream's only listener is the now-playing screen, which is not mounted at tap time (#2141). The play-queue POST now rides the shared endpoint failover (replaying it is safe: an orphaned duplicate queue on the server is inert), createPlayQueue and fetchInstantMix propagate typed errors, playInstantMix reports started/empty/superseded and throws fetch failures, and the tap site shows a translated snackbar for a failed or empty mix. A failed collection or playlist queue launch now also reaches the real failure snackbar instead of the misleading "no items" path.
348 lines
12 KiB
Dart
348 lines
12 KiB
Dart
import 'dart:async';
|
|
import '../media/ids.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../media/media_item.dart';
|
|
import '../media/media_kind.dart';
|
|
import '../media/media_playlist.dart';
|
|
import '../models/plex/play_queue_response.dart';
|
|
import '../providers/multi_server_provider.dart';
|
|
import '../providers/playback_state_provider.dart';
|
|
import '../utils/video_player_navigation.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import 'media_list_playback_launcher.dart';
|
|
import 'plex_client.dart';
|
|
|
|
/// Plex-specific play queue launcher.
|
|
///
|
|
/// Centralizes the common pattern of:
|
|
/// 1. Creating a play queue via Plex's server-side `/playQueues` resource
|
|
/// 2. Setting up [PlaybackStateProvider]
|
|
/// 3. Navigating to the video player
|
|
/// 4. Handling errors with appropriate feedback
|
|
///
|
|
/// Implements the backend-neutral [MediaListPlaybackLauncher] entry points
|
|
/// on top of that resource.
|
|
class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
|
final BuildContext context;
|
|
final PlexClient client;
|
|
final String? serverId;
|
|
final String? serverName;
|
|
|
|
/// Narrow test seam for asserting queue publication without building the
|
|
/// full player route dependency tree.
|
|
final PlaybackStateProvider? playbackStateForTesting;
|
|
final Future<void> Function(MediaItem item)? navigateForTesting;
|
|
|
|
PlexPlayQueueLauncher({
|
|
required this.context,
|
|
required this.client,
|
|
this.serverId,
|
|
this.serverName,
|
|
this.playbackStateForTesting,
|
|
this.navigateForTesting,
|
|
});
|
|
|
|
/// Resolve the right [PlexClient] for [item]'s server and build a launcher.
|
|
/// Falls back to the first available Plex client when [item] doesn't carry
|
|
/// a `serverId`.
|
|
factory PlexPlayQueueLauncher.forContext(BuildContext context, Object item) {
|
|
final String? itemServerId;
|
|
final String? itemServerName;
|
|
if (item is MediaItem) {
|
|
itemServerId = item.serverId;
|
|
itemServerName = item.serverName;
|
|
} else if (item is MediaPlaylist) {
|
|
itemServerId = item.serverId;
|
|
itemServerName = item.serverName;
|
|
} else {
|
|
itemServerId = null;
|
|
itemServerName = null;
|
|
}
|
|
|
|
final provider = Provider.of<MultiServerProvider>(context, listen: false);
|
|
PlexClient? plexClient;
|
|
if (itemServerId != null) {
|
|
// Plex-only: server-side `/playQueues` resource has no Jellyfin equivalent.
|
|
plexClient = provider.getPlexClientForServer(ServerId(itemServerId));
|
|
}
|
|
if (plexClient == null) {
|
|
// Fall back to the first online Plex client.
|
|
for (final id in provider.onlineServerIds) {
|
|
final c = provider.getPlexClientForServer(ServerId(id));
|
|
if (c != null) {
|
|
plexClient = c;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (plexClient == null) {
|
|
throw Exception(t.errors.noClientAvailable);
|
|
}
|
|
return PlexPlayQueueLauncher(
|
|
context: context,
|
|
client: plexClient,
|
|
serverId: itemServerId,
|
|
serverName: itemServerName,
|
|
);
|
|
}
|
|
|
|
/// Launch playback from a collection or playlist.
|
|
///
|
|
/// Accepts a [MediaItem] (collection) or [MediaPlaylist]. Typed as [Object]
|
|
/// because Dart has no nominal union type.
|
|
@override
|
|
Future<PlayQueueResult> launchFromCollectionOrPlaylist({
|
|
required Object item,
|
|
required bool shuffle,
|
|
MediaItem? startItem,
|
|
bool showLoadingIndicator = true,
|
|
}) async {
|
|
final facts = MediaListPlaybackLauncher.classifyItem(item);
|
|
if (facts == null) {
|
|
return PlayQueueError(Exception('Item must be either a collection or playlist'));
|
|
}
|
|
final ratingKey = facts.id;
|
|
final itemServerId = facts.serverId ?? serverId;
|
|
final itemServerName = facts.serverName ?? serverName;
|
|
|
|
return executeWithLoading(
|
|
context: context,
|
|
showLoading: showLoadingIndicator,
|
|
actionLabel: t.common.shuffle,
|
|
execute: (dismissLoading) async {
|
|
PlayQueueResponse playQueue;
|
|
final sourceLibraryId = facts.isCollection && item is MediaItem ? item.libraryId : null;
|
|
final sourceLibraryTitle = facts.isCollection && item is MediaItem ? item.libraryTitle : null;
|
|
// Plex's `key` param positions the queue's selected item — passed
|
|
// through when the caller wants playback to start at a specific
|
|
// entry. Ignored on shuffle (the server picks a random head).
|
|
final selectedKey = (!shuffle && startItem != null) ? '/library/metadata/${startItem.id}' : null;
|
|
|
|
if (facts.isCollection) {
|
|
final machineId = client.config.machineIdentifier ?? await client.getMachineIdentifier();
|
|
|
|
if (machineId == null) {
|
|
throw Exception('Could not get server machine identifier');
|
|
}
|
|
|
|
final collectionUri = 'server://$machineId/com.plexapp.plugins.library/library/collections/$ratingKey';
|
|
playQueue = await client.createPlayQueue(
|
|
uri: collectionUri,
|
|
type: 'video',
|
|
shuffle: shuffle ? 1 : 0,
|
|
key: selectedKey,
|
|
librarySectionID: sourceLibraryId,
|
|
librarySectionTitle: sourceLibraryTitle,
|
|
);
|
|
} else {
|
|
// For playlists, use playlistID parameter
|
|
playQueue = await client.createPlayQueue(
|
|
playlistID: int.parse(ratingKey),
|
|
type: 'video',
|
|
shuffle: shuffle ? 1 : 0,
|
|
key: selectedKey,
|
|
);
|
|
}
|
|
|
|
playQueue = await _refetchIfEmpty(playQueue, libraryId: sourceLibraryId, libraryTitle: sourceLibraryTitle);
|
|
|
|
// Close loading dialog before navigating to the player
|
|
await dismissLoading();
|
|
|
|
return _launchFromQueue(
|
|
playQueue: playQueue,
|
|
ratingKey: ratingKey,
|
|
serverId: serverIdOrNull(itemServerId),
|
|
serverName: itemServerName,
|
|
libraryId: sourceLibraryId,
|
|
libraryTitle: sourceLibraryTitle,
|
|
selectedItem: selectedKey != null ? _resolveSelectedMediaItem(playQueue) : null,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Launch shuffled playback for a show or season.
|
|
@override
|
|
Future<PlayQueueResult> launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async {
|
|
final kind = metadata.kind;
|
|
|
|
if (kind != MediaKind.show && kind != MediaKind.season) {
|
|
return PlayQueueError(Exception('Shuffle play only works for shows and seasons'));
|
|
}
|
|
|
|
return executeWithLoading(
|
|
context: context,
|
|
showLoading: showLoadingIndicator,
|
|
actionLabel: t.common.shuffle,
|
|
execute: (dismissLoading) async {
|
|
// Determine the rating key for the play queue
|
|
String showRatingKey;
|
|
if (kind == MediaKind.show) {
|
|
showRatingKey = metadata.id;
|
|
} else {
|
|
// For seasons, we need the show's rating key
|
|
if (metadata.parentId == null) {
|
|
throw Exception('Season is missing parentRatingKey');
|
|
}
|
|
showRatingKey = metadata.parentId!;
|
|
}
|
|
|
|
final playQueue = await client.createShowPlayQueue(
|
|
showRatingKey: showRatingKey,
|
|
shuffle: 1,
|
|
librarySectionID: metadata.libraryId,
|
|
librarySectionTitle: metadata.libraryTitle,
|
|
);
|
|
|
|
// Close loading dialog before navigating to the player
|
|
await dismissLoading();
|
|
|
|
return _launchFromQueue(
|
|
playQueue: playQueue,
|
|
ratingKey: showRatingKey,
|
|
serverId: serverIdOrNull(metadata.serverId ?? serverId),
|
|
serverName: metadata.serverName ?? serverName,
|
|
libraryId: metadata.libraryId,
|
|
libraryTitle: metadata.libraryTitle,
|
|
copyServerInfo: true,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Launch playback from a folder's contents. The `/folder` key and the
|
|
/// owning library are both stamped onto the folder row by the listing
|
|
/// fetch, so the neutral [MediaItem] carries everything Plex needs.
|
|
@override
|
|
Future<PlayQueueResult> launchFromFolder({
|
|
required MediaItem folder,
|
|
required bool shuffle,
|
|
bool showLoadingIndicator = true,
|
|
}) async {
|
|
final folderKey = folder.backendFolderKey;
|
|
if (folderKey == null) {
|
|
return PlayQueueError(Exception('Folder is missing its backend folder key'));
|
|
}
|
|
final libraryId = folder.libraryId;
|
|
final libraryTitle = folder.libraryTitle;
|
|
|
|
return executeWithLoading(
|
|
context: context,
|
|
showLoading: showLoadingIndicator,
|
|
actionLabel: shuffle ? t.common.shuffle : t.common.play,
|
|
execute: (dismissLoading) async {
|
|
final folderUri = await client.buildFolderUri(folderKey);
|
|
|
|
var playQueue = await client.createPlayQueue(
|
|
uri: folderUri,
|
|
type: 'video',
|
|
shuffle: shuffle ? 1 : 0,
|
|
librarySectionID: libraryId,
|
|
librarySectionTitle: libraryTitle,
|
|
);
|
|
|
|
playQueue = await _refetchIfEmpty(playQueue, libraryId: libraryId, libraryTitle: libraryTitle);
|
|
|
|
await dismissLoading();
|
|
|
|
return _launchFromQueue(
|
|
playQueue: playQueue,
|
|
ratingKey: folderKey,
|
|
serverId: serverIdOrNull(serverId),
|
|
serverName: serverName,
|
|
libraryId: libraryId,
|
|
libraryTitle: libraryTitle,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Creation sometimes returns a queue without its items; re-read it by ID
|
|
/// and keep the refetched copy only when it actually carries items.
|
|
Future<PlayQueueResponse> _refetchIfEmpty(
|
|
PlayQueueResponse playQueue, {
|
|
String? libraryId,
|
|
String? libraryTitle,
|
|
}) async {
|
|
if (playQueue.items != null && playQueue.items!.isNotEmpty) {
|
|
return playQueue;
|
|
}
|
|
final fetchedQueue = await client.getPlayQueue(
|
|
playQueue.playQueueID,
|
|
librarySectionID: libraryId,
|
|
librarySectionTitle: libraryTitle,
|
|
);
|
|
if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) {
|
|
return fetchedQueue;
|
|
}
|
|
return playQueue;
|
|
}
|
|
|
|
/// Core method to launch playback from a play queue.
|
|
Future<PlayQueueResult> _launchFromQueue({
|
|
required PlayQueueResponse? playQueue,
|
|
required String ratingKey,
|
|
ServerId? serverId,
|
|
String? serverName,
|
|
String? libraryId,
|
|
String? libraryTitle,
|
|
MediaItem? selectedItem,
|
|
bool copyServerInfo = false,
|
|
}) async {
|
|
if (playQueue == null || playQueue.items == null || playQueue.items!.isEmpty) {
|
|
return const PlayQueueEmpty();
|
|
}
|
|
|
|
if (!context.mounted && navigateForTesting == null) {
|
|
return const PlayQueueError('Context not mounted');
|
|
}
|
|
|
|
final playbackState = playbackStateForTesting ?? context.read<PlaybackStateProvider>();
|
|
playbackState.setPlayQueueWindowFetcher(
|
|
libraryId == null
|
|
? (id, {center, window = 50}) => client.getPlayQueue(id, center: center, window: window)
|
|
: (id, {center, window = 50}) => client.getPlayQueue(
|
|
id,
|
|
center: center,
|
|
window: window,
|
|
librarySectionID: libraryId,
|
|
librarySectionTitle: libraryTitle,
|
|
),
|
|
);
|
|
await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey);
|
|
|
|
if (!context.mounted && navigateForTesting == null) {
|
|
return const PlayQueueError('Context not mounted');
|
|
}
|
|
|
|
var itemToPlay = selectedItem ?? playQueue.items!.first;
|
|
|
|
if (copyServerInfo && serverId != null) {
|
|
itemToPlay = itemToPlay.copyWith(
|
|
serverId: serverId,
|
|
serverName: serverName ?? itemToPlay.serverName,
|
|
libraryId: itemToPlay.libraryId ?? libraryId,
|
|
libraryTitle: itemToPlay.libraryTitle ?? libraryTitle,
|
|
);
|
|
}
|
|
|
|
if (navigateForTesting != null) {
|
|
await navigateForTesting!(itemToPlay);
|
|
} else {
|
|
await navigateToVideoPlayer(context, metadata: itemToPlay);
|
|
}
|
|
|
|
return const PlayQueueSuccess();
|
|
}
|
|
}
|
|
|
|
/// Pull the selected item from a play queue. The selected item is identified
|
|
/// by `playQueueSelectedItemID`; returns null if the queue has no selection.
|
|
MediaItem? _resolveSelectedMediaItem(PlayQueueResponse? playQueue) {
|
|
return playQueue?.selectedItem;
|
|
}
|