The search screens, the out-of-band auth dialogs, the live TV guide and the list-download paths each carried their own copy of the same shell. Extracts SearchInputField and PendingAuthDialog and routes the duplicated download and guide helpers through one implementation.
2023 lines
75 KiB
Dart
2023 lines
75 KiB
Dart
import 'dart:async';
|
|
import '../media/ids.dart';
|
|
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/library_query.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 '../services/plex_client.dart';
|
|
import '../services/media_list_playback_launcher.dart';
|
|
import '../services/jellyfin_sequential_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/content_utils.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 'dialog_action_button.dart';
|
|
import '../focus/focusable_text_field.dart';
|
|
import '../focus/key_event_utils.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(MediaItem source)? 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(MediaItem source) {
|
|
if (!mounted) return;
|
|
widget.onRefresh?.call(source);
|
|
}
|
|
|
|
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 used for playlist and download sync operations.
|
|
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;
|
|
|
|
// 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));
|
|
}
|
|
|
|
final openedFromKeyboard = _openedFromKeyboard;
|
|
_openedFromKeyboard = false;
|
|
|
|
var position = _tapPosition;
|
|
if (position == null) {
|
|
final RenderBox? overlay = Overlay.of(context).context.findRenderObject() as RenderBox?;
|
|
final RenderBox renderBox = context.findRenderObject() as RenderBox;
|
|
position = renderBox.localToGlobal(Offset.zero, ancestor: overlay);
|
|
}
|
|
|
|
// 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).
|
|
final selected = await showAdaptiveAppMenu<String>(
|
|
this.context,
|
|
title: _itemDisplayTitle(),
|
|
entries: _menuEntries(menuActions),
|
|
position: position,
|
|
focusFirstItem: openedFromKeyboard,
|
|
isScrollControlled: true,
|
|
);
|
|
|
|
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);
|
|
}
|
|
} 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);
|
|
}
|
|
}
|
|
} 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);
|
|
}
|
|
break;
|
|
|
|
case 'match':
|
|
didNavigate = true;
|
|
if (context.mounted) {
|
|
final item = mediaItem!;
|
|
await Navigator.push(context, MaterialPageRoute(builder: (context) => PlexMatchScreen(metadata: item)));
|
|
_notifyRefresh(item);
|
|
}
|
|
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,
|
|
(context, item) async {
|
|
final target = mediaDetailNavigationTargetFor(mediaItem, metadataOverride: item);
|
|
await Navigator.push(
|
|
context,
|
|
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, navigateToAlbum, t.common.error);
|
|
break;
|
|
|
|
case 'music_artist':
|
|
didNavigate = true;
|
|
await _navigateToRelated(
|
|
context,
|
|
mediaItem!.kind == MediaKind.album ? mediaItem.parentId : mediaItem.grandparentId,
|
|
navigateToArtist,
|
|
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);
|
|
if (_mediaItem case final source?) _notifyRefresh(source);
|
|
}
|
|
} 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);
|
|
} else {
|
|
showErrorSnackBar(context, t.matchScreen.unmatchFailed);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch and navigate to a related item (series, album, or artist).
|
|
Future<void> _navigateToRelated(
|
|
BuildContext context,
|
|
String? id,
|
|
Future<void> Function(BuildContext context, MediaItem item) navigate,
|
|
String errorPrefix,
|
|
) async {
|
|
if (id == null) return;
|
|
|
|
final client = _getMediaClientForItem();
|
|
final source = _mediaItem;
|
|
if (source == null) return;
|
|
|
|
try {
|
|
final metadata = await client.fetchItem(id);
|
|
if (metadata != null && context.mounted) {
|
|
await navigate(context, metadata);
|
|
_notifyRefresh(source);
|
|
}
|
|
} 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;
|
|
}
|
|
// No onError: a failed container fetch falls through to this menu's own
|
|
// error boundary, which logs it and shows the snackbar.
|
|
await playFetchedTracks(
|
|
context,
|
|
fetch: () => _musicTracksForItem(item),
|
|
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 queueSessionRevision = service.queueSessionRevision;
|
|
List<MediaItem> tracks;
|
|
try {
|
|
tracks = await _musicTracksForItem(_mediaItem!);
|
|
} catch (_) {
|
|
if (!context.mounted || service.queueSessionRevision != queueSessionRevision) return;
|
|
rethrow;
|
|
}
|
|
if (!context.mounted || service.queueSessionRevision != queueSessionRevision || 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;
|
|
|
|
await _addItemToContainer<MediaPlaylist>(
|
|
context,
|
|
kind: 'playlist',
|
|
item: item,
|
|
client: client,
|
|
result: result,
|
|
createPrompt: (
|
|
title: t.playlists.create,
|
|
label: t.playlists.playlistName,
|
|
hint: t.playlists.enterPlaylistName,
|
|
),
|
|
create: (name) => client.createPlaylist(title: name, items: [item]),
|
|
createdLog: (playlist) => 'Successfully created playlist: ${playlist.title}',
|
|
eagerSyncId: (_) => null,
|
|
add: () => client.addToPlaylist(playlistId: result, items: [item]),
|
|
messages: (
|
|
created: t.playlists.created,
|
|
createError: t.playlists.errorCreating,
|
|
added: t.playlists.itemAdded,
|
|
addError: t.playlists.errorAdding,
|
|
),
|
|
notifyChanged: () => LibraryRefreshNotifier().notifyPlaylistsChanged(),
|
|
);
|
|
} 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;
|
|
|
|
await _addItemToContainer<String>(
|
|
context,
|
|
kind: 'collection',
|
|
item: item,
|
|
client: client,
|
|
result: result,
|
|
createPrompt: (
|
|
title: t.common.createNew,
|
|
label: t.collections.collectionName,
|
|
hint: t.collections.enterCollectionName,
|
|
),
|
|
create: (name) => client.createCollection(
|
|
libraryId: resolvedLibraryId,
|
|
title: name,
|
|
items: [item],
|
|
itemKind: itemKind,
|
|
),
|
|
createdLog: (id) => 'Successfully created collection with ID: $id',
|
|
eagerSyncId: (id) => id,
|
|
add: () => client.addToCollection(collectionId: result, items: [item]),
|
|
messages: (
|
|
created: t.collections.created,
|
|
createError: t.collections.errorAddingToCollection,
|
|
added: t.collections.addedToCollection,
|
|
addError: t.collections.errorAddingToCollection,
|
|
),
|
|
notifyChanged: () => LibraryRefreshNotifier().notifyCollectionsChanged(),
|
|
);
|
|
} 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()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create-or-add tail shared by the "Add to playlist" and "Add to collection"
|
|
/// flows. [result] is the picker selection: an existing container id, or the
|
|
/// `_create_new` sentinel to prompt for a name and create one via [create].
|
|
/// [eagerSyncId] maps a freshly created container to the id to eager-sync, or
|
|
/// `null` to skip it.
|
|
Future<void> _addItemToContainer<T extends Object>(
|
|
BuildContext context, {
|
|
required String kind,
|
|
required MediaItem item,
|
|
required MediaServerClient client,
|
|
required String result,
|
|
required ({String title, String label, String hint}) createPrompt,
|
|
required Future<T?> Function(String name) create,
|
|
required String Function(T created) createdLog,
|
|
required String? Function(T created) eagerSyncId,
|
|
required Future<bool> Function() add,
|
|
required ({String created, String createError, String added, String addError}) messages,
|
|
required VoidCallback notifyChanged,
|
|
}) async {
|
|
if (result == '_create_new') {
|
|
final name = await showTextInputDialog(
|
|
context,
|
|
title: createPrompt.title,
|
|
labelText: createPrompt.label,
|
|
hintText: createPrompt.hint,
|
|
);
|
|
|
|
if (name == null || name.isEmpty || !context.mounted) return;
|
|
|
|
appLogger.d('Creating $kind "$name" seeded with item ${item.id}');
|
|
final created = await create(name);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (created != null) {
|
|
appLogger.d(createdLog(created));
|
|
showSuccessSnackBar(context, messages.created);
|
|
notifyChanged();
|
|
final syncId = eagerSyncId(created);
|
|
if (syncId != null) {
|
|
_triggerEagerSyncIfRuleExists(context, client.serverId, syncId);
|
|
}
|
|
} else {
|
|
appLogger.e('Failed to create $kind - API returned null');
|
|
showErrorSnackBar(context, messages.createError);
|
|
}
|
|
} else {
|
|
appLogger.d('Adding item ${item.id} to $kind $result');
|
|
final success = await add();
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (success) {
|
|
appLogger.d('Successfully added item(s) to $kind $result');
|
|
showSuccessSnackBar(context, messages.added);
|
|
notifyChanged();
|
|
_triggerEagerSyncIfRuleExists(context, client.serverId, result);
|
|
} else {
|
|
appLogger.e('Failed to add item(s) to $kind $result - API returned false');
|
|
showErrorSnackBar(context, messages.addError);
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
isScrollControlled: true,
|
|
builder: (context) => RatingBottomSheet(
|
|
item: item,
|
|
serverClient: client,
|
|
onServerRatingChanged: (_) => _notifyRefresh(item),
|
|
onServerFavoriteChanged: (_) => _notifyRefresh(item),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 {
|
|
final playlist = _playlist;
|
|
if (playlist?.playlistType == 'audio') {
|
|
await _launchAudioPlaylist(context, playlist!, shuffle: shuffle);
|
|
return;
|
|
}
|
|
|
|
// Launcher accepts both MediaItem (for collections) and MediaPlaylist.
|
|
final launcher = MediaListPlaybackLauncher.forItem(context, widget.item);
|
|
await launcher.launchFromCollectionOrPlaylist(
|
|
item: widget.item,
|
|
shuffle: shuffle,
|
|
showLoadingIndicator: launcher is JellyfinSequentialLauncher,
|
|
);
|
|
}
|
|
|
|
Future<void> _launchAudioPlaylist(BuildContext context, MediaPlaylist playlist, {required bool shuffle}) async {
|
|
// Match PlaylistDetailScreen: fail the availability gate before paying
|
|
// for a full playlist fetch, then hand the tracks to the music session.
|
|
await playFetchedTracks(
|
|
context,
|
|
fetch: () => fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id),
|
|
playContext: MusicPlayContext(id: playlist.id, title: playlist.title, kind: MusicPlayContextKind.playlist),
|
|
onError: (e, st) {
|
|
appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st);
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
},
|
|
onEmpty: () => showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems),
|
|
shuffle: shuffle,
|
|
);
|
|
}
|
|
|
|
/// 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 [showListDownloadOptionsAndQueue].
|
|
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 showListDownloadOptionsAndQueue(
|
|
context,
|
|
rootMetadata: collection,
|
|
targetType: ContentTypes.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 showListDownloadOptionsAndQueue(
|
|
context,
|
|
rootMetadata: playlistMetadata,
|
|
targetType: ContentTypes.playlist,
|
|
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);
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
typedef _PickerPageLoader<T> = Future<LibraryPage<T>> Function(int start, int size, AbortController abort);
|
|
|
|
typedef _PickerItemBuilder<T> = Widget Function(BuildContext context, T item);
|
|
|
|
/// Shared loading, filtering, and TV focus shell for collection-style pickers.
|
|
class _PickerDialogScaffold<T> extends StatefulWidget {
|
|
final String title;
|
|
final String searchHint;
|
|
final String emptyMessage;
|
|
final _PickerPageLoader<T> loadPage;
|
|
final String Function(T item) itemTitle;
|
|
final _PickerItemBuilder<T> itemBuilder;
|
|
|
|
const _PickerDialogScaffold({
|
|
required this.title,
|
|
required this.searchHint,
|
|
required this.emptyMessage,
|
|
required this.loadPage,
|
|
required this.itemTitle,
|
|
required this.itemBuilder,
|
|
});
|
|
|
|
@override
|
|
State<_PickerDialogScaffold<T>> createState() => _PickerDialogScaffoldState<T>();
|
|
}
|
|
|
|
class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
|
|
static const int _pageSize = 100;
|
|
static const int _filterThreshold = 10;
|
|
|
|
final _filterController = TextEditingController();
|
|
final _filterFocusNode = FocusNode(debugLabel: 'PickerFilter');
|
|
final _firstItemFocusNode = FocusNode(debugLabel: 'PickerFirstItem');
|
|
final _abortController = AbortController();
|
|
final _scrollController = ScrollController();
|
|
final List<T> _items = [];
|
|
List<T> _filteredItems = [];
|
|
bool _isLoading = false;
|
|
bool _initialFocusRequested = false;
|
|
String? _errorMessage;
|
|
int? _totalCount;
|
|
|
|
bool get _hasMore => _totalCount == null || _items.length < _totalCount!;
|
|
bool get _showFilter => _items.length >= _filterThreshold;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
unawaited(_loadNextPage());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_abortController.abort();
|
|
_scrollController.dispose();
|
|
_filterController.dispose();
|
|
_filterFocusNode.dispose();
|
|
_firstItemFocusNode.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.loadPage(_items.length, _pageSize, _abortController);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_items.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;
|
|
});
|
|
}
|
|
_requestInitialFocus();
|
|
}
|
|
|
|
void _requestInitialFocus() {
|
|
if (_initialFocusRequested) return;
|
|
_initialFocusRequested = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
(_showFilter ? _filterFocusNode : _firstItemFocusNode).requestFocus();
|
|
});
|
|
}
|
|
|
|
void _onFilterChanged(String query) {
|
|
setState(() => _applyFilter(query));
|
|
if (query.isNotEmpty && _hasMore) {
|
|
unawaited(_loadNextPage());
|
|
}
|
|
}
|
|
|
|
void _applyFilter(String query) {
|
|
final lower = query.toLowerCase();
|
|
_filteredItems = lower.isEmpty
|
|
? List.of(_items)
|
|
: _items.where((item) => widget.itemTitle(item).toLowerCase().contains(lower)).toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final showStatus = _hasMore || _isLoading || _errorMessage != null || _filteredItems.isEmpty;
|
|
return Focus(
|
|
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
|
|
child: AlertDialog(
|
|
title: Text(widget.title),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
if (_showFilter) ...[
|
|
FocusableTextField(
|
|
controller: _filterController,
|
|
focusNode: _filterFocusNode,
|
|
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
|
onNavigateDown: _firstItemFocusNode.requestFocus,
|
|
decoration: pillInputDecoration(
|
|
context,
|
|
hintText: widget.searchHint,
|
|
prefixIcon: const AppIcon(Symbols.search_rounded, size: 20),
|
|
),
|
|
onChanged: _onFilterChanged,
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
Flexible(
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
shrinkWrap: true,
|
|
itemCount: _filteredItems.length + 1 + (showStatus ? 1 : 0),
|
|
itemBuilder: (context, index) {
|
|
if (index == 0) {
|
|
return FocusableListTile(
|
|
focusNode: _firstItemFocusNode,
|
|
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
|
title: Text(t.common.createNew),
|
|
onTap: () => Navigator.pop(context, '_create_new'),
|
|
);
|
|
}
|
|
|
|
if (index <= _filteredItems.length) {
|
|
return widget.itemBuilder(context, _filteredItems[index - 1]);
|
|
}
|
|
|
|
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) {
|
|
if (_hasMore && !_isLoading) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) unawaited(_loadNextPage());
|
|
});
|
|
}
|
|
return const Padding(
|
|
padding: .all(16),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
return Padding(
|
|
padding: const .all(16),
|
|
child: Text(widget.emptyMessage, textAlign: TextAlign.center),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel)],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Dialog to select a playlist or create a new one.
|
|
class _PlaylistSelectionDialog extends StatelessWidget {
|
|
final MediaServerClient client;
|
|
|
|
const _PlaylistSelectionDialog({required this.client});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _PickerDialogScaffold<MediaPlaylist>(
|
|
title: t.playlists.selectPlaylist,
|
|
searchHint: t.playlists.searchPlaylists,
|
|
emptyMessage: t.playlists.noPlaylists,
|
|
loadPage: (start, size, abort) =>
|
|
client.fetchPlaylistsPage(playlistType: 'video', smart: false, start: start, size: size, abort: abort),
|
|
itemTitle: (playlist) => playlist.title,
|
|
itemBuilder: (context, playlist) {
|
|
final leafCount = playlist.leafCount;
|
|
final subtitleText = leafCount == 1 ? t.playlists.oneItem : t.playlists.itemCount(count: leafCount ?? 0);
|
|
return FocusableListTile(
|
|
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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Dialog to select a collection or create a new one
|
|
class _CollectionSelectionDialog extends StatelessWidget {
|
|
final MediaServerClient client;
|
|
final String libraryId;
|
|
|
|
const _CollectionSelectionDialog({required this.client, required this.libraryId});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _PickerDialogScaffold<MediaItem>(
|
|
title: t.collections.selectCollection,
|
|
searchHint: t.collections.searchCollections,
|
|
emptyMessage: t.libraries.noCollections,
|
|
loadPage: (start, size, abort) => client.fetchCollectionsPage(libraryId, start: start, size: size, abort: abort),
|
|
itemTitle: (collection) => collection.title ?? '',
|
|
itemBuilder: (context, collection) => 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),
|
|
),
|
|
);
|
|
}
|
|
}
|