feat: jellyfin
This commit is contained in:
@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/focusable_action_bar.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../../services/play_queue_launcher.dart';
|
||||
import '../../models/plex_playlist.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../media/media_playlist.dart';
|
||||
import '../../services/media_list_playback_launcher.dart';
|
||||
import '../../services/playlist_items_loader.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
@@ -17,11 +17,9 @@ import 'package:provider/provider.dart';
|
||||
import 'playlist_item_card.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../providers/download_provider.dart';
|
||||
import '../../utils/content_utils.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/dialogs.dart';
|
||||
import '../../utils/download_utils.dart';
|
||||
import '../../utils/global_key_utils.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../base_media_list_detail_screen.dart';
|
||||
import '../focusable_detail_screen_mixin.dart';
|
||||
@@ -29,7 +27,7 @@ import '../../mixins/grid_focus_node_mixin.dart';
|
||||
|
||||
/// Screen to display the contents of a playlist
|
||||
class PlaylistDetailScreen extends StatefulWidget {
|
||||
final PlexPlaylist playlist;
|
||||
final MediaPlaylist playlist;
|
||||
|
||||
const PlaylistDetailScreen({super.key, required this.playlist});
|
||||
|
||||
@@ -43,7 +41,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
GridFocusNodeMixin<PlaylistDetailScreen>,
|
||||
FocusableDetailScreenMixin<PlaylistDetailScreen> {
|
||||
@override
|
||||
dynamic get mediaItem => widget.playlist;
|
||||
Object get mediaItem => widget.playlist;
|
||||
|
||||
@override
|
||||
String get title => widget.playlist.title;
|
||||
@@ -57,13 +55,18 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
@override
|
||||
bool get hasItems => items.isNotEmpty;
|
||||
|
||||
/// True when the playlist can't be reordered or have items removed.
|
||||
/// Currently only Plex smart playlists (server-side rule-based; managed via
|
||||
/// filter rules, not direct edits). Jellyfin has no equivalent concept.
|
||||
bool get _isReadOnly => widget.playlist.smart;
|
||||
|
||||
@override
|
||||
List<FocusableAction> getAppBarActions() {
|
||||
final isVideoPlaylist = widget.playlist.playlistType == 'video';
|
||||
final globalKey = _playlistGlobalKey();
|
||||
final ruleKey = _playlistSyncRuleKey();
|
||||
// Select the specific bool we care about so unrelated DownloadProvider
|
||||
// ticks (e.g. active download progress) don't rebuild the app bar.
|
||||
final hasRule = isVideoPlaylist && context.select<DownloadProvider, bool>((p) => p.hasSyncRule(globalKey));
|
||||
final hasRule = isVideoPlaylist && context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
|
||||
|
||||
return [
|
||||
if (items.isNotEmpty) ...[
|
||||
@@ -83,6 +86,10 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
tooltip: t.downloads.removeSyncRule,
|
||||
onPressed: _removePlaylistSyncRule,
|
||||
),
|
||||
// Delete works on both backends now (Jellyfin uses /Items/{id} DELETE,
|
||||
// wrapped in the neutral [MediaServerClient.deletePlaylist]). Smart
|
||||
// playlists are still skipped — they're a Plex concept and are
|
||||
// managed server-side via filter rules, not via DELETE.
|
||||
if (!widget.playlist.smart)
|
||||
FocusableAction(
|
||||
icon: Symbols.delete_rounded,
|
||||
@@ -93,24 +100,30 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
];
|
||||
}
|
||||
|
||||
PlexMetadata _playlistAsMetadata() => PlexMetadata(
|
||||
ratingKey: widget.playlist.ratingKey,
|
||||
type: ContentTypes.playlist,
|
||||
/// Synthesise a [MediaItem] view of the current playlist for the
|
||||
/// download_utils helpers.
|
||||
MediaItem _playlistAsMetadata() => MediaItem(
|
||||
id: widget.playlist.id,
|
||||
backend: widget.playlist.backend,
|
||||
kind: MediaKind.playlist,
|
||||
title: widget.playlist.title,
|
||||
thumb: widget.playlist.thumb,
|
||||
serverId: widget.playlist.serverId ?? client.serverId,
|
||||
thumbPath: widget.playlist.thumbPath,
|
||||
serverId: widget.playlist.serverId ?? mediaClient.serverId,
|
||||
serverName: widget.playlist.serverName,
|
||||
);
|
||||
|
||||
String _playlistGlobalKey() => buildGlobalKey(widget.playlist.serverId ?? client.serverId, widget.playlist.ratingKey);
|
||||
String _playlistSyncRuleKey() {
|
||||
final serverId = widget.playlist.serverId ?? mediaClient.serverId;
|
||||
return context.read<DownloadProvider>().syncRuleKeyForClient(mediaClient, widget.playlist.id, serverId: serverId);
|
||||
}
|
||||
|
||||
Future<void> _managePlaylistSyncRule() =>
|
||||
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _playlistGlobalKey());
|
||||
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _playlistSyncRuleKey());
|
||||
|
||||
Future<void> _removePlaylistSyncRule() => removeSyncRuleAndSnack(
|
||||
context,
|
||||
downloadProvider: context.read<DownloadProvider>(),
|
||||
globalKey: _playlistGlobalKey(),
|
||||
globalKey: _playlistSyncRuleKey(),
|
||||
displayTitle: widget.playlist.title,
|
||||
);
|
||||
|
||||
@@ -124,7 +137,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
// Move mode state
|
||||
int? _movingIndex;
|
||||
int? _originalIndex;
|
||||
List<PlexMetadata>? _originalOrder;
|
||||
List<MediaItem>? _originalOrder;
|
||||
|
||||
// Estimated item height for scroll-into-view (card + vertical margins)
|
||||
static const double _estimatedItemHeight = 114.0;
|
||||
@@ -137,8 +150,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> fetchItems() async {
|
||||
return await client.fetchAllPlaylistItems(widget.playlist.ratingKey);
|
||||
Future<List<MediaItem>> fetchItems() async {
|
||||
return fetchAllPlaylistItems(mediaClient, widget.playlist.id);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -155,7 +168,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
_focusedIndex = 0;
|
||||
_focusedColumn = 0;
|
||||
});
|
||||
if (widget.playlist.smart) {
|
||||
if (_isReadOnly) {
|
||||
firstItemFocusNode.requestFocus();
|
||||
} else {
|
||||
_listFocusNode.requestFocus();
|
||||
@@ -175,7 +188,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
void navigateToGrid() {
|
||||
if (!hasItems) return;
|
||||
|
||||
if (widget.playlist.smart) {
|
||||
if (_isReadOnly) {
|
||||
super.navigateToGrid();
|
||||
} else {
|
||||
setState(() {
|
||||
@@ -185,11 +198,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the correct PlexClient for this playlist's server
|
||||
PlexClient _getClientForPlaylist() {
|
||||
return context.getClientForServer(widget.playlist.serverId!);
|
||||
}
|
||||
|
||||
Future<void> _downloadPlaylist() async {
|
||||
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
||||
|
||||
@@ -198,7 +206,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
context,
|
||||
playlistMetadata: _playlistAsMetadata(),
|
||||
items: items,
|
||||
client: client,
|
||||
client: mediaClient,
|
||||
downloadProvider: downloadProvider,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
@@ -222,31 +230,30 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
message: t.playlists.deleteMessage(name: widget.playlist.title),
|
||||
);
|
||||
|
||||
if (confirmed && mounted) {
|
||||
final success = await client.deletePlaylist(widget.playlist.ratingKey);
|
||||
if (!confirmed || !mounted) return;
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.playlists.deleted);
|
||||
Navigator.pop(context); // Return to playlists screen
|
||||
} else {
|
||||
showErrorSnackBar(context, t.playlists.errorDeleting);
|
||||
}
|
||||
}
|
||||
bool success = false;
|
||||
try {
|
||||
success = await mediaClient.deletePlaylist(widget.playlist);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete playlist', error: e);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.playlists.deleted);
|
||||
Navigator.pop(context); // Return to playlists screen
|
||||
} else {
|
||||
showErrorSnackBar(context, t.playlists.errorDeleting);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the afterPlaylistItemId for reordering at the given index.
|
||||
/// Returns null if validation fails, showing an error if [showError] is true.
|
||||
int? _getAfterPlaylistItemId(int newIndex, {bool showError = true}) {
|
||||
if (newIndex == 0) return 0;
|
||||
final afterItem = items[newIndex - 1];
|
||||
if (afterItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: after item missing playlistItemID');
|
||||
if (showError && mounted) showErrorSnackBar(context, t.playlists.errorReordering);
|
||||
return null;
|
||||
}
|
||||
return afterItem.playlistItemID!;
|
||||
/// The item that should sit immediately before the moved item at [newIndex],
|
||||
/// or null when moving to position 0. Pushes per-backend id-extraction down
|
||||
/// into the client implementations.
|
||||
MediaItem? _afterItemForIndex(int newIndex) {
|
||||
if (newIndex == 0) return null;
|
||||
return items[newIndex - 1];
|
||||
}
|
||||
|
||||
Future<void> _onReorder(int oldIndex, int newIndex) async {
|
||||
@@ -260,20 +267,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
|
||||
final movedItem = items[oldIndex];
|
||||
|
||||
// Check if item has playlistItemID (required for reordering)
|
||||
if (movedItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.playlists.errorReordering);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the "after" item ID
|
||||
final afterPlaylistItemId = _getAfterPlaylistItemId(newIndex);
|
||||
if (afterPlaylistItemId == null) return;
|
||||
|
||||
appLogger.d('Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)');
|
||||
appLogger.d('Reordering item from $oldIndex to $newIndex');
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
@@ -281,12 +275,17 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
items.insert(newIndex, item);
|
||||
});
|
||||
|
||||
// Call API to persist the change
|
||||
final success = await client.movePlaylistItem(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: movedItem.playlistItemID!,
|
||||
afterPlaylistItemId: afterPlaylistItemId,
|
||||
);
|
||||
bool success = false;
|
||||
try {
|
||||
success = await mediaClient.movePlaylistItem(
|
||||
playlistId: widget.playlist.id,
|
||||
item: movedItem,
|
||||
newIndex: newIndex,
|
||||
afterItem: _afterItemForIndex(newIndex),
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to reorder playlist item', error: e);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
// Revert on failure
|
||||
@@ -305,38 +304,22 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
/// Persist a move that was already done in the UI (during move mode).
|
||||
/// The item is already at newIndex in the items list.
|
||||
Future<void> _persistMoveToServer(int originalIndex, int newIndex) async {
|
||||
// Item is already at newIndex in the list
|
||||
final movedItem = items[newIndex];
|
||||
|
||||
// Check if item has playlistItemID (required for reordering)
|
||||
if (movedItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot persist move: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.playlists.errorReordering);
|
||||
_revertMove(newIndex, originalIndex);
|
||||
}
|
||||
return;
|
||||
appLogger.d('Persisting move from $originalIndex to $newIndex');
|
||||
|
||||
bool success = false;
|
||||
try {
|
||||
success = await mediaClient.movePlaylistItem(
|
||||
playlistId: widget.playlist.id,
|
||||
item: movedItem,
|
||||
newIndex: newIndex,
|
||||
afterItem: _afterItemForIndex(newIndex),
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to persist move', error: e);
|
||||
}
|
||||
|
||||
// Determine the "after" item ID based on where the item is now
|
||||
final afterPlaylistItemId = _getAfterPlaylistItemId(newIndex, showError: false);
|
||||
if (afterPlaylistItemId == null) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.playlists.errorReordering);
|
||||
_revertMove(newIndex, originalIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d('Persisting move from $originalIndex to $newIndex (after ID: $afterPlaylistItemId)');
|
||||
|
||||
// Call API to persist the change (UI is already updated)
|
||||
final success = await client.movePlaylistItem(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: movedItem.playlistItemID!,
|
||||
afterPlaylistItemId: afterPlaylistItemId,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
// Revert on failure
|
||||
appLogger.e('Failed to persist move, reverting UI');
|
||||
@@ -360,16 +343,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
if (items.isEmpty || index < 0 || index >= items.length) return;
|
||||
final item = items[index];
|
||||
|
||||
// Check if item has playlistItemID (required for removal)
|
||||
if (item.playlistItemID == null) {
|
||||
appLogger.e('Cannot remove: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.playlists.errorRemoving);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d('Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist');
|
||||
appLogger.d('Removing item ${item.title} from playlist');
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
@@ -382,11 +356,12 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
});
|
||||
|
||||
// Call API to persist the change
|
||||
final success = await client.removeFromPlaylist(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: item.playlistItemID.toString(),
|
||||
);
|
||||
bool success = false;
|
||||
try {
|
||||
success = await mediaClient.removeFromPlaylist(playlistId: widget.playlist.id, item: item);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to remove playlist item', error: e);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
@@ -407,19 +382,12 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
Future<void> _playFromItem(int index) async {
|
||||
if (items.isEmpty || index < 0 || index >= items.length) return;
|
||||
|
||||
final plexClient = _getClientForPlaylist();
|
||||
final selectedItem = items[index];
|
||||
|
||||
final launcher = PlayQueueLauncher(
|
||||
context: context,
|
||||
client: plexClient,
|
||||
serverId: widget.playlist.serverId,
|
||||
serverName: widget.playlist.serverName,
|
||||
);
|
||||
|
||||
await launcher.launchFromPlaylistItem(
|
||||
playlist: widget.playlist,
|
||||
selectedItem: selectedItem,
|
||||
final launcher = MediaListPlaybackLauncher.forItem(context, widget.playlist);
|
||||
await launcher.launchFromCollectionOrPlaylist(
|
||||
item: widget.playlist,
|
||||
shuffle: false,
|
||||
startItem: selectedItem,
|
||||
showLoadingIndicator: true,
|
||||
);
|
||||
}
|
||||
@@ -528,7 +496,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
if (key.isLeftKey) {
|
||||
// Navigate left within columns
|
||||
if (_focusedColumn == 0 && !widget.playlist.smart) {
|
||||
if (_focusedColumn == 0 && !_isReadOnly) {
|
||||
// Go to drag handle (column 1)
|
||||
setState(() => _focusedColumn = 1);
|
||||
return KeyEventResult.handled;
|
||||
@@ -540,7 +508,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
if (key.isRightKey) {
|
||||
// Navigate right within columns
|
||||
if (_focusedColumn == 0) {
|
||||
if (_focusedColumn == 0 && !_isReadOnly) {
|
||||
// Go to remove button (column 2)
|
||||
setState(() => _focusedColumn = 2);
|
||||
return KeyEventResult.handled;
|
||||
@@ -554,7 +522,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
if (_focusedColumn == 0) {
|
||||
// Play from this item
|
||||
_playFromItem(_focusedIndex);
|
||||
} else if (_focusedColumn == 1 && !widget.playlist.smart) {
|
||||
} else if (_focusedColumn == 1 && !_isReadOnly) {
|
||||
// Enter move mode
|
||||
setState(() {
|
||||
_movingIndex = _focusedIndex;
|
||||
@@ -612,7 +580,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
|
||||
// For regular playlists, wrap the scroll view with the Focus widget
|
||||
// (Focus is a RenderObject widget and cannot directly wrap a sliver)
|
||||
final needsListFocus = !widget.playlist.smart && items.isNotEmpty;
|
||||
final needsListFocus = !_isReadOnly && items.isNotEmpty;
|
||||
|
||||
Widget scrollView = CustomScrollView(
|
||||
controller: scrollController,
|
||||
@@ -649,11 +617,12 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
if (widget.playlist.smart)
|
||||
// Smart playlists: Use focusable grid view (cannot be reordered)
|
||||
if (_isReadOnly)
|
||||
// Smart playlists / Jellyfin playlists: focusable grid view
|
||||
// (read-only, no reordering or removal)
|
||||
buildFocusableGrid(items: items, onRefresh: updateItem)
|
||||
else
|
||||
// Regular playlists: Use sliver reorderable list
|
||||
// Plex regular playlists: sliver reorderable list
|
||||
_buildReorderableList(isKeyboardMode),
|
||||
],
|
||||
);
|
||||
@@ -700,15 +669,23 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
final isFocused = inKeyboardMode && index == _focusedIndex && !isAppBarFocused;
|
||||
final isMoving = index == _movingIndex;
|
||||
|
||||
// Both backends populate playlistItemId in playlist responses; the
|
||||
// backend prefix avoids collisions if the same numeric/uuid string
|
||||
// ever shows up across servers in the same key namespace.
|
||||
final keyId = switch (item) {
|
||||
PlexMediaItem(:final playlistItemId?) => 'p:$playlistItemId',
|
||||
JellyfinMediaItem(:final playlistItemId?) => 'j:$playlistItemId',
|
||||
_ => item.id,
|
||||
};
|
||||
return RepaintBoundary(
|
||||
key: ValueKey(item.playlistItemID ?? item.ratingKey),
|
||||
key: ValueKey(keyId),
|
||||
child: PlaylistItemCard(
|
||||
item: item,
|
||||
index: index,
|
||||
onRemove: () => _removeItem(index),
|
||||
onTap: () => _playFromItem(index),
|
||||
onRefresh: updateItem,
|
||||
canReorder: !widget.playlist.smart,
|
||||
canReorder: !_isReadOnly,
|
||||
isFocused: isFocused,
|
||||
focusedColumn: isFocused ? _focusedColumn : null,
|
||||
isMoving: isMoving,
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../mixins/context_menu_tap_mixin.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../widgets/media_context_menu.dart';
|
||||
import '../../widgets/media_progress_bar.dart';
|
||||
import '../../widgets/plex_optimized_image.dart';
|
||||
import '../../widgets/optimized_media_image.dart';
|
||||
|
||||
/// Custom list item widget for playlist items
|
||||
/// Shows drag handle, poster, title/metadata, duration, and remove button
|
||||
class PlaylistItemCard extends StatefulWidget {
|
||||
final PlexMetadata item;
|
||||
final MediaItem item;
|
||||
final int index;
|
||||
final VoidCallback onRemove;
|
||||
final VoidCallback? onTap;
|
||||
final void Function(String ratingKey)? onRefresh;
|
||||
final void Function(String itemId)? onRefresh;
|
||||
final bool canReorder; // Whether drag handle should be shown
|
||||
|
||||
// Focus state for keyboard/D-pad navigation
|
||||
@@ -149,12 +149,12 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
),
|
||||
|
||||
// Progress indicator if partially watched
|
||||
if (widget.item.viewOffset != null && widget.item.duration != null)
|
||||
if (widget.item.viewOffsetMs != null && widget.item.durationMs != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: MediaProgressBar(
|
||||
viewOffset: widget.item.viewOffset!,
|
||||
duration: widget.item.duration!,
|
||||
viewOffset: widget.item.viewOffsetMs!,
|
||||
duration: widget.item.durationMs!,
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
@@ -165,9 +165,9 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Duration
|
||||
if (widget.item.duration != null)
|
||||
if (widget.item.durationMs != null)
|
||||
Text(
|
||||
formatDurationTextual(widget.item.duration!),
|
||||
formatDurationTextual(widget.item.durationMs!),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||
),
|
||||
|
||||
@@ -196,17 +196,14 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the correct PlexClient for this item's server
|
||||
PlexClient _getClientForItem(BuildContext context) {
|
||||
return context.getClientForServer(widget.item.serverId!);
|
||||
}
|
||||
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
final posterUrl = widget.item.posterThumb();
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: PlexOptimizedImage.poster(
|
||||
client: _getClientForItem(context),
|
||||
child: OptimizedMediaImage.poster(
|
||||
// Backend-neutral lookup so Jellyfin items render via their own
|
||||
// image transcoder; null falls through to the placeholder below.
|
||||
client: context.tryGetMediaClientWithFallback(widget.item.serverId),
|
||||
imagePath: posterUrl,
|
||||
width: 60,
|
||||
height: 90,
|
||||
@@ -227,9 +224,9 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
}
|
||||
|
||||
String _buildSubtitle() {
|
||||
final itemType = widget.item.mediaType;
|
||||
final kind = widget.item.kind;
|
||||
|
||||
if (itemType == PlexMediaType.episode) {
|
||||
if (kind == MediaKind.episode) {
|
||||
// For episodes, show "S#E# - Episode Title"
|
||||
final season = widget.item.parentIndex;
|
||||
final episode = widget.item.index;
|
||||
@@ -237,16 +234,17 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
return 'S${season}E$episode${widget.item.displaySubtitle != null ? ' - ${widget.item.displaySubtitle}' : ''}';
|
||||
}
|
||||
return widget.item.displaySubtitle ?? t.discover.tvShow;
|
||||
} else if (itemType == PlexMediaType.movie) {
|
||||
// For movies, show year and edition
|
||||
} else if (kind == MediaKind.movie) {
|
||||
// For movies, show year and edition (edition is Plex-only; null elsewhere)
|
||||
final year = widget.item.year?.toString();
|
||||
if (year != null && widget.item.editionTitle != null) {
|
||||
return '$year · ${widget.item.editionTitle}';
|
||||
final edition = widget.item.editionTitle;
|
||||
if (year != null && edition != null) {
|
||||
return '$year · $edition';
|
||||
}
|
||||
return year ?? t.discover.movie;
|
||||
}
|
||||
|
||||
// Default to type
|
||||
return widget.item.mediaType.name;
|
||||
return kind.name;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user