import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:forui/forui.dart'; import '../../core/contracts/library.dart'; import '../../core/domain/ports/emby_gateway.dart'; import '../../providers/di_providers.dart'; import '../../providers/session_provider.dart'; import '../../shared/mappers/media_image_url.dart'; import '../../shared/utils/format_utils.dart'; import '../../shared/utils/media_nav.dart'; import '../../shared/widgets/app_sliver_header.dart'; import '../../shared/widgets/app_loading_ring.dart'; import '../../shared/widgets/empty_state.dart'; import '../aggregated/aggregated_grouping.dart'; import '../aggregated/widgets/aggregated_grouped_grid.dart'; import '../aggregated/widgets/aggregated_media_card.dart'; import '../aggregated/widgets/server_filter_bar.dart'; const _favoriteTypeFilters = <_FavoriteTypeFilter>[ _FavoriteTypeFilter(includeItemTypes: 'Movie,Series,Episode', label: '全部'), _FavoriteTypeFilter(includeItemTypes: 'Movie', label: '电影'), _FavoriteTypeFilter(includeItemTypes: 'Series', label: '剧集'), _FavoriteTypeFilter(includeItemTypes: 'Episode', label: '单集'), ]; class AggregatedFavoritesPage extends ConsumerStatefulWidget { final bool embedded; const AggregatedFavoritesPage({super.key, this.embedded = false}); @override ConsumerState createState() => _AggregatedFavoritesPageState(); } class _AggregatedFavoritesPageState extends ConsumerState { StreamSubscription? _subscription; _FavoriteTypeFilter _selectedFilter = _favoriteTypeFilters.first; List _serverResults = []; bool _loading = false; bool _switching = false; final Map _favoriteLoadingMap = {}; final Set _collapsed = {}; String? _serverFilter; @override void initState() { super.initState(); _load(); } @override void dispose() { _subscription?.cancel(); super.dispose(); } Future _load() async { _subscription?.cancel(); _subscription = null; setState(() { _loading = true; _serverResults = []; }); try { final uc = await ref.read(aggregatedFavoritesUseCaseProvider.future); final stream = uc.executeStream( includeItemTypes: _selectedFilter.includeItemTypes, ); _subscription = stream.listen( (result) { if (mounted) { setState(() => _serverResults = [..._serverResults, result]); } }, onDone: () { if (mounted) setState(() => _loading = false); }, onError: (_) { if (mounted) setState(() => _loading = false); }, ); } catch (_) { if (mounted) setState(() => _loading = false); } } Future _onItemTap( BuildContext context, GlobalSearchServerResult serverResult, EmbyRawItem item, ) async { if (_switching) return; setState(() => _switching = true); try { await goMediaDetailOnServer( context, ref, serverId: serverResult.serverId, item: item, ); } finally { if (mounted) setState(() => _switching = false); } } Future _removeFavorite( GlobalSearchServerResult serverResult, EmbyRawItem item, ) async { if (_favoriteLoadingMap[item.Id] == true) return; setState(() => _favoriteLoadingMap[item.Id] = true); try { final gateway = await ref.read(embyGatewayProvider.future); final ctx = AuthedRequestContext( baseUrl: serverResult.serverUrl, token: serverResult.token, userId: serverResult.userId, ); await gateway.setFavoriteStatus( ctx: ctx, itemId: item.Id, isFavorite: false, ); if (mounted) { setState(() { _serverResults = _serverResults .map((sr) { if (sr.serverId != serverResult.serverId) return sr; final filtered = sr.items .where((i) => i.Id != item.Id) .toList(); return GlobalSearchServerResult( serverId: sr.serverId, serverName: sr.serverName, serverUrl: sr.serverUrl, userId: sr.userId, token: sr.token, items: filtered, ); }) .where((sr) => sr.items.isNotEmpty) .toList(); }); } } finally { if (mounted) setState(() => _favoriteLoadingMap[item.Id] = false); } } List _cardMenuItems( GlobalSearchServerResult sr, EmbyRawItem item, ) => [ FItem( prefix: const Icon(Icons.favorite, size: 16), enabled: !(_favoriteLoadingMap[item.Id] ?? false), title: const Text('取消收藏'), onPress: () => _removeFavorite(sr, item), ), ]; void _changeType(_FavoriteTypeFilter filter) { if (_selectedFilter == filter) return; setState(() => _selectedFilter = filter); _load(); } void _toggle(String key) { setState(() { if (!_collapsed.remove(key)) _collapsed.add(key); }); } @override Widget build(BuildContext context) { ref.listen(sessionProvider, (previous, next) { final prevIds = previous?.value?.sessions.map((s) => s.serverId).toSet() ?? {}; final nextIds = next.value?.sessions.map((s) => s.serverId).toSet() ?? {}; if (prevIds.length == nextIds.length && prevIds.containsAll(nextIds)) { return; } if (_serverFilter != null && !nextIds.contains(_serverFilter)) { setState(() => _serverFilter = null); } _load(); }); if (widget.embedded) { return Stack( fit: StackFit.expand, children: [ CustomScrollView( slivers: [ SliverOverlapInjector( handle: NestedScrollView.sliverOverlapAbsorberHandleFor( context, ), ), SliverToBoxAdapter(child: _buildTypeFilterRow()), ..._buildContentSlivers(context), ], ), if (_switching) _switchingOverlay(), ], ); } return Stack( fit: StackFit.expand, children: [ Scaffold( extendBodyBehindAppBar: true, body: CustomScrollView( slivers: [ AppSliverHeader( title: '我的收藏', actions: [ IconButton( onPressed: _loading ? null : _load, icon: _loading ? const AppLoadingRing(size: 16) : const Icon(Icons.refresh, size: 18), tooltip: '刷新', ), ], ), SliverToBoxAdapter(child: _buildTypeFilterRow()), ..._buildContentSlivers(context), ], ), ), if (_switching) _switchingOverlay(), ], ); } Widget _switchingOverlay() => const Positioned.fill( child: ColoredBox( color: Color(0x44000000), child: Center(child: AppLoadingRing()), ), ); Widget _buildTypeFilterRow() => Padding( padding: const EdgeInsets.fromLTRB(24, 8, 24, 8), child: Align( alignment: Alignment.centerLeft, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: _favoriteTypeFilters.map(_buildTypeButton).toList(), ), ), ), ); Widget _buildTypeButton(_FavoriteTypeFilter filter) { final selected = _selectedFilter == filter; final child = Text(filter.label); return Padding( padding: const EdgeInsets.only(right: 8), child: FButton( variant: selected ? FButtonVariant.primary : FButtonVariant.ghost, onPress: () => _changeType(filter), child: child, ), ); } List _buildContentSlivers(BuildContext context) { if (_serverResults.isEmpty && !_loading) { return const [ SliverFillRemaining( hasScrollBody: false, child: EmptyState(icon: Icons.favorite_border, title: '暂无收藏内容'), ), ]; } if (_serverResults.isEmpty && _loading) { return const [ SliverFillRemaining( hasScrollBody: false, child: Center(child: AppLoadingRing()), ), ]; } final now = DateTime.now(); final entries = flatten( _serverResults, serverFilter: _serverFilter, deduplicateAcrossServers: true, ); final groups = groupEntries(entries, GroupAxis.mediaType, now); final servers = _serverResults .map((sr) => (id: sr.serverId, name: sr.serverName)) .toList(growable: false); return [ SliverToBoxAdapter( child: ServerFilterBar( servers: servers, selected: _serverFilter, onSelect: (v) => setState(() => _serverFilter = v), ), ), ...buildAggregatedGridSlivers( groups: groups, collapsed: _collapsed, onToggle: _toggle, cardBuilder: (entry) => _card(context, entry), ), ]; } Widget _card(BuildContext context, AggregatedEntry entry) { final sr = entry.source; final item = entry.item; final episode = formatEpisodeNumber(item.IndexNumber); final subtitle = item.Type == 'Episode' && episode != null ? '${item.SeriesName ?? ''} $episode'.trim() : (item.ProductionYear != null ? '${item.ProductionYear}' : null); return AggregatedMediaCard( item: item, imageUrl: EmbyImageUrl.landscapeCard( baseUrl: sr.serverUrl, token: sr.token, item: item, maxWidth: 400, ), imageHeaders: buildEmbyImageHeaders( ref, token: sr.token, userId: sr.userId, ), title: item.SeriesName ?? item.Name, subtitle: subtitle, serverName: sr.serverName, showProgress: false, onTap: () => _onItemTap(context, sr, item), contextMenuItems: _cardMenuItems(sr, item), ); } } class _FavoriteTypeFilter { final String includeItemTypes; final String label; const _FavoriteTypeFilter({ required this.includeItemTypes, required this.label, }); }