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 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(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 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 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 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 _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 _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(); 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; }