import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:forui/forui.dart'; import '../../core/contracts/library.dart'; import '../theme/app_theme.dart'; import '../utils/emby_ticks.dart'; import '../utils/format_utils.dart'; import 'auto_dismiss_menu.dart'; import 'card_progress_bar.dart'; import 'smart_image.dart'; enum MediaCardVariant { poster, landscape } class MediaPosterCard extends StatefulWidget { final EmbyRawItem item; final String? imageUrl; final VoidCallback? onTap; final double width; final MediaCardVariant variant; final bool showHoverPlayIcon; final bool enableContextMenu; final bool? isFavoriteOverride; final bool? isPlayedOverride; final VoidCallback? onAddFavorite; final VoidCallback? onMarkPlayed; final VoidCallback? onHideFromResume; final bool favoriteLoading; final bool playedLoading; final bool hideFromResumeLoading; final bool showUnplayedCountBadge; final bool isActive; final String? fallbackImageUrl; final String? preloadImageUrl; final Map? imageHeaders; final Color? textColor; const MediaPosterCard({ super.key, required this.item, required this.imageUrl, this.onTap, this.width = 160, this.variant = MediaCardVariant.poster, this.showHoverPlayIcon = true, this.enableContextMenu = false, this.isFavoriteOverride, this.isPlayedOverride, this.onAddFavorite, this.onMarkPlayed, this.onHideFromResume, this.favoriteLoading = false, this.playedLoading = false, this.hideFromResumeLoading = false, this.showUnplayedCountBadge = true, this.isActive = false, this.fallbackImageUrl, this.preloadImageUrl, this.imageHeaders, this.textColor, }); @override State createState() => _MediaPosterCardState(); } class _MediaPosterCardState extends State { bool _hovered = false; String? _preloadedImageUrl; bool get _isFavorite => widget.isFavoriteOverride ?? (widget.item.UserData?.IsFavorite ?? false); bool get _isPlayed => widget.isPlayedOverride ?? (widget.item.UserData?.Played ?? false); double get _aspectRatio => widget.variant == MediaCardVariant.landscape ? 16 / 9 : 0.66; String? _subtitleText() { final item = widget.item; if (widget.variant == MediaCardVariant.landscape) { if (item.Type == 'Episode' && item.IndexNumber != null) { final season = _seasonNumber(item); final epLabel = formatEpisodeNumber(item.IndexNumber)!; final locator = season != null && season > 1 ? '${formatSeasonNumber(season)} $epLabel' : epLabel; return [ locator, item.Name, ].where((segment) => segment.isNotEmpty).join(' · '); } if (item.Type == 'Episode') { final seasonLabel = _seasonLabel(item); if (seasonLabel != null) { return [ seasonLabel, item.Name, ].where((segment) => segment.isNotEmpty).join(' · '); } } if (item.ProductionYear != null) return '${item.ProductionYear}'; return null; } if (item.Type == 'Episode' && item.SeriesName != null) { final season = item.SeasonName ?? ''; final episode = formatEpisodeNumber(item.IndexNumber) ?? ''; return [ item.SeriesName, season, episode, ].where((segment) => segment != null && segment.isNotEmpty).join(' · '); } if (item.ProductionYear != null) return '${item.ProductionYear}'; return null; } int? _seasonNumber(EmbyRawItem item) => (item.extra['ParentIndexNumber'] as num?)?.toInt(); String? _seasonLabel(EmbyRawItem item) { final name = item.SeasonName; if (name != null && name.isNotEmpty) return name; final season = (item.extra['ParentIndexNumber'] as num?)?.toInt(); if (season != null) return formatSeasonNumber(season); return null; } String get _displayTitle { if (widget.variant == MediaCardVariant.landscape) { return widget.item.SeriesName ?? widget.item.Name; } return widget.item.Name; } List _contextMenuItems() => [ if (widget.onAddFavorite != null) FItem( prefix: Icon( _isFavorite ? Icons.favorite : Icons.favorite_border, size: 16, ), enabled: !widget.favoriteLoading, title: Text(_isFavorite ? '取消收藏' : '添加到收藏'), onPress: () => widget.onAddFavorite?.call(), ), if (widget.onMarkPlayed != null) FItem( prefix: Icon( _isPlayed ? Icons.check_circle : Icons.check_circle_outline, size: 16, ), enabled: !widget.playedLoading, title: Text(_isPlayed ? '取消已观看' : '标记为已观看'), onPress: () => widget.onMarkPlayed?.call(), ), if (widget.onHideFromResume != null) FItem( prefix: const Icon(Icons.visibility_off_outlined, size: 16), enabled: !widget.hideFromResumeLoading, title: const Text('从“继续观看”中移除'), onPress: () => widget.onHideFromResume?.call(), ), ]; void _preloadImage() { final url = widget.preloadImageUrl; if (url == null || url.isEmpty || _preloadedImageUrl == url) return; _preloadedImageUrl = url; var logicalWidth = widget.width; if (!logicalWidth.isFinite || logicalWidth <= 0) { logicalWidth = context.size?.width ?? 0; } final scaledWidth = logicalWidth * MediaQuery.devicePixelRatioOf(context); final memCacheWidth = scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.ceil() : null; unawaited( precacheImage( ResizeImage.resizeIfNeeded( memCacheWidth, null, CachedNetworkImageProvider(url, headers: widget.imageHeaders), ), context, onError: (_, _) {}, ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.appTokens; final progress = playbackProgress(widget.item); final unplayed = widget.item.UserData?.UnplayedItemCount ?? 0; final showUnplayed = widget.showUnplayedCountBadge && unplayed > 0; final resolvedImageUrl = widget.imageUrl ?? widget.fallbackImageUrl; final radius = tokens.posterRadius; final remainingLabel = formatRemainingLabel(widget.item); final subtitleText = _subtitleText(); final menuItems = widget.enableContextMenu ? _contextMenuItems() : []; final hasMenu = menuItems.isNotEmpty; Widget content = SizedBox( width: widget.width, child: MouseRegion( onEnter: (_) { _preloadImage(); setState(() => _hovered = true); }, onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap == null ? null : () { _preloadImage(); widget.onTap!(); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Flexible( child: AnimatedOpacity( opacity: _hovered ? 0.85 : 1.0, duration: const Duration(milliseconds: 160), child: Stack( children: [ SmartImage( url: resolvedImageUrl, aspectRatio: _aspectRatio, borderRadius: radius, httpHeaders: widget.imageHeaders, ), if (widget.showHoverPlayIcon) Positioned.fill( child: AnimatedOpacity( opacity: _hovered ? 1.0 : 0.0, duration: const Duration(milliseconds: 160), child: ClipRRect( borderRadius: BorderRadius.circular(radius), child: Container( color: Colors.black.withValues(alpha: 0.3), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.play_arrow_rounded, size: 32, color: Colors.white, ), if (remainingLabel != null) ...[ const SizedBox(height: 4), Text( remainingLabel, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w500, ), ), ], ], ), ), ), ), ), ), if (progress > 0) CardProgressBar(progress: progress), if (_isPlayed) Positioned( top: 6, left: 6, child: Icon( Icons.check_circle, size: 16, color: Colors.white.withValues(alpha: 0.9), ), ), if (_isFavorite) Positioned( top: 6, right: showUnplayed ? 32 : 6, child: Icon( Icons.favorite, size: 14, color: Colors.white.withValues(alpha: 0.9), ), ), if (showUnplayed) Positioned( top: 6, right: 6, child: Container( padding: const EdgeInsets.symmetric( horizontal: 5, vertical: 2, ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(2), ), child: Text( '$unplayed', style: const TextStyle( color: Colors.black, fontSize: 10, fontWeight: FontWeight.w700, ), ), ), ), if (widget.isActive) Positioned( left: 0, right: 0, bottom: 0, child: Container( height: 2, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( bottomLeft: Radius.circular(radius), bottomRight: Radius.circular(radius), ), ), ), ), ], ), ), ), const SizedBox(height: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( _displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, color: widget.textColor, ), ), if (subtitleText != null) ...[ const SizedBox(height: 2), Text( subtitleText, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: widget.textColor != null ? widget.textColor!.withValues(alpha: 0.55) : tokens.mutedForeground, ), ), ], ], ), ], ), ), ), ); if (hasMenu) { content = FContextMenu( menuBuilder: autoDismissMenuBuilder, menu: [FItemGroup(children: menuItems)], child: content, ); } return content; } }