390 lines
13 KiB
Dart
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;
|
|
}
|
|
}
|