2055 lines
75 KiB
Dart
2055 lines
75 KiB
Dart
import 'dart:async';
|
|
import '../media/ids.dart';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:plezy/widgets/app_icon.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../providers/watch_state_store.dart';
|
|
import '../media/media_backend.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_item_types.dart';
|
|
import '../media/media_kind.dart';
|
|
import '../media/media_playlist.dart';
|
|
import '../media/media_server_client.dart';
|
|
import '../metadata_edit/metadata_edit_adapters.dart';
|
|
import '../media/media_version.dart';
|
|
import '../mixins/controller_disposer_mixin.dart';
|
|
import '../services/plex_client.dart';
|
|
import '../services/media_list_playback_launcher.dart';
|
|
import '../services/music/music_playback_service.dart';
|
|
import '../services/offline_watch_sync_service.dart';
|
|
import '../services/playlist_items_loader.dart';
|
|
import '../services/watch_actions.dart';
|
|
import '../models/transcode_quality_preset.dart';
|
|
import '../utils/download_version_utils.dart';
|
|
import '../utils/download_utils.dart';
|
|
import '../utils/quality_preset_labels.dart';
|
|
import '../utils/media_version_resolver.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
import '../providers/download_provider.dart';
|
|
import '../providers/multi_server_provider.dart';
|
|
import '../providers/offline_mode_provider.dart';
|
|
import '../profiles/active_profile_provider.dart';
|
|
import '../profiles/profile.dart';
|
|
import '../utils/provider_extensions.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/library_refresh_notifier.dart';
|
|
import '../utils/media_navigation_helper.dart';
|
|
import '../utils/media_server_http_client.dart';
|
|
import '../utils/music_navigation.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import '../utils/dialogs.dart';
|
|
import '../services/external_player_service.dart';
|
|
import '../focus/focusable_button.dart';
|
|
import '../focus/focusable_text_field.dart';
|
|
import '../screens/plex_match_screen.dart';
|
|
import '../screens/media_detail_screen.dart';
|
|
import '../screens/metadata_edit_screen.dart';
|
|
import '../screens/music/album_detail_screen.dart';
|
|
import '../screens/music/artist_detail_screen.dart';
|
|
import '../utils/smart_deletion_handler.dart';
|
|
import '../utils/video_player_navigation.dart';
|
|
import '../utils/deletion_notifier.dart';
|
|
import '../widgets/app_menu.dart';
|
|
import '../widgets/file_info_bottom_sheet.dart';
|
|
import 'pill_input_decoration.dart';
|
|
import '../widgets/focusable_list_tile.dart';
|
|
import '../widgets/overlay_sheet.dart';
|
|
import '../widgets/rating_bottom_sheet.dart';
|
|
import '../i18n/strings.g.dart';
|
|
|
|
class _MenuAction {
|
|
final String value;
|
|
final IconData icon;
|
|
final String label;
|
|
final bool destructive;
|
|
|
|
_MenuAction({required this.value, required this.icon, required this.label, this.destructive = false});
|
|
}
|
|
|
|
bool isAdminActionAllowedForMediaItem({
|
|
required bool isOwnerOrAdmin,
|
|
required MediaBackend? itemBackend,
|
|
required Profile? activeProfile,
|
|
}) {
|
|
final blockedByPlexHomeRole =
|
|
itemBackend == MediaBackend.plex && activeProfile != null && activeProfile.isPlexHome && !activeProfile.plexAdmin;
|
|
return isOwnerOrAdmin && !blockedByPlexHomeRole;
|
|
}
|
|
|
|
/// A reusable wrapper widget that adds a context menu (long press / right click)
|
|
/// to any media item with appropriate actions based on the item type.
|
|
/// Caller-supplied entry appended to a [MediaContextMenu] (e.g. the
|
|
/// now-playing screen's Sleep timer). Selection runs [onSelected].
|
|
class MediaMenuExtraEntry {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onSelected;
|
|
|
|
const MediaMenuExtraEntry({required this.icon, required this.label, required this.onSelected});
|
|
}
|
|
|
|
class MediaContextMenu extends StatefulWidget {
|
|
/// Either a [MediaItem] or a [MediaPlaylist]. Typed as [Object] because
|
|
/// Dart has no nominal union type — guarded at runtime via the
|
|
/// [_itemAsMediaItem] / [_itemAsPlaylist] helpers.
|
|
final Object item;
|
|
final void Function(String itemId)? onRefresh;
|
|
final VoidCallback? onRemoveFromContinueWatching;
|
|
final VoidCallback? onListRefresh; // For refreshing list after deletion
|
|
final VoidCallback? onTap;
|
|
|
|
/// Plays the item's trailer. When non-null a "Play trailer" item is added to
|
|
/// the menu. Only the detail screen passes this (it resolves the trailer from
|
|
/// Plex extras), so the item never appears on card/browse context menus. This
|
|
/// keeps the trailer reachable even when the detail row hides its trailer
|
|
/// button to fit a small screen.
|
|
final VoidCallback? onPlayTrailer;
|
|
final Widget child;
|
|
final bool isInContinueWatching;
|
|
final String? collectionId; // The collection ID if displaying within a collection
|
|
|
|
/// Extra entries appended after the standard actions.
|
|
final List<MediaMenuExtraEntry> extraEntries;
|
|
|
|
const MediaContextMenu({
|
|
super.key,
|
|
required this.item,
|
|
this.onRefresh,
|
|
this.onRemoveFromContinueWatching,
|
|
this.onListRefresh,
|
|
this.onTap,
|
|
this.onPlayTrailer,
|
|
required this.child,
|
|
this.isInContinueWatching = false,
|
|
this.collectionId,
|
|
this.extraEntries = const [],
|
|
});
|
|
|
|
@override
|
|
State<MediaContextMenu> createState() => MediaContextMenuState();
|
|
}
|
|
|
|
class MediaContextMenuState extends State<MediaContextMenu> {
|
|
Offset? _tapPosition;
|
|
|
|
bool _openedFromKeyboard = false;
|
|
bool _isContextMenuOpen = false;
|
|
|
|
bool get isContextMenuOpen => _isContextMenuOpen;
|
|
|
|
void _notifyRefresh(String itemId) {
|
|
if (!mounted) return;
|
|
widget.onRefresh?.call(itemId);
|
|
}
|
|
|
|
void _notifyListRefresh() {
|
|
if (!mounted) return;
|
|
widget.onListRefresh?.call();
|
|
}
|
|
|
|
/// The widget's [item] cast as a [MediaItem], resolved against the session
|
|
/// watch-state store so the offered actions match what the card shows.
|
|
/// Returns `null` for playlists.
|
|
MediaItem? get _mediaItem {
|
|
final item = widget.item;
|
|
return item is MediaItem ? context.readFreshWatchState(item) : null;
|
|
}
|
|
|
|
/// The widget's [item] cast as a [MediaPlaylist]. Returns `null` for media items.
|
|
MediaPlaylist? get _playlist => widget.item is MediaPlaylist ? widget.item as MediaPlaylist : null;
|
|
|
|
/// Show the context menu programmatically.
|
|
/// Used for keyboard/gamepad long-press activation.
|
|
/// If [position] is null, the menu will appear at the center of this widget.
|
|
void showContextMenu(BuildContext menuContext, {Offset? position}) {
|
|
_openedFromKeyboard = position == null;
|
|
if (position != null) {
|
|
_tapPosition = position;
|
|
} else {
|
|
// Calculate center of the widget for keyboard activation
|
|
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
|
|
if (renderBox != null) {
|
|
final size = renderBox.size;
|
|
final topLeft = renderBox.localToGlobal(Offset.zero);
|
|
_tapPosition = Offset(topLeft.dx + size.width / 2, topLeft.dy + size.height / 2);
|
|
}
|
|
}
|
|
_showContextMenu(menuContext);
|
|
}
|
|
|
|
/// Get the serverId from the typed item.
|
|
String? get _itemServerId => switch (widget.item) {
|
|
MediaItem(:final serverId) => serverId,
|
|
MediaPlaylist(:final serverId) => serverId,
|
|
_ => null,
|
|
};
|
|
|
|
/// Item identifier for refresh callbacks.
|
|
String _itemId() => switch (widget.item) {
|
|
MediaItem(:final id) => id,
|
|
MediaPlaylist(:final id) => id,
|
|
_ => '',
|
|
};
|
|
|
|
/// Get the correct PlexClient for this item's server. Throws on
|
|
/// non-Plex backends — Plex-only flows (Add to Collection, match,
|
|
/// unmatch, etc.) call this directly. Backend-neutral flows must use
|
|
/// [_getMediaClientForItem] instead.
|
|
PlexClient _getClientForItem() => context.getPlexClientWithFallback(serverIdOrNull(_itemServerId));
|
|
|
|
/// Backend-neutral client for the active item's server. Used by flows
|
|
/// that work for Jellyfin too (downloads, basic browse).
|
|
MediaServerClient _getMediaClientForItem() => context.getMediaClientWithFallback(serverIdOrNull(_itemServerId));
|
|
|
|
void _showContextMenu(BuildContext context) async {
|
|
if (_isContextMenuOpen) return;
|
|
_isContextMenuOpen = true;
|
|
|
|
final previousFocus = FocusManager.instance.primaryFocus;
|
|
bool didNavigate = false;
|
|
|
|
final mediaItem = _mediaItem;
|
|
final playlist = _playlist;
|
|
final isPlaylist = playlist != null;
|
|
final mediaKind = mediaItem?.kind;
|
|
final isCollection = mediaKind == MediaKind.collection;
|
|
|
|
// Backend-aware gate: a few menu items remain Plex-only because the
|
|
// server-side feature has no Jellyfin equivalent (match/unmatch).
|
|
// No fallback: items without a backend marker show only neutral actions —
|
|
// dispatching a Plex-only action against an unknown-backend item could
|
|
// crash or hit the wrong server.
|
|
final itemBackend = mediaItem?.backend ?? playlist?.backend;
|
|
final isPlex = itemBackend == MediaBackend.plex;
|
|
|
|
final isPartiallyWatched = mediaItem?.isPartiallyWatched ?? false;
|
|
|
|
final hasActiveProgress =
|
|
mediaKind != null &&
|
|
(mediaKind == MediaKind.movie || mediaKind == MediaKind.episode) &&
|
|
mediaItem?.hasActiveProgress == true;
|
|
|
|
final useBottomSheet = Platform.isIOS || Platform.isAndroid;
|
|
|
|
// Check if user has admin privileges. Backend-neutral: Plex uses the
|
|
// server-owned flag (folded with the active Plex Home profile's admin
|
|
// bit, when applicable); Jellyfin uses `JellyfinConnection.isAdministrator`
|
|
// captured at sign-in.
|
|
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
|
final activeProfile = context.read<ActiveProfileProvider>().active;
|
|
final isOwnerOrAdmin =
|
|
_itemServerId != null && multiServerProvider.serverManager.isOwnerOrAdmin(ServerId(_itemServerId!));
|
|
final isAdmin = isAdminActionAllowedForMediaItem(
|
|
isOwnerOrAdmin: isOwnerOrAdmin,
|
|
itemBackend: itemBackend,
|
|
activeProfile: activeProfile,
|
|
);
|
|
|
|
// Backend capabilities gate menu items so we don't expose actions the
|
|
// active server cannot perform.
|
|
final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(ServerId(_itemServerId!)) : null;
|
|
final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false;
|
|
// Static capabilities stay truthy while the server is unreachable, so
|
|
// version/quality choices need a liveness check on top.
|
|
final itemServerOnline =
|
|
_itemServerId != null && multiServerProvider.serverManager.isClientOnline(ServerId(_itemServerId!));
|
|
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
|
|
final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind);
|
|
|
|
final menuActions = <_MenuAction>[];
|
|
|
|
if (isCollection || isPlaylist) {
|
|
menuActions.add(_MenuAction(value: 'play', icon: Symbols.play_arrow_rounded, label: t.common.play));
|
|
|
|
menuActions.add(_MenuAction(value: 'shuffle', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay));
|
|
|
|
// Download + sync-rule management. Video and audio playlists and any
|
|
// collection qualify — collections can contain movies, episodes,
|
|
// shows, albums, and artists; audio playlists queue their tracks.
|
|
final isDownloadablePlaylist =
|
|
isPlaylist && (playlist.playlistType == 'video' || playlist.playlistType == 'audio');
|
|
if ((isDownloadablePlaylist || isCollection) && !PlatformDetector.isAppleTV()) {
|
|
final hasRule = Provider.of<DownloadProvider>(context, listen: false).hasSyncRule(_itemSyncRuleKey(context));
|
|
if (hasRule) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule),
|
|
);
|
|
menuActions.add(
|
|
_MenuAction(value: 'remove_sync', icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule),
|
|
);
|
|
} else {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: isPlaylist ? 'download_playlist' : 'download_collection',
|
|
icon: Symbols.download_rounded,
|
|
label: t.downloads.downloadNow,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
menuActions.add(
|
|
_MenuAction(value: 'delete', icon: Symbols.delete_rounded, label: t.common.delete, destructive: true),
|
|
);
|
|
} else {
|
|
// Music (artist/album/track) playback + navigation actions. Play is
|
|
// always offered — the shared music_navigation helpers surface the
|
|
// "not supported yet" notice while the stub service is bound. Queue
|
|
// insertion only exists once a real playback engine is available.
|
|
final isMusicKind = mediaKind != null && mediaKind.isMusic;
|
|
if (isMusicKind) {
|
|
menuActions.add(_MenuAction(value: 'music_play', icon: Symbols.play_arrow_rounded, label: t.common.play));
|
|
|
|
final musicAvailable = context.read<MusicPlaybackService?>()?.isAvailable ?? false;
|
|
if (musicAvailable) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'music_play_next', icon: Symbols.playlist_play_rounded, label: t.music.playNext),
|
|
);
|
|
menuActions.add(
|
|
_MenuAction(value: 'music_add_queue', icon: Symbols.queue_music_rounded, label: t.music.addToQueue),
|
|
);
|
|
}
|
|
|
|
// Instant Mix — capability-gated, and only while the server is
|
|
// reachable (capabilities stay truthy for offline servers).
|
|
if (itemServerOnline && (mediaClient?.capabilities.instantMix ?? false)) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'music_instant_mix', icon: Symbols.instant_mix_rounded, label: t.music.instantMix),
|
|
);
|
|
}
|
|
|
|
// Go to Album (tracks only) — hidden when already on that album's
|
|
// detail screen, mirroring the Go to Series ancestor check.
|
|
final ancestorAlbumId = context.findAncestorWidgetOfExactType<AlbumDetailScreen>()?.album.id;
|
|
if (mediaKind == MediaKind.track && mediaItem!.parentId != null && ancestorAlbumId != mediaItem.parentId) {
|
|
menuActions.add(_MenuAction(value: 'music_album', icon: Symbols.album_rounded, label: t.music.goToAlbum));
|
|
}
|
|
|
|
// Go to Artist — album: parent, track: grandparent; hidden when
|
|
// already on that artist's detail screen.
|
|
final musicArtistId = switch (mediaKind) {
|
|
MediaKind.album => mediaItem!.parentId,
|
|
MediaKind.track => mediaItem!.grandparentId,
|
|
_ => null,
|
|
};
|
|
final ancestorArtistId = context.findAncestorWidgetOfExactType<ArtistDetailScreen>()?.artist.id;
|
|
if (musicArtistId != null && ancestorArtistId != musicArtistId) {
|
|
menuActions.add(_MenuAction(value: 'music_artist', icon: Symbols.artist_rounded, label: t.music.goToArtist));
|
|
}
|
|
}
|
|
|
|
if (hasActiveProgress) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'play_from_beginning', icon: Symbols.replay_rounded, label: t.mediaMenu.playFromBeginning),
|
|
);
|
|
}
|
|
|
|
// Trailer playback. The detail row may hide its trailer button on small
|
|
// screens, so surface it here whenever the screen wires up onPlayTrailer.
|
|
if (widget.onPlayTrailer != null) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'play_trailer', icon: Symbols.theaters_rounded, label: t.tooltips.playTrailer),
|
|
);
|
|
}
|
|
|
|
if (!mediaItem!.isWatched || isPartiallyWatched || hasActiveProgress) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'watch', icon: Symbols.check_circle_outline_rounded, label: t.mediaMenu.markAsWatched),
|
|
);
|
|
}
|
|
|
|
if (mediaItem.isWatched || isPartiallyWatched || hasActiveProgress) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'unwatch',
|
|
icon: Symbols.remove_circle_outline_rounded,
|
|
label: t.mediaMenu.markAsUnwatched,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (widget.isInContinueWatching && canRemoveFromContinueWatching) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'remove_from_continue_watching',
|
|
icon: Symbols.close_rounded,
|
|
label: t.mediaMenu.removeFromContinueWatching,
|
|
),
|
|
);
|
|
}
|
|
|
|
final isVideoKind = mediaItem.isVideoContent;
|
|
|
|
if (widget.isInContinueWatching && isVideoKind) {
|
|
menuActions.add(_MenuAction(value: 'details', icon: Symbols.info_rounded, label: t.mediaMenu.viewDetails));
|
|
}
|
|
|
|
if (isVideoKind) {
|
|
menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate));
|
|
}
|
|
|
|
// Edit Metadata — admin-only and backend-capability gated.
|
|
if (canEditMetadata) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'edit_metadata', icon: Symbols.edit_rounded, label: t.metadataEdit.editMetadata),
|
|
);
|
|
}
|
|
|
|
// Match / Unmatch — Plex-only (Jellyfin doesn't expose match agents).
|
|
if (isPlex && isAdmin && (mediaKind == MediaKind.movie || mediaKind == MediaKind.show)) {
|
|
final isUnmatched = _isUnmatched(mediaItem);
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'match',
|
|
icon: Symbols.search_rounded,
|
|
label: isUnmatched ? t.matchScreen.match : t.matchScreen.fixMatch,
|
|
),
|
|
);
|
|
if (!isUnmatched) {
|
|
menuActions.add(_MenuAction(value: 'unmatch', icon: Symbols.link_off_rounded, label: t.matchScreen.unmatch));
|
|
}
|
|
}
|
|
|
|
// Remove from Collection (only when viewing items within a collection).
|
|
// Plex-only — uses `removeFromCollection` API; Jellyfin's collection
|
|
// membership API isn't wired here yet.
|
|
if (isPlex && widget.collectionId != null) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'remove_from_collection',
|
|
icon: Symbols.delete_outline_rounded,
|
|
label: t.collections.removeFromCollection,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Go to Series (for episodes and seasons) — hide if already on that series' detail screen
|
|
final ancestorMediaDetail = context.findAncestorWidgetOfExactType<MediaDetailScreen>();
|
|
final ancestorMeta = ancestorMediaDetail?.metadata;
|
|
final ancestorSeriesKey = ancestorMeta != null && ancestorMeta.kind == MediaKind.season
|
|
? ancestorMeta.parentId
|
|
: ancestorMeta?.id;
|
|
// For episodes, the show key is grandparentId; for seasons, it's parentId
|
|
final itemSeriesKey = mediaKind == MediaKind.episode ? mediaItem.grandparentId : mediaItem.parentId;
|
|
if ((mediaKind == MediaKind.episode || mediaKind == MediaKind.season) &&
|
|
itemSeriesKey != null &&
|
|
!widget.isInContinueWatching &&
|
|
ancestorSeriesKey != itemSeriesKey) {
|
|
menuActions.add(_MenuAction(value: 'series', icon: Symbols.tv_rounded, label: t.mediaMenu.goToSeries));
|
|
}
|
|
|
|
if (mediaKind == MediaKind.show || mediaKind == MediaKind.season) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'shuffle_play', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay),
|
|
);
|
|
}
|
|
|
|
// Play Version (for episodes and movies). Hidden when there's
|
|
// nothing to choose: a single source on a backend that can't
|
|
// transcode (Jellyfin v1, or Plex installs without a working
|
|
// transcoder) would just bounce straight to playback with default
|
|
// settings, which is what the regular Play action already does.
|
|
// Both backends inline their version list in browse responses
|
|
// (`Media[]` for Plex, `MediaSources` for Jellyfin), so the count
|
|
// is known up front. Also hidden while the item's server is
|
|
// unreachable: at most one version exists locally and plain Play
|
|
// already targets it, so the picker would be a no-op detour
|
|
// offering versions that can't play (issue #1440).
|
|
final versionCount = (mediaItem.mediaVersions ?? const []).length;
|
|
final hasVersionChoice = versionCount > 1;
|
|
if ((mediaKind == MediaKind.episode || mediaKind == MediaKind.movie) &&
|
|
(hasVersionChoice || canTranscode) &&
|
|
itemServerOnline) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'play_version', icon: Symbols.video_file_rounded, label: t.mediaMenu.playVersion),
|
|
);
|
|
}
|
|
|
|
// File Info (for episodes and movies). Backend-neutral — both
|
|
// PlexClient and JellyfinClient implement [getFileInfo], reading
|
|
// codec/stream metadata from `Media`/`MediaSources` respectively.
|
|
// Hidden when the item has no backend marker so we don't fan out
|
|
// to an arbitrary client.
|
|
if (itemBackend != null && (mediaKind == MediaKind.episode || mediaKind == MediaKind.movie)) {
|
|
menuActions.add(_MenuAction(value: 'fileinfo', icon: Symbols.info_rounded, label: t.mediaMenu.fileInfo));
|
|
}
|
|
|
|
if (PlatformDetector.supportsExternalPlayers() &&
|
|
(mediaKind == MediaKind.episode || mediaKind == MediaKind.movie)) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'play_external',
|
|
icon: Symbols.open_in_new_rounded,
|
|
label: t.externalPlayer.playInExternalPlayer,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Download options (for episodes, movies, shows, seasons, albums, and
|
|
// tracks — not artists, whose full discography is too large for a
|
|
// one-tap download). Apple TV has no user-accessible file storage —
|
|
// skip entirely.
|
|
if (!PlatformDetector.isAppleTV() &&
|
|
(mediaKind == MediaKind.episode ||
|
|
mediaKind == MediaKind.movie ||
|
|
mediaKind == MediaKind.show ||
|
|
mediaKind == MediaKind.season ||
|
|
mediaKind == MediaKind.album ||
|
|
mediaKind == MediaKind.track)) {
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final globalKey = mediaItem.globalKey;
|
|
final hasSyncRule = downloadProvider.hasSyncRule(_itemSyncRuleKey(context));
|
|
final hasAnyDownload = downloadProvider.getProgress(globalKey) != null;
|
|
|
|
if (hasSyncRule) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule),
|
|
);
|
|
menuActions.add(
|
|
_MenuAction(value: 'remove_sync', icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule),
|
|
);
|
|
if (hasAnyDownload) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'delete_download',
|
|
icon: Symbols.delete_rounded,
|
|
label: t.downloads.deleteDownload,
|
|
destructive: true,
|
|
),
|
|
);
|
|
}
|
|
} else if (hasAnyDownload) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'delete_download',
|
|
icon: Symbols.delete_rounded,
|
|
label: t.downloads.deleteDownload,
|
|
destructive: true,
|
|
),
|
|
);
|
|
} else {
|
|
menuActions.add(
|
|
_MenuAction(value: 'download', icon: Symbols.download_rounded, label: t.downloads.downloadNow),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Add to... (for episodes, movies, shows, and seasons). Plex-only —
|
|
// uses `buildMetadataUri` + `addToPlaylist` / `addToCollection`. The
|
|
// Jellyfin item-add API is different and not wired here yet.
|
|
if (isPlex &&
|
|
(mediaKind == MediaKind.episode ||
|
|
mediaKind == MediaKind.movie ||
|
|
mediaKind == MediaKind.show ||
|
|
mediaKind == MediaKind.season)) {
|
|
menuActions.add(_MenuAction(value: 'add_to', icon: Symbols.add_rounded, label: t.common.addTo));
|
|
}
|
|
|
|
// Delete media item (for episodes, movies, shows, and seasons) — admin
|
|
// only. Backend-neutral: routed through `MediaServerClient.deleteMediaItem`,
|
|
// which both Plex and Jellyfin implement (DELETE /library/metadata/{id}
|
|
// and DELETE /Items/{id} respectively).
|
|
if (isAdmin &&
|
|
(mediaKind == MediaKind.episode ||
|
|
mediaKind == MediaKind.movie ||
|
|
mediaKind == MediaKind.show ||
|
|
mediaKind == MediaKind.season)) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'delete_media',
|
|
icon: Symbols.delete_forever_rounded,
|
|
label: t.mediaMenu.deleteFromServer,
|
|
destructive: true,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (var i = 0; i < widget.extraEntries.length; i++) {
|
|
final entry = widget.extraEntries[i];
|
|
menuActions.add(_MenuAction(value: 'extra_$i', icon: entry.icon, label: entry.label));
|
|
}
|
|
|
|
String? selected;
|
|
|
|
final openedFromKeyboard = _openedFromKeyboard;
|
|
_openedFromKeyboard = false;
|
|
|
|
if (useBottomSheet) {
|
|
// Present from the menu's own context: it sits at the trigger widget,
|
|
// below any screen-level OverlaySheetHost, while callers often pass a
|
|
// screen context from ABOVE its host (which would skip the host and
|
|
// fall back to a hostless modal sheet).
|
|
selected = await OverlaySheetController.showAdaptive<String>(
|
|
this.context,
|
|
showDragHandle: true,
|
|
builder: (context) => AppMenuSheet<String>(
|
|
title: _itemDisplayTitle(),
|
|
entries: _menuEntries(menuActions),
|
|
focusFirstItem: openedFromKeyboard,
|
|
),
|
|
);
|
|
} else {
|
|
Offset position;
|
|
if (_tapPosition != null) {
|
|
position = _tapPosition!;
|
|
} else {
|
|
final RenderBox? overlay = Overlay.of(context).context.findRenderObject() as RenderBox?;
|
|
final RenderBox renderBox = context.findRenderObject() as RenderBox;
|
|
position = renderBox.localToGlobal(Offset.zero, ancestor: overlay);
|
|
}
|
|
|
|
selected = await showAppMenu<String>(
|
|
context,
|
|
entries: _menuEntries(menuActions),
|
|
position: position,
|
|
focusFirstItem: openedFromKeyboard,
|
|
);
|
|
}
|
|
|
|
try {
|
|
if (!context.mounted) return;
|
|
|
|
// Caller-supplied extra entries dispatch straight to their callback.
|
|
if (selected != null && selected.startsWith('extra_')) {
|
|
final index = int.tryParse(selected.substring('extra_'.length));
|
|
if (index != null && index >= 0 && index < widget.extraEntries.length) {
|
|
widget.extraEntries[index].onSelected();
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (selected) {
|
|
case 'play_from_beginning':
|
|
didNavigate = true;
|
|
if (context.mounted) {
|
|
await navigateToVideoPlayer(
|
|
context,
|
|
metadata: mediaItem!.copyWith(viewOffsetMs: 0),
|
|
resolveWatchState: false,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'play_trailer':
|
|
didNavigate = true;
|
|
widget.onPlayTrailer?.call();
|
|
break;
|
|
|
|
case 'watch':
|
|
case 'unwatch':
|
|
final watched = selected == 'watch';
|
|
final item = mediaItem;
|
|
if (item == null) break;
|
|
final isOffline = context.read<OfflineModeProvider>().isOffline;
|
|
if (isOffline && item.serverId != null) {
|
|
// Queue for later sync — the offline provider emits the WatchStateEvent.
|
|
await WatchActions.setWatched(context, item, watched: watched, offline: true);
|
|
if (context.mounted) {
|
|
showAppSnackBar(
|
|
context,
|
|
watched ? t.messages.markedAsWatchedOffline : t.messages.markedAsUnwatchedOffline,
|
|
);
|
|
_notifyRefresh(item.id);
|
|
}
|
|
} else {
|
|
await _executeAction(context, () async {
|
|
await WatchActions.setWatched(context, item, watched: watched, offline: false);
|
|
}, watched ? t.messages.markedAsWatched : t.messages.markedAsUnwatched);
|
|
}
|
|
break;
|
|
|
|
case 'remove_from_continue_watching':
|
|
// Remove from Continue Watching without affecting watch status or progress
|
|
// This preserves the progression for partially watched items
|
|
// and doesn't mark unwatched next episodes as watched
|
|
try {
|
|
await WatchActions.removeFromContinueWatching(context, mediaItem!);
|
|
if (context.mounted) {
|
|
showSuccessSnackBar(context, t.messages.removedFromContinueWatching);
|
|
if (widget.onRemoveFromContinueWatching != null) {
|
|
widget.onRemoveFromContinueWatching!();
|
|
} else {
|
|
_notifyRefresh(mediaItem.id);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'details':
|
|
didNavigate = true;
|
|
if (context.mounted) {
|
|
await navigateToMediaItemDetails(context, mediaItem!, onRefresh: _notifyRefresh);
|
|
}
|
|
break;
|
|
|
|
case 'rate':
|
|
if (context.mounted) {
|
|
try {
|
|
final client = _getMediaClientForItem();
|
|
await _showRatingSheet(context, mediaItem!, client);
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'edit_metadata':
|
|
didNavigate = true;
|
|
if (context.mounted) {
|
|
final item = mediaItem!;
|
|
await Navigator.push(context, MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: item)));
|
|
_notifyRefresh(item.id);
|
|
}
|
|
break;
|
|
|
|
case 'match':
|
|
didNavigate = true;
|
|
if (context.mounted) {
|
|
final item = mediaItem!;
|
|
await Navigator.push(context, MaterialPageRoute(builder: (context) => PlexMatchScreen(metadata: item)));
|
|
_notifyRefresh(item.id);
|
|
}
|
|
break;
|
|
|
|
case 'unmatch':
|
|
await _handleUnmatch(context, mediaItem!);
|
|
break;
|
|
|
|
case 'remove_from_collection':
|
|
await _handleRemoveFromCollection(context, mediaItem!);
|
|
break;
|
|
|
|
case 'series':
|
|
didNavigate = true;
|
|
await _navigateToRelated(
|
|
context,
|
|
mediaItem!.kind == MediaKind.season ? mediaItem.parentId : mediaItem.grandparentId,
|
|
(item) {
|
|
final target = mediaDetailNavigationTargetFor(mediaItem, metadataOverride: item);
|
|
return mediaDetailRoute(
|
|
metadata: target.metadata,
|
|
initialSeasonIndex: target.initialSeasonIndex,
|
|
initialSeasonId: target.initialSeasonId,
|
|
initialEpisodeId: target.initialEpisodeId,
|
|
);
|
|
},
|
|
t.messages.errorLoadingSeries,
|
|
);
|
|
break;
|
|
|
|
case 'play_version':
|
|
didNavigate = await _handlePlayVersion(context);
|
|
break;
|
|
|
|
case 'fileinfo':
|
|
await _showFileInfo(context);
|
|
break;
|
|
|
|
case 'add_to':
|
|
await _showAddToSubmenu(context);
|
|
break;
|
|
|
|
case 'shuffle_play':
|
|
await _handleShufflePlayWithQueue(context);
|
|
break;
|
|
|
|
case 'play':
|
|
await _handlePlay(context, isCollection, isPlaylist);
|
|
break;
|
|
|
|
case 'shuffle':
|
|
await _handleShuffle(context, isCollection, isPlaylist);
|
|
break;
|
|
|
|
case 'delete':
|
|
await _handleDelete(context, isCollection, isPlaylist);
|
|
break;
|
|
|
|
case 'play_external':
|
|
await _handlePlayExternal(context);
|
|
break;
|
|
|
|
case 'download_playlist':
|
|
await _handleDownloadPlaylist(context);
|
|
break;
|
|
|
|
case 'download_collection':
|
|
await _handleDownloadCollection(context);
|
|
break;
|
|
|
|
case 'download':
|
|
await _handleDownload(context);
|
|
break;
|
|
|
|
case 'delete_download':
|
|
await _handleDeleteDownload(context);
|
|
break;
|
|
|
|
case 'manage_sync':
|
|
await _handleManageSyncRule(context);
|
|
break;
|
|
|
|
case 'remove_sync':
|
|
await _handleRemoveSyncRule(context);
|
|
break;
|
|
|
|
case 'delete_media':
|
|
await _handleDeleteMediaItem(context, mediaKind);
|
|
break;
|
|
|
|
case 'music_play':
|
|
await _handleMusicPlay(context);
|
|
break;
|
|
|
|
case 'music_play_next':
|
|
await _handleMusicEnqueue(context, playNext: true);
|
|
break;
|
|
|
|
case 'music_add_queue':
|
|
await _handleMusicEnqueue(context, playNext: false);
|
|
break;
|
|
|
|
case 'music_instant_mix':
|
|
await playInstantMix(context, mediaItem!);
|
|
break;
|
|
|
|
case 'music_album':
|
|
didNavigate = true;
|
|
await _navigateToRelated(
|
|
context,
|
|
mediaItem!.parentId,
|
|
(item) => MaterialPageRoute(builder: (_) => AlbumDetailScreen(album: item)),
|
|
t.common.error,
|
|
);
|
|
break;
|
|
|
|
case 'music_artist':
|
|
didNavigate = true;
|
|
await _navigateToRelated(
|
|
context,
|
|
mediaItem!.kind == MediaKind.album ? mediaItem.parentId : mediaItem.grandparentId,
|
|
(item) => MaterialPageRoute(builder: (_) => ArtistDetailScreen(artist: item)),
|
|
t.common.error,
|
|
);
|
|
break;
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.e('Media context menu action failed', error: e, stackTrace: st);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
} finally {
|
|
_isContextMenuOpen = false;
|
|
|
|
// Restore focus to the previously focused item after the menu closes,
|
|
// but only if no navigation occurred and the focus node is still valid
|
|
if (!didNavigate && previousFocus != null && previousFocus.canRequestFocus) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (previousFocus.canRequestFocus) {
|
|
previousFocus.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
List<AppMenuEntry<String>> _menuEntries(List<_MenuAction> actions) {
|
|
return [
|
|
for (final action in actions)
|
|
AppMenuItem<String>(
|
|
value: action.value,
|
|
icon: action.icon,
|
|
label: action.label,
|
|
destructive: action.destructive,
|
|
),
|
|
];
|
|
}
|
|
|
|
/// Execute an action with error handling and refresh
|
|
Future<void> _executeAction(BuildContext context, Future<void> Function() action, String successMessage) async {
|
|
try {
|
|
await action();
|
|
if (context.mounted) {
|
|
showSuccessSnackBar(context, successMessage);
|
|
_notifyRefresh(_itemId());
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plex-only: an item is unmatched when its [MediaItem.guid] is missing or
|
|
/// references the Plex no-agent marker.
|
|
bool _isUnmatched(MediaItem item) {
|
|
final g = item.guid;
|
|
return g == null || g.isEmpty || g.contains('agents.none://');
|
|
}
|
|
|
|
Future<void> _handleUnmatch(BuildContext context, MediaItem item) async {
|
|
final confirmed = await showConfirmDialog(
|
|
context,
|
|
title: t.matchScreen.unmatch,
|
|
message: t.matchScreen.unmatchConfirm,
|
|
confirmText: t.matchScreen.unmatch,
|
|
isDestructive: true,
|
|
);
|
|
if (!confirmed || !context.mounted) return;
|
|
|
|
final client = _getClientForItem();
|
|
try {
|
|
final success = await client.unmatchItem(item.id);
|
|
if (!context.mounted) return;
|
|
if (success) {
|
|
showSuccessSnackBar(context, t.matchScreen.unmatchSuccess);
|
|
_notifyRefresh(item.id);
|
|
} else {
|
|
showErrorSnackBar(context, t.matchScreen.unmatchFailed);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Navigate to a related item (series or season)
|
|
Future<void> _navigateToRelated(
|
|
BuildContext context,
|
|
String? id,
|
|
Route<Object?> Function(MediaItem) routeBuilder,
|
|
String errorPrefix,
|
|
) async {
|
|
if (id == null) return;
|
|
|
|
final client = _getMediaClientForItem();
|
|
final refreshItemId = _itemId();
|
|
|
|
try {
|
|
final metadata = await client.fetchItem(id);
|
|
if (metadata != null && context.mounted) {
|
|
await Navigator.push(context, routeBuilder(metadata));
|
|
_notifyRefresh(refreshItemId);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, '$errorPrefix: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showFileInfo(BuildContext context) async {
|
|
var loadingShown = false;
|
|
|
|
try {
|
|
final client = _getMediaClientForItem();
|
|
if (context.mounted) {
|
|
showLoadingDialog(context);
|
|
loadingShown = true;
|
|
}
|
|
|
|
// Fetch file info
|
|
final item = _mediaItem!;
|
|
final fileInfo = await client.getFileInfo(item);
|
|
|
|
// Close loading indicator
|
|
if (loadingShown && context.mounted) {
|
|
Navigator.pop(context);
|
|
loadingShown = false;
|
|
}
|
|
|
|
if (fileInfo != null && context.mounted && mounted) {
|
|
// Show file info bottom sheet, presented from the menu's own context
|
|
// so a screen-level OverlaySheetHost is found (see _showContextMenu).
|
|
await OverlaySheetController.showAdaptive(
|
|
this.context,
|
|
isScrollControlled: true,
|
|
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: item.displayTitle),
|
|
);
|
|
} else if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
|
|
}
|
|
} catch (e) {
|
|
// Close loading indicator if it's still open
|
|
if (loadingShown && context.mounted && Navigator.canPop(context)) {
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoadingFileInfo(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _handlePlayVersion(BuildContext context) async {
|
|
final item = _mediaItem!;
|
|
final itemServerId = serverIdOrNull(_itemServerId);
|
|
final client = context.tryGetMediaClientForServer(itemServerId);
|
|
final itemServerOnline =
|
|
itemServerId != null && context.read<MultiServerProvider>().serverManager.isClientOnline(itemServerId);
|
|
// Same flag the in-player Version & Quality sheet reads — keeps both
|
|
// surfaces honest about what the active backend can actually do. Also
|
|
// requires a reachable server: capabilities are static, and a server
|
|
// dropping between menu open and tap must not offer transcodes.
|
|
final canTranscode = itemServerOnline && (client?.capabilities.videoTranscoding ?? false);
|
|
final versions = client == null ? item.mediaVersions ?? const [] : await resolveMediaVersions(item, client);
|
|
if (!context.mounted) return false;
|
|
|
|
int selectedVersionIndex = 0;
|
|
if (versions.length > 1) {
|
|
final picked = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion);
|
|
if (picked == null || !context.mounted) return false;
|
|
selectedVersionIndex = picked;
|
|
}
|
|
|
|
final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null;
|
|
TranscodeQualityPreset selectedQuality = TranscodeQualityPreset.original;
|
|
if (canTranscode) {
|
|
final picked = await showQualityPickerDialog(
|
|
context,
|
|
sourceBitrateKbps: selectedVersion?.bitrate,
|
|
sourceDurationMs: item.durationMs,
|
|
sourceSizeBytes: _versionSizeBytes(selectedVersion),
|
|
);
|
|
if (picked == null || !context.mounted) return false;
|
|
selectedQuality = picked;
|
|
}
|
|
|
|
// Remember the pick so Continue Watching / plain Play resume this version
|
|
// (#1492) — same store the in-player version switch writes.
|
|
if (versions.length > 1) {
|
|
await saveMediaVersionPreferenceFor(item, index: selectedVersionIndex, versions: versions);
|
|
if (!context.mounted) return false;
|
|
}
|
|
|
|
await navigateToVideoPlayer(
|
|
context,
|
|
metadata: item,
|
|
selectedMediaIndex: selectedVersionIndex,
|
|
selectedMediaSourceId: selectedVersion?.id,
|
|
selectedQualityPreset: selectedQuality,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/// Sum of [MediaPart.sizeBytes] across all parts of [version]. Returns
|
|
/// null when any part is missing a size (a partial sum would be misleading
|
|
/// for the "Original" row in the quality picker).
|
|
int? _versionSizeBytes(MediaVersion? version) {
|
|
if (version == null || version.parts.isEmpty) return null;
|
|
var total = 0;
|
|
for (final p in version.parts) {
|
|
final s = p.sizeBytes;
|
|
if (s == null || s <= 0) return null;
|
|
total += s;
|
|
}
|
|
return total > 0 ? total : null;
|
|
}
|
|
|
|
/// The track list music playback should operate on for [item]: the item
|
|
/// itself for a track, an album's tracks, or an artist's playable
|
|
/// descendants (one server round-trip for the container kinds).
|
|
Future<List<MediaItem>> _musicTracksForItem(MediaItem item) async {
|
|
final client = _getMediaClientForItem();
|
|
return switch (item.kind) {
|
|
MediaKind.album => await client.fetchAlbumTracks(item.id),
|
|
MediaKind.artist => await client.fetchPlayableDescendants(item.id),
|
|
_ => [item],
|
|
};
|
|
}
|
|
|
|
Future<void> _handleMusicPlay(BuildContext context) async {
|
|
final item = _mediaItem!;
|
|
if (item.kind == MediaKind.track) {
|
|
await playTrackWithAlbumContext(context, item);
|
|
return;
|
|
}
|
|
// Availability gate before the container fetch so the stub costs no
|
|
// server round-trip.
|
|
if (!ensureMusicPlaybackAvailable(context)) return;
|
|
final tracks = await _musicTracksForItem(item);
|
|
if (!context.mounted) return;
|
|
await playTracks(
|
|
context,
|
|
tracks: tracks,
|
|
playContext: MusicPlayContext(
|
|
id: item.id,
|
|
title: item.displayTitle,
|
|
kind: item.kind == MediaKind.artist ? MusicPlayContextKind.artist : MusicPlayContextKind.album,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleMusicEnqueue(BuildContext context, {required bool playNext}) async {
|
|
final service = context.read<MusicPlaybackService?>();
|
|
// Menu entries are hidden on the stub; defensive re-check.
|
|
if (service == null || !service.isAvailable) return;
|
|
final tracks = await _musicTracksForItem(_mediaItem!);
|
|
if (tracks.isEmpty) return;
|
|
if (playNext) {
|
|
service.addNext(tracks);
|
|
} else {
|
|
service.addToEnd(tracks);
|
|
}
|
|
}
|
|
|
|
/// Handle shuffle play using play queues — dispatches via the
|
|
/// neutral [MediaListPlaybackLauncher] so Jellyfin items get routed to
|
|
/// [JellyfinSequentialLauncher] instead of falling through to the
|
|
/// Plex-only `/playQueues` flow.
|
|
Future<void> _handleShufflePlayWithQueue(BuildContext context) async {
|
|
final mediaItem = _mediaItem;
|
|
if (mediaItem == null) return;
|
|
final launcher = MediaListPlaybackLauncher.forItem(context, mediaItem);
|
|
await launcher.launchShuffledShow(metadata: mediaItem, showLoadingIndicator: true);
|
|
}
|
|
|
|
/// Show submenu for Add to... (Playlist or Collection)
|
|
Future<void> _showAddToSubmenu(BuildContext context) async {
|
|
final selected = await showOptionPickerDialog<String>(
|
|
context,
|
|
title: t.common.addTo,
|
|
options: [
|
|
(icon: Symbols.playlist_play_rounded, label: t.playlists.playlist, value: 'playlist'),
|
|
(icon: Symbols.collections_rounded, label: t.collections.collection, value: 'collection'),
|
|
],
|
|
);
|
|
|
|
if (selected == 'playlist' && context.mounted) {
|
|
await _showAddToPlaylistDialog(context);
|
|
} else if (selected == 'collection' && context.mounted) {
|
|
await _showAddToCollectionDialog(context);
|
|
}
|
|
}
|
|
|
|
Future<void> _showAddToPlaylistDialog(BuildContext context) async {
|
|
final client = _getMediaClientForItem();
|
|
|
|
try {
|
|
final item = _mediaItem!;
|
|
|
|
final result = await showScopedDialog<String>(
|
|
context: context,
|
|
builder: (context) => _PlaylistSelectionDialog(client: client),
|
|
);
|
|
|
|
if (result == null || !context.mounted) return;
|
|
|
|
if (result == '_create_new') {
|
|
final playlistName = await showTextInputDialog(
|
|
context,
|
|
title: t.playlists.create,
|
|
labelText: t.playlists.playlistName,
|
|
hintText: t.playlists.enterPlaylistName,
|
|
);
|
|
|
|
if (playlistName == null || playlistName.isEmpty || !context.mounted) {
|
|
return;
|
|
}
|
|
|
|
appLogger.d('Creating playlist "$playlistName" seeded with item ${item.id}');
|
|
final newPlaylist = await client.createPlaylist(title: playlistName, items: [item]);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (context.mounted) {
|
|
if (newPlaylist != null) {
|
|
appLogger.d('Successfully created playlist: ${newPlaylist.title}');
|
|
showSuccessSnackBar(context, t.playlists.created);
|
|
// Trigger refresh of playlists tab
|
|
LibraryRefreshNotifier().notifyPlaylistsChanged();
|
|
} else {
|
|
appLogger.e('Failed to create playlist - API returned null');
|
|
showErrorSnackBar(context, t.playlists.errorCreating);
|
|
}
|
|
}
|
|
} else {
|
|
appLogger.d('Adding item ${item.id} to playlist $result');
|
|
final success = await client.addToPlaylist(playlistId: result, items: [item]);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (context.mounted) {
|
|
if (success) {
|
|
appLogger.d('Successfully added item(s) to playlist $result');
|
|
showSuccessSnackBar(context, t.playlists.itemAdded);
|
|
// Trigger refresh of playlists tab
|
|
LibraryRefreshNotifier().notifyPlaylistsChanged();
|
|
_triggerEagerSyncIfRuleExists(context, client.serverId, result);
|
|
} else {
|
|
appLogger.e('Failed to add item(s) to playlist $result - API returned false');
|
|
showErrorSnackBar(context, t.playlists.errorAdding);
|
|
}
|
|
}
|
|
}
|
|
} catch (e, stackTrace) {
|
|
appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, '${t.playlists.errorLoading}: ${e.toString()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showAddToCollectionDialog(BuildContext context) async {
|
|
final client = _getMediaClientForItem();
|
|
|
|
try {
|
|
final item = _mediaItem!;
|
|
final itemKind = item.kind;
|
|
|
|
// Resolve the library/section id from the item itself, falling back to
|
|
// a metadata round-trip and the show's library if missing. Both
|
|
// backends store this on [MediaItem.libraryId].
|
|
String? libraryId = item.libraryId;
|
|
appLogger.d('Resolving libraryId for ${item.title} (initial: $libraryId)');
|
|
|
|
if (libraryId == null || libraryId.isEmpty) {
|
|
try {
|
|
final fullMetadata = await client.fetchItem(item.id);
|
|
libraryId = fullMetadata?.libraryId;
|
|
appLogger.d(' - libraryId from full metadata: $libraryId');
|
|
} catch (e) {
|
|
appLogger.w('Failed to get full metadata for libraryId: $e');
|
|
}
|
|
}
|
|
|
|
if ((libraryId == null || libraryId.isEmpty) && item.grandparentId != null) {
|
|
try {
|
|
final parentMeta = await client.fetchItem(item.grandparentId!);
|
|
libraryId = parentMeta?.libraryId;
|
|
appLogger.d(' - libraryId from grandparent: $libraryId');
|
|
} catch (e) {
|
|
appLogger.w('Failed to get parent metadata for libraryId: $e');
|
|
}
|
|
}
|
|
|
|
if (libraryId == null || libraryId.isEmpty) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.unableToDetermineLibrarySection);
|
|
}
|
|
return;
|
|
}
|
|
final resolvedLibraryId = libraryId;
|
|
if (!context.mounted) return;
|
|
|
|
final result = await showScopedDialog<String>(
|
|
context: context,
|
|
builder: (context) => _CollectionSelectionDialog(client: client, libraryId: resolvedLibraryId),
|
|
);
|
|
|
|
if (result == null || !context.mounted) return;
|
|
|
|
if (result == '_create_new') {
|
|
final collectionName = await showTextInputDialog(
|
|
context,
|
|
title: t.common.createNew,
|
|
labelText: t.collections.collectionName,
|
|
hintText: t.collections.enterCollectionName,
|
|
);
|
|
|
|
if (collectionName == null || collectionName.isEmpty || !context.mounted) {
|
|
return;
|
|
}
|
|
|
|
appLogger.d('Creating collection "$collectionName" seeded with item ${item.id}');
|
|
final newCollectionId = await client.createCollection(
|
|
libraryId: resolvedLibraryId,
|
|
title: collectionName,
|
|
items: [item],
|
|
itemKind: itemKind,
|
|
);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (context.mounted) {
|
|
if (newCollectionId != null) {
|
|
appLogger.d('Successfully created collection with ID: $newCollectionId');
|
|
showSuccessSnackBar(context, t.collections.created);
|
|
// Trigger refresh of collections tab
|
|
LibraryRefreshNotifier().notifyCollectionsChanged();
|
|
_triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId);
|
|
} else {
|
|
appLogger.e('Failed to create collection - API returned null');
|
|
showErrorSnackBar(context, t.collections.errorAddingToCollection);
|
|
}
|
|
}
|
|
} else {
|
|
appLogger.d('Adding item ${item.id} to collection $result');
|
|
final success = await client.addToCollection(collectionId: result, items: [item]);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (context.mounted) {
|
|
if (success) {
|
|
appLogger.d('Successfully added item(s) to collection $result');
|
|
showSuccessSnackBar(context, t.collections.addedToCollection);
|
|
// Trigger refresh of collections tab
|
|
LibraryRefreshNotifier().notifyCollectionsChanged();
|
|
_triggerEagerSyncIfRuleExists(context, client.serverId, result);
|
|
} else {
|
|
appLogger.e('Failed to add item(s) to collection $result - API returned false');
|
|
showErrorSnackBar(context, t.collections.errorAddingToCollection);
|
|
}
|
|
}
|
|
}
|
|
} catch (e, stackTrace) {
|
|
appLogger.e('Error in add to collection flow', error: e, stackTrace: stackTrace);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, '${t.collections.errorAddingToCollection}: ${e.toString()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async {
|
|
if (!mounted) return;
|
|
// Presented from the menu's own context so a screen-level
|
|
// OverlaySheetHost is found (see _showContextMenu).
|
|
await OverlaySheetController.showAdaptive(
|
|
this.context,
|
|
showDragHandle: true,
|
|
builder: (context) => RatingBottomSheet(
|
|
item: item,
|
|
serverClient: client,
|
|
onServerRatingChanged: (_) => _notifyRefresh(item.id),
|
|
onServerFavoriteChanged: (_) => _notifyRefresh(item.id),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleRemoveFromCollection(BuildContext context, MediaItem item) async {
|
|
final client = _getMediaClientForItem();
|
|
|
|
if (widget.collectionId == null) {
|
|
appLogger.e('Cannot remove from collection: collectionId is null');
|
|
return;
|
|
}
|
|
|
|
// Show confirmation dialog
|
|
final confirmed = await showDeleteConfirmation(
|
|
context,
|
|
title: t.collections.removeFromCollection,
|
|
message: t.collections.removeFromCollectionConfirm(title: item.displayTitle),
|
|
);
|
|
|
|
if (!confirmed || !context.mounted) return;
|
|
|
|
try {
|
|
appLogger.d('Removing item ${item.id} from collection ${widget.collectionId}');
|
|
final success = await client.removeFromCollection(collectionId: widget.collectionId!, item: item);
|
|
|
|
if (context.mounted) {
|
|
if (success) {
|
|
showSuccessSnackBar(context, t.collections.removedFromCollection);
|
|
// Trigger refresh of collections tab
|
|
LibraryRefreshNotifier().notifyCollectionsChanged();
|
|
// Trigger list refresh to remove the item from the view
|
|
_notifyListRefresh();
|
|
} else {
|
|
showErrorSnackBar(context, t.collections.removeFromCollectionFailed);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Failed to remove from collection', error: e);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.collections.removeFromCollectionError(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle play action for collections and playlists
|
|
Future<void> _handlePlay(BuildContext context, bool _, bool _) async {
|
|
await _launchCollectionOrPlaylist(context, shuffle: false);
|
|
}
|
|
|
|
/// Handle shuffle action for collections and playlists
|
|
Future<void> _handleShuffle(BuildContext context, bool _, bool _) async {
|
|
await _launchCollectionOrPlaylist(context, shuffle: true);
|
|
}
|
|
|
|
/// Launch playback for collection or playlist.
|
|
///
|
|
/// Dispatches to the right launcher implementation based on the item's
|
|
/// backend — Plex uses server-side `/playQueues`, Jellyfin builds an
|
|
/// in-memory queue locally.
|
|
Future<void> _launchCollectionOrPlaylist(BuildContext context, {required bool shuffle}) async {
|
|
// Launcher accepts both MediaItem (for collections) and MediaPlaylist.
|
|
final launcher = MediaListPlaybackLauncher.forItem(context, widget.item);
|
|
await launcher.launchFromCollectionOrPlaylist(item: widget.item, shuffle: shuffle, showLoadingIndicator: false);
|
|
}
|
|
|
|
/// Handle delete action for collections and playlists
|
|
Future<void> _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async {
|
|
final client = _getMediaClientForItem();
|
|
|
|
final itemTitle = _itemDisplayTitle();
|
|
final itemTypeLabel = isCollection ? t.collections.collection : t.playlists.playlist;
|
|
|
|
// Show confirmation dialog
|
|
final confirmed = await showDeleteConfirmation(
|
|
context,
|
|
title: isCollection ? t.collections.deleteCollection : t.playlists.delete,
|
|
message: isCollection
|
|
? t.collections.deleteConfirm(title: itemTitle)
|
|
: t.playlists.deleteMessage(name: itemTitle),
|
|
);
|
|
|
|
if (!confirmed || !context.mounted) return;
|
|
|
|
try {
|
|
bool success = false;
|
|
|
|
if (isCollection) {
|
|
success = await client.deleteCollection(_mediaItem!);
|
|
} else if (isPlaylist) {
|
|
success = await client.deletePlaylist(_playlist!);
|
|
}
|
|
|
|
if (context.mounted) {
|
|
if (success) {
|
|
showSuccessSnackBar(context, isCollection ? t.collections.deleted : t.playlists.deleted);
|
|
// Trigger list refresh
|
|
_notifyListRefresh();
|
|
} else {
|
|
showErrorSnackBar(context, isCollection ? t.collections.deleteFailed : t.playlists.errorDeleting);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Failed to delete $itemTypeLabel', error: e);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(
|
|
context,
|
|
isCollection ? t.collections.deleteFailedWithError(error: e.toString()) : t.playlists.errorDeleting,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle play in external player action
|
|
Future<void> _handlePlayExternal(BuildContext context) async {
|
|
if (!PlatformDetector.supportsExternalPlayers()) return;
|
|
|
|
final item = _mediaItem!;
|
|
|
|
// Check if the item is downloaded and use local file path if available
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final offlineWatchService = Provider.of<OfflineWatchSyncService>(context, listen: false);
|
|
final client = _getMediaClientForItem();
|
|
final globalKey = item.globalKey;
|
|
if (downloadProvider.isDownloaded(globalKey)) {
|
|
final videoPath = await downloadProvider.getVideoFilePath(globalKey);
|
|
if (videoPath != null && context.mounted) {
|
|
final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath';
|
|
await ExternalPlayerService.launch(
|
|
context: context,
|
|
videoUrl: videoUrl,
|
|
metadata: item,
|
|
client: client,
|
|
offlineWatchService: offlineWatchService,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
await ExternalPlayerService.launch(
|
|
context: context,
|
|
metadata: item,
|
|
client: client,
|
|
offlineWatchService: offlineWatchService,
|
|
);
|
|
}
|
|
|
|
/// Handle download collection action — opens the same sync/one-time dialog
|
|
/// as playlists, wired to [showCollectionDownloadOptionsAndQueue].
|
|
Future<void> _handleDownloadCollection(BuildContext context) async {
|
|
final collection = _mediaItem!;
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final client = _getMediaClientForItem();
|
|
|
|
try {
|
|
final items = await fetchAllCollectionItemsPaged(
|
|
client,
|
|
collection.id,
|
|
libraryId: collection.libraryId,
|
|
libraryTitle: collection.libraryTitle,
|
|
);
|
|
if (!context.mounted) return;
|
|
|
|
final result = await showCollectionDownloadOptionsAndQueue(
|
|
context,
|
|
collectionMetadata: collection,
|
|
items: items,
|
|
client: client,
|
|
downloadProvider: downloadProvider,
|
|
);
|
|
if (result == null || !context.mounted) return;
|
|
|
|
showSuccessSnackBar(context, result.toSnackBarMessage());
|
|
} on CellularDownloadBlockedException {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Failed to queue collection download', error: e);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle download playlist action
|
|
Future<void> _handleDownloadPlaylist(BuildContext context) async {
|
|
final playlist = _playlist!;
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final client = _getMediaClientForItem();
|
|
|
|
try {
|
|
// Page through the playlist via the neutral interface so Jellyfin
|
|
// playlists download too.
|
|
final items = await fetchAllPlaylistItems(client, playlist.id);
|
|
if (!context.mounted) return;
|
|
|
|
final playlistMetadata = MediaItem(
|
|
id: playlist.id,
|
|
backend: playlist.backend,
|
|
kind: MediaKind.playlist,
|
|
title: playlist.title,
|
|
thumbPath: playlist.thumbPath,
|
|
serverId: playlist.serverId ?? client.serverId,
|
|
serverName: playlist.serverName,
|
|
);
|
|
|
|
final result = await showPlaylistDownloadOptionsAndQueue(
|
|
context,
|
|
playlistMetadata: playlistMetadata,
|
|
items: items,
|
|
client: client,
|
|
downloadProvider: downloadProvider,
|
|
);
|
|
if (result == null || !context.mounted) return;
|
|
|
|
showSuccessSnackBar(context, result.toSnackBarMessage());
|
|
} on CellularDownloadBlockedException {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Failed to queue playlist download', error: e);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle download action
|
|
Future<void> _handleDownload(BuildContext context) async {
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final item = _mediaItem!;
|
|
|
|
try {
|
|
// Backend-agnostic resolve so Jellyfin items can be downloaded too.
|
|
final client = context.getMediaClientWithFallback(serverIdOrNull(_itemServerId));
|
|
final result = await showDownloadOptionsAndQueue(
|
|
context,
|
|
metadata: item,
|
|
client: client,
|
|
downloadProvider: downloadProvider,
|
|
);
|
|
if (result == null || !context.mounted) return;
|
|
|
|
showSuccessSnackBar(context, result.toSnackBarMessage());
|
|
} on CellularDownloadBlockedException {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Failed to queue download', error: e);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle delete download action
|
|
Future<void> _handleDeleteDownload(BuildContext context) async {
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final item = _mediaItem!;
|
|
final globalKey = item.globalKey;
|
|
|
|
// Show confirmation dialog
|
|
final confirmed = await showDeleteConfirmation(
|
|
context,
|
|
title: t.downloads.deleteDownload,
|
|
message: t.downloads.deleteConfirm(title: item.displayTitle),
|
|
);
|
|
|
|
if (!confirmed || !context.mounted) return;
|
|
|
|
try {
|
|
// Use smart deletion handler (shows progress only if >500ms)
|
|
await SmartDeletionHandler.deleteWithProgress(context: context, provider: downloadProvider, globalKey: globalKey);
|
|
|
|
if (context.mounted) {
|
|
showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
|
// DownloadProvider.deleteDownload now broadcasts the DeletionEvent,
|
|
// so DeletionAware screens (e.g. offline season detail) update without
|
|
// a duplicate notification here.
|
|
_notifyRefresh(item.id);
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Failed to delete download', error: e);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolve the sync-rule global key for whatever the menu item is — works
|
|
/// for items (shows/seasons/collections/movies/episodes) and playlists.
|
|
String _itemGlobalKey() {
|
|
final raw = widget.item;
|
|
return switch (raw) {
|
|
MediaItem() => raw.globalKey,
|
|
MediaPlaylist() => raw.globalKey,
|
|
_ => '',
|
|
};
|
|
}
|
|
|
|
String _itemSyncRuleKey(BuildContext context) {
|
|
final globalKey = _itemGlobalKey();
|
|
final serverId = _itemServerId;
|
|
if (serverId == null) return globalKey;
|
|
final client = context.tryGetMediaClientForServer(ServerId(serverId));
|
|
if (client == null) return globalKey;
|
|
return context.read<DownloadProvider>().syncRuleKeyForClient(client, _itemId(), serverId: ServerId(serverId));
|
|
}
|
|
|
|
String _itemDisplayTitle() => switch (widget.item) {
|
|
MediaItem(:final displayTitle) => displayTitle,
|
|
MediaPlaylist(:final displayTitle) => displayTitle,
|
|
_ => '',
|
|
};
|
|
|
|
Future<void> _handleManageSyncRule(BuildContext context) => manageSyncRule(
|
|
context,
|
|
downloadProvider: context.read<DownloadProvider>(),
|
|
globalKey: _itemSyncRuleKey(context),
|
|
displayTitle: _itemDisplayTitle(),
|
|
);
|
|
|
|
/// Fire-and-forget: if a sync rule exists for the target list, run it now so
|
|
/// newly-added items download immediately instead of waiting for the next
|
|
/// cooldown-gated general pass. Fails silently — errors are logged only.
|
|
static void _triggerEagerSyncIfRuleExists(BuildContext context, ServerId serverId, String listId) {
|
|
try {
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final client = Provider.of<MultiServerProvider>(context, listen: false).getClientForServer(serverId);
|
|
final globalKey = client == null
|
|
? buildGlobalKey(ServerId(serverId), listId)
|
|
: downloadProvider.syncRuleKeyForClient(client, listId, serverId: serverId);
|
|
if (!downloadProvider.hasSyncRule(globalKey)) return;
|
|
final serverManager = Provider.of<MultiServerProvider>(context, listen: false).serverManager;
|
|
unawaited(
|
|
downloadProvider.executeSyncRuleFor(globalKey, serverManager).catchError((e) {
|
|
appLogger.w('Eager sync-rule run failed for $globalKey: $e');
|
|
return null;
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
appLogger.w('Failed to schedule eager sync-rule run: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _handleRemoveSyncRule(BuildContext context) => removeSyncRuleAndSnack(
|
|
context,
|
|
downloadProvider: context.read<DownloadProvider>(),
|
|
globalKey: _itemSyncRuleKey(context),
|
|
displayTitle: _itemDisplayTitle(),
|
|
);
|
|
|
|
/// Handle delete media item action
|
|
/// This permanently removes the media item and its associated files from the server
|
|
Future<void> _handleDeleteMediaItem(BuildContext context, MediaKind? mediaKind) async {
|
|
final item = _mediaItem!;
|
|
final isMultipleMediaItems = mediaKind == MediaKind.show || mediaKind == MediaKind.season;
|
|
|
|
// Show confirmation dialog
|
|
final confirmed = await showDeleteConfirmation(
|
|
context,
|
|
title: t.mediaMenu.deleteFromServer,
|
|
message: "${t.mediaMenu.confirmDelete}${isMultipleMediaItems ? "\n\n${t.mediaMenu.deleteMultipleWarning}" : ""}",
|
|
confirmText: t.mediaMenu.deleteFromServer,
|
|
);
|
|
|
|
if (!confirmed || !context.mounted) return;
|
|
|
|
try {
|
|
final client = _getMediaClientForItem();
|
|
final success = await client.deleteMediaItem(item);
|
|
|
|
if (context.mounted) {
|
|
if (success) {
|
|
showSuccessSnackBar(context, t.mediaMenu.mediaDeletedSuccessfully);
|
|
// Broadcast deletion event for cross-screen propagation
|
|
DeletionNotifier().notifyDeletedItem(item: item);
|
|
// Backward-compatible list refresh for screens that are not DeletionAware yet
|
|
_notifyListRefresh();
|
|
} else {
|
|
showErrorSnackBar(context, t.mediaMenu.mediaFailedToDelete);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appLogger.e(t.mediaMenu.mediaFailedToDelete, error: e);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.mediaMenu.mediaFailedToDelete);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// GestureDetector wrapping removed — gesture callbacks are now on InkWell
|
|
// directly in the card widgets, saving 1 element level. The context menu
|
|
// is still accessible programmatically via showContextMenu().
|
|
return widget.child;
|
|
}
|
|
}
|
|
|
|
/// Dialog to select a playlist or create a new one.
|
|
class _PlaylistSelectionDialog extends StatefulWidget {
|
|
final MediaServerClient client;
|
|
|
|
const _PlaylistSelectionDialog({required this.client});
|
|
|
|
@override
|
|
State<_PlaylistSelectionDialog> createState() => _PlaylistSelectionDialogState();
|
|
}
|
|
|
|
class _PlaylistSelectionDialogState extends State<_PlaylistSelectionDialog> {
|
|
static const int _pageSize = 100;
|
|
|
|
final AbortController _abortController = AbortController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
final List<MediaPlaylist> _playlists = [];
|
|
bool _isLoading = false;
|
|
String? _errorMessage;
|
|
int? _totalCount;
|
|
|
|
bool get _hasMore => _totalCount == null || _playlists.length < _totalCount!;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
unawaited(_loadNextPage());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_abortController.abort();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scrollController.hasClients || !_hasMore || _isLoading) return;
|
|
final position = _scrollController.position;
|
|
if (position.pixels >= position.maxScrollExtent - 240) {
|
|
unawaited(_loadNextPage());
|
|
}
|
|
}
|
|
|
|
Future<void> _loadNextPage() async {
|
|
if (_isLoading || !_hasMore) return;
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
try {
|
|
final page = await widget.client.fetchPlaylistsPage(
|
|
playlistType: 'video',
|
|
smart: false,
|
|
start: _playlists.length,
|
|
size: _pageSize,
|
|
abort: _abortController,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_playlists.addAll(page.items);
|
|
_totalCount = page.totalCount;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(t.playlists.selectPlaylist),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
shrinkWrap: true,
|
|
itemCount: _playlists.length + 1 + (_hasMore || _isLoading || _errorMessage != null ? 1 : 0),
|
|
itemBuilder: (context, index) {
|
|
if (index == 0) {
|
|
// Create new playlist option (always shown first)
|
|
return ListTile(
|
|
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
|
title: Text(t.common.createNew),
|
|
onTap: () => Navigator.pop(context, '_create_new'),
|
|
);
|
|
}
|
|
|
|
if (index > _playlists.length) {
|
|
if (_errorMessage != null) {
|
|
return ListTile(
|
|
leading: const AppIcon(Symbols.error_rounded, fill: 1),
|
|
title: Text(t.messages.errorLoading(error: _errorMessage!)),
|
|
trailing: TextButton(onPressed: _loadNextPage, child: Text(t.common.retry)),
|
|
);
|
|
}
|
|
if (_hasMore && !_isLoading) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) unawaited(_loadNextPage());
|
|
});
|
|
}
|
|
return const Padding(
|
|
padding: .all(16),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
final playlist = _playlists[index - 1];
|
|
final leafCount = playlist.leafCount;
|
|
final subtitleText = leafCount == 1 ? t.playlists.oneItem : t.playlists.itemCount(count: leafCount ?? 0);
|
|
return ListTile(
|
|
leading: playlist.smart
|
|
? const AppIcon(Symbols.auto_awesome_rounded, fill: 1)
|
|
: const AppIcon(Symbols.playlist_play_rounded, fill: 1),
|
|
title: Text(playlist.title),
|
|
subtitle: playlist.leafCount != null ? Text(subtitleText) : null,
|
|
onTap: playlist.smart
|
|
? null // Disable smart playlists
|
|
: () => Navigator.pop(context, playlist.id),
|
|
enabled: !playlist.smart,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
FocusableButton(
|
|
autofocus: true,
|
|
onPressed: () => Navigator.pop(context),
|
|
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Dialog to select a collection or create a new one
|
|
class _CollectionSelectionDialog extends StatefulWidget {
|
|
final MediaServerClient client;
|
|
final String libraryId;
|
|
|
|
const _CollectionSelectionDialog({required this.client, required this.libraryId});
|
|
|
|
@override
|
|
State<_CollectionSelectionDialog> createState() => _CollectionSelectionDialogState();
|
|
}
|
|
|
|
class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> with ControllerDisposerMixin {
|
|
static const int _pageSize = 100;
|
|
|
|
late final _filterController = createTextEditingController();
|
|
final _filterFocusNode = FocusNode(debugLabel: 'CollectionFilter');
|
|
final _firstCollectionFocusNode = FocusNode(debugLabel: 'CollectionFirstItem');
|
|
final AbortController _abortController = AbortController();
|
|
final _scrollController = ScrollController();
|
|
final List<MediaItem> _collections = [];
|
|
List<MediaItem> _filteredCollections = [];
|
|
bool _isLoading = false;
|
|
String? _errorMessage;
|
|
int? _totalCount;
|
|
|
|
bool get _hasMore => _totalCount == null || _collections.length < _totalCount!;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
unawaited(_loadNextPage());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_abortController.abort();
|
|
_scrollController.dispose();
|
|
_filterFocusNode.dispose();
|
|
_firstCollectionFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scrollController.hasClients || !_hasMore || _isLoading) return;
|
|
final position = _scrollController.position;
|
|
if (position.pixels >= position.maxScrollExtent - 240) {
|
|
unawaited(_loadNextPage());
|
|
}
|
|
}
|
|
|
|
Future<void> _loadNextPage() async {
|
|
if (_isLoading || !_hasMore) return;
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
try {
|
|
while (mounted && _hasMore) {
|
|
final page = await widget.client.fetchCollectionsPage(
|
|
widget.libraryId,
|
|
start: _collections.length,
|
|
size: _pageSize,
|
|
abort: _abortController,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_collections.addAll(page.items);
|
|
_totalCount = page.totalCount;
|
|
_applyFilter(_filterController.text);
|
|
});
|
|
if (_filterController.text.isEmpty || page.items.isEmpty) break;
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _onFilterChanged(String query) {
|
|
setState(() {
|
|
_applyFilter(query);
|
|
});
|
|
if (query.isNotEmpty && _hasMore) {
|
|
unawaited(_loadNextPage());
|
|
}
|
|
}
|
|
|
|
void _applyFilter(String query) {
|
|
final lower = query.toLowerCase();
|
|
_filteredCollections = lower.isEmpty
|
|
? List.of(_collections)
|
|
: _collections.where((c) => (c.title ?? '').toLowerCase().contains(lower)).toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(t.collections.selectCollection),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
if (_collections.length >= 10) ...[
|
|
FocusableTextField(
|
|
controller: _filterController,
|
|
focusNode: _filterFocusNode,
|
|
autofocus: true,
|
|
onNavigateDown: _firstCollectionFocusNode.requestFocus,
|
|
decoration: pillInputDecoration(
|
|
context,
|
|
hintText: t.collections.searchCollections,
|
|
prefixIcon: const Icon(Symbols.search_rounded, size: 20),
|
|
),
|
|
onChanged: _onFilterChanged,
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
Flexible(
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
shrinkWrap: true,
|
|
itemCount: _filteredCollections.length + 1 + (_hasMore || _isLoading || _errorMessage != null ? 1 : 0),
|
|
itemBuilder: (context, index) {
|
|
if (index == 0) {
|
|
return FocusableListTile(
|
|
focusNode: _firstCollectionFocusNode,
|
|
autofocus: _collections.length < 10,
|
|
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
|
title: Text(t.common.createNew),
|
|
onTap: () => Navigator.pop(context, '_create_new'),
|
|
);
|
|
}
|
|
|
|
if (index > _filteredCollections.length) {
|
|
if (_errorMessage != null) {
|
|
return FocusableListTile(
|
|
leading: const AppIcon(Symbols.error_rounded, fill: 1),
|
|
title: Text(t.messages.errorLoading(error: _errorMessage!)),
|
|
onTap: _loadNextPage,
|
|
);
|
|
}
|
|
if (_hasMore && !_isLoading) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) unawaited(_loadNextPage());
|
|
});
|
|
}
|
|
return const Padding(
|
|
padding: .all(16),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
final collection = _filteredCollections[index - 1];
|
|
return FocusableListTile(
|
|
leading: const AppIcon(Symbols.collections_rounded, fill: 1),
|
|
title: Text(collection.title ?? ''),
|
|
subtitle: collection.childCount != null
|
|
? Text(t.playlists.itemCount(count: collection.childCount!))
|
|
: null,
|
|
onTap: () => Navigator.pop(context, collection.id),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
FocusableButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|