Files
plezy/lib/services/play_queue_launcher.dart
edde746 352b88109b refactor: extract shared mixins and helpers, drop dead abstractions
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.
2026-07-26 06:09:48 +02:00

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;
}