Plex API returns Directory entries without type field, causing TypeError crash in fromJson. Made fields nullable and migrated callers to use mediaType enum and displayTitle getter instead of raw string access.
1120 lines
39 KiB
Dart
1120 lines
39 KiB
Dart
import 'dart:ui';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:plezy/utils/content_utils.dart';
|
|
import 'package:plezy/widgets/app_icon.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../models/plex_metadata.dart';
|
|
import '../models/plex_playlist.dart';
|
|
import '../providers/download_provider.dart';
|
|
import '../services/download_storage_service.dart';
|
|
import '../providers/settings_provider.dart';
|
|
import '../screens/media_detail_screen.dart';
|
|
import '../services/settings_service.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_progress_bar.dart';
|
|
import 'plex_optimized_image.dart';
|
|
|
|
class MediaCard extends StatefulWidget {
|
|
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
|
final double? width;
|
|
final double? height;
|
|
final void Function(String ratingKey)? onRefresh;
|
|
final VoidCallback? onRemoveFromContinueWatching;
|
|
final VoidCallback? onListRefresh; // Callback to refresh the entire parent list
|
|
final bool forceGridMode;
|
|
final bool forceListMode;
|
|
final bool isInContinueWatching;
|
|
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)
|
|
|
|
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,
|
|
this.collectionId,
|
|
this.isOffline = false,
|
|
this.mixedHubContext = false,
|
|
this.showServerName = false,
|
|
});
|
|
|
|
@override
|
|
State<MediaCard> createState() => MediaCardState();
|
|
}
|
|
|
|
class MediaCardState extends State<MediaCard> {
|
|
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
|
|
Offset? _tapPosition;
|
|
|
|
void _storeTapPosition(TapDownDetails details) {
|
|
_tapPosition = details.globalPosition;
|
|
}
|
|
|
|
void _showContextMenu() {
|
|
_contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition);
|
|
}
|
|
|
|
/// Public method to trigger tap action (for keyboard/gamepad SELECT)
|
|
void handleTap() {
|
|
_handleTap(context);
|
|
}
|
|
|
|
/// Public method to show context menu (for keyboard/gamepad context menu key)
|
|
void showContextMenu() {
|
|
_contextMenuKey.currentState?.showContextMenu(context);
|
|
}
|
|
|
|
String _buildSemanticLabel() {
|
|
final item = widget.item;
|
|
|
|
// Playlists don't expose mediaType, so build a simple localized label and exit early
|
|
if (item is PlexPlaylist) {
|
|
final count = item.leafCount;
|
|
final countText = count != null ? ', ${t.playlists.itemCount(count: count)}' : '';
|
|
return '${item.displayTitle}, ${t.playlists.playlist}$countText';
|
|
}
|
|
|
|
// Build base label based on PlexMetadata media type
|
|
if (item is! PlexMetadata) {
|
|
return '$item';
|
|
}
|
|
|
|
String baseLabel;
|
|
switch (item.mediaType) {
|
|
case PlexMediaType.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 PlexMediaType.season:
|
|
final seasonInfo = item.parentIndex != null ? 'Season ${item.parentIndex}' : '';
|
|
baseLabel = t.accessibility.mediaCardSeason(title: item.displayTitle, seasonInfo: seasonInfo);
|
|
case PlexMediaType.movie:
|
|
baseLabel = t.accessibility.mediaCardMovie(title: item.displayTitle);
|
|
default:
|
|
baseLabel = t.accessibility.mediaCardShow(title: item.displayTitle);
|
|
}
|
|
|
|
// Add watched status
|
|
final hasActiveProgress =
|
|
item.viewOffset != null && item.duration != null && item.viewOffset! > 0 && item.viewOffset! < item.duration!;
|
|
|
|
if (hasActiveProgress) {
|
|
final percent = ((item.viewOffset! / item.duration!) * 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) async {
|
|
// Ignore taps while context menu is open to avoid double-activating
|
|
if (_contextMenuKey.currentState?.isContextMenuOpen == true) {
|
|
return;
|
|
}
|
|
|
|
final result = await navigateToMediaItem(
|
|
context,
|
|
widget.item,
|
|
onRefresh: widget.onRefresh,
|
|
isOffline: widget.isOffline,
|
|
playDirectly: widget.isInContinueWatching,
|
|
);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
switch (result) {
|
|
case MediaNavigationResult.unsupported:
|
|
showAppSnackBar(context, t.messages.musicNotSupported);
|
|
case MediaNavigationResult.listRefreshNeeded:
|
|
widget.onListRefresh?.call();
|
|
case MediaNavigationResult.navigated:
|
|
// Item refresh already handled by onRefresh callback in helper
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// Get the local poster path for offline mode
|
|
String? _getLocalPosterPath(BuildContext context) {
|
|
if (!widget.isOffline) return null;
|
|
if (widget.item is! PlexMetadata) return null;
|
|
|
|
final metadata = widget.item as PlexMetadata;
|
|
if (metadata.serverId == null) return null;
|
|
|
|
final downloadProvider = context.read<DownloadProvider>();
|
|
final globalKey = metadata.globalKey;
|
|
|
|
// Get artwork reference and resolve to local path using hash (includes serverId)
|
|
final artwork = downloadProvider.getArtworkPaths(globalKey);
|
|
return artwork?.getLocalPath(DownloadStorageService.instance, metadata.serverId!);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final settingsProvider = context.watch<SettingsProvider>();
|
|
final ViewMode viewMode;
|
|
if (widget.forceListMode) {
|
|
viewMode = ViewMode.list;
|
|
} else if (widget.forceGridMode) {
|
|
viewMode = ViewMode.grid;
|
|
} else {
|
|
viewMode = settingsProvider.viewMode;
|
|
}
|
|
|
|
final semanticLabel = _buildSemanticLabel();
|
|
final localPosterPath = _getLocalPosterPath(context);
|
|
|
|
final cardWidget = viewMode == ViewMode.grid
|
|
? _buildGridCard(context, semanticLabel, localPosterPath)
|
|
: _MediaCardList(
|
|
item: widget.item,
|
|
semanticLabel: semanticLabel,
|
|
onTap: () => _handleTap(context),
|
|
onTapDown: _storeTapPosition,
|
|
onLongPress: _showContextMenu,
|
|
onSecondaryTapDown: _storeTapPosition,
|
|
onSecondaryTap: _showContextMenu,
|
|
density: settingsProvider.libraryDensity,
|
|
isOffline: widget.isOffline,
|
|
localPosterPath: localPosterPath,
|
|
showServerName: widget.showServerName,
|
|
);
|
|
|
|
// 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: widget.item,
|
|
onRefresh: widget.onRefresh,
|
|
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
|
|
onListRefresh: widget.onListRefresh,
|
|
onTap: () => _handleTap(context),
|
|
isInContinueWatching: widget.isInContinueWatching,
|
|
collectionId: widget.collectionId,
|
|
child: cardWidget,
|
|
);
|
|
}
|
|
|
|
/// Grid layout — inlined from former _MediaCardGrid, _PosterOverlay, and
|
|
/// flattened Column. Semantics removed (InkWell provides button semantics).
|
|
Widget _buildGridCard(BuildContext context, String semanticLabel, String? localPosterPath) {
|
|
final item = widget.item;
|
|
// Compute actual poster dimensions from card dimensions
|
|
final posterWidth = widget.width != null ? widget.width! - 6 : null; // 3px padding each side
|
|
final posterHeight = widget.height;
|
|
|
|
return SizedBox(
|
|
width: widget.width,
|
|
child: InkWell(
|
|
canRequestFocus: false,
|
|
onTap: () => _handleTap(context),
|
|
onTapDown: _storeTapPosition,
|
|
onLongPress: _showContextMenu,
|
|
onSecondaryTapDown: _storeTapPosition,
|
|
onSecondaryTap: _showContextMenu,
|
|
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(3),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Poster with overlay
|
|
if (posterHeight != null)
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: posterHeight,
|
|
child: Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
|
child: _buildPosterImage(
|
|
context,
|
|
item,
|
|
isOffline: widget.isOffline,
|
|
localPosterPath: localPosterPath,
|
|
mixedHubContext: widget.mixedHubContext,
|
|
knownWidth: posterWidth,
|
|
knownHeight: posterHeight,
|
|
),
|
|
),
|
|
// Inlined _PosterOverlay
|
|
if (item is PlexMetadata) _MediaCardHelpers.buildWatchProgress(context, item),
|
|
],
|
|
),
|
|
)
|
|
else
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
|
child: _buildPosterImage(
|
|
context,
|
|
item,
|
|
isOffline: widget.isOffline,
|
|
localPosterPath: localPosterPath,
|
|
mixedHubContext: widget.mixedHubContext,
|
|
),
|
|
),
|
|
if (item is PlexMetadata) _MediaCardHelpers.buildWatchProgress(context, item),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
// Title (flattened — no inner Column)
|
|
if (item is PlexMetadata && _hasClickableTitle(item))
|
|
_ClickableText(
|
|
text: item.displayTitle,
|
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1),
|
|
onTap: () => _navigateToDetail(context, item, isOffline: widget.isOffline),
|
|
)
|
|
else
|
|
Text(
|
|
item is PlexPlaylist ? item.title : (item as PlexMetadata).displayTitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1),
|
|
),
|
|
// Subtitle
|
|
if (item is PlexPlaylist)
|
|
_MediaCardHelpers.buildPlaylistMeta(context, item)
|
|
else if (item is PlexMetadata)
|
|
_MediaCardHelpers.buildMetadataSubtitle(context, item, isOffline: widget.isOffline),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// List layout for media cards
|
|
class _MediaCardList extends StatelessWidget {
|
|
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
|
final String semanticLabel;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onLongPress;
|
|
final void Function(TapDownDetails)? onTapDown;
|
|
final VoidCallback? onSecondaryTap;
|
|
final void Function(TapDownDetails)? onSecondaryTapDown;
|
|
final LibraryDensity density;
|
|
final bool isOffline;
|
|
final String? localPosterPath;
|
|
final bool showServerName;
|
|
|
|
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,
|
|
});
|
|
|
|
double _basePosterWidth() {
|
|
switch (density) {
|
|
case LibraryDensity.compact:
|
|
return 80;
|
|
case LibraryDensity.normal:
|
|
return 100;
|
|
case LibraryDensity.comfortable:
|
|
return 120;
|
|
}
|
|
}
|
|
|
|
double _posterWidth(BuildContext context) {
|
|
final base = _basePosterWidth();
|
|
// For episodes with thumbnail mode, use wider width to maintain reasonable thumbnail size
|
|
if (item is PlexMetadata) {
|
|
final mode = context.watch<SettingsProvider>().episodePosterMode;
|
|
if ((item as PlexMetadata).usesWideAspectRatio(mode)) {
|
|
return base * 1.6; // Wider for 16:9 thumbnails
|
|
}
|
|
}
|
|
return base;
|
|
}
|
|
|
|
double _posterHeight(BuildContext context) {
|
|
final base = _basePosterWidth();
|
|
// For episodes with thumbnail mode, use 16:9 aspect ratio
|
|
if (item is PlexMetadata) {
|
|
final mode = context.watch<SettingsProvider>().episodePosterMode;
|
|
if ((item as PlexMetadata).usesWideAspectRatio(mode)) {
|
|
// 16:9: height = width * 9/16 = base * 1.6 * 9/16 = base * 0.9
|
|
return base * 0.9;
|
|
}
|
|
}
|
|
return base * 1.5; // Default 2:3 aspect ratio
|
|
}
|
|
|
|
double get _titleFontSize {
|
|
switch (density) {
|
|
case LibraryDensity.compact:
|
|
return 14;
|
|
case LibraryDensity.normal:
|
|
return 15;
|
|
case LibraryDensity.comfortable:
|
|
return 16;
|
|
}
|
|
}
|
|
|
|
double get _metadataFontSize {
|
|
switch (density) {
|
|
case LibraryDensity.compact:
|
|
return 11;
|
|
case LibraryDensity.normal:
|
|
return 12;
|
|
case LibraryDensity.comfortable:
|
|
return 13;
|
|
}
|
|
}
|
|
|
|
double get _subtitleFontSize {
|
|
switch (density) {
|
|
case LibraryDensity.compact:
|
|
return 12;
|
|
case LibraryDensity.normal:
|
|
return 13;
|
|
case LibraryDensity.comfortable:
|
|
return 14;
|
|
}
|
|
}
|
|
|
|
double get _summaryFontSize {
|
|
// Summary uses the same sizing as metadata text
|
|
return _metadataFontSize;
|
|
}
|
|
|
|
int get _summaryMaxLines {
|
|
switch (density) {
|
|
case LibraryDensity.compact:
|
|
return 2;
|
|
case LibraryDensity.normal:
|
|
return 3;
|
|
case LibraryDensity.comfortable:
|
|
return 4;
|
|
}
|
|
}
|
|
|
|
String _buildMetadataLine() {
|
|
final parts = <String>[];
|
|
|
|
if (item is PlexPlaylist) {
|
|
final playlist = item as PlexPlaylist;
|
|
// Add item count
|
|
if (playlist.leafCount != null && playlist.leafCount! > 0) {
|
|
parts.add(t.playlists.itemCount(count: playlist.leafCount!));
|
|
}
|
|
|
|
// Add duration
|
|
if (playlist.duration != null) {
|
|
parts.add(formatDurationTextual(playlist.duration!));
|
|
}
|
|
|
|
// Add smart playlist badge
|
|
if (playlist.smart) {
|
|
parts.add(t.playlists.smartPlaylist);
|
|
}
|
|
} else if (item is PlexMetadata) {
|
|
final metadata = item as PlexMetadata;
|
|
|
|
// For collections, show item count
|
|
if (metadata.mediaType == PlexMediaType.collection) {
|
|
final count = metadata.childCount ?? metadata.leafCount;
|
|
if (count != null && count > 0) {
|
|
parts.add(t.playlists.itemCount(count: count));
|
|
}
|
|
} else {
|
|
// For other media types, show standard metadata
|
|
// Add content rating
|
|
if (metadata.contentRating != null && metadata.contentRating!.isNotEmpty) {
|
|
final rating = formatContentRating(metadata.contentRating);
|
|
if (rating.isNotEmpty) {
|
|
parts.add(rating);
|
|
}
|
|
}
|
|
|
|
// Add year
|
|
if (metadata.year != null) {
|
|
parts.add('${metadata.year}');
|
|
}
|
|
|
|
// Add edition title
|
|
if (metadata.editionTitle != null) {
|
|
parts.add(metadata.editionTitle!);
|
|
}
|
|
|
|
// Add duration
|
|
if (metadata.duration != null) {
|
|
parts.add(formatDurationTextual(metadata.duration!));
|
|
}
|
|
|
|
// Add user rating
|
|
if (metadata.rating != null) {
|
|
parts.add('${metadata.rating!.toStringAsFixed(1)}★');
|
|
}
|
|
|
|
// Add studio
|
|
if (metadata.studio != null && metadata.studio!.isNotEmpty) {
|
|
parts.add(metadata.studio!);
|
|
}
|
|
}
|
|
}
|
|
|
|
return parts.join(' • ');
|
|
}
|
|
|
|
String? _buildSubtitleText() {
|
|
if (item is PlexPlaylist) {
|
|
// Playlists don't have subtitles
|
|
return null;
|
|
} else if (item is PlexMetadata) {
|
|
final metadata = item as PlexMetadata;
|
|
|
|
// For TV episodes, show S#E# format
|
|
if (metadata.parentIndex != null && metadata.index != null) {
|
|
return 'S${metadata.parentIndex} E${metadata.index}';
|
|
}
|
|
|
|
// Otherwise use existing subtitle logic
|
|
if (metadata.displaySubtitle != null) {
|
|
return metadata.displaySubtitle;
|
|
} else if (metadata.parentTitle != null) {
|
|
return metadata.parentTitle;
|
|
}
|
|
}
|
|
|
|
// Year is now shown in metadata line, so don't show it here
|
|
return null;
|
|
}
|
|
|
|
Widget _buildEpisodeSubtitle(BuildContext context, PlexMetadata metadata) {
|
|
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
|
fontSize: _subtitleFontSize,
|
|
);
|
|
final episodeTitle = metadata.displaySubtitle ?? metadata.displayTitle;
|
|
final episodeNum = metadata.index != null ? ' E${metadata.index}' : '';
|
|
return Row(
|
|
children: [
|
|
_ClickableText(
|
|
text: 'S${metadata.parentIndex}',
|
|
style: style,
|
|
onTap: () => _navigateToSeason(context, metadata, isOffline: isOffline),
|
|
),
|
|
Text('$episodeNum · ', style: style),
|
|
Expanded(
|
|
child: Text(
|
|
episodeTitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: style,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final metadataLine = _buildMetadataLine();
|
|
final subtitle = _buildSubtitleText();
|
|
|
|
return InkWell(
|
|
canRequestFocus: false, // Keyboard handled by FocusableMediaCard
|
|
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: CrossAxisAlignment.start,
|
|
children: [
|
|
// Poster (responsive size based on density)
|
|
SizedBox(
|
|
width: _posterWidth(context),
|
|
height: _posterHeight(context),
|
|
child: Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
|
child: _buildPosterImage(context, item, isOffline: isOffline, localPosterPath: localPosterPath),
|
|
),
|
|
if (item is PlexMetadata) _MediaCardHelpers.buildWatchProgress(context, item as PlexMetadata),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Metadata
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
// Title
|
|
if (item is PlexMetadata && _hasClickableTitle(item as PlexMetadata))
|
|
_ClickableText(
|
|
text: item.displayTitle,
|
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: _titleFontSize, height: 1.2),
|
|
onTap: () => _navigateToDetail(context, item as PlexMetadata, isOffline: isOffline),
|
|
)
|
|
else
|
|
Text(
|
|
item.displayTitle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: _titleFontSize, height: 1.2),
|
|
),
|
|
const SizedBox(height: 4),
|
|
// Metadata info line (rating, duration, score, studio)
|
|
if (metadataLine.isNotEmpty) ...[
|
|
Text(
|
|
metadataLine,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: tokens(context).textMuted.withValues(alpha: 0.9),
|
|
fontSize: _metadataFontSize,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
],
|
|
// Subtitle (S# · Episode Title, or year/parent title)
|
|
if (item is PlexMetadata && (item as PlexMetadata).isEpisode && (item as PlexMetadata).parentIndex != null && (item as PlexMetadata).parentRatingKey != null) ...[
|
|
_buildEpisodeSubtitle(context, item as PlexMetadata),
|
|
const SizedBox(height: 4),
|
|
] else if (subtitle != null) ...[
|
|
Text(
|
|
subtitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
|
fontSize: _subtitleFontSize,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
],
|
|
// Summary (hidden when spoiler protection is active)
|
|
if (!(item is PlexMetadata &&
|
|
context.watch<SettingsProvider>().hideSpoilers &&
|
|
(item as PlexMetadata).shouldHideSpoiler) &&
|
|
item.summary != null) ...[
|
|
Text(
|
|
item.summary!,
|
|
maxLines: _summaryMaxLines,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: tokens(context).textMuted.withValues(alpha: 0.7),
|
|
fontSize: _summaryFontSize,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
],
|
|
// Server name (multi-server mode)
|
|
if (showServerName && item is PlexMetadata && (item as PlexMetadata).serverName != null) ...[
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
AppIcon(
|
|
Symbols.dns_rounded,
|
|
fill: 1,
|
|
size: _metadataFontSize + 2,
|
|
color: tokens(context).textMuted.withValues(alpha: 0.6),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
(item as PlexMetadata).serverName!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: tokens(context).textMuted.withValues(alpha: 0.6),
|
|
fontSize: _metadataFontSize,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildPosterImage(
|
|
BuildContext context,
|
|
dynamic item, {
|
|
bool isOffline = false,
|
|
String? localPosterPath,
|
|
bool mixedHubContext = false,
|
|
double? knownWidth,
|
|
double? knownHeight,
|
|
}) {
|
|
String? posterUrl;
|
|
IconData fallbackIcon = Symbols.movie_rounded;
|
|
|
|
if (item is PlexPlaylist) {
|
|
posterUrl = item.displayImage;
|
|
fallbackIcon = Symbols.playlist_play_rounded;
|
|
|
|
return PlexOptimizedImage.playlist(
|
|
client: isOffline ? null : context.getClientWithFallback(item.serverId),
|
|
imagePath: posterUrl,
|
|
width: knownWidth ?? double.infinity,
|
|
height: knownHeight ?? double.infinity,
|
|
fit: BoxFit.cover,
|
|
localFilePath: localPosterPath,
|
|
);
|
|
} else if (item is PlexMetadata) {
|
|
final settingsProvider = context.watch<SettingsProvider>();
|
|
final episodePosterMode = settingsProvider.episodePosterMode;
|
|
final shouldBlur =
|
|
settingsProvider.hideSpoilers &&
|
|
item.shouldHideSpoiler &&
|
|
episodePosterMode == EpisodePosterMode.episodeThumbnail;
|
|
posterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext);
|
|
|
|
Widget image;
|
|
|
|
// Use thumb image type for 16:9 content (episodes, or movies in mixed hubs)
|
|
if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) {
|
|
image = PlexOptimizedImage.thumb(
|
|
client: isOffline ? null : context.getClientWithFallback(item.serverId),
|
|
imagePath: posterUrl,
|
|
width: knownWidth ?? double.infinity,
|
|
height: knownHeight ?? double.infinity,
|
|
fit: BoxFit.cover,
|
|
localFilePath: localPosterPath,
|
|
);
|
|
} else {
|
|
image = PlexOptimizedImage.poster(
|
|
client: isOffline ? null : context.getClientWithFallback(item.serverId),
|
|
imagePath: posterUrl,
|
|
width: knownWidth ?? double.infinity,
|
|
height: knownHeight ?? double.infinity,
|
|
fit: BoxFit.cover,
|
|
localFilePath: localPosterPath,
|
|
);
|
|
}
|
|
|
|
if (shouldBlur) {
|
|
return ClipRect(
|
|
child: ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: image),
|
|
);
|
|
}
|
|
return image;
|
|
}
|
|
|
|
return SkeletonLoader(
|
|
child: Center(child: AppIcon(fallbackIcon, fill: 1, size: 40, color: Colors.white54)),
|
|
);
|
|
}
|
|
|
|
/// Helper methods for building media card metadata and subtitles
|
|
class _MediaCardHelpers {
|
|
/// Builds playlist metadata (item count)
|
|
static Widget buildPlaylistMeta(BuildContext context, PlexPlaylist playlist) {
|
|
if (playlist.leafCount != null && playlist.leafCount! > 0) {
|
|
return Text(
|
|
t.playlists.itemCount(count: playlist.leafCount!),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.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, PlexMetadata metadata, {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 (metadata.mediaType == PlexMediaType.collection) {
|
|
final count = metadata.childCount ?? metadata.leafCount;
|
|
if (count != null && count > 0) {
|
|
return Text(
|
|
t.playlists.itemCount(count: count),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: subtitleStyle,
|
|
);
|
|
}
|
|
}
|
|
|
|
// For episodes, show "S# · Episode Title" with clickable season link
|
|
if (metadata.isEpisode && metadata.parentIndex != null) {
|
|
final episodeTitle = metadata.displaySubtitle ?? metadata.displayTitle;
|
|
if (metadata.parentRatingKey != null) {
|
|
return Row(
|
|
children: [
|
|
_ClickableText(
|
|
text: 'S${metadata.parentIndex}',
|
|
style: subtitleStyle,
|
|
onTap: () => _navigateToSeason(context, metadata, isOffline: isOffline),
|
|
),
|
|
Text(' · ', style: subtitleStyle),
|
|
Expanded(
|
|
child: Text(
|
|
episodeTitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: subtitleStyle,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return Text(
|
|
'S${metadata.parentIndex} · $episodeTitle',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: subtitleStyle,
|
|
);
|
|
}
|
|
|
|
// For other media types, show subtitle/parent/year
|
|
if (metadata.displaySubtitle != null) {
|
|
return Text(
|
|
metadata.displaySubtitle!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: subtitleStyle,
|
|
);
|
|
} else if (metadata.parentTitle != null) {
|
|
return Text(
|
|
metadata.parentTitle!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: subtitleStyle,
|
|
);
|
|
} else if (metadata.year != null) {
|
|
return Text(
|
|
metadata.editionTitle != null ? '${metadata.year} · ${metadata.editionTitle}' : '${metadata.year}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: subtitleStyle,
|
|
);
|
|
}
|
|
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
/// Builds watch progress overlay (checkmark for watched, progress bar for in-progress)
|
|
static Widget buildWatchProgress(BuildContext context, PlexMetadata metadata) {
|
|
final showUnwatchedCount = context.watch<SettingsProvider>().showUnwatchedCount;
|
|
|
|
final hasActiveProgress =
|
|
metadata.viewOffset != null &&
|
|
metadata.duration != null &&
|
|
metadata.viewOffset! > 0 &&
|
|
metadata.viewOffset! < metadata.duration!;
|
|
|
|
return Stack(
|
|
children: [
|
|
// Watched indicator (checkmark)
|
|
if (metadata.isWatched && !hasActiveProgress)
|
|
Positioned(
|
|
top: 4,
|
|
right: 4,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: tokens(context).text,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)],
|
|
),
|
|
child: AppIcon(Symbols.check_rounded, fill: 1, color: tokens(context).bg, size: 16),
|
|
),
|
|
),
|
|
if (showUnwatchedCount &&
|
|
!metadata.isWatched &&
|
|
(metadata.mediaType == PlexMediaType.show || metadata.mediaType == PlexMediaType.season) &&
|
|
(metadata.leafCount != null && metadata.leafCount! > 0 && metadata.viewedLeafCount != null))
|
|
Positioned(
|
|
top: 4,
|
|
right: 4,
|
|
child: Container(
|
|
width: 24,
|
|
height: 24,
|
|
decoration: BoxDecoration(
|
|
color: tokens(context).text,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
'${metadata.leafCount! - metadata.viewedLeafCount!}',
|
|
style: TextStyle(color: tokens(context).bg, fontSize: 12, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
),
|
|
// Progress bar for partially watched content (episodes/movies)
|
|
if (hasActiveProgress)
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8)),
|
|
child: MediaProgressBar(viewOffset: metadata.viewOffset!, duration: metadata.duration!),
|
|
),
|
|
),
|
|
// Progress bar for seasons (viewedLeafCount / leafCount)
|
|
if (metadata.isSeason &&
|
|
metadata.viewedLeafCount != null &&
|
|
metadata.leafCount != null &&
|
|
metadata.leafCount! > 0 &&
|
|
metadata.viewedLeafCount! > 0 &&
|
|
metadata.viewedLeafCount! < metadata.leafCount!)
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8)),
|
|
child: LinearProgressIndicator(
|
|
value: metadata.viewedLeafCount! / metadata.leafCount!,
|
|
backgroundColor: tokens(context).outline,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).colorScheme.primary),
|
|
minHeight: 4,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Whether this metadata item has a clickable title that navigates somewhere.
|
|
/// Episodes/seasons navigate to their parent show; movies navigate to their detail page.
|
|
bool _hasClickableTitle(PlexMetadata metadata) {
|
|
if (metadata.isEpisode) return metadata.grandparentRatingKey != null;
|
|
if (metadata.isSeason) return metadata.parentRatingKey != null;
|
|
if (metadata.isMovie) return true;
|
|
return false;
|
|
}
|
|
|
|
/// Navigate to a show with the season tab pre-selected from episode metadata
|
|
void _navigateToSeason(BuildContext context, PlexMetadata episode, {bool isOffline = false}) {
|
|
if (episode.grandparentRatingKey != null) {
|
|
// Navigate to the show with the season pre-selected
|
|
final showStub = PlexMetadata(
|
|
ratingKey: episode.grandparentRatingKey!,
|
|
key: '/library/metadata/${episode.grandparentRatingKey}',
|
|
type: 'show',
|
|
title: episode.grandparentTitle ?? episode.displayTitle,
|
|
thumb: episode.grandparentThumb,
|
|
art: episode.grandparentArt,
|
|
serverId: episode.serverId,
|
|
serverName: episode.serverName,
|
|
);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => MediaDetailScreen(
|
|
metadata: showStub,
|
|
isOffline: isOffline,
|
|
initialSeasonIndex: episode.parentIndex,
|
|
),
|
|
),
|
|
);
|
|
} else if (episode.parentRatingKey != null) {
|
|
// Fallback: navigate to season directly if no grandparent
|
|
final seasonStub = PlexMetadata(
|
|
ratingKey: episode.parentRatingKey!,
|
|
key: '/library/metadata/${episode.parentRatingKey}',
|
|
type: 'season',
|
|
title: episode.parentTitle ?? 'Season ${episode.parentIndex ?? ''}',
|
|
index: episode.parentIndex,
|
|
parentRatingKey: episode.grandparentRatingKey,
|
|
thumb: episode.parentThumb,
|
|
serverId: episode.serverId,
|
|
serverName: episode.serverName,
|
|
);
|
|
Navigator.push(context, MaterialPageRoute(builder: (_) => MediaDetailScreen(metadata: seasonStub, isOffline: isOffline)));
|
|
}
|
|
}
|
|
|
|
/// Navigate to the detail screen for a metadata item.
|
|
/// For episodes/seasons: navigates to the parent show with season pre-selected.
|
|
/// For movies and other types: navigates to the item's own detail page.
|
|
void _navigateToDetail(BuildContext context, PlexMetadata metadata, {bool isOffline = false}) {
|
|
PlexMetadata target = metadata;
|
|
int? initialSeasonIndex;
|
|
|
|
if (metadata.isEpisode && metadata.grandparentRatingKey != null) {
|
|
target = PlexMetadata(
|
|
ratingKey: metadata.grandparentRatingKey!,
|
|
key: '/library/metadata/${metadata.grandparentRatingKey}',
|
|
type: 'show',
|
|
title: metadata.grandparentTitle ?? metadata.displayTitle,
|
|
thumb: metadata.grandparentThumb,
|
|
art: metadata.grandparentArt,
|
|
serverId: metadata.serverId,
|
|
serverName: metadata.serverName,
|
|
);
|
|
} else if (metadata.isSeason && metadata.parentRatingKey != null) {
|
|
initialSeasonIndex = metadata.index;
|
|
target = PlexMetadata(
|
|
ratingKey: metadata.parentRatingKey!,
|
|
key: '/library/metadata/${metadata.parentRatingKey}',
|
|
type: 'show',
|
|
title: metadata.grandparentTitle ?? metadata.parentTitle ?? metadata.displayTitle,
|
|
thumb: metadata.grandparentThumb ?? metadata.parentThumb,
|
|
art: metadata.grandparentArt,
|
|
serverId: metadata.serverId,
|
|
serverName: metadata.serverName,
|
|
);
|
|
}
|
|
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => MediaDetailScreen(metadata: target, isOffline: isOffline, initialSeasonIndex: initialSeasonIndex),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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: TextOverflow.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: TextOverflow.ellipsis,
|
|
style: baseStyle.copyWith(
|
|
decoration: _isHovered ? TextDecoration.underline : null,
|
|
decorationColor: baseStyle.color,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Skeleton loader widget with subtle opacity pulse animation
|
|
class SkeletonLoader extends StatefulWidget {
|
|
final Widget? child;
|
|
final BorderRadius? borderRadius;
|
|
|
|
const SkeletonLoader({super.key, this.child, this.borderRadius});
|
|
|
|
@override
|
|
State<SkeletonLoader> createState() => _SkeletonLoaderState();
|
|
}
|
|
|
|
class _SkeletonLoaderState extends State<SkeletonLoader> with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
late Animation<double> _animation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(duration: const Duration(milliseconds: 1500), vsync: this);
|
|
_animation = Tween<double>(
|
|
begin: 0.3,
|
|
end: 0.7,
|
|
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
|
|
_animationController.repeat(reverse: true);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _animation,
|
|
builder: (context, child) {
|
|
return Semantics(
|
|
label: "skeleton-loader",
|
|
identifier: "skeleton-loader",
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: _animation.value * 0.15),
|
|
borderRadius: widget.borderRadius ?? BorderRadius.circular(tokens(context).radiusSm),
|
|
),
|
|
child: widget.child,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|