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 createState() => _PlaylistItemCardState(); } class _PlaylistItemCardState extends State with ContextMenuTapMixin { 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; } }