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