Initial commit
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user