Replace AlertDialog-based selection dialogs with popup dropdowns on desktop/TV and bottom sheets on mobile, matching the context menu pattern. Fix popup background blending into cards by tinting surface.
1463 lines
53 KiB
Dart
1463 lines
53 KiB
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 '../services/plex_client.dart';
|
|
import '../services/play_queue_launcher.dart';
|
|
import '../models/plex_metadata.dart';
|
|
import '../models/plex_playlist.dart';
|
|
import '../providers/download_provider.dart';
|
|
import '../providers/offline_mode_provider.dart';
|
|
import '../providers/offline_watch_provider.dart';
|
|
import '../utils/provider_extensions.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/library_refresh_notifier.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import '../utils/dialogs.dart';
|
|
import '../utils/focus_utils.dart';
|
|
import '../services/external_player_service.dart';
|
|
import '../focus/focusable_button.dart';
|
|
import '../focus/dpad_navigator.dart';
|
|
import '../screens/media_detail_screen.dart';
|
|
import '../screens/metadata_edit_screen.dart';
|
|
import '../screens/season_detail_screen.dart';
|
|
import '../utils/smart_deletion_handler.dart';
|
|
import '../utils/deletion_notifier.dart';
|
|
import '../theme/mono_tokens.dart';
|
|
import '../widgets/file_info_bottom_sheet.dart';
|
|
import '../widgets/focusable_list_tile.dart';
|
|
import '../widgets/overlay_sheet.dart';
|
|
import '../widgets/rating_bottom_sheet.dart';
|
|
import '../i18n/strings.g.dart';
|
|
|
|
/// Helper class to store menu action data
|
|
class _MenuAction {
|
|
final String value;
|
|
final IconData icon;
|
|
final String label;
|
|
final Color? hoverColor;
|
|
final Color? foregroundColor;
|
|
|
|
_MenuAction({required this.value, required this.icon, required this.label, this.hoverColor, this.foregroundColor});
|
|
}
|
|
|
|
/// 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.
|
|
class MediaContextMenu extends StatefulWidget {
|
|
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
|
final void Function(String ratingKey)? onRefresh;
|
|
final VoidCallback? onRemoveFromContinueWatching;
|
|
final VoidCallback? onListRefresh; // For refreshing list after deletion
|
|
final VoidCallback? onTap;
|
|
final Widget child;
|
|
final bool isInContinueWatching;
|
|
final String? collectionId; // The collection ID if displaying within a collection
|
|
|
|
const MediaContextMenu({
|
|
super.key,
|
|
required this.item,
|
|
this.onRefresh,
|
|
this.onRemoveFromContinueWatching,
|
|
this.onListRefresh,
|
|
this.onTap,
|
|
required this.child,
|
|
this.isInContinueWatching = false,
|
|
this.collectionId,
|
|
});
|
|
|
|
@override
|
|
State<MediaContextMenu> createState() => MediaContextMenuState();
|
|
}
|
|
|
|
class MediaContextMenuState extends State<MediaContextMenu> {
|
|
Offset? _tapPosition;
|
|
|
|
bool _openedFromKeyboard = false;
|
|
bool _isContextMenuOpen = false;
|
|
|
|
bool get isContextMenuOpen => _isContextMenuOpen;
|
|
|
|
/// 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 item (PlexMetadata or PlexPlaylist)
|
|
String? get _itemServerId {
|
|
if (widget.item is PlexMetadata) return (widget.item as PlexMetadata).serverId;
|
|
if (widget.item is PlexPlaylist) return (widget.item as PlexPlaylist).serverId;
|
|
return null;
|
|
}
|
|
|
|
/// Get the correct PlexClient for this item's server
|
|
PlexClient _getClientForItem() => context.getClientWithFallback(_itemServerId);
|
|
|
|
void _showContextMenu(BuildContext context) async {
|
|
if (_isContextMenuOpen) return;
|
|
_isContextMenuOpen = true;
|
|
|
|
// Capture the currently focused node for restoration after menu closes
|
|
final previousFocus = FocusManager.instance.primaryFocus;
|
|
bool didNavigate = false;
|
|
|
|
final isPlaylist = widget.item is PlexPlaylist;
|
|
final metadata = isPlaylist ? null : widget.item as PlexMetadata;
|
|
final mediaType = isPlaylist ? null : metadata!.mediaType;
|
|
final isCollection = mediaType == PlexMediaType.collection;
|
|
|
|
final isPartiallyWatched =
|
|
!isPlaylist &&
|
|
metadata!.viewedLeafCount != null &&
|
|
metadata.leafCount != null &&
|
|
metadata.viewedLeafCount! > 0 &&
|
|
metadata.viewedLeafCount! < metadata.leafCount!;
|
|
|
|
final hasActiveProgress =
|
|
mediaType != null &&
|
|
(mediaType == PlexMediaType.movie || mediaType == PlexMediaType.episode) &&
|
|
metadata?.hasActiveProgress == true;
|
|
|
|
// Check if we should use bottom sheet (on iOS and Android)
|
|
final useBottomSheet = Platform.isIOS || Platform.isAndroid;
|
|
|
|
// Build menu actions
|
|
final menuActions = <_MenuAction>[];
|
|
|
|
// Special actions for collections and playlists
|
|
if (isCollection || isPlaylist) {
|
|
// Play
|
|
menuActions.add(_MenuAction(value: 'play', icon: Symbols.play_arrow_rounded, label: t.common.play));
|
|
|
|
// Shuffle
|
|
menuActions.add(_MenuAction(value: 'shuffle', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay));
|
|
|
|
// Delete
|
|
menuActions.add(_MenuAction(value: 'delete', icon: Symbols.delete_rounded, label: t.common.delete));
|
|
|
|
// Skip other menu items for collections and playlists
|
|
} else {
|
|
// Regular menu items for other types
|
|
|
|
// Mark as Watched
|
|
if (!metadata!.isWatched || isPartiallyWatched || hasActiveProgress) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'watch', icon: Symbols.check_circle_outline_rounded, label: t.mediaMenu.markAsWatched),
|
|
);
|
|
}
|
|
|
|
// Mark as Unwatched
|
|
if (metadata.isWatched || isPartiallyWatched || hasActiveProgress) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'unwatch',
|
|
icon: Symbols.remove_circle_outline_rounded,
|
|
label: t.mediaMenu.markAsUnwatched,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Remove from Continue Watching (only in continue watching section)
|
|
if (widget.isInContinueWatching) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'remove_from_continue_watching',
|
|
icon: Symbols.close_rounded,
|
|
label: t.mediaMenu.removeFromContinueWatching,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Rate (for movies, shows, seasons, and episodes)
|
|
if (mediaType == PlexMediaType.movie ||
|
|
mediaType == PlexMediaType.show ||
|
|
mediaType == PlexMediaType.season ||
|
|
mediaType == PlexMediaType.episode) {
|
|
menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate));
|
|
}
|
|
|
|
// Edit Metadata (for movies, shows, seasons, and episodes)
|
|
if (mediaType == PlexMediaType.movie ||
|
|
mediaType == PlexMediaType.show ||
|
|
mediaType == PlexMediaType.season ||
|
|
mediaType == PlexMediaType.episode) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'edit_metadata', icon: Symbols.edit_rounded, label: t.metadataEdit.editMetadata),
|
|
);
|
|
}
|
|
|
|
// Remove from Collection (only when viewing items within a collection)
|
|
if (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,
|
|
// or on a season screen belonging to the same series
|
|
final ancestorMediaDetail = context.findAncestorWidgetOfExactType<MediaDetailScreen>();
|
|
final ancestorSeasonDetail = context.findAncestorWidgetOfExactType<SeasonDetailScreen>();
|
|
final ancestorSeriesKey =
|
|
ancestorMediaDetail?.metadata.ratingKey ?? ancestorSeasonDetail?.season.parentRatingKey;
|
|
// For episodes, the show key is grandparentRatingKey; for seasons, it's parentRatingKey
|
|
final itemSeriesKey =
|
|
mediaType == PlexMediaType.episode ? metadata.grandparentRatingKey : metadata.parentRatingKey;
|
|
if ((mediaType == PlexMediaType.episode || mediaType == PlexMediaType.season) &&
|
|
itemSeriesKey != null &&
|
|
ancestorSeriesKey != itemSeriesKey) {
|
|
menuActions.add(_MenuAction(value: 'series', icon: Symbols.tv_rounded, label: t.mediaMenu.goToSeries));
|
|
}
|
|
|
|
// Go to Season (for episodes) — hide if already on that season's detail screen
|
|
if (mediaType == PlexMediaType.episode &&
|
|
metadata.parentTitle != null &&
|
|
context.findAncestorWidgetOfExactType<SeasonDetailScreen>()?.season.ratingKey !=
|
|
metadata.parentRatingKey) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'season', icon: Symbols.playlist_play_rounded, label: t.mediaMenu.goToSeason),
|
|
);
|
|
}
|
|
|
|
// Shuffle Play (for shows and seasons)
|
|
if (mediaType == PlexMediaType.show || mediaType == PlexMediaType.season) {
|
|
menuActions.add(
|
|
_MenuAction(value: 'shuffle_play', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay),
|
|
);
|
|
}
|
|
|
|
// File Info (for episodes and movies)
|
|
if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) {
|
|
menuActions.add(_MenuAction(value: 'fileinfo', icon: Symbols.info_rounded, label: t.mediaMenu.fileInfo));
|
|
}
|
|
|
|
// Play in External Player (for episodes and movies)
|
|
if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'play_external',
|
|
icon: Symbols.open_in_new_rounded,
|
|
label: t.externalPlayer.playInExternalPlayer,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Download options (for episodes, movies, shows, and seasons)
|
|
if (mediaType == PlexMediaType.episode ||
|
|
mediaType == PlexMediaType.movie ||
|
|
mediaType == PlexMediaType.show ||
|
|
mediaType == PlexMediaType.season) {
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final globalKey = metadata.globalKey;
|
|
final isDownloaded = downloadProvider.isDownloaded(globalKey);
|
|
|
|
if (isDownloaded) {
|
|
// Show delete download option
|
|
menuActions.add(
|
|
_MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload),
|
|
);
|
|
} else {
|
|
// Show download option
|
|
menuActions.add(
|
|
_MenuAction(value: 'download', icon: Symbols.download_rounded, label: t.downloads.downloadNow),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Add to... (for episodes, movies, shows, and seasons)
|
|
if (mediaType == PlexMediaType.episode ||
|
|
mediaType == PlexMediaType.movie ||
|
|
mediaType == PlexMediaType.show ||
|
|
mediaType == PlexMediaType.season) {
|
|
menuActions.add(_MenuAction(value: 'add_to', icon: Symbols.add_rounded, label: t.common.addTo));
|
|
}
|
|
|
|
// Delete media item (for episodes, movies, shows, and seasons)
|
|
if (mediaType == PlexMediaType.episode ||
|
|
mediaType == PlexMediaType.movie ||
|
|
mediaType == PlexMediaType.show ||
|
|
mediaType == PlexMediaType.season) {
|
|
menuActions.add(
|
|
_MenuAction(
|
|
value: 'delete_media',
|
|
icon: Symbols.delete_forever_rounded,
|
|
label: t.mediaMenu.deleteFromServer,
|
|
hoverColor: Theme.of(context).colorScheme.error,
|
|
foregroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
);
|
|
}
|
|
} // End of regular menu items else block
|
|
|
|
String? selected;
|
|
|
|
final openedFromKeyboard = _openedFromKeyboard;
|
|
_openedFromKeyboard = false;
|
|
|
|
if (useBottomSheet) {
|
|
selected = await OverlaySheetController.showAdaptive<String>(
|
|
context,
|
|
showDragHandle: true,
|
|
builder: (context) => _FocusableContextMenuSheet(
|
|
title: widget.item.title,
|
|
actions: menuActions,
|
|
focusFirstItem: openedFromKeyboard,
|
|
),
|
|
);
|
|
} else {
|
|
// Show custom focusable popup menu on larger screens
|
|
// Use stored tap position or fallback to widget position
|
|
final RenderBox? overlay = Overlay.of(context).context.findRenderObject() as RenderBox?;
|
|
|
|
Offset position;
|
|
if (_tapPosition != null) {
|
|
position = _tapPosition!;
|
|
} else {
|
|
final RenderBox renderBox = context.findRenderObject() as RenderBox;
|
|
position = renderBox.localToGlobal(Offset.zero, ancestor: overlay);
|
|
}
|
|
|
|
selected = await showDialog<String>(
|
|
context: context,
|
|
barrierColor: Colors.transparent,
|
|
builder: (dialogContext) =>
|
|
_FocusablePopupMenu(actions: menuActions, position: position, focusFirstItem: openedFromKeyboard),
|
|
);
|
|
}
|
|
|
|
try {
|
|
if (!context.mounted) return;
|
|
|
|
switch (selected) {
|
|
case 'watch':
|
|
final isOffline = context.read<OfflineModeProvider>().isOffline;
|
|
if (isOffline && metadata?.serverId != null) {
|
|
// Offline mode: queue action for later sync (emits WatchStateEvent)
|
|
final offlineWatch = context.read<OfflineWatchProvider>();
|
|
await offlineWatch.markAsWatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey);
|
|
if (context.mounted) {
|
|
showAppSnackBar(context, t.messages.markedAsWatchedOffline);
|
|
widget.onRefresh?.call(metadata.ratingKey);
|
|
}
|
|
} else {
|
|
// Pass metadata to emit WatchStateEvent for cross-screen updates
|
|
await _executeAction(
|
|
context,
|
|
() => _getClientForItem().markAsWatched(metadata!.ratingKey, metadata: metadata),
|
|
t.messages.markedAsWatched,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'unwatch':
|
|
final isOffline = context.read<OfflineModeProvider>().isOffline;
|
|
if (isOffline && metadata?.serverId != null) {
|
|
// Offline mode: queue action for later sync (emits WatchStateEvent)
|
|
final offlineWatch = context.read<OfflineWatchProvider>();
|
|
await offlineWatch.markAsUnwatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey);
|
|
if (context.mounted) {
|
|
showAppSnackBar(context, t.messages.markedAsUnwatchedOffline);
|
|
widget.onRefresh?.call(metadata.ratingKey);
|
|
}
|
|
} else {
|
|
// Pass metadata to emit WatchStateEvent for cross-screen updates
|
|
await _executeAction(
|
|
context,
|
|
() => _getClientForItem().markAsUnwatched(metadata!.ratingKey, metadata: metadata),
|
|
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 {
|
|
final client = _getClientForItem();
|
|
await client.removeFromOnDeck(metadata!.ratingKey);
|
|
if (context.mounted) {
|
|
showSuccessSnackBar(context, t.messages.removedFromContinueWatching);
|
|
// Use specific callback if provided, otherwise fallback to onRefresh
|
|
if (widget.onRemoveFromContinueWatching != null) {
|
|
widget.onRemoveFromContinueWatching!();
|
|
} else {
|
|
widget.onRefresh?.call(metadata.ratingKey);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'rate':
|
|
if (context.mounted) {
|
|
try {
|
|
final client = _getClientForItem();
|
|
await _showRatingSheet(context, metadata!, client);
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'edit_metadata':
|
|
didNavigate = true;
|
|
if (context.mounted) {
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: metadata!)),
|
|
);
|
|
widget.onRefresh?.call(metadata!.ratingKey);
|
|
}
|
|
break;
|
|
|
|
case 'remove_from_collection':
|
|
await _handleRemoveFromCollection(context, metadata!);
|
|
break;
|
|
|
|
case 'series':
|
|
didNavigate = true;
|
|
await _navigateToRelated(
|
|
context,
|
|
metadata!.mediaType == PlexMediaType.season
|
|
? metadata.parentRatingKey
|
|
: metadata.grandparentRatingKey,
|
|
(metadata) => MediaDetailScreen(metadata: metadata),
|
|
t.messages.errorLoadingSeries,
|
|
);
|
|
break;
|
|
|
|
case 'season':
|
|
didNavigate = true;
|
|
await _navigateToRelated(
|
|
context,
|
|
metadata!.parentRatingKey,
|
|
(metadata) => SeasonDetailScreen(season: metadata),
|
|
t.messages.errorLoadingSeason,
|
|
);
|
|
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':
|
|
await _handleDownload(context);
|
|
break;
|
|
|
|
case 'delete_download':
|
|
await _handleDeleteDownload(context);
|
|
break;
|
|
|
|
case 'delete_media':
|
|
await _handleDeleteMediaItem(context, mediaType);
|
|
break;
|
|
}
|
|
} 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();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
widget.onRefresh?.call(widget.item.ratingKey);
|
|
}
|
|
} 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? ratingKey,
|
|
Widget Function(PlexMetadata) screenBuilder,
|
|
String errorPrefix,
|
|
) async {
|
|
if (ratingKey == null) return;
|
|
|
|
final client = _getClientForItem();
|
|
|
|
try {
|
|
final metadata = await client.getMetadataWithImages(ratingKey);
|
|
if (metadata != null && context.mounted) {
|
|
await Navigator.push(context, MaterialPageRoute(builder: (context) => screenBuilder(metadata)));
|
|
widget.onRefresh?.call(widget.item.ratingKey);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, '$errorPrefix: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Show file info bottom sheet
|
|
Future<void> _showFileInfo(BuildContext context) async {
|
|
final client = _getClientForItem();
|
|
|
|
try {
|
|
// Show loading indicator
|
|
if (context.mounted) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => const Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
// Fetch file info
|
|
final metadata = widget.item as PlexMetadata;
|
|
final fileInfo = await client.getFileInfo(metadata.ratingKey);
|
|
|
|
// Close loading indicator
|
|
if (context.mounted) {
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
if (fileInfo != null && context.mounted) {
|
|
// Show file info bottom sheet
|
|
await OverlaySheetController.showAdaptive(
|
|
context,
|
|
isScrollControlled: true,
|
|
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
|
|
);
|
|
} else if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
|
|
}
|
|
} catch (e) {
|
|
// Close loading indicator if it's still open
|
|
if (context.mounted && Navigator.canPop(context)) {
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoadingFileInfo(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle shuffle play using play queues
|
|
Future<void> _handleShufflePlayWithQueue(BuildContext context) async {
|
|
final client = _getClientForItem();
|
|
final metadata = widget.item as PlexMetadata;
|
|
|
|
final launcher = PlayQueueLauncher(
|
|
context: context,
|
|
client: client,
|
|
serverId: metadata.serverId,
|
|
serverName: metadata.serverName,
|
|
);
|
|
|
|
await launcher.launchShuffledShow(metadata: metadata, showLoadingIndicator: true);
|
|
}
|
|
|
|
/// Show submenu for Add to... (Playlist or Collection)
|
|
Future<void> _showAddToSubmenu(BuildContext context) async {
|
|
final submenuActions = [
|
|
_MenuAction(value: 'playlist', icon: Symbols.playlist_play_rounded, label: t.playlists.playlist),
|
|
_MenuAction(value: 'collection', icon: Symbols.collections_rounded, label: t.collections.collection),
|
|
];
|
|
|
|
final selected = await showDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) => SimpleDialog(
|
|
title: Text(t.common.addTo),
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
|
children: submenuActions.map((action) {
|
|
return SimpleDialogOption(
|
|
onPressed: () => Navigator.pop(dialogContext, action.value),
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
|
child: Row(
|
|
children: [AppIcon(action.icon, fill: 1, size: 24), const SizedBox(width: 16), Text(action.label, style: Theme.of(dialogContext).textTheme.bodyLarge)],
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
|
|
// Handle the submenu selection
|
|
if (selected == 'playlist' && context.mounted) {
|
|
await _showAddToPlaylistDialog(context);
|
|
} else if (selected == 'collection' && context.mounted) {
|
|
await _showAddToCollectionDialog(context);
|
|
}
|
|
}
|
|
|
|
/// Show dialog to select playlist and add item
|
|
Future<void> _showAddToPlaylistDialog(BuildContext context) async {
|
|
final client = _getClientForItem();
|
|
|
|
try {
|
|
final metadata = widget.item as PlexMetadata;
|
|
final itemType = metadata.type.toLowerCase();
|
|
|
|
// Load playlists
|
|
final playlists = await client.getPlaylists(playlistType: 'video');
|
|
|
|
if (!context.mounted) return;
|
|
|
|
// Show dialog to select playlist or create new
|
|
final result = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) => _PlaylistSelectionDialog(playlists: playlists),
|
|
);
|
|
|
|
if (result == null || !context.mounted) return;
|
|
|
|
// Build URI for the item (works for all types: movies, episodes, seasons, shows)
|
|
// For seasons/shows, the Plex API should automatically expand to include all episodes
|
|
final itemUri = await client.buildMetadataUri(metadata.ratingKey);
|
|
appLogger.d('Built URI for $itemType: $itemUri');
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (result == '_create_new') {
|
|
// Create new playlist flow
|
|
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;
|
|
}
|
|
|
|
// Create playlist with the item(s)
|
|
appLogger.d('Creating playlist "$playlistName" with URI length: ${itemUri.length}');
|
|
final newPlaylist = await client.createPlaylist(title: playlistName, uri: itemUri);
|
|
|
|
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 {
|
|
// Add to existing playlist
|
|
appLogger.d('Adding to playlist $result with URI: $itemUri');
|
|
final success = await client.addToPlaylist(playlistId: result, uri: itemUri);
|
|
|
|
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();
|
|
} 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()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Show dialog to select collection and add item
|
|
Future<void> _showAddToCollectionDialog(BuildContext context) async {
|
|
final client = _getClientForItem();
|
|
|
|
try {
|
|
final metadata = widget.item as PlexMetadata;
|
|
final itemType = metadata.type.toLowerCase();
|
|
|
|
// Get the library section ID from the item
|
|
// First try from the metadata itself
|
|
int? sectionId = metadata.librarySectionID;
|
|
appLogger.d('Attempting to get section ID for ${metadata.title}');
|
|
appLogger.d(' - librarySectionID: $sectionId');
|
|
appLogger.d(' - key: ${metadata.key}');
|
|
|
|
// If not available, fetch the full metadata which should include the section ID
|
|
if (sectionId == null) {
|
|
try {
|
|
appLogger.d(' - Fetching full metadata for: ${metadata.ratingKey}');
|
|
final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey);
|
|
if (fullMetadata != null) {
|
|
sectionId = fullMetadata.librarySectionID;
|
|
appLogger.d(' - Section ID from full metadata: $sectionId');
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to get full metadata for section ID: $e');
|
|
}
|
|
}
|
|
|
|
// If still not found, try to extract from the key field
|
|
if (sectionId == null) {
|
|
final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key);
|
|
if (keyMatch != null) {
|
|
sectionId = int.tryParse(keyMatch.group(1)!);
|
|
appLogger.d(' - Extracted from key: $sectionId');
|
|
}
|
|
}
|
|
|
|
// Last resort: try to get it from the item's parent (for episodes/seasons)
|
|
if (sectionId == null && metadata.grandparentRatingKey != null) {
|
|
try {
|
|
appLogger.d(' - Trying to get from parent: ${metadata.grandparentRatingKey}');
|
|
final parentMeta = await client.getMetadataWithImages(metadata.grandparentRatingKey!);
|
|
sectionId = parentMeta?.librarySectionID;
|
|
appLogger.d(' - Parent sectionId: $sectionId');
|
|
} catch (e) {
|
|
appLogger.w('Failed to get parent metadata for section ID: $e');
|
|
}
|
|
}
|
|
|
|
appLogger.d(' - Final sectionId: $sectionId');
|
|
|
|
if (sectionId == null) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, 'Unable to determine library section for this item');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Load collections for this library section
|
|
final collections = await client.getLibraryCollections(sectionId.toString());
|
|
|
|
if (!context.mounted) return;
|
|
|
|
// Show dialog to select collection or create new
|
|
final result = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) => _CollectionSelectionDialog(collections: collections),
|
|
);
|
|
|
|
if (result == null || !context.mounted) return;
|
|
|
|
// Build URI for the item
|
|
final itemUri = await client.buildMetadataUri(metadata.ratingKey);
|
|
appLogger.d('Built URI for $itemType: $itemUri');
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (result == '_create_new') {
|
|
// Create new collection flow
|
|
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;
|
|
}
|
|
|
|
// Create collection first (without items)
|
|
// Determine the collection type based on the item type
|
|
int? collectionType;
|
|
switch (itemType) {
|
|
case 'movie':
|
|
collectionType = 1;
|
|
break;
|
|
case 'show':
|
|
collectionType = 2;
|
|
break;
|
|
case 'season':
|
|
collectionType = 3;
|
|
break;
|
|
case 'episode':
|
|
collectionType = 4;
|
|
break;
|
|
}
|
|
|
|
appLogger.d('Creating collection "$collectionName" with type $collectionType');
|
|
final newCollectionId = await client.createCollection(
|
|
sectionId: sectionId.toString(),
|
|
title: collectionName,
|
|
uri: '', // Empty for regular collections
|
|
type: collectionType,
|
|
);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (context.mounted) {
|
|
if (newCollectionId != null) {
|
|
appLogger.d('Successfully created collection with ID: $newCollectionId');
|
|
|
|
// Now add the item to the newly created collection
|
|
appLogger.d('Adding item to new collection $newCollectionId with URI: $itemUri');
|
|
final addSuccess = await client.addToCollection(collectionId: newCollectionId, uri: itemUri);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (addSuccess) {
|
|
appLogger.d('Successfully added item to new collection');
|
|
showSuccessSnackBar(context, t.collections.created);
|
|
// Trigger refresh of collections tab
|
|
LibraryRefreshNotifier().notifyCollectionsChanged();
|
|
} else {
|
|
appLogger.e('Failed to add item to new collection');
|
|
showErrorSnackBar(context, t.collections.errorAddingToCollection);
|
|
}
|
|
} else {
|
|
appLogger.e('Failed to create collection - API returned null');
|
|
showErrorSnackBar(context, t.collections.errorAddingToCollection);
|
|
}
|
|
}
|
|
} else {
|
|
// Add to existing collection
|
|
appLogger.d('Adding to collection $result with URI: $itemUri');
|
|
final success = await client.addToCollection(collectionId: result, uri: itemUri);
|
|
|
|
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();
|
|
} 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()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle remove from collection action
|
|
Future<void> _showRatingSheet(BuildContext context, PlexMetadata metadata, PlexClient client) async {
|
|
final currentStarValue = (metadata.userRating != null && metadata.userRating! > 0)
|
|
? metadata.userRating! / 2.0
|
|
: 0.0;
|
|
await OverlaySheetController.showAdaptive(
|
|
context,
|
|
showDragHandle: true,
|
|
builder: (context) => RatingBottomSheet(
|
|
currentRating: currentStarValue,
|
|
onRate: (stars) async {
|
|
final plexRating = stars * 2.0;
|
|
final success = await client.rateItem(metadata.ratingKey, plexRating);
|
|
if (success) widget.onRefresh?.call(metadata.ratingKey);
|
|
},
|
|
onClear: () async {
|
|
final success = await client.rateItem(metadata.ratingKey, -1);
|
|
if (success) widget.onRefresh?.call(metadata.ratingKey);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleRemoveFromCollection(BuildContext context, PlexMetadata metadata) async {
|
|
final client = _getClientForItem();
|
|
|
|
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: metadata.title),
|
|
);
|
|
|
|
if (!confirmed || !context.mounted) return;
|
|
|
|
try {
|
|
appLogger.d('Removing item ${metadata.ratingKey} from collection ${widget.collectionId}');
|
|
final success = await client.removeFromCollection(collectionId: widget.collectionId!, itemId: metadata.ratingKey);
|
|
|
|
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
|
|
widget.onListRefresh?.call();
|
|
} 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
|
|
Future<void> _launchCollectionOrPlaylist(BuildContext context, {required bool shuffle}) async {
|
|
final client = _getClientForItem();
|
|
final item = widget.item;
|
|
|
|
final launcher = PlayQueueLauncher(
|
|
context: context,
|
|
client: client,
|
|
serverId: item is PlexMetadata ? item.serverId : (item as PlexPlaylist).serverId,
|
|
serverName: item is PlexMetadata ? item.serverName : (item as PlexPlaylist).serverName,
|
|
);
|
|
|
|
await launcher.launchFromCollectionOrPlaylist(item: item, shuffle: shuffle, showLoadingIndicator: false);
|
|
}
|
|
|
|
/// Handle delete action for collections and playlists
|
|
Future<void> _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async {
|
|
final client = _getClientForItem();
|
|
|
|
final itemTitle = widget.item.title;
|
|
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) {
|
|
final metadata = widget.item as PlexMetadata;
|
|
final sectionId = metadata.librarySectionID?.toString() ?? '0';
|
|
success = await client.deleteCollection(sectionId, metadata.ratingKey);
|
|
} else if (isPlaylist) {
|
|
final playlist = widget.item as PlexPlaylist;
|
|
success = await client.deletePlaylist(playlist.ratingKey);
|
|
}
|
|
|
|
if (context.mounted) {
|
|
if (success) {
|
|
showSuccessSnackBar(context, isCollection ? t.collections.deleted : t.playlists.deleted);
|
|
// Trigger list refresh
|
|
widget.onListRefresh?.call();
|
|
} 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 {
|
|
final metadata = widget.item as PlexMetadata;
|
|
|
|
// Check if the item is downloaded and use local file path if available
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final globalKey = metadata.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);
|
|
return;
|
|
}
|
|
}
|
|
|
|
final client = _getClientForItem();
|
|
if (!context.mounted) return;
|
|
await ExternalPlayerService.launch(context: context, metadata: metadata, client: client);
|
|
}
|
|
|
|
/// Handle download action
|
|
Future<void> _handleDownload(BuildContext context) async {
|
|
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
|
final metadata = widget.item as PlexMetadata;
|
|
final client = _getClientForItem();
|
|
|
|
try {
|
|
final count = await downloadProvider.queueDownload(metadata, client);
|
|
if (context.mounted) {
|
|
// Show appropriate message based on count
|
|
final message = count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued;
|
|
showSuccessSnackBar(context, message);
|
|
}
|
|
} 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 metadata = widget.item as PlexMetadata;
|
|
final globalKey = metadata.globalKey;
|
|
|
|
// Show confirmation dialog
|
|
final confirmed = await showDeleteConfirmation(
|
|
context,
|
|
title: t.downloads.deleteDownload,
|
|
message: t.downloads.deleteConfirm(title: metadata.title),
|
|
);
|
|
|
|
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);
|
|
// Notify DeletionAware screens (e.g. offline season detail)
|
|
DeletionNotifier().notifyDeleted(metadata: metadata, isDownloadOnly: true);
|
|
// Refresh the view if needed
|
|
widget.onRefresh?.call(metadata.ratingKey);
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Failed to delete download', error: e);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle delete media item action
|
|
/// This permanently removes the media item and its associated files from the server
|
|
Future<void> _handleDeleteMediaItem(BuildContext context, PlexMediaType? mediaType) async {
|
|
final metadata = widget.item as PlexMetadata;
|
|
final isMultipleMediaItems = mediaType == PlexMediaType.show || mediaType == PlexMediaType.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 = _getClientForItem();
|
|
final success = await client.deleteMediaItem(metadata.ratingKey);
|
|
|
|
if (context.mounted) {
|
|
if (success) {
|
|
showSuccessSnackBar(context, t.mediaMenu.mediaDeletedSuccessfully);
|
|
// Broadcast deletion event for cross-screen propagation
|
|
DeletionNotifier().notifyDeleted(metadata: metadata);
|
|
// Backward-compatible list refresh for screens that are not DeletionAware yet
|
|
widget.onListRefresh?.call();
|
|
} 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 StatelessWidget {
|
|
final List<PlexPlaylist> playlists;
|
|
|
|
const _PlaylistSelectionDialog({required this.playlists});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(t.playlists.selectPlaylist),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: playlists.length + 1,
|
|
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'),
|
|
);
|
|
}
|
|
|
|
final playlist = playlists[index - 1];
|
|
final subtitleText = playlist.leafCount == 1
|
|
? t.playlists.oneItem
|
|
: t.playlists.itemCount(count: playlist.leafCount!);
|
|
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.ratingKey),
|
|
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 StatelessWidget {
|
|
final List<PlexMetadata> collections;
|
|
|
|
const _CollectionSelectionDialog({required this.collections});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(t.collections.selectCollection),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: collections.length + 1,
|
|
itemBuilder: (context, index) {
|
|
if (index == 0) {
|
|
// Create new collection 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'),
|
|
);
|
|
}
|
|
|
|
final collection = collections[index - 1];
|
|
return ListTile(
|
|
leading: const AppIcon(Symbols.collections_rounded, fill: 1),
|
|
title: Text(collection.title),
|
|
subtitle: collection.childCount != null ? Text('${collection.childCount} items') : null,
|
|
onTap: () => Navigator.pop(context, collection.ratingKey),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
FocusableButton(
|
|
autofocus: true,
|
|
onPressed: () => Navigator.pop(context),
|
|
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Focusable context menu sheet for keyboard/gamepad navigation (mobile)
|
|
class _FocusableContextMenuSheet extends StatefulWidget {
|
|
final String title;
|
|
final List<_MenuAction> actions;
|
|
final bool focusFirstItem;
|
|
|
|
const _FocusableContextMenuSheet({required this.title, required this.actions, this.focusFirstItem = false});
|
|
|
|
@override
|
|
State<_FocusableContextMenuSheet> createState() => _FocusableContextMenuSheetState();
|
|
}
|
|
|
|
class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet> {
|
|
late final FocusNode _initialFocusNode;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initialFocusNode = FocusNode(debugLabel: 'ContextMenuSheetInitialFocus');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_initialFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
|
child: Text(
|
|
widget.title,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
...widget.actions.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final action = entry.value;
|
|
return FocusableListTile(
|
|
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
|
|
leading: AppIcon(action.icon, fill: 1),
|
|
title: Text(action.label),
|
|
onTap: () => OverlaySheetController.closeAdaptive(context, action.value),
|
|
hoverColor: action.hoverColor,
|
|
textColor: action.foregroundColor,
|
|
iconColor: action.foregroundColor,
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Focusable popup menu for keyboard/gamepad navigation (desktop)
|
|
class _FocusablePopupMenu extends StatefulWidget {
|
|
final List<_MenuAction> actions;
|
|
final Offset position;
|
|
final bool focusFirstItem;
|
|
|
|
const _FocusablePopupMenu({required this.actions, required this.position, this.focusFirstItem = false});
|
|
|
|
@override
|
|
State<_FocusablePopupMenu> createState() => _FocusablePopupMenuState();
|
|
}
|
|
|
|
class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
|
|
late final FocusNode _initialFocusNode;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initialFocusNode = FocusNode(debugLabel: 'PopupMenuInitialFocus');
|
|
if (widget.focusFirstItem) {
|
|
FocusUtils.requestFocusAfterBuild(this, _initialFocusNode);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_initialFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final screenSize = MediaQuery.of(context).size;
|
|
const menuWidth = 220.0;
|
|
|
|
// Clamp menu position to stay within screen bounds
|
|
const edgePadding = 8.0;
|
|
final left = widget.position.dx.clamp(edgePadding, screenSize.width - menuWidth - edgePadding);
|
|
|
|
final estimatedHeight = widget.actions.length * 48.0 + 16;
|
|
final spaceBelow = screenSize.height - widget.position.dy - edgePadding;
|
|
final spaceAbove = widget.position.dy - edgePadding;
|
|
|
|
// Place menu above the click point if it doesn't fit below and there's more room above
|
|
final double top;
|
|
final double maxHeight;
|
|
if (estimatedHeight <= spaceBelow) {
|
|
top = widget.position.dy;
|
|
maxHeight = spaceBelow;
|
|
} else if (spaceAbove > spaceBelow) {
|
|
final menuHeight = estimatedHeight.clamp(0.0, spaceAbove);
|
|
top = widget.position.dy - menuHeight;
|
|
maxHeight = menuHeight;
|
|
} else {
|
|
top = widget.position.dy;
|
|
maxHeight = spaceBelow;
|
|
}
|
|
|
|
return FocusScope(
|
|
// When opened via mouse, don't autofocus any item — let hover handle highlights.
|
|
// When opened via keyboard/dpad, autofocus is handled by _initialFocusNode.
|
|
autofocus: false,
|
|
child: Focus(
|
|
canRequestFocus: false,
|
|
skipTraversal: true,
|
|
onKeyEvent: (node, event) {
|
|
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
// Barrier to close menu when clicking outside
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
onTap: () => Navigator.pop(context),
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(color: Colors.transparent),
|
|
),
|
|
),
|
|
// Menu
|
|
Positioned(
|
|
left: left,
|
|
top: top,
|
|
child: Material(
|
|
elevation: 8,
|
|
color: Color.alphaBlend(
|
|
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08),
|
|
Theme.of(context).colorScheme.surface,
|
|
),
|
|
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth, maxHeight: maxHeight),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: widget.actions.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final action = entry.value;
|
|
return FocusableListTile(
|
|
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
|
|
leading: AppIcon(action.icon, fill: 1, size: 20),
|
|
title: Text(action.label),
|
|
onTap: () => Navigator.pop(context, action.value),
|
|
hoverColor: action.hoverColor,
|
|
textColor: action.foregroundColor,
|
|
iconColor: action.foregroundColor,
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|