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