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,536 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart' as forui;
import '../../../core/contracts/library.dart';
import '../../../providers/di_providers.dart';
import '../../../providers/session_provider.dart';
import '../../../providers/version_priority_provider.dart';
import '../../../shared/constants/breakpoints.dart';
import '../../../shared/utils/cross_server_search_key.dart';
import '../../../shared/widgets/all_versions_sheet.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../../../shared/widgets/frosted_panel.dart';
import '../../../shared/widgets/hover_scrollable_row.dart';
import '../../../shared/widgets/version_filter.dart';
import '../../../shared/widgets/version_grouping.dart';
import '../../../shared/widgets/version_source_card.dart';
import 'multi_source_ordering.dart';
import 'multi_source_scroll.dart';
typedef MultiSourceTap =
Future<void> Function(
String serverId,
String itemId, {
String? mediaSourceId,
CrossServerSourceCard? card,
});
typedef LocalSourceTap = void Function(EmbyRawMediaSource src, int index);
class MultiSourceSection extends ConsumerStatefulWidget {
final CrossServerSearchReq req;
final MultiSourceTap? onSourceTap;
final bool embedded;
final List<EmbyRawMediaSource> localSources;
final String? currentLocalSourceId;
final String? currentServerName;
final LocalSourceTap? onLocalSourceTap;
final String? excludedServerIdOverride;
final String title;
final String? selectedCrossServerKey;
final bool showLoadingWhenEmpty;
final bool externalLoading;
final String loadingText;
const MultiSourceSection({
super.key,
required this.req,
this.onSourceTap,
this.embedded = false,
this.localSources = const [],
this.currentLocalSourceId,
this.currentServerName,
this.onLocalSourceTap,
this.excludedServerIdOverride,
this.title = '多源聚合',
this.selectedCrossServerKey,
this.showLoadingWhenEmpty = false,
this.externalLoading = false,
this.loadingText = '正在查找可用版本…',
});
@override
ConsumerState<MultiSourceSection> createState() => _MultiSourceSectionState();
}
String _bucketOf(CrossServerSourceCard card) {
return versionBucketOf(card.quality?.resolutionLabel);
}
String _bucketOfLocal(EmbyRawMediaSource src) {
return versionBucketOf(MediaQualityInfo.fromMediaSource(src).resolutionLabel);
}
class _MultiSourceSectionState extends ConsumerState<MultiSourceSection> {
static final double _cardWidth = Platform.isAndroid ? 170.0 : 220.0;
static const double _cardGap = 12.0;
final ScrollController _scrollController = ScrollController();
String? _selectedBucket;
String? _autoScrolledKey;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final session = ref.watch(activeSessionProvider);
if (session == null) return const SizedBox.shrink();
final req = widget.req;
final key = (
activeServerId: session.serverId,
anchorType: req.anchorType,
providerIdQuery: encodeProviderIdsForKey(req.providerIds),
seriesProviderIdQuery: encodeProviderIdsForKey(
req.seriesProviderIds ?? const {},
),
parentIndexNumber: req.parentIndexNumber ?? -1,
indexNumber: req.indexNumber ?? -1,
itemName: req.itemName,
excludedServerId: widget.excludedServerIdOverride ?? session.serverId,
);
final asyncResult = ref.watch(crossServerSearchDataProvider(key));
return _renderContent(
asyncResult,
widget.excludedServerIdOverride ?? session.serverId,
);
}
Widget _renderContent(
AsyncValue<List<CrossServerSourceCard>> asyncResult,
String currentServerId,
) {
final cards = asyncResult.maybeWhen(
data: (list) => list,
orElse: () => asyncResult.value ?? const <CrossServerSourceCard>[],
);
final priorities =
ref.watch(versionPriorityProvider).value?.activePriorities ??
const <VersionPriority>[];
final entries = <MultiSourceVersionEntry>[];
final localSources = widget.localSources;
for (var i = 0; i < localSources.length; i++) {
final src = localSources[i];
final isCurrent =
widget.currentLocalSourceId != null &&
src.Id == widget.currentLocalSourceId;
entries.add(
MultiSourceVersionEntry(
bucket: _bucketOfLocal(src),
data: _localCardData(src, i, currentServerId),
source: src,
isLocal: true,
isActive: isCurrent,
localIndex: i,
serverName: widget.currentServerName ?? '本服务器',
serverId: '',
itemId: '',
mediaSourceId: src.Id ?? '',
),
);
}
for (final c in cards) {
final isSelected =
widget.selectedCrossServerKey != null &&
widget.selectedCrossServerKey == '${c.serverId}|${c.mediaSourceId}';
entries.add(
MultiSourceVersionEntry(
bucket: _bucketOf(c),
data: _crossServerCardData(c),
source: c.mediaSource,
isLocal: false,
isActive: isSelected,
localIndex: -1,
serverName: c.serverName,
serverId: c.serverId,
itemId: c.landingItemId,
mediaSourceId: c.mediaSourceId,
),
);
}
sortMultiSourceEntries(entries, priorities);
final allGroups = groupVersionEntries([
for (final entry in entries) entry.data,
]);
final bucketsPresent = <String>{};
for (final e in entries) {
bucketsPresent.add(e.bucket);
}
final buckets = kVersionBucketOrder
.where(bucketsPresent.contains)
.toList(growable: false);
final canonicalSelected =
_selectedBucket != null && bucketsPresent.contains(_selectedBucket)
? _selectedBucket!
: defaultSelectedBucketForEntries(entries);
final filteredGroups = canonicalSelected == null
? allGroups
: allGroups
.where(
(group) =>
versionBucketOf(group.representative.resolutionLabel) ==
canonicalSelected,
)
.toList(growable: false);
final hasMergedGroup = allGroups.any((group) => group.members.length > 1);
final showAllVersionsButton =
filteredGroups.length > inlineVersionLimit() || hasMergedGroup;
final width = MediaQuery.sizeOf(context).width;
final compact = width < Breakpoints.detail;
final hPad = compact ? 16.0 : 32.0;
if (entries.isEmpty) {
final loading = widget.externalLoading || asyncResult.isLoading;
if (widget.showLoadingWhenEmpty && loading) {
return _wrapContent(
_buildLoadingContent(widget.embedded ? 0.0 : hPad),
hPad,
);
}
return const SizedBox.shrink();
}
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(
buckets,
canonicalSelected,
allGroups,
entries.length,
showAllVersionsButton,
widget.embedded ? 0.0 : hPad,
),
const SizedBox(height: 16),
_buildCardList(
filteredGroups,
canonicalSelected,
widget.embedded ? 0.0 : hPad,
),
],
);
return _wrapContent(content, hPad);
}
Widget _wrapContent(Widget content, double hPad) {
if (widget.embedded) return content;
return Padding(
padding: EdgeInsets.symmetric(horizontal: hPad),
child: FrostedPanel(
radius: 16,
padding: EdgeInsets.all(hPad),
blurSigma: 15,
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
color: Colors.black.withValues(alpha: 0.3),
child: content,
),
);
}
Widget _buildLoadingContent(double hPad) {
final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: hPad),
child: Row(
children: [
Text(
widget.title,
style: TextStyle(
fontSize: compact ? 17 : 20,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.9),
),
),
const Spacer(),
const AppLoadingRing(size: 16, color: Colors.white),
const SizedBox(width: 8),
Flexible(
child: Text(
widget.loadingText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.62),
),
),
),
],
),
),
const SizedBox(height: 16),
SizedBox(height: Platform.isAndroid ? 100 : 120),
],
);
}
Widget _buildHeader(
List<String> buckets,
String? selectedBucket,
List<VersionSourceGroup> allGroups,
int sourceCount,
bool showAllVersionsButton,
double hPad,
) {
final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
final titleAndAction = Wrap(
spacing: 10,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
widget.title,
style: TextStyle(
fontSize: compact ? 17 : 20,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.9),
),
),
if (showAllVersionsButton)
forui.FButton(
variant: forui.FButtonVariant.secondary,
size: forui.FButtonSizeVariant.xs,
mainAxisSize: MainAxisSize.min,
onPress: () =>
showAllVersionsSheet(context: context, groups: allGroups),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('全部 $sourceCount 个版本'),
const SizedBox(width: 2),
const Icon(Icons.chevron_right_rounded, size: 16),
],
),
),
],
);
final filterChips = Row(
mainAxisSize: MainAxisSize.min,
children: [
for (
var bucketIndex = 0;
bucketIndex < buckets.length;
bucketIndex++
) ...[
if (bucketIndex > 0) const SizedBox(width: 8),
VersionFilterChip(
label: buckets[bucketIndex],
selected: selectedBucket == buckets[bucketIndex],
showDot: true,
onTap: () => setState(() => _selectedBucket = buckets[bucketIndex]),
),
],
],
);
return Padding(
padding: EdgeInsets.symmetric(horizontal: hPad),
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
titleAndAction,
if (buckets.isNotEmpty) ...[
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: filterChips,
),
],
],
)
: Row(
children: [
titleAndAction,
if (buckets.isNotEmpty) ...[
const SizedBox(width: 16),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: filterChips,
),
),
),
],
],
),
);
}
Widget _buildCardList(
List<VersionSourceGroup> groups,
String? bucket,
double hPad,
) {
if (groups.isEmpty) return const SizedBox.shrink();
final inlineLimit = inlineVersionLimit();
final inlineGroups = groups.take(inlineLimit).toList(growable: true);
final activeGroupIndex = groups.indexWhere(
(group) => group.hasActiveMember,
);
if (activeGroupIndex >= inlineLimit && inlineGroups.isNotEmpty) {
inlineGroups[inlineGroups.length - 1] = groups[activeGroupIndex];
}
_maybeScrollActiveIntoView(inlineGroups, bucket, hPad);
final itemCount = inlineGroups.length;
return SizedBox(
height: Platform.isAndroid ? 100 : 120,
child: HoverScrollableRow(
controller: _scrollController,
builder: (_, controller) => ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 0),
itemCount: itemCount,
itemBuilder: (_, i) => Padding(
padding: EdgeInsets.only(right: i < itemCount - 1 ? 12 : 0),
child: VersionSourceCard(
data: inlineGroups[i].displayData,
onMergedServersTap: inlineGroups[i].members.length > 1
? () => showAllVersionsSheet(
context: context,
groups: [inlineGroups[i]],
title: '选择服务器',
)
: null,
),
),
),
),
);
}
void _maybeScrollActiveIntoView(
List<VersionSourceGroup> groups,
String? bucket,
double leadingPad,
) {
final activeGroupIndex = groups.indexWhere(
(group) => group.hasActiveMember,
);
final activeSourceKey = activeGroupIndex < 0
? '<none>'
: groups[activeGroupIndex].activeMember?.sourceKey ?? '<none>';
final key = '${bucket ?? '<all>'}|$activeSourceKey';
if (key == _autoScrolledKey) return;
_autoScrolledKey = key;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) return;
final position = _scrollController.position;
_scrollController.jumpTo(
multiSourceScrollOffset(
activeIndex: activeGroupIndex,
viewportWidth: position.viewportDimension,
leadingPad: leadingPad,
maxScrollExtent: position.maxScrollExtent,
cardWidth: _cardWidth,
cardGap: _cardGap,
),
);
});
}
VersionSourceCardData _localCardData(
EmbyRawMediaSource src,
int index,
String currentServerId,
) {
final tap = widget.onLocalSourceTap;
final isCurrent =
widget.currentLocalSourceId != null &&
src.Id == widget.currentLocalSourceId;
return VersionSourceCardData.fromLocalSource(
src,
serverName: widget.currentServerName ?? '本服务器',
serverKey: currentServerId,
isCurrent: isCurrent,
tooltip: isCurrent ? null : '切换到该版本',
onTap: (tap == null || isCurrent) ? null : () async => tap(src, index),
);
}
VersionSourceCardData _crossServerCardData(CrossServerSourceCard card) {
final selectedKey = widget.selectedCrossServerKey;
final isSelected =
selectedKey != null &&
selectedKey == '${card.serverId}|${card.mediaSourceId}';
final data = VersionSourceCardData.fromCrossServer(
card,
selected: isSelected,
onTap: _tapHandlerFor(card),
);
return data;
}
Future<void> Function()? _tapHandlerFor(CrossServerSourceCard card) {
final tap = widget.onSourceTap;
if (tap == null) return null;
return () => tap(
card.serverId,
card.landingItemId,
mediaSourceId: card.mediaSourceId,
card: card,
);
}
}