Files
plezy/lib/screens/playlist/playlist_item_card.dart
2026-08-10 20:28:41 +02:00

263 lines
9.6 KiB
Dart

import 'package:flutter/material.dart';
import '../../media/ids.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 '../../providers/watch_state_store.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/formatters.dart';
import '../../utils/media_image_helper.dart';
import '../../utils/provider_extensions.dart';
import '../../i18n/strings.g.dart';
import '../../widgets/media_context_menu.dart';
import '../../widgets/watched_indicator.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 MediaItem item;
final int index;
final VoidCallback? onRemove;
final VoidCallback? onTap;
final void Function(MediaItem source)? onRefresh;
final bool canReorder;
final bool isFocused;
final int? focusedColumn;
final bool isMoving;
const PlaylistItemCard({
super.key,
required this.item,
required this.index,
required this.onRemove,
this.onTap,
this.onRefresh,
this.canReorder = true,
this.isFocused = false,
this.focusedColumn,
this.isMoving = false,
});
@override
State<PlaylistItemCard> createState() => _PlaylistItemCardState();
}
class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTapMixin<PlaylistItemCard> {
MediaItem _effectiveItem(BuildContext context) => context.withFreshWatchState(widget.item);
@override
Widget build(BuildContext context) {
final item = _effectiveItem(context);
final colorScheme = Theme.of(context).colorScheme;
final textMuted = tokens(context).textMuted;
final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
Color? cardColor;
ShapeBorder? cardShape;
if (widget.isMoving) {
cardColor = colorScheme.primaryContainer;
} else if (isRowFocused) {
cardColor = colorScheme.surfaceContainerHighest;
cardShape = RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
side: BorderSide(color: colorScheme.primary, width: 2.5),
);
}
return MediaContextMenu(
key: contextMenuKey,
item: item,
onRefresh: widget.onRefresh,
onTap: widget.onTap,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: cardColor,
shape: cardShape,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: widget.onTap,
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
onSecondaryTap: showContextMenuFromTap,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
if (widget.canReorder)
GestureDetector(
// ignore: no-empty-block - consumes long-press to prevent context menu on drag
onLongPress: () {},
child: ReorderableDragStartListener(
index: widget.index,
child: Container(
color: Colors.transparent,
height: 90,
padding: const EdgeInsets.only(right: 4),
alignment: .center,
child: Container(
padding: const EdgeInsets.fromLTRB(2, 8, 6, 8),
decoration: isDragHandleFocused
? BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(8)),
)
: null,
child: AppIcon(
widget.isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded,
fill: 1,
color: (widget.isMoving || isDragHandleFocused) ? colorScheme.primary : textMuted,
),
),
),
),
),
_buildPosterImage(context, item),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Text(
item.displayTitle,
style: const TextStyle(fontSize: 15, fontWeight: .w500),
maxLines: 1,
overflow: .ellipsis,
),
const SizedBox(height: 4),
Text(
_buildSubtitle(item),
style: TextStyle(fontSize: 13, color: textMuted),
maxLines: 1,
overflow: .ellipsis,
),
],
),
),
const SizedBox(width: 12),
if (item.durationMs != null)
Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)),
const SizedBox(width: 8),
Container(
decoration: isRemoveButtonFocused
? BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(20)),
)
: null,
child: IconButton(
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20),
onPressed: widget.onRemove,
tooltip: t.playlists.removeItem,
color: isRemoveButtonFocused ? colorScheme.primary : textMuted,
),
),
],
),
),
),
),
);
}
Widget _buildPosterImage(BuildContext context, MediaItem item) {
final posterUrl = item.posterThumb();
final isSquare = item.kind.isMusic;
final imageSize = isSquare ? const Size.square(60) : const Size(60, 90);
return SizedBox(
width: 60,
height: 90,
child: Center(
child: SizedBox.fromSize(
size: imageSize,
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)),
child: OptimizedMediaImage(
// Backend-neutral lookup so Jellyfin items render via their own
// image transcoder; null falls through to the placeholder below.
client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: posterUrl,
width: imageSize.width,
height: imageSize.height,
fit: BoxFit.cover,
placeholder: (context, url) => _buildPlaceholder(imageSize, item),
errorWidget: (context, url, error) => _buildPlaceholder(imageSize, item),
fallbackIcon: _fallbackIcon(item),
imageType: isSquare ? ImageType.square : ImageType.poster,
),
),
WatchedIndicator(item: item, size: WatchedIndicatorSize.compact),
],
),
),
),
);
}
Widget _buildPlaceholder(Size size, MediaItem item) {
return Container(
width: size.width,
height: size.height,
decoration: BoxDecoration(color: Colors.grey[850], borderRadius: const BorderRadius.all(Radius.circular(6))),
child: AppIcon(_fallbackIcon(item), fill: 1, color: Colors.grey, size: 24),
);
}
IconData _fallbackIcon(MediaItem item) => switch (item.kind) {
MediaKind.artist => Symbols.artist_rounded,
MediaKind.album => Symbols.album_rounded,
MediaKind.track => Symbols.music_note_rounded,
MediaKind.show || MediaKind.season || MediaKind.episode => Symbols.tv_rounded,
_ => Symbols.movie_rounded,
};
String _buildSubtitle(MediaItem item) {
final kind = item.kind;
if (kind == MediaKind.episode) {
// For episodes, show "S#E# - Episode Title"
final season = item.parentIndex;
final episode = item.index;
if (season != null && episode != null) {
return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}';
}
return item.displaySubtitle ?? t.discover.tvShow;
} else if (kind == MediaKind.movie) {
// For movies, show year and edition (edition is Plex-only; null elsewhere)
final year = item.year?.toString();
final edition = item.editionTitle;
if (year != null && edition != null) {
return '$year · $edition';
}
return year ?? t.discover.movie;
} else if (kind == MediaKind.track) {
// Music: "Artist · Album" (either half may be missing).
final parts = [item.trackArtistTitle, item.albumTitle].nonNulls.where((part) => part.isNotEmpty).toList();
if (parts.isNotEmpty) return parts.join(' · ');
}
// Default to type
return kind.name;
}
}