537 lines
16 KiB
Dart
537 lines
16 KiB
Dart
|
|
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,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|