Memoize the per-image SHA-1 disk-cache key (ran on the UI thread on every image build), fade images in via paint alpha instead of an AnimatedSwitcher saveLayer per in-flight tile, create SettingsBuilder's merged listenable once instead of per build, and drop the pointer chrome (InkWell ink machinery, cursor MouseRegions) from cards on TV where no pointer exists.
1029 lines
36 KiB
Dart
1029 lines
36 KiB
Dart
import 'dart:ui';
|
||
import '../media/ids.dart';
|
||
|
||
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 '../focus/card_focus_scope.dart';
|
||
import '../focus/input_mode_tracker.dart';
|
||
import '../media/media_item.dart';
|
||
import '../media/media_item_types.dart';
|
||
import '../media/media_kind.dart';
|
||
import '../media/media_playlist.dart';
|
||
import '../mixins/context_menu_tap_mixin.dart';
|
||
import '../providers/download_provider.dart';
|
||
import '../providers/watch_state_store.dart';
|
||
import '../services/download_storage_service.dart';
|
||
import '../services/settings_service.dart';
|
||
import 'settings_builder.dart';
|
||
import 'watched_indicator.dart';
|
||
import '../utils/content_utils.dart';
|
||
import '../utils/platform_detector.dart';
|
||
import '../utils/provider_extensions.dart';
|
||
import '../utils/formatters.dart';
|
||
import '../utils/media_navigation_helper.dart';
|
||
import '../utils/snackbar_helper.dart';
|
||
import '../theme/mono_tokens.dart';
|
||
import '../i18n/strings.g.dart';
|
||
import 'media_context_menu.dart';
|
||
import 'media_card_list_layout.dart';
|
||
import 'backend_badge.dart';
|
||
import 'optimized_media_image.dart';
|
||
|
||
const _failedPosterUrlCacheLimit = 512;
|
||
final _failedPosterUrls = <String>{};
|
||
|
||
bool _hasFailedPosterUrl(String? url) => url != null && _failedPosterUrls.contains(url);
|
||
|
||
void _rememberFailedPosterUrl(String? url) {
|
||
if (url == null || url.isEmpty) return;
|
||
_failedPosterUrls.remove(url);
|
||
_failedPosterUrls.add(url);
|
||
if (_failedPosterUrls.length > _failedPosterUrlCacheLimit) {
|
||
_failedPosterUrls.remove(_failedPosterUrls.first);
|
||
}
|
||
}
|
||
|
||
class MediaCard extends StatefulWidget {
|
||
/// Either a [MediaItem] or a [MediaPlaylist]. Typed as [Object] because Dart
|
||
/// has no nominal union type — runtime `is` checks select the variant.
|
||
final Object item;
|
||
final double? width;
|
||
final double? height;
|
||
final void Function(String itemId)? onRefresh;
|
||
final VoidCallback? onRemoveFromContinueWatching;
|
||
final VoidCallback? onListRefresh; // Callback to refresh the entire parent list
|
||
final bool forceGridMode;
|
||
final bool forceListMode;
|
||
final bool isInContinueWatching;
|
||
final bool usesContinueWatchingAction;
|
||
final String? collectionId; // The collection ID if displaying within a collection
|
||
final bool isOffline; // True for downloaded content without server access
|
||
final bool mixedHubContext; // True when in a hub with mixed content (movies + episodes)
|
||
final bool showServerName; // Show server name in list view (multi-server)
|
||
final EpisodePosterMode? episodePosterModeOverride;
|
||
final bool fullBleedImage;
|
||
|
||
const MediaCard({
|
||
super.key,
|
||
required this.item,
|
||
this.width,
|
||
this.height,
|
||
this.onRefresh,
|
||
this.onRemoveFromContinueWatching,
|
||
this.onListRefresh,
|
||
this.forceGridMode = false,
|
||
this.forceListMode = false,
|
||
this.isInContinueWatching = false,
|
||
bool? usesContinueWatchingAction,
|
||
this.collectionId,
|
||
this.isOffline = false,
|
||
this.mixedHubContext = false,
|
||
this.showServerName = false,
|
||
this.episodePosterModeOverride,
|
||
this.fullBleedImage = false,
|
||
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
|
||
|
||
@override
|
||
State<MediaCard> createState() => MediaCardState();
|
||
}
|
||
|
||
class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard> {
|
||
/// Public method to trigger tap action (for keyboard/gamepad SELECT)
|
||
void handleTap() {
|
||
_handleTap(context, _effectiveItemForAction(context));
|
||
}
|
||
|
||
Object _effectiveItem(BuildContext context) {
|
||
final item = widget.item;
|
||
return item is MediaItem ? context.withFreshWatchState(item) : item;
|
||
}
|
||
|
||
Object _effectiveItemForAction(BuildContext context) {
|
||
final item = widget.item;
|
||
return item is MediaItem ? context.readFreshWatchState(item) : item;
|
||
}
|
||
|
||
String _buildSemanticLabel(Object item) {
|
||
// Playlists don't expose kind, so build a simple localized label and exit early
|
||
if (item is MediaPlaylist) {
|
||
final count = item.leafCount;
|
||
final countText = count != null ? ', ${t.playlists.itemCount(count: count)}' : '';
|
||
return '${item.displayTitle}, ${t.playlists.playlist}$countText';
|
||
}
|
||
|
||
if (item is! MediaItem) {
|
||
return '$item';
|
||
}
|
||
|
||
String baseLabel;
|
||
switch (item.kind) {
|
||
case MediaKind.episode:
|
||
final episodeInfo = item.parentIndex != null && item.index != null ? 'S${item.parentIndex} E${item.index}' : '';
|
||
baseLabel = t.accessibility.mediaCardEpisode(title: item.displayTitle, episodeInfo: episodeInfo);
|
||
case MediaKind.season:
|
||
final seasonInfo = item.title?.isNotEmpty == true
|
||
? item.title!
|
||
: item.index != null
|
||
? t.common.seasonNumber(number: item.index!)
|
||
: '';
|
||
baseLabel = t.accessibility.mediaCardSeason(title: item.displayTitle, seasonInfo: seasonInfo);
|
||
case MediaKind.movie:
|
||
baseLabel = t.accessibility.mediaCardMovie(title: item.displayTitle);
|
||
default:
|
||
baseLabel = t.accessibility.mediaCardShow(title: item.displayTitle);
|
||
}
|
||
|
||
// Add watched status
|
||
final hasActiveProgress =
|
||
item.viewOffsetMs != null &&
|
||
item.durationMs != null &&
|
||
item.viewOffsetMs! > 0 &&
|
||
item.viewOffsetMs! < item.durationMs!;
|
||
|
||
if (hasActiveProgress) {
|
||
final percent = ((item.viewOffsetMs! / item.durationMs!) * 100).round();
|
||
baseLabel = '$baseLabel, ${t.accessibility.mediaCardPartiallyWatched(percent: percent)}';
|
||
} else if (item.isWatched) {
|
||
baseLabel = '$baseLabel, ${t.accessibility.mediaCardWatched}';
|
||
} else {
|
||
baseLabel = '$baseLabel, ${t.accessibility.mediaCardUnwatched}';
|
||
}
|
||
|
||
return baseLabel;
|
||
}
|
||
|
||
void _handleTap(BuildContext context, Object item) async {
|
||
// Ignore taps while context menu is open to avoid double-activating
|
||
if (contextMenuKey.currentState?.isContextMenuOpen == true) {
|
||
return;
|
||
}
|
||
|
||
final result = await navigateToMediaItem(
|
||
context,
|
||
item,
|
||
onRefresh: widget.onRefresh,
|
||
isOffline: widget.isOffline,
|
||
playDirectly: widget.usesContinueWatchingAction,
|
||
);
|
||
|
||
if (!context.mounted) return;
|
||
|
||
switch (result) {
|
||
case MediaNavigationResult.unsupported:
|
||
showAppSnackBar(context, t.messages.musicNotSupported);
|
||
case MediaNavigationResult.listRefreshNeeded:
|
||
widget.onListRefresh?.call();
|
||
case MediaNavigationResult.navigated:
|
||
case MediaNavigationResult.librarySelected:
|
||
// Item refresh already handled by onRefresh callback in helper
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// Get the local poster path for offline mode
|
||
String? _getLocalPosterPath(BuildContext context, Object item) {
|
||
if (!widget.isOffline) return null;
|
||
if (item is! MediaItem) return null;
|
||
|
||
if (item.serverId == null) return null;
|
||
|
||
final downloadProvider = context.read<DownloadProvider>();
|
||
final globalKey = item.globalKey;
|
||
|
||
// Get artwork reference and resolve to local path using hash (includes serverId)
|
||
final artwork = downloadProvider.getArtworkPaths(globalKey);
|
||
return artwork?.getLocalPath(DownloadStorageService.instance, ServerId(item.serverId!));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SettingsBuilder(
|
||
prefs: const [
|
||
SettingsService.viewMode,
|
||
SettingsService.libraryDensity,
|
||
SettingsService.episodePosterMode,
|
||
SettingsService.showEpisodeNumberOnCards,
|
||
SettingsService.hideSpoilers,
|
||
SettingsService.showUnwatchedCount,
|
||
],
|
||
builder: _buildContent,
|
||
);
|
||
}
|
||
|
||
Widget _buildContent(BuildContext context) {
|
||
final item = _effectiveItem(context);
|
||
final ViewMode viewMode;
|
||
if (widget.forceListMode) {
|
||
viewMode = ViewMode.list;
|
||
} else if (widget.forceGridMode) {
|
||
viewMode = ViewMode.grid;
|
||
} else {
|
||
viewMode = SettingsService.instance.read(SettingsService.viewMode);
|
||
}
|
||
|
||
final semanticLabel = _buildSemanticLabel(item);
|
||
final localPosterPath = _getLocalPosterPath(context, item);
|
||
|
||
final cardWidget = viewMode == ViewMode.grid
|
||
? _buildGridCard(context, item, localPosterPath)
|
||
: _MediaCardList(
|
||
item: item,
|
||
semanticLabel: semanticLabel,
|
||
onTap: () => _handleTap(context, item),
|
||
onTapDown: storeTapPosition,
|
||
onLongPress: showContextMenuFromTap,
|
||
onSecondaryTapDown: storeTapPosition,
|
||
onSecondaryTap: showContextMenuFromTap,
|
||
density: SettingsService.instance.read(SettingsService.libraryDensity),
|
||
isOffline: widget.isOffline,
|
||
localPosterPath: localPosterPath,
|
||
showServerName: widget.showServerName,
|
||
episodePosterModeOverride: widget.episodePosterModeOverride,
|
||
);
|
||
|
||
// MediaContextMenu as a non-widget helper — only wrap with its key for
|
||
// programmatic context menu access; gesture callbacks are on InkWell directly.
|
||
return MediaContextMenu(
|
||
key: contextMenuKey,
|
||
item: item,
|
||
onRefresh: widget.onRefresh,
|
||
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
|
||
onListRefresh: widget.onListRefresh,
|
||
onTap: () => _handleTap(context, item),
|
||
isInContinueWatching: widget.isInContinueWatching,
|
||
collectionId: widget.collectionId,
|
||
child: cardWidget,
|
||
);
|
||
}
|
||
|
||
/// Grid layout — inlined from former _MediaCardGrid, _PosterOverlay, and
|
||
/// flattened Column. Semantics removed (InkWell provides button semantics).
|
||
///
|
||
/// MergeSemantics collapses the card (texts, progress, button) into ONE
|
||
/// semantics node. Browse rails/grids show dozens of cards and the
|
||
/// platform-driven semantics pass runs every frame on TV boxes with an
|
||
/// accessibility service active — node count is the cost driver. The card
|
||
/// has a single action (tap; long-press menu), so merging is safe and gives
|
||
/// screen readers one coherent announcement per card.
|
||
Widget _buildGridCard(BuildContext context, Object item, String? localPosterPath) {
|
||
if (widget.fullBleedImage) {
|
||
return MergeSemantics(
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final cardWidth = widget.width ?? (constraints.hasBoundedWidth ? constraints.maxWidth : null);
|
||
final cardHeight = widget.height ?? (constraints.hasBoundedHeight ? constraints.maxHeight : null);
|
||
if (cardHeight == null) return _buildStandardGridCard(context, item, localPosterPath);
|
||
return _buildFullBleedGridCard(context, item, localPosterPath, width: cardWidth, height: cardHeight);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
return MergeSemantics(child: _buildStandardGridCard(context, item, localPosterPath));
|
||
}
|
||
|
||
Widget _buildFullBleedGridCard(
|
||
BuildContext context,
|
||
Object item,
|
||
String? localPosterPath, {
|
||
required double? width,
|
||
required double height,
|
||
}) {
|
||
return SizedBox(
|
||
width: width,
|
||
height: height,
|
||
child: _CardTapRegion(
|
||
onTap: () => _handleTap(context, item),
|
||
onTapDown: storeTapPosition,
|
||
onLongPress: showContextMenuFromTap,
|
||
onSecondaryTapDown: storeTapPosition,
|
||
onSecondaryTap: showContextMenuFromTap,
|
||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||
child: CardFocusBorder(
|
||
borderRadius: tokens(context).radiusSm,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
_buildPosterImage(
|
||
context,
|
||
item,
|
||
isOffline: widget.isOffline,
|
||
localPosterPath: localPosterPath,
|
||
mixedHubContext: widget.mixedHubContext,
|
||
episodePosterModeOverride: widget.episodePosterModeOverride,
|
||
knownWidth: width,
|
||
knownHeight: height,
|
||
),
|
||
if (item is MediaItem) WatchedIndicator(item: item),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildStandardGridCard(BuildContext context, Object item, String? localPosterPath) {
|
||
// Compute actual poster dimensions from card dimensions
|
||
final posterWidth = widget.width != null ? widget.width! - 6 : null;
|
||
final posterHeight = widget.height;
|
||
|
||
// The focus border hugs the poster (captions stay outside it), matching
|
||
// the full-bleed card treatment.
|
||
final poster = CardFocusBorder(
|
||
borderRadius: tokens(context).radiusSm,
|
||
child: Stack(
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||
child: _buildPosterImage(
|
||
context,
|
||
item,
|
||
isOffline: widget.isOffline,
|
||
localPosterPath: localPosterPath,
|
||
mixedHubContext: widget.mixedHubContext,
|
||
episodePosterModeOverride: widget.episodePosterModeOverride,
|
||
knownWidth: posterHeight != null ? posterWidth : null,
|
||
knownHeight: posterHeight,
|
||
),
|
||
),
|
||
if (item is MediaItem) WatchedIndicator(item: item),
|
||
],
|
||
),
|
||
);
|
||
|
||
return SizedBox(
|
||
width: widget.width,
|
||
child: _CardTapRegion(
|
||
onTap: () => _handleTap(context, item),
|
||
onTapDown: storeTapPosition,
|
||
onLongPress: showContextMenuFromTap,
|
||
onSecondaryTapDown: storeTapPosition,
|
||
onSecondaryTap: showContextMenuFromTap,
|
||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(3, 3, 3, 1),
|
||
child: Column(
|
||
mainAxisSize: .min,
|
||
crossAxisAlignment: .start,
|
||
children: [
|
||
// Poster with overlay
|
||
if (posterHeight != null)
|
||
SizedBox(width: double.infinity, height: posterHeight, child: poster)
|
||
else
|
||
Expanded(child: poster),
|
||
const SizedBox(height: 2),
|
||
// Title (flattened — no inner Column)
|
||
if (item is MediaItem && _hasClickableTitle(item))
|
||
_ClickableText(
|
||
text: item.displayTitle,
|
||
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
|
||
onTap: () => _navigateToFocusedDetail(context, item, isOffline: widget.isOffline),
|
||
)
|
||
else
|
||
Text(
|
||
item is MediaPlaylist ? item.title : (item as MediaItem).displayTitle,
|
||
maxLines: 1,
|
||
overflow: .ellipsis,
|
||
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
|
||
),
|
||
// Subtitle
|
||
if (item is MediaPlaylist)
|
||
_MediaCardHelpers.buildPlaylistMeta(context, item)
|
||
else if (item is MediaItem)
|
||
_MediaCardHelpers.buildMetadataSubtitle(context, item, isOffline: widget.isOffline),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MediaCardList extends StatelessWidget {
|
||
/// Either a [MediaItem] or a [MediaPlaylist].
|
||
final Object item;
|
||
final String semanticLabel;
|
||
final VoidCallback onTap;
|
||
final VoidCallback onLongPress;
|
||
final void Function(TapDownDetails)? onTapDown;
|
||
final VoidCallback? onSecondaryTap;
|
||
final void Function(TapDownDetails)? onSecondaryTapDown;
|
||
final int density;
|
||
final bool isOffline;
|
||
final String? localPosterPath;
|
||
final bool showServerName;
|
||
final EpisodePosterMode? episodePosterModeOverride;
|
||
|
||
const _MediaCardList({
|
||
required this.item,
|
||
required this.semanticLabel,
|
||
required this.onTap,
|
||
required this.onLongPress,
|
||
this.onTapDown,
|
||
this.onSecondaryTap,
|
||
this.onSecondaryTapDown,
|
||
required this.density,
|
||
this.isOffline = false,
|
||
this.localPosterPath,
|
||
this.showServerName = false,
|
||
this.episodePosterModeOverride,
|
||
});
|
||
|
||
bool _usesWideAspectRatio() {
|
||
if (item is! MediaItem) return false;
|
||
final EpisodePosterMode mode =
|
||
episodePosterModeOverride ?? SettingsService.instance.read(SettingsService.episodePosterMode);
|
||
return (item as MediaItem).usesWideAspectRatio(mode);
|
||
}
|
||
|
||
double _posterWidth() =>
|
||
MediaCardListLayout.posterWidth(density: density, usesWideAspectRatio: _usesWideAspectRatio());
|
||
|
||
double _posterHeight() =>
|
||
MediaCardListLayout.posterHeight(density: density, usesWideAspectRatio: _usesWideAspectRatio());
|
||
|
||
double get _titleFontSize => 13 + LibraryDensity.factor(density) * 3; // 13–16
|
||
|
||
double get _metadataFontSize => 10 + LibraryDensity.factor(density) * 3; // 10–13
|
||
|
||
double get _subtitleFontSize => 11 + LibraryDensity.factor(density) * 3; // 11–14
|
||
|
||
double get _summaryFontSize {
|
||
// Summary uses the same sizing as metadata text
|
||
return _metadataFontSize;
|
||
}
|
||
|
||
int get _summaryMaxLines => density <= 2 ? 2 : density; // 2, 2, 3, 4, 5
|
||
|
||
String _buildMetadataLine() {
|
||
final parts = <String>[];
|
||
|
||
if (item is MediaPlaylist) {
|
||
final playlist = item as MediaPlaylist;
|
||
if (playlist.leafCount != null && playlist.leafCount! > 0) {
|
||
parts.add(t.playlists.itemCount(count: playlist.leafCount!));
|
||
}
|
||
|
||
if (playlist.durationMs != null) {
|
||
parts.add(formatDurationTextual(playlist.durationMs!));
|
||
}
|
||
|
||
if (playlist.smart) {
|
||
parts.add(t.playlists.smartPlaylist);
|
||
}
|
||
} else if (item is MediaItem) {
|
||
final mi = item as MediaItem;
|
||
|
||
if (mi.kind == MediaKind.collection) {
|
||
final count = mi.childCount ?? mi.leafCount;
|
||
if (count != null && count > 0) {
|
||
parts.add(t.playlists.itemCount(count: count));
|
||
}
|
||
} else {
|
||
if (mi.contentRating != null && mi.contentRating!.isNotEmpty) {
|
||
final rating = formatContentRating(mi.contentRating);
|
||
if (rating.isNotEmpty) {
|
||
parts.add(rating);
|
||
}
|
||
}
|
||
|
||
if (mi.year != null) {
|
||
parts.add('${mi.year}');
|
||
}
|
||
|
||
if (mi.editionTitle case final editionTitle?) {
|
||
parts.add(editionTitle);
|
||
}
|
||
|
||
if (mi.durationMs != null) {
|
||
parts.add(formatDurationTextual(mi.durationMs!));
|
||
}
|
||
|
||
if (mi.rating != null) {
|
||
parts.add('${formatRating(mi.rating!)}★');
|
||
}
|
||
|
||
if (mi.studio != null && mi.studio!.isNotEmpty) {
|
||
parts.add(mi.studio!);
|
||
}
|
||
}
|
||
}
|
||
|
||
return parts.join(' • ');
|
||
}
|
||
|
||
String? _buildSubtitleText() {
|
||
if (item is MediaPlaylist) {
|
||
return null;
|
||
} else if (item is MediaItem) {
|
||
final mi = item as MediaItem;
|
||
|
||
if (mi.parentIndex != null && mi.index != null) {
|
||
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
|
||
return showEp ? 'S${mi.parentIndex} E${mi.index}' : 'S${mi.parentIndex}';
|
||
}
|
||
|
||
if (mi.displaySubtitle != null) {
|
||
return mi.displaySubtitle;
|
||
} else if (mi.parentTitle != null) {
|
||
return mi.parentTitle;
|
||
}
|
||
}
|
||
|
||
// Year is now shown in metadata line, so don't show it here
|
||
return null;
|
||
}
|
||
|
||
String? _summary() {
|
||
final it = item;
|
||
if (it is MediaItem) return it.summary;
|
||
if (it is MediaPlaylist) return it.summary;
|
||
return null;
|
||
}
|
||
|
||
String _displayTitle() {
|
||
final it = item;
|
||
if (it is MediaItem) return it.displayTitle;
|
||
if (it is MediaPlaylist) return it.displayTitle;
|
||
return '';
|
||
}
|
||
|
||
Widget _buildEpisodeSubtitle(BuildContext context, MediaItem mi) {
|
||
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
||
fontSize: _subtitleFontSize,
|
||
);
|
||
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
|
||
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
|
||
final episodeNum = (showEp && mi.index != null) ? ' E${mi.index}' : '';
|
||
return Row(
|
||
children: [
|
||
_ClickableText(
|
||
text: 'S${mi.parentIndex}',
|
||
style: style,
|
||
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
|
||
),
|
||
Text('$episodeNum · ', style: style),
|
||
Expanded(
|
||
child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: style),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final metadataLine = _buildMetadataLine();
|
||
final subtitle = _buildSubtitleText();
|
||
|
||
// List rows keep the whole-row border; inside stroke so adjacent rows
|
||
// don't overlap.
|
||
return CardFocusBorder(
|
||
borderRadius: tokens(context).radiusSm,
|
||
strokeAlign: BorderSide.strokeAlignInside,
|
||
child: _CardTapRegion(
|
||
onTap: onTap,
|
||
onTapDown: onTapDown,
|
||
onLongPress: onLongPress,
|
||
onSecondaryTapDown: onSecondaryTapDown,
|
||
onSecondaryTap: onSecondaryTap,
|
||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: Row(
|
||
crossAxisAlignment: .start,
|
||
children: [
|
||
SizedBox(
|
||
width: _posterWidth(),
|
||
height: _posterHeight(),
|
||
child: Stack(
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||
child: _buildPosterImage(
|
||
context,
|
||
item,
|
||
isOffline: isOffline,
|
||
localPosterPath: localPosterPath,
|
||
episodePosterModeOverride: episodePosterModeOverride,
|
||
),
|
||
),
|
||
if (item is MediaItem) WatchedIndicator(item: item as MediaItem),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: .start,
|
||
mainAxisAlignment: .start,
|
||
children: [
|
||
if (item is MediaItem && _hasClickableTitle(item as MediaItem))
|
||
_ClickableText(
|
||
text: (item as MediaItem).displayTitle,
|
||
style: TextStyle(fontWeight: .w600, fontSize: _titleFontSize, height: 1.2),
|
||
onTap: () => _navigateToFocusedDetail(context, item as MediaItem, isOffline: isOffline),
|
||
)
|
||
else
|
||
Text(
|
||
_displayTitle(),
|
||
maxLines: 2,
|
||
overflow: .ellipsis,
|
||
style: TextStyle(fontWeight: .w600, fontSize: _titleFontSize, height: 1.2),
|
||
),
|
||
const SizedBox(height: 4),
|
||
if (metadataLine.isNotEmpty) ...[
|
||
Text(
|
||
metadataLine,
|
||
maxLines: 1,
|
||
overflow: .ellipsis,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: tokens(context).textMuted.withValues(alpha: 0.9),
|
||
fontSize: _metadataFontSize,
|
||
fontWeight: .w500,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
],
|
||
if (item is MediaItem &&
|
||
(item as MediaItem).isEpisode &&
|
||
(item as MediaItem).parentIndex != null &&
|
||
(item as MediaItem).parentId != null) ...[
|
||
_buildEpisodeSubtitle(context, item as MediaItem),
|
||
const SizedBox(height: 4),
|
||
] else if (subtitle != null) ...[
|
||
Text(
|
||
subtitle,
|
||
maxLines: 1,
|
||
overflow: .ellipsis,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
||
fontSize: _subtitleFontSize,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
],
|
||
if (!(item is MediaItem &&
|
||
SettingsService.instance.read(SettingsService.hideSpoilers) &&
|
||
(item as MediaItem).shouldHideSpoiler) &&
|
||
_summary() != null) ...[
|
||
Text(
|
||
_summary()!,
|
||
maxLines: _summaryMaxLines,
|
||
overflow: .ellipsis,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: tokens(context).textMuted.withValues(alpha: 0.7),
|
||
fontSize: _summaryFontSize,
|
||
height: 1.3,
|
||
),
|
||
),
|
||
],
|
||
if (showServerName && item is MediaItem && (item as MediaItem).serverName != null) ...[
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
children: [
|
||
BackendBadge(
|
||
backend: (item as MediaItem).backend,
|
||
size: _metadataFontSize + 2,
|
||
color: tokens(context).textMuted.withValues(alpha: 0.6),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Flexible(
|
||
child: Text(
|
||
(item as MediaItem).serverName!,
|
||
maxLines: 1,
|
||
overflow: .ellipsis,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: tokens(context).textMuted.withValues(alpha: 0.6),
|
||
fontSize: _metadataFontSize,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _buildPosterLoadingPlaceholder(BuildContext context, String _) {
|
||
return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest, child: const SizedBox.expand());
|
||
}
|
||
|
||
IconData _mediaPosterFallbackIcon(MediaItem item) {
|
||
if (item.isShow || item.isSeason || item.isEpisode) return Symbols.tv_rounded;
|
||
return Symbols.movie_rounded;
|
||
}
|
||
|
||
Widget _buildPosterImage(
|
||
BuildContext context,
|
||
Object item, {
|
||
bool isOffline = false,
|
||
String? localPosterPath,
|
||
bool mixedHubContext = false,
|
||
EpisodePosterMode? episodePosterModeOverride,
|
||
double? knownWidth,
|
||
double? knownHeight,
|
||
}) {
|
||
String? posterUrl;
|
||
|
||
if (item is MediaPlaylist) {
|
||
posterUrl = item.displayImagePath;
|
||
|
||
return OptimizedMediaImage.playlist(
|
||
client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
|
||
imagePath: posterUrl,
|
||
width: knownWidth ?? double.infinity,
|
||
height: knownHeight ?? double.infinity,
|
||
fit: BoxFit.cover,
|
||
placeholder: _buildPosterLoadingPlaceholder,
|
||
localFilePath: localPosterPath,
|
||
);
|
||
} else if (item is MediaItem) {
|
||
final EpisodePosterMode episodePosterMode =
|
||
episodePosterModeOverride ?? SettingsService.instance.read(SettingsService.episodePosterMode);
|
||
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
|
||
final shouldBlur =
|
||
hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail;
|
||
final primaryPosterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext);
|
||
final posterFallbackUrl = item.posterThumbFallback(mode: episodePosterMode, mixedHubContext: mixedHubContext);
|
||
final useRememberedFallback = posterFallbackUrl != null && _hasFailedPosterUrl(primaryPosterUrl);
|
||
posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl;
|
||
final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId));
|
||
final fallbackIcon = _mediaPosterFallbackIcon(item);
|
||
|
||
Widget image;
|
||
|
||
// Use thumb image type for 16:9 content (episodes, or movies in mixed hubs)
|
||
if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) {
|
||
image = OptimizedMediaImage.thumb(
|
||
client: mediaClient,
|
||
imagePath: posterUrl,
|
||
width: knownWidth ?? double.infinity,
|
||
height: knownHeight ?? double.infinity,
|
||
fit: BoxFit.cover,
|
||
placeholder: _buildPosterLoadingPlaceholder,
|
||
fallbackIcon: fallbackIcon,
|
||
localFilePath: localPosterPath,
|
||
);
|
||
} else {
|
||
image = OptimizedMediaImage.poster(
|
||
client: mediaClient,
|
||
imagePath: posterUrl,
|
||
width: knownWidth ?? double.infinity,
|
||
height: knownHeight ?? double.infinity,
|
||
fit: BoxFit.cover,
|
||
placeholder: _buildPosterLoadingPlaceholder,
|
||
fallbackIcon: fallbackIcon,
|
||
errorWidget: posterFallbackUrl == null || useRememberedFallback
|
||
? null
|
||
: (_, _, _) {
|
||
_rememberFailedPosterUrl(primaryPosterUrl);
|
||
return OptimizedMediaImage.poster(
|
||
client: mediaClient,
|
||
imagePath: posterFallbackUrl,
|
||
width: knownWidth ?? double.infinity,
|
||
height: knownHeight ?? double.infinity,
|
||
fit: BoxFit.cover,
|
||
placeholder: _buildPosterLoadingPlaceholder,
|
||
fallbackIcon: fallbackIcon,
|
||
);
|
||
},
|
||
localFilePath: localPosterPath,
|
||
);
|
||
}
|
||
|
||
if (shouldBlur) {
|
||
return ClipRect(
|
||
child: ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: image),
|
||
);
|
||
}
|
||
return image;
|
||
}
|
||
|
||
return SkeletonLoader(
|
||
child: const Center(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 40, color: Colors.white54)),
|
||
);
|
||
}
|
||
|
||
class _MediaCardHelpers {
|
||
static Widget buildPlaylistMeta(BuildContext context, MediaPlaylist playlist) {
|
||
if (playlist.leafCount != null && playlist.leafCount! > 0) {
|
||
return Text(
|
||
t.playlists.itemCount(count: playlist.leafCount!),
|
||
maxLines: 1,
|
||
overflow: .ellipsis,
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1),
|
||
);
|
||
}
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
/// Builds metadata subtitle (for collections, episodes, movies, shows)
|
||
static Widget buildMetadataSubtitle(BuildContext context, MediaItem mi, {bool isOffline = false}) {
|
||
final subtitleStyle = Theme.of(
|
||
context,
|
||
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1);
|
||
|
||
// For collections, show item count
|
||
if (mi.kind == MediaKind.collection) {
|
||
final count = mi.childCount ?? mi.leafCount;
|
||
if (count != null && count > 0) {
|
||
return Text(
|
||
t.playlists.itemCount(count: count),
|
||
maxLines: 1,
|
||
overflow: .ellipsis,
|
||
style: subtitleStyle,
|
||
);
|
||
}
|
||
}
|
||
|
||
// For episodes, show "S# · Episode Title" with clickable season link
|
||
if (mi.isEpisode && mi.parentIndex != null) {
|
||
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
|
||
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
|
||
final episodeSuffix = (showEp && mi.index != null) ? ' E${mi.index}' : '';
|
||
if (mi.parentId != null) {
|
||
return Row(
|
||
children: [
|
||
_ClickableText(
|
||
text: 'S${mi.parentIndex}',
|
||
style: subtitleStyle,
|
||
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
|
||
),
|
||
Text('$episodeSuffix · ', style: subtitleStyle),
|
||
Expanded(
|
||
child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
return Text(
|
||
'S${mi.parentIndex}$episodeSuffix · $episodeTitle',
|
||
maxLines: 1,
|
||
overflow: .ellipsis,
|
||
style: subtitleStyle,
|
||
);
|
||
}
|
||
|
||
// For other media types, show subtitle/parent/year
|
||
if (mi.displaySubtitle != null) {
|
||
return Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle);
|
||
} else if (mi.parentTitle != null) {
|
||
return Text(mi.parentTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle);
|
||
} else if (mi.year != null) {
|
||
final edition = mi.editionTitle;
|
||
return Text(
|
||
edition != null ? '${mi.year} · $edition' : '${mi.year}',
|
||
maxLines: 1,
|
||
overflow: .ellipsis,
|
||
style: subtitleStyle,
|
||
);
|
||
}
|
||
|
||
return const SizedBox.shrink();
|
||
}
|
||
}
|
||
|
||
/// Whether this media item has a clickable title that navigates somewhere.
|
||
/// Episodes/seasons navigate to their parent show; movies navigate to their detail page.
|
||
bool _hasClickableTitle(MediaItem mi) {
|
||
if (mi.isEpisode) return mi.grandparentId != null;
|
||
if (mi.isSeason) return mi.parentId != null;
|
||
if (mi.isMovie) return true;
|
||
return false;
|
||
}
|
||
|
||
void _navigateToFocusedDetail(BuildContext context, MediaItem item, {bool isOffline = false}) {
|
||
navigateToMediaItemDetails(context, item, isOffline: isOffline);
|
||
}
|
||
|
||
/// Text widget that shows hover underline + pointer cursor only in pointer mode.
|
||
/// In keyboard/dpad mode, renders as plain text with no interaction.
|
||
class _ClickableText extends StatefulWidget {
|
||
final String text;
|
||
final TextStyle? style;
|
||
final VoidCallback onTap;
|
||
|
||
const _ClickableText({required this.text, this.style, required this.onTap});
|
||
|
||
@override
|
||
State<_ClickableText> createState() => _ClickableTextState();
|
||
}
|
||
|
||
class _ClickableTextState extends State<_ClickableText> {
|
||
bool _isHovered = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isKeyboard = InputModeTracker.isKeyboardMode(context);
|
||
final baseStyle = widget.style ?? const TextStyle();
|
||
|
||
if (isKeyboard) {
|
||
return Text(widget.text, maxLines: 1, overflow: .ellipsis, style: baseStyle);
|
||
}
|
||
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
onEnter: (_) => setState(() => _isHovered = true),
|
||
onExit: (_) => setState(() => _isHovered = false),
|
||
child: GestureDetector(
|
||
onTap: widget.onTap,
|
||
child: Text(
|
||
widget.text,
|
||
maxLines: 1,
|
||
overflow: .ellipsis,
|
||
style: baseStyle.copyWith(
|
||
decoration: _isHovered ? TextDecoration.underline : null,
|
||
decorationColor: baseStyle.color,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Static skeleton placeholder with a fixed semi-transparent fill.
|
||
class SkeletonLoader extends StatelessWidget {
|
||
final Widget? child;
|
||
final BorderRadius? borderRadius;
|
||
|
||
const SkeletonLoader({super.key, this.child, this.borderRadius});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.075),
|
||
borderRadius: borderRadius ?? BorderRadius.circular(tokens(context).radiusSm),
|
||
),
|
||
child: child,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Tap surface for a card: a full [InkWell] (ripple, hover, cursor) where a
|
||
/// pointer exists, a bare [GestureDetector] on TV where the d-pad drives
|
||
/// activation and the per-card ink/hover/focus machinery is dead weight.
|
||
/// Keyboard focus is handled by the focus wrappers either way
|
||
/// (canRequestFocus stays false on the InkWell).
|
||
class _CardTapRegion extends StatelessWidget {
|
||
const _CardTapRegion({
|
||
required this.onTap,
|
||
this.onTapDown,
|
||
this.onLongPress,
|
||
this.onSecondaryTap,
|
||
this.onSecondaryTapDown,
|
||
this.borderRadius,
|
||
required this.child,
|
||
});
|
||
|
||
final VoidCallback onTap;
|
||
final GestureTapDownCallback? onTapDown;
|
||
final VoidCallback? onLongPress;
|
||
final VoidCallback? onSecondaryTap;
|
||
final GestureTapDownCallback? onSecondaryTapDown;
|
||
final BorderRadius? borderRadius;
|
||
final Widget child;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (PlatformDetector.isTV()) {
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
onTapDown: onTapDown,
|
||
onLongPress: onLongPress,
|
||
onSecondaryTap: onSecondaryTap,
|
||
onSecondaryTapDown: onSecondaryTapDown,
|
||
child: child,
|
||
);
|
||
}
|
||
return InkWell(
|
||
mouseCursor: SystemMouseCursors.click,
|
||
canRequestFocus: false,
|
||
onTap: onTap,
|
||
onTapDown: onTapDown,
|
||
onLongPress: onLongPress,
|
||
onSecondaryTapDown: onSecondaryTapDown,
|
||
onSecondaryTap: onSecondaryTap,
|
||
borderRadius: borderRadius,
|
||
child: child,
|
||
);
|
||
}
|
||
}
|