import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/contracts/library.dart'; import '../../providers/di_providers.dart'; import '../../providers/emby_headers_provider.dart'; import '../../providers/last_played_version_store.dart'; import '../../providers/playback_info_prefetch_provider.dart'; import '../../providers/playback_active_provider.dart'; import '../../providers/session_provider.dart'; import '../../providers/version_priority_provider.dart'; import '../../shared/theme/app_theme.dart'; import '../../shared/utils/cross_server_search_key.dart'; import '../../shared/utils/app_logger.dart'; import '../../shared/widgets/app_snack_bar.dart'; import '../player/player_launcher.dart'; import 'emby_detail_body_android.dart'; import 'emby_detail_body_builder.dart'; import 'emby_detail_helpers.dart'; import 'android/android_detail_geometry.dart'; import 'model/selected_episode_merge.dart'; import 'model/stream_selection.dart'; import 'widgets/detail_scroll_progress.dart'; class EmbyDetailView extends ConsumerStatefulWidget { final String itemId; final String? initialSeriesId; final String? initialSeasonId; final EmbyRawItem? seedItem; const EmbyDetailView({ super.key, required this.itemId, this.initialSeriesId, this.initialSeasonId, this.seedItem, }); @override ConsumerState createState() => _EmbyDetailViewState(); } class _EmbyDetailViewState extends ConsumerState { static const _tag = 'EmbyDetailView'; Future? _detailFuture; MediaDetailRes? _data; EmbyRawItem? _selectedItem; EmbyRawItem? _tmdbSeriesItem; bool _isFavorite = false; bool _isPlayed = false; int _selectedSourceIdx = 0; int? _selectedSubtitleIdx; int _selectedAudioIdx = 0; bool _isLaunchingPlayer = false; String? _detailServerId; String? _originServerId; bool _serverReloadScheduled = false; String? _overrideItemId; String? _pendingMediaSourceId; bool _isRestoringOriginServer = false; CrossServerSourceCard? _selectedCrossServerCard; bool _crossServerPlaybackInFlight = false; bool _backInFlight = false; final _scrollController = ScrollController(); final _scrollProgress = ValueNotifier(0.0); late final DetailPageActivity _detailActivity; bool _didEnterDetailActivity = false; String get _detailItemId { if (_overrideItemId != null && _overrideItemId!.isNotEmpty) { return _overrideItemId!; } return widget.itemId; } @override void initState() { super.initState(); _detailActivity = ref.read(detailPageActivityProvider.notifier); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _didEnterDetailActivity = true; _detailActivity.enter(); }); _originServerId = ref.read(activeSessionProvider)?.serverId; _scrollController.addListener(_onScroll); _reload(); } @override void dispose() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_didEnterDetailActivity) _detailActivity.leave(); }); _scrollController.removeListener(_onScroll); _scrollController.dispose(); _scrollProgress.dispose(); super.dispose(); } void _onScroll() { _scrollProgress.value = computeDetailScrollProgress( context, _scrollController.offset, collapseExtent: Platform.isAndroid ? computeAndroidDetailCollapseExtent(context) : null, ); } @override void didUpdateWidget(covariant EmbyDetailView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.itemId != widget.itemId || oldWidget.initialSeriesId != widget.initialSeriesId || oldWidget.initialSeasonId != widget.initialSeasonId) { _overrideItemId = null; _reload(); } } void _reload({bool invalidateCache = false}) { _detailServerId = ref.read(activeSessionProvider)?.serverId; _serverReloadScheduled = false; _resetScrollState(); if (invalidateCache) { final serverId = _detailServerId ?? ''; ref.invalidate( mediaDetailDataProvider((serverId: serverId, itemId: _detailItemId)), ); } setState(() { _data = null; _selectedItem = null; _tmdbSeriesItem = null; _selectedSourceIdx = 0; _selectedSubtitleIdx = null; _selectedAudioIdx = 0; _selectedCrossServerCard = null; _isLaunchingPlayer = false; _detailFuture = _loadDetail(); }); } void _resetScrollState() { _scrollProgress.value = 0.0; if (_scrollController.hasClients) { _scrollController.jumpTo(0); } } Future _loadDetail() async { final capturedServerId = _detailServerId ?? ''; final initialSeriesId = widget.initialSeriesId; if (initialSeriesId != null && initialSeriesId.isNotEmpty && capturedServerId.isNotEmpty) { unawaited( ref.read( mediaDetailDataProvider(( serverId: capturedServerId, itemId: initialSeriesId, )).future, ), ); } final res = await ref.read( mediaDetailDataProvider(( serverId: capturedServerId, itemId: _detailItemId, )).future, ); if (!mounted) return res; AppLogger.debug( _tag, '_loadDetail entry itemId=$_detailItemId ' 'detailServerId=$capturedServerId ' 'activeServerId=${ref.read(activeSessionProvider)?.serverId} ' 'base.Id=${res.base.Id} type=${res.base.Type} ' 'seriesId=${res.base.SeriesId} seasonId=${res.base.SeasonId} ' 'index=${res.base.IndexNumber} ' 'sources=${mediaSourcesOfItem(res.base).length}', ); setState(() { _data = res; _selectedItem = res.base; _isFavorite = res.base.UserData?.IsFavorite ?? false; _isPlayed = res.base.UserData?.Played ?? false; }); unawaited(_loadRememberedVersion(res.base)); final seriesId = seriesIdForEpisode(res.base, widget.itemId); if (seriesId != null) { unawaited(_loadSeriesInfo(seriesId, capturedServerId, res.base)); } else { _firePrewarm(res.base); } _prefetchPlaybackInfoFor(res.base); return res; } Future _loadSeriesInfo( String seriesId, String serverId, EmbyRawItem episode, ) async { try { final seriesRes = await ref.read( mediaDetailDataProvider((serverId: serverId, itemId: seriesId)).future, ); if (!mounted) return; setState(() { _tmdbSeriesItem = seriesRes.base; }); _firePrewarm(seriesRes.base); } catch (_) { _firePrewarm(episode); } } void _firePrewarm(EmbyRawItem refItem) { if (refItem.Type != 'Series' && refItem.Type != 'Episode') return; final pids = providerIdsOfItem(refItem); if (pids.isEmpty) return; final activeServerId = ref.read(activeSessionProvider)?.serverId ?? ''; if (activeServerId.isEmpty) return; unawaited( ref.read( seriesPrewarmProvider(( activeServerId: activeServerId, seriesProviderIdQuery: encodeProviderIdsForKey(pids), )).future, ), ); } void _syncDetailWithActiveServer(String serverId) { final loadedServerId = _detailServerId; if (loadedServerId == null) { _detailServerId = serverId; return; } if (_crossServerPlaybackInFlight) return; if (loadedServerId == serverId || _serverReloadScheduled) return; _serverReloadScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _serverReloadScheduled = false; final currentServerId = ref.read(activeSessionProvider)?.serverId; if (currentServerId == null || _detailServerId == currentServerId) { return; } _reload(invalidateCache: true); }); } void _selectCrossServerSource(CrossServerSourceCard card) { setState(() { _selectedCrossServerCard = card; _selectedSubtitleIdx = null; _selectedAudioIdx = 0; }); _prefetchPlaybackInfoForCrossServer(card); } void _onEpisodeTap(EmbyRawItem episode) { AppLogger.debug( _tag, '_onEpisodeTap id=${episode.Id} name=${episode.Name} ' 'type=${episode.Type} seriesId=${episode.SeriesId} ' 'seasonId=${episode.SeasonId} index=${episode.IndexNumber} ' 'positionTicks=${episode.UserData?.PlaybackPositionTicks} ' 'sources=${mediaSourcesOfItem(episode).length}', ); setState(() { _selectedItem = episode; _isFavorite = episode.UserData?.IsFavorite ?? false; _isPlayed = episode.UserData?.Played ?? false; _selectedSourceIdx = 0; _selectedSubtitleIdx = null; _selectedAudioIdx = 0; _selectedCrossServerCard = null; }); unawaited(_loadSelectedEpisodeDetail(episode.Id)); } Future _loadSelectedEpisodeDetail(String episodeId) async { final serverId = _detailServerId ?? ''; if (serverId.isEmpty || episodeId.isEmpty) return; try { final res = await ref.read( mediaDetailDataProvider((serverId: serverId, itemId: episodeId)).future, ); final listItem = _selectedItem; if (!mounted || listItem?.Id != episodeId) return; final merged = listItem != null ? mergeSelectedEpisodeDetail(detail: res.base, listItem: listItem) : res.base; final mergedSources = mediaSourcesOfItem(merged); AppLogger.debug( _tag, '_loadSelectedEpisodeDetail episodeId=$episodeId ' 'listItem.Id=${listItem?.Id} detail.base.Id=${res.base.Id} ' 'merged.Id=${merged.Id} mergedSources=${mergedSources.length} ' 'firstSourceId=${mergedSources.isNotEmpty ? mergedSources.first.Id : null}', ); setState(() { _selectedItem = merged; _isFavorite = merged.UserData?.IsFavorite ?? false; _isPlayed = merged.UserData?.Played ?? false; }); unawaited(_loadRememberedVersion(merged)); _prefetchPlaybackInfoFor(merged); } catch (_) {} } void _selectSourceIndex(int index) { setState(() { _selectedSourceIdx = index; _selectedSubtitleIdx = null; _selectedAudioIdx = 0; _selectedCrossServerCard = null; _pendingMediaSourceId = null; }); final item = _selectedItem; if (item != null) _prefetchPlaybackInfoFor(item); } void _selectSubtitleIndex(int? index) { setState(() => _selectedSubtitleIdx = index); } void _selectAudioIndex(int index) { setState(() => _selectedAudioIdx = index); } void _applyPendingMediaSource(int index) { if (!mounted) return; setState(() { _selectedSourceIdx = index; _pendingMediaSourceId = null; }); final item = _selectedItem; if (item != null) _prefetchPlaybackInfoFor(item); } void _clearPendingMediaSource() { _pendingMediaSourceId = null; } int _prefetchStartTimeTicksForCrossServer(CrossServerSourceCard card) { final selectedTicks = _selectedItem?.UserData?.PlaybackPositionTicks; if (selectedTicks != null && selectedTicks > 0) return selectedTicks; return 0; } void _prefetchPlaybackInfoFor(EmbyRawItem item) { final serverId = _detailServerId ?? ''; if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return; final key = _playbackInfoPrefetchKeyFor( item, mediaSourceId: _currentLocalMediaSourceIdFor(item), fromStart: false, ); AppLogger.debug( _tag, 'playbackInfo prefetch request local ' '${describePlaybackInfoPrefetchKey(key)}', ); unawaited(ref.read(playbackInfoPrefetchProvider(key).future)); } void _prefetchPlaybackInfoForCrossServer(CrossServerSourceCard card) { if (card.serverId.isEmpty || card.landingItemId.isEmpty || card.item.Type == 'Series') { return; } final key = ( serverId: card.serverId, itemId: card.landingItemId, mediaSourceId: card.mediaSourceId.isEmpty ? null : card.mediaSourceId, startTimeTicks: _prefetchStartTimeTicksForCrossServer(card), ); AppLogger.debug( _tag, 'playbackInfo prefetch request crossServer ' '${describePlaybackInfoPrefetchKey(key)}', ); unawaited(ref.read(playbackInfoPrefetchProvider(key).future)); } PlaybackInfoPrefetchKey _playbackInfoPrefetchKeyFor( EmbyRawItem item, { String? mediaSourceId, required bool fromStart, }) { return ( serverId: _detailServerId ?? '', itemId: item.Id, mediaSourceId: mediaSourceId, startTimeTicks: fromStart ? 0 : (item.UserData?.PlaybackPositionTicks ?? 0), ); } String? _currentLocalMediaSourceIdFor(EmbyRawItem item) { if (_selectedCrossServerCard != null) return null; final priorities = ref.read(versionPriorityProvider).value?.activePriorities ?? []; final sources = sortMediaSources(mediaSourcesOfItem(item), priorities); if (_selectedSourceIdx < 0 || _selectedSourceIdx >= sources.length) { return null; } return sources[_selectedSourceIdx].Id; } Future _loadRememberedVersion(EmbyRawItem item) async { final serverId = _detailServerId ?? ''; if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return; try { final remembered = await ref .read(lastPlayedVersionStoreProvider) .get(serverId: serverId, itemId: item.Id); if (!mounted || remembered == null) return; if (_selectedItem?.Id != item.Id) return; if (_selectedSourceIdx != 0) return; setState(() => _pendingMediaSourceId = remembered); } catch (_) {} } void _refreshSelectedItemState(EmbyRawItem item) { setState(() { _selectedItem = item; _isFavorite = item.UserData?.IsFavorite ?? false; _isPlayed = item.UserData?.Played ?? false; }); } Future _toggleFavorite() async { final item = _selectedItem; if (item == null) return; final next = !_isFavorite; final uc = await ref.read(setFavoriteUseCaseProvider.future); final res = await uc.execute( FavoriteSetReq(itemId: item.Id, isFavorite: next), ); if (!mounted) return; setState(() => _isFavorite = res.isFavorite); final serverId = _detailServerId; if (serverId != null && serverId.isNotEmpty) { ref.invalidate( mediaDetailDataProvider((serverId: serverId, itemId: item.Id)), ); } } Future _togglePlayed() async { final item = _selectedItem; if (item == null) return; final next = !_isPlayed; final uc = await ref.read(setPlayedUseCaseProvider.future); final res = await uc.execute(PlayedSetReq(itemId: item.Id, played: next)); if (!mounted) return; setState(() => _isPlayed = res.played); unawaited(_syncTraktPlayedChange(item, res.played)); final serverId = _detailServerId; if (serverId != null && serverId.isNotEmpty) { ref.invalidate( mediaDetailDataProvider((serverId: serverId, itemId: item.Id)), ); } } Future _syncTraktPlayedChange(EmbyRawItem item, bool played) async { try { final service = await ref.read(traktSyncServiceProvider.future); await service.syncEmbyPlayedChange( item: item, played: played, seriesItem: _tmdbSeriesItem, ); } catch (_) {} } Future _goBack() async { if (_backInFlight || _isRestoringOriginServer) return; _backInFlight = true; final currentServerId = ref.read(activeSessionProvider)?.serverId; if (_originServerId != null && currentServerId != _originServerId) { setState(() => _isRestoringOriginServer = true); try { await ref .read(sessionProvider.notifier) .switchSession(_originServerId!); } catch (_) { if (!mounted) return; setState(() => _isRestoringOriginServer = false); _backInFlight = false; showAppSnackBar(context, '恢复原服务器失败', tone: AppSnackTone.error); return; } } if (!mounted) return; if (_isRestoringOriginServer) { setState(() => _isRestoringOriginServer = false); } if (context.canPop()) { context.pop(); } else { context.go('/'); } } Future _launchPlayer( EmbyRawItem displayItem, List sources, { bool fromStart = false, int? overrideSubtitleIdx, String? backdropUrl, }) async { if (_isLaunchingPlayer) return; setState(() => _isLaunchingPlayer = true); try { final crossServerCard = _selectedCrossServerCard; final selectedSrc = _selectedSourceIdx < sources.length ? sources[_selectedSourceIdx] : null; final subIdx = overrideSubtitleIdx ?? _selectedSubtitleIdx; final audioIdx = _selectedAudioIdx; final streamSelection = resolveStreamSelection( selectedSrc, subIdx, audioIdx, ); final subStreamIndex = streamSelection.preferredSubtitleStreamIndex; final audioStreamIndex = streamSelection.preferredAudioStreamIndex; final crossServerMediaSourceId = crossServerCard?.mediaSourceId.isEmpty ?? true ? null : crossServerCard?.mediaSourceId; final playedItemId = crossServerCard?.landingItemId ?? displayItem.Id; final playServerId = crossServerCard?.serverId ?? _detailServerId; final playMediaSourceId = crossServerCard != null ? crossServerMediaSourceId : selectedSrc?.Id; final playStartTicks = crossServerCard != null ? _prefetchStartTimeTicksForCrossServer(crossServerCard) : (displayItem.UserData?.PlaybackPositionTicks ?? 0); final effectiveFromStart = crossServerCard != null ? (fromStart || playStartTicks <= 0) : fromStart; AppLogger.debug( _tag, '_launchPlayer displayItem.Id=$playedItemId name=${displayItem.Name} ' 'type=${displayItem.Type} selectedSourceIdx=$_selectedSourceIdx ' 'serverId=$playServerId crossServer=${crossServerCard != null} ' 'selectedSrc.Id=${selectedSrc?.Id} sources=${sources.length} ' 'subIdx=$subIdx audioIdx=$audioIdx ' 'subStreamIndex=$subStreamIndex audioStreamIndex=$audioStreamIndex ' 'fromStart=$effectiveFromStart ' 'positionTicks=$playStartTicks', ); Future doLaunch() => PlayerLauncher.launch( context: context, itemId: playedItemId, serverId: playServerId?.isEmpty ?? true ? null : playServerId, mediaSourceId: playMediaSourceId, preferredSubtitleStreamIndex: crossServerCard != null ? -1 : subStreamIndex, preferredAudioStreamIndex: crossServerCard != null ? null : audioStreamIndex, startPositionTicks: effectiveFromStart ? null : playStartTicks, fromStart: effectiveFromStart, backdropUrl: backdropUrl, ); final PlayerExitResult? exitResult; if (crossServerCard != null) { exitResult = await _withCrossServerSession( crossServerCard, action: doLaunch, ); } else { exitResult = await doLaunch(); } final positionTicks = exitResult?.positionTicks; if (mounted && positionTicks != null && positionTicks > 0) { final item = _selectedItem; if (item != null && item.Id == exitResult?.itemId) { final userData = (item.UserData ?? const EmbyRawUserData()).copyWith( PlaybackPositionTicks: positionTicks, ); _refreshSelectedItemState(item.copyWith(UserData: userData)); final exitSourceId = exitResult?.mediaSourceId; if (exitSourceId != null && exitSourceId.isNotEmpty) { final idx = sources.indexWhere((s) => s.Id == exitSourceId); if (idx != -1 && idx != _selectedSourceIdx) { _selectSourceIndex(idx); } } } } unawaited( _refreshSelectedItemProgress( displayItem.Id, optimisticTicks: positionTicks, ), ); } finally { if (mounted && _isLaunchingPlayer) { setState(() => _isLaunchingPlayer = false); } } } Future _withCrossServerSession( CrossServerSourceCard card, { required Future Function() action, }) async { _crossServerPlaybackInFlight = true; final detailServerId = _detailServerId; try { if (ref.read(activeSessionProvider)?.serverId != card.serverId) { await ref.read(sessionProvider.notifier).switchSession(card.serverId); } return await action(); } finally { if (mounted && detailServerId != null && detailServerId.isNotEmpty && ref.read(activeSessionProvider)?.serverId != detailServerId) { try { await ref .read(sessionProvider.notifier) .switchSession(detailServerId); } catch (_) {} } if (mounted) _crossServerPlaybackInFlight = false; } } Future _refreshSelectedItemProgress( String itemId, { int? optimisticTicks, }) async { if (!mounted) return; try { final capturedServerId = _detailServerId ?? ''; final uc = await ref.read(mediaDetailUseCaseProvider.future); if (!mounted) return; final res = await uc.execute(MediaDetailReq(itemId: itemId)); final prev = _selectedItem; if (!mounted || prev?.Id != itemId) return; var merged = prev != null ? mergeSelectedEpisodeDetail(detail: res.base, listItem: prev) : res.base; if (optimisticTicks != null && optimisticTicks > 0 && merged.UserData?.Played != true) { final serverTicks = merged.UserData?.PlaybackPositionTicks ?? 0; if (optimisticTicks > serverTicks) { merged = merged.copyWith( UserData: (merged.UserData ?? const EmbyRawUserData()).copyWith( PlaybackPositionTicks: optimisticTicks, ), ); } } _refreshSelectedItemState(merged); if (capturedServerId.isNotEmpty) { ref.invalidate( mediaDetailDataProvider((serverId: capturedServerId, itemId: itemId)), ); } } catch (_) {} } @override Widget build(BuildContext context) { final session = ref.watch(activeSessionProvider); if (session == null) return const SizedBox.shrink(); _syncDetailWithActiveServer(session.serverId); final imageHeaders = ref.watch(embyImageHeadersProvider).value; final priorities = ref.watch(versionPriorityProvider).value?.activePriorities ?? []; Widget buildBody(AsyncSnapshot snap) { if (Platform.isAndroid) { return EmbyDetailBodyAndroid( snap: snap, detailData: _data, seedItem: widget.seedItem, selectedItem: _selectedItem, tmdbSeriesItem: _tmdbSeriesItem, detailServerId: _detailServerId, initialSeasonId: widget.initialSeasonId, pendingMediaSourceId: _pendingMediaSourceId, scrollController: _scrollController, scrollProgress: _scrollProgress, imageHeaders: imageHeaders, session: session, priorities: priorities, selectedSourceIdx: _selectedSourceIdx, selectedSubtitleIdx: _selectedSubtitleIdx, selectedAudioIdx: _selectedAudioIdx, selectedCrossServerCard: _selectedCrossServerCard, isPlayed: _isPlayed, isFavorite: _isFavorite, isLaunchingPlayer: _isLaunchingPlayer, onBack: () => unawaited(_goBack()), onRetry: () => _reload(invalidateCache: true), onTogglePlayed: () => unawaited(_togglePlayed()), onToggleFavorite: () => unawaited(_toggleFavorite()), onSelectSourceIndex: _selectSourceIndex, onSelectSubtitleIndex: _selectSubtitleIndex, onSelectAudioIndex: _selectAudioIndex, onEpisodeTap: _onEpisodeTap, onSelectCrossServerSource: _selectCrossServerSource, onApplyPendingMediaSource: _applyPendingMediaSource, onClearPendingMediaSource: _clearPendingMediaSource, onLaunchPlayer: _launchPlayer, ); } return buildEmbyDetailBody( context: context, ref: ref, snap: snap, detailData: _data, seedItem: widget.seedItem, selectedItem: _selectedItem, tmdbSeriesItem: _tmdbSeriesItem, detailServerId: _detailServerId, initialSeasonId: widget.initialSeasonId, pendingMediaSourceId: _pendingMediaSourceId, scrollController: _scrollController, scrollProgress: _scrollProgress, imageHeaders: imageHeaders, session: session, priorities: priorities, selectedSourceIdx: _selectedSourceIdx, selectedSubtitleIdx: _selectedSubtitleIdx, selectedAudioIdx: _selectedAudioIdx, selectedCrossServerCard: _selectedCrossServerCard, isPlayed: _isPlayed, isFavorite: _isFavorite, isLaunchingPlayer: _isLaunchingPlayer, onBack: () => unawaited(_goBack()), onRetry: () => _reload(invalidateCache: true), onTogglePlayed: () => unawaited(_togglePlayed()), onToggleFavorite: () => unawaited(_toggleFavorite()), onSelectSourceIndex: _selectSourceIndex, onSelectSubtitleIndex: _selectSubtitleIndex, onSelectAudioIndex: _selectAudioIndex, onEpisodeTap: _onEpisodeTap, onSelectCrossServerSource: _selectCrossServerSource, onApplyPendingMediaSource: _applyPendingMediaSource, onClearPendingMediaSource: _clearPendingMediaSource, onLaunchPlayer: _launchPlayer, ); } final content = FutureBuilder( future: _detailFuture, builder: (_, snap) => buildBody(snap), ); if (Platform.isAndroid) { return Theme(data: AppTheme.dark(), child: content); } return Theme( data: AppTheme.dark(), child: Scaffold(backgroundColor: Colors.black, body: content), ); } }