263 lines
9.6 KiB
Dart
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;
|
|
}
|
|
}
|