import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/contracts/library.dart'; import '../../providers/di_providers.dart'; import '../../providers/session_provider.dart'; import '../../providers/tmdb_settings_provider.dart'; import '../../shared/mappers/tmdb_image_url.dart'; import '../../shared/utils/format_utils.dart'; import '../../shared/utils/media_nav.dart'; import '../../shared/mappers/media_image_url.dart'; import '../../shared/utils/time_bucket.dart'; import '../../shared/widgets/app_loading_ring.dart'; import '../../shared/utils/tmdb_key_utils.dart'; import '../../shared/widgets/app_sliver_header.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'; import 'widgets/history_source_picker.dart'; class HistoryPage extends ConsumerStatefulWidget { final bool embedded; const HistoryPage({super.key, this.embedded = false}); @override ConsumerState createState() => _HistoryPageState(); } class _HistoryPageState extends ConsumerState { StreamSubscription? _subscription; List _serverResults = []; bool _loading = false; bool _switching = false; 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(aggregatedResumeUseCaseProvider.future); final stream = uc.executeStream(); _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 _onServerSourcesTap( BuildContext context, AggregatedEntry entry, DateTime now, ) async { if (_switching || entry.sources.length < 2) return; final selectedSource = await showHistorySourcePicker( context, entry: entry, now: now, ); if (selectedSource == null || !mounted || !context.mounted) return; await _onItemTap(context, selectedSource.source, selectedSource.item); } 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, ), ), ..._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: '刷新', ), ], ), ..._buildContentSlivers(context), ], ), ), if (_switching) _switchingOverlay(), ], ); } Widget _switchingOverlay() => const Positioned.fill( child: ColoredBox( color: Color(0x44000000), child: Center(child: AppLoadingRing()), ), ); List _buildContentSlivers(BuildContext context) { if (_serverResults.isEmpty && !_loading) { return const [ SliverFillRemaining( hasScrollBody: false, child: EmptyState(icon: Icons.play_circle_outline, 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.watchedTime, now); final servers = _serverResults .map((sr) => (id: sr.serverId, name: sr.serverName)) .toList(growable: false); return [ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(top: 8), child: ServerFilterBar( servers: servers, selected: _serverFilter, onSelect: (v) => setState(() => _serverFilter = v), ), ), ), ...buildAggregatedGridSlivers( groups: groups, collapsed: _collapsed, onToggle: _toggle, cardBuilder: (entry) => _card(context, entry, now), ), ]; } Widget _card(BuildContext context, AggregatedEntry entry, DateTime now) { return _TmdbAwareHistoryCard( entry: entry, now: now, imageHeaders: buildEmbyImageHeaders( ref, token: entry.source.token, userId: entry.source.userId, ), onTap: () => _onItemTap(context, entry.source, entry.item), onServerSourcesTap: entry.sources.length > 1 ? () => unawaited(_onServerSourcesTap(context, entry, now)) : null, ); } } class _TmdbAwareHistoryCard extends ConsumerWidget { final AggregatedEntry entry; final DateTime now; final Map imageHeaders; final VoidCallback onTap; final VoidCallback? onServerSourcesTap; const _TmdbAwareHistoryCard({ required this.entry, required this.now, required this.imageHeaders, required this.onTap, this.onServerSourcesTap, }); @override Widget build(BuildContext context, WidgetRef ref) { final sr = entry.source; final item = entry.item; final episode = formatEpisodeNumber(item.IndexNumber); final subtitle = episode != null ? '$episode · ${item.Name}' : (item.ProductionYear != null ? '${item.ProductionYear}' : null); final key = tmdbMediaKeyFrom(item); String? networkName; String? networkLogoUrl; if (key != null) { final enriched = ref.watch(tmdbEnrichedDetailProvider(key)).value; final network = enriched?.networks.firstOrNull; if (network != null) { networkName = network.name; final imageBase = ref .watch(tmdbSettingsProvider) .value ?.effectiveImageBaseUrl; if (imageBase != null) { networkLogoUrl = TmdbImageUrl.logo( imageBase, network.logoPath, size: 'w92', ); } } } return AggregatedMediaCard( item: item, imageUrl: EmbyImageUrl.landscapeCard( baseUrl: sr.serverUrl, token: sr.token, item: item, maxWidth: 400, ), imageHeaders: imageHeaders, title: item.SeriesName ?? item.Name, subtitle: subtitle, serverName: sr.serverName, serverSourceCount: entry.sources.length, networkName: networkName, networkLogoUrl: networkLogoUrl, timeLabel: relativeTimeLabel(entry.dateFor(GroupAxis.watchedTime), now), showProgress: true, onTap: onTap, onServerSourcesTap: onServerSourcesTap, ); } }