Files
2026-07-14 11:11:36 +08:00

390 lines
13 KiB
Dart

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<String, String>? 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<MediaPosterCard> createState() => _MediaPosterCardState();
}
class _MediaPosterCardState extends State<MediaPosterCard> {
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<FItem> _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() : <FItem>[];
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;
}
}