Drops dead code across services, models, utils and widgets, including the connection auth service, which had no implementer, and the Live TV DVR provisioning models, which had no caller. Tests that only covered deleted behaviour are removed or trimmed. No behaviour change.
345 lines
12 KiB
Dart
345 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 [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for
|
|
/// the backend-neutral entry point. Flows outside that abstraction, such as
|
|
/// [launchFromFolder], live directly on this class.
|
|
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.
|
|
Future<PlayQueueResult> launchFromFolder({
|
|
required String folderKey,
|
|
required bool shuffle,
|
|
String? libraryId,
|
|
String? libraryTitle,
|
|
bool showLoadingIndicator = true,
|
|
}) async {
|
|
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;
|
|
}
|