Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action.
352 lines
12 KiB
Dart
352 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';
|
|
|
|
// Re-export the result types so existing imports of this file keep working.
|
|
export 'media_list_playback_launcher.dart'
|
|
show PlayQueueResult, PlayQueueSuccess, PlayQueueEmpty, PlayQueueCancelled, PlayQueueError;
|
|
|
|
/// 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 == null || (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;
|
|
}
|