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 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 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 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 { 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> asyncResult, String currentServerId, ) { final cards = asyncResult.maybeWhen( data: (list) => list, orElse: () => asyncResult.value ?? const [], ); final priorities = ref.watch(versionPriorityProvider).value?.activePriorities ?? const []; final entries = []; 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 = {}; 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 buckets, String? selectedBucket, List 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 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 groups, String? bucket, double leadingPad, ) { final activeGroupIndex = groups.indexWhere( (group) => group.hasActiveMember, ); final activeSourceKey = activeGroupIndex < 0 ? '' : groups[activeGroupIndex].activeMember?.sourceKey ?? ''; final key = '${bucket ?? ''}|$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 Function()? _tapHandlerFor(CrossServerSourceCard card) { final tap = widget.onSourceTap; if (tap == null) return null; return () => tap( card.serverId, card.landingItemId, mediaSourceId: card.mediaSourceId, card: card, ); } }