Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../../shared/layout/adaptive_card_grid.dart';
import '../../../shared/widgets/section_header.dart';
import 'aggregated_media_card.dart';
import '../aggregated_grouping.dart';
List<Widget> buildAggregatedGridSlivers({
required List<AggregatedGroup> groups,
required Set<String> collapsed,
required ValueChanged<String> onToggle,
required Widget Function(AggregatedEntry entry) cardBuilder,
EdgeInsets padding = const EdgeInsets.fromLTRB(24, 4, 24, 24),
}) {
final slivers = <Widget>[];
for (final group in groups) {
final isCollapsed = collapsed.contains(group.key);
slivers.add(
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(left: padding.left, right: padding.right),
child: SectionHeader(
label: group.label,
count: group.entries.length,
collapsible: true,
collapsed: isCollapsed,
onToggle: () => onToggle(group.key),
),
),
),
);
if (!isCollapsed) {
final entries = group.entries;
slivers.add(
SliverPadding(
padding: EdgeInsets.fromLTRB(padding.left, 0, padding.right, 20),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.crossAxisExtent;
final columnCount = cardColumnsFor(
availableWidth,
minCardWidth: kRichCardMinWidth,
);
final gapTotalWidth = kCardGap * (columnCount - 1);
final cardWidth = (availableWidth - gapTotalWidth) / columnCount;
return SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columnCount,
mainAxisExtent: AggregatedMediaCard.height,
crossAxisSpacing: kCardGap,
mainAxisSpacing: kCardGap,
),
delegate: SliverChildBuilderDelegate(
(context, index) => SizedBox(
width: cardWidth,
child: cardBuilder(entries[index]),
),
childCount: entries.length,
),
);
},
),
),
);
}
}
return slivers;
}
@@ -0,0 +1,243 @@
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,
),
),
),
],
],
),
],
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
class ServerFilterBar extends StatelessWidget {
final List<({String id, String name})> servers;
final String? selected;
final ValueChanged<String?> onSelect;
final EdgeInsetsGeometry padding;
const ServerFilterBar({
super.key,
required this.servers,
required this.selected,
required this.onSelect,
this.padding = const EdgeInsets.fromLTRB(24, 0, 24, 12),
});
@override
Widget build(BuildContext context) {
if (servers.length < 2) return const SizedBox.shrink();
return Padding(
padding: padding,
child: Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_chip(label: '全部', value: null),
for (final s in servers) _chip(label: s.name, value: s.id),
],
),
),
),
);
}
Widget _chip({required String label, required String? value}) {
final active = selected == value;
final child = Text(label);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FButton(
variant: active ? FButtonVariant.primary : FButtonVariant.ghost,
onPress: () => onSelect(value),
child: child,
),
);
}
}