244 lines
7.0 KiB
Dart
244 lines
7.0 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:forui/forui.dart';
|
||
|
|
|
||
|
|
import '../../../core/contracts/library.dart';
|
||
|
|
import '../../../shared/theme/app_theme.dart';
|
||
|
|
import '../../../shared/utils/emby_ticks.dart';
|
||
|
|
import '../../../shared/widgets/app_card.dart';
|
||
|
|
import '../../../shared/widgets/card_progress_bar.dart';
|
||
|
|
import '../../../shared/widgets/metadata_chip.dart';
|
||
|
|
import '../../../shared/widgets/smart_image.dart';
|
||
|
|
|
||
|
|
|
||
|
|
class AggregatedMediaCard extends StatefulWidget {
|
||
|
|
final EmbyRawItem item;
|
||
|
|
final String? imageUrl;
|
||
|
|
final String? fallbackImageUrl;
|
||
|
|
final Map<String, String>? imageHeaders;
|
||
|
|
|
||
|
|
final String title;
|
||
|
|
final String? subtitle;
|
||
|
|
final String serverName;
|
||
|
|
final int serverSourceCount;
|
||
|
|
final String? timeLabel;
|
||
|
|
|
||
|
|
final String? networkName;
|
||
|
|
final String? networkLogoUrl;
|
||
|
|
|
||
|
|
final bool showProgress;
|
||
|
|
final VoidCallback? onTap;
|
||
|
|
final VoidCallback? onServerSourcesTap;
|
||
|
|
final List<FItem>? contextMenuItems;
|
||
|
|
|
||
|
|
const AggregatedMediaCard({
|
||
|
|
super.key,
|
||
|
|
required this.item,
|
||
|
|
required this.imageUrl,
|
||
|
|
required this.title,
|
||
|
|
required this.serverName,
|
||
|
|
this.serverSourceCount = 1,
|
||
|
|
this.fallbackImageUrl,
|
||
|
|
this.imageHeaders,
|
||
|
|
this.subtitle,
|
||
|
|
this.timeLabel,
|
||
|
|
this.networkName,
|
||
|
|
this.networkLogoUrl,
|
||
|
|
this.showProgress = false,
|
||
|
|
this.onTap,
|
||
|
|
this.onServerSourcesTap,
|
||
|
|
this.contextMenuItems,
|
||
|
|
}) : assert(serverSourceCount > 0);
|
||
|
|
|
||
|
|
static const double height = 84;
|
||
|
|
static const double _thumbWidth = 124;
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<AggregatedMediaCard> createState() => _AggregatedMediaCardState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _AggregatedMediaCardState extends State<AggregatedMediaCard> {
|
||
|
|
bool _hovered = false;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
final tokens = context.appTokens;
|
||
|
|
final radius = tokens.radiusCard;
|
||
|
|
final progress = widget.showProgress ? playbackProgress(widget.item) : 0.0;
|
||
|
|
final remaining = widget.showProgress
|
||
|
|
? formatRemainingLabel(widget.item)
|
||
|
|
: null;
|
||
|
|
|
||
|
|
return AppCard(
|
||
|
|
onTap: widget.onTap,
|
||
|
|
contextMenuItems: widget.contextMenuItems,
|
||
|
|
radius: radius,
|
||
|
|
padding: EdgeInsets.zero,
|
||
|
|
onHoverChanged: (h) => setState(() => _hovered = h),
|
||
|
|
child: SizedBox(
|
||
|
|
height: AggregatedMediaCard.height,
|
||
|
|
child: Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
_thumbnail(radius, progress),
|
||
|
|
Expanded(
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 10, 8),
|
||
|
|
child: _info(theme, tokens, remaining),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _thumbnail(double radius, double progress) {
|
||
|
|
return SizedBox(
|
||
|
|
width: AggregatedMediaCard._thumbWidth,
|
||
|
|
child: Stack(
|
||
|
|
fit: StackFit.expand,
|
||
|
|
children: [
|
||
|
|
SmartImage(
|
||
|
|
url: widget.imageUrl,
|
||
|
|
fallbackUrls: widget.fallbackImageUrl != null
|
||
|
|
? [widget.fallbackImageUrl!]
|
||
|
|
: const [],
|
||
|
|
httpHeaders: widget.imageHeaders,
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
borderRadius: 0,
|
||
|
|
),
|
||
|
|
if (_hovered)
|
||
|
|
Container(
|
||
|
|
color: Colors.black.withValues(alpha: 0.32),
|
||
|
|
child: const Center(
|
||
|
|
child: Icon(
|
||
|
|
Icons.play_arrow_rounded,
|
||
|
|
size: 30,
|
||
|
|
color: Colors.white,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
if (progress > 0) CardProgressBar(progress: progress),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _networkBadge(AppTokens tokens) {
|
||
|
|
if (widget.networkLogoUrl != null) {
|
||
|
|
return SizedBox(
|
||
|
|
height: 16,
|
||
|
|
child: SmartImage(
|
||
|
|
url: widget.networkLogoUrl,
|
||
|
|
fit: BoxFit.contain,
|
||
|
|
borderRadius: 0,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return MetadataChip(
|
||
|
|
label: widget.networkName!,
|
||
|
|
dense: true,
|
||
|
|
foreground: tokens.mutedForeground,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _serverBadge() {
|
||
|
|
final hasMultipleSources = widget.serverSourceCount > 1;
|
||
|
|
if (!hasMultipleSources || widget.onServerSourcesTap == null) {
|
||
|
|
return MetadataChip(label: widget.serverName, dense: true);
|
||
|
|
}
|
||
|
|
|
||
|
|
final label = '${widget.serverName} ${widget.serverSourceCount}服';
|
||
|
|
return Tooltip(
|
||
|
|
message: '选择服务器并查看各自的播放进度',
|
||
|
|
child: Semantics(
|
||
|
|
button: true,
|
||
|
|
label: '$label,选择其他服务器',
|
||
|
|
child: MouseRegion(
|
||
|
|
cursor: SystemMouseCursors.click,
|
||
|
|
child: GestureDetector(
|
||
|
|
behavior: HitTestBehavior.opaque,
|
||
|
|
onTap: widget.onServerSourcesTap,
|
||
|
|
child: MetadataChip(
|
||
|
|
label: label,
|
||
|
|
icon: Icons.keyboard_arrow_down_rounded,
|
||
|
|
dense: true,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _info(ThemeData theme, AppTokens tokens, String? remaining) {
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
|
|
children: [
|
||
|
|
Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
widget.title,
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
if (widget.subtitle != null) ...[
|
||
|
|
const SizedBox(height: 2),
|
||
|
|
Text(
|
||
|
|
widget.subtitle!,
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
||
|
|
color: tokens.mutedForeground,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
_serverBadge(),
|
||
|
|
if (widget.networkName != null &&
|
||
|
|
widget.networkName!.isNotEmpty) ...[
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
_networkBadge(tokens),
|
||
|
|
],
|
||
|
|
if (remaining != null) ...[
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Flexible(
|
||
|
|
child: Text(
|
||
|
|
remaining,
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
||
|
|
color: tokens.mutedForeground,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
] else if (widget.timeLabel != null) ...[
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Flexible(
|
||
|
|
child: Text(
|
||
|
|
widget.timeLabel!,
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
||
|
|
color: tokens.mutedForeground,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|