import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../focus/focus_theme.dart'; import '../focus/focusable_wrapper.dart'; import '../mixins/context_menu_tap_mixin.dart'; import '../models/download_models.dart'; import '../providers/download_provider.dart'; import '../providers/watch_state_overlay_provider.dart'; import 'package:provider/provider.dart'; import '../services/settings_service.dart'; import 'settings_builder.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../widgets/collapsible_text.dart'; import '../widgets/download_status_icon.dart'; import '../widgets/optimized_media_image.dart'; import '../utils/platform_detector.dart'; import '../utils/formatters.dart'; import '../widgets/media_context_menu.dart'; import '../widgets/placeholder_container.dart'; import '../theme/mono_tokens.dart'; import '../media/media_server_client.dart'; /// Episode card widget with D-pad long-press support class EpisodeCard extends StatefulWidget { final MediaItem episode; final MediaServerClient? client; final VoidCallback onTap; final Future Function(String)? onRefresh; final Future Function()? onListRefresh; final bool autofocus; final bool isOffline; final String? localPosterPath; final FocusNode? focusNode; final VoidCallback? onNavigateUp; const EpisodeCard({ super.key, required this.episode, this.client, required this.onTap, this.onRefresh, this.onListRefresh, this.autofocus = false, this.isOffline = false, this.localPosterPath, this.focusNode, this.onNavigateUp, }); @override State createState() => _EpisodeCardState(); } class _EpisodeCardState extends State with ContextMenuTapMixin { MediaItem _effectiveEpisode(BuildContext context) { try { final patch = context.select( (provider) => provider.patchForGlobalKey(widget.episode.globalKey), ); return WatchStateOverlayProvider.applyPatch(widget.episode, patch); } on ProviderNotFoundException { return widget.episode; } } Widget _buildEpisodeMetaRow(BuildContext context, MediaItem episode) { final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12); final dot = Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Text('•', style: mutedStyle), ); return Row( children: [ if (episode.durationMs != null) Text(formatDurationTimestamp(Duration(milliseconds: episode.durationMs!)), style: mutedStyle), if (episode.originallyAvailableAt != null) ...[ dot, Text(formatFullDate(episode.originallyAvailableAt!), style: mutedStyle), ], if (episode.userRating != null && episode.userRating! > 0) ...[ dot, const Padding( padding: EdgeInsets.only(top: 2), child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber), ), const SizedBox(width: 2), Text( (episode.userRating! / 2) == (episode.userRating! / 2).truncateToDouble() ? '${(episode.userRating! / 2).toInt()}' : formatRating(episode.userRating! / 2), style: mutedStyle, ), ], ], ); } @override Widget build(BuildContext context) { return SettingValueBuilder( pref: SettingsService.hideSpoilers, builder: (context, hideSpoilers, _) => _buildContent(context, hideSpoilers: hideSpoilers), ); } Widget _buildContent(BuildContext context, {required bool hideSpoilers}) { final episode = _effectiveEpisode(context); final shouldBlur = hideSpoilers && episode.shouldHideSpoiler; // Hide progress when offline (not tracked) final hasProgress = !widget.isOffline && episode.viewOffsetMs != null && episode.durationMs != null && episode.viewOffsetMs! > 0; final progress = hasProgress ? episode.viewOffsetMs! / episode.durationMs! : 0.0; final hasActiveProgress = hasProgress && episode.viewOffsetMs! < episode.durationMs!; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: FocusableWrapper( focusNode: widget.focusNode, autofocus: widget.autofocus, enableLongPress: true, onNavigateUp: widget.onNavigateUp, onSelect: widget.onTap, onLongPress: showContextMenuFromTap, disableScale: true, child: MediaContextMenu( key: contextMenuKey, item: episode, onRefresh: widget.onRefresh, onListRefresh: widget.onListRefresh, onTap: widget.onTap, child: InkWell( key: Key(episode.id), borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius), onTap: widget.onTap, canRequestFocus: false, onTapDown: storeTapPosition, onLongPress: showContextMenuFromTap, onSecondaryTapDown: storeTapPosition, onSecondaryTap: showContextMenuFromTap, hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05), child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius), ), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 160, child: Stack( children: [ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(6)), child: AspectRatio( aspectRatio: 16 / 9, child: shouldBlur ? ClipRect( child: ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: _buildEpisodeThumbnail(episode), ), ) : _buildEpisodeThumbnail(episode), ), ), Positioned.fill( child: Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(6)), gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black.withValues(alpha: 0.2)], ), ), child: Center( child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle, ), child: const AppIcon( Symbols.play_arrow_rounded, fill: 1, color: Colors.white, size: 20, ), ), ), ), ), if (hasActiveProgress) Positioned( bottom: 0, left: 0, right: 0, child: ClipRRect( borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(6), bottomRight: Radius.circular(6), ), child: LinearProgressIndicator( value: progress, backgroundColor: tokens(context).outline, minHeight: 3, ), ), ), if (episode.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: 12), ), ), ], ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Selector( selector: (_, p) => _DownloadSlice.from(p.getProgress(episode.globalKey), p.isQueueing(episode.globalKey)), builder: (context, slice, _) { Widget? downloadStatusIcon; // Only show download status in online mode if (!widget.isOffline && episode.serverId != null) { final status = slice.status; final mutedBase = tokens(context).textMuted; if (slice.isQueueing) { downloadStatusIcon = DownloadQueueingSpinner(size: 12, color: mutedBase); } else if (status != null) { final iconSize = status == DownloadStatus.downloading ? 14.0 : 12.0; downloadStatusIcon = DownloadStatusIcon( status: status, size: iconSize, variant: DownloadStatusIconVariant.muted, mutedBase: mutedBase, progress: slice.progressPercent, ); } // Note: No icon shown if not downloaded (null) } return Row( children: [ if (episode.index != null) Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: const BorderRadius.all(Radius.circular(3)), ), child: Text( 'E${episode.index}', style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, fontSize: 11, fontWeight: FontWeight.w600, ), ), ), if (downloadStatusIcon != null) ...[const SizedBox(width: 6), downloadStatusIcon], const SizedBox(width: 8), Expanded( child: Text( episode.title!, style: Theme.of( context, ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ], ); }, ), if (!shouldBlur && episode.summary != null && episode.summary!.isNotEmpty) ...[ const SizedBox(height: 6), if (PlatformDetector.isTV()) Text( episode.summary!, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3), maxLines: 3, overflow: TextOverflow.ellipsis, ) else CollapsibleText( text: episode.summary!, maxLines: 3, small: true, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3), ), ], const SizedBox(height: 8), _buildEpisodeMetaRow(context, episode), ], ), ), ], ), ), ), ), ), ); } Widget _buildEpisodeThumbnail(MediaItem episode) { if (widget.isOffline && widget.localPosterPath != null) { return OptimizedMediaImage.thumb( client: null, imagePath: null, localFilePath: widget.localPosterPath, fit: BoxFit.cover, errorWidget: (context, url, error) => const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)), ); } if (episode.thumbPath != null) { return OptimizedMediaImage.thumb( client: widget.client, imagePath: episode.thumbPath, filterQuality: FilterQuality.medium, fit: BoxFit.cover, placeholder: (context, url) => const PlaceholderContainer(), errorWidget: (context, url, error) => const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)), ); } return const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)); } } /// Captures only primitives so Selector equality avoids rebuilds on unrelated /// download ticks (e.g. other episodes, unused `DownloadProgress` fields). class _DownloadSlice { final DownloadStatus? status; final double? progressPercent; final bool isQueueing; const _DownloadSlice({required this.status, required this.progressPercent, required this.isQueueing}); factory _DownloadSlice.from(DownloadProgress? p, bool isQueueing) => _DownloadSlice(status: p?.status, progressPercent: p?.progressPercent, isQueueing: isQueueing); @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is _DownloadSlice && other.status == status && other.progressPercent == progressPercent && other.isQueueing == isQueueing; } @override int get hashCode => Object.hash(status, progressPercent, isQueueing); }