import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/contracts/auth.dart'; import '../../core/contracts/library.dart'; import '../../providers/di_providers.dart'; import '../../providers/session_provider.dart'; import '../../providers/tmdb_settings_provider.dart'; import '../../providers/version_priority_provider.dart'; import '../../shared/constants/breakpoints.dart'; import '../../shared/mappers/media_image_url.dart'; import '../../shared/mappers/tmdb_image_selector.dart'; import '../../shared/utils/user_error_formatter.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/app_loading_ring.dart'; import 'emby_detail_helpers.dart'; import 'emby_detail_hero_builder.dart'; import 'emby_detail_sections_builder.dart'; import 'model/detail_hero_model.dart'; import 'model/detail_image_model.dart'; import 'model/detail_view_state.dart'; import 'model/stream_selection.dart'; import 'widgets/detail_immersive_scaffold.dart'; import 'widgets/detail_nav_bar.dart'; import 'widgets/detail_view_renderer.dart'; Widget buildEmbyDetailBody({ required BuildContext context, required WidgetRef ref, required AsyncSnapshot snap, required MediaDetailRes? detailData, required EmbyRawItem? seedItem, required EmbyRawItem? selectedItem, required EmbyRawItem? tmdbSeriesItem, required String? detailServerId, required String? initialSeasonId, required String? pendingMediaSourceId, required ScrollController scrollController, required ValueNotifier scrollProgress, required Map? imageHeaders, required AuthedSession session, required List priorities, required int selectedSourceIdx, required int? selectedSubtitleIdx, required int selectedAudioIdx, required CrossServerSourceCard? selectedCrossServerCard, required bool isPlayed, required bool isFavorite, required bool isLaunchingPlayer, required VoidCallback onBack, required VoidCallback onRetry, required VoidCallback onTogglePlayed, required VoidCallback onToggleFavorite, required ValueChanged onSelectSourceIndex, required ValueChanged onSelectSubtitleIndex, required ValueChanged onSelectAudioIndex, required ValueChanged onEpisodeTap, required ValueChanged onSelectCrossServerSource, required ValueChanged onApplyPendingMediaSource, required VoidCallback onClearPendingMediaSource, required Future Function( EmbyRawItem displayItem, List sources, { bool fromStart, int? overrideSubtitleIdx, String? backdropUrl, }) onLaunchPlayer, }) { if (snap.connectionState != ConnectionState.done && detailData == null && seedItem == null) { return Stack( children: [ const Center(child: AppLoadingRing()), Positioned( top: 0, left: 0, right: 0, child: DetailNavBar( scrollProgress: scrollProgress, title: '', onBack: onBack, ), ), ], ); } final data = detailData ?? snap.data; if (snap.hasError && data == null) { return Stack( children: [ AppErrorState( message: formatUserError(snap.error, fallback: '详情加载失败'), onRetry: onRetry, ), Positioned( top: 0, left: 0, right: 0, child: DetailNavBar( scrollProgress: scrollProgress, title: '', onBack: onBack, ), ), ], ); } final baseItem = data?.base ?? seedItem; if (baseItem == null) return const Center(child: Text('未找到数据')); final isSeedOnly = data == null; final displayItem = selectedItem ?? baseItem; final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail; final tmdbMediaRef = tmdbMediaRefFor(baseItem, tmdbSeriesItem); final tmdbSettings = tmdbMediaRef != null ? ref.watch(tmdbSettingsProvider).value : null; final tmdbImageSet = tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false) ? ref.watch( tmdbImagesProvider(( tmdbId: tmdbMediaRef.id, mediaType: tmdbMediaRef.mediaType, )), ) : null; final tmdbImageBaseUrl = tmdbSettings?.effectiveImageBaseUrl; final tmdbDetail = tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false) ? ref.watch( tmdbMediaDetailProvider(( tmdbId: tmdbMediaRef.id, mediaType: tmdbMediaRef.mediaType, )), ) : null; final tmdbBackdropUrl = TmdbImageSelector.backdropUrl( tmdbImageSet, tmdbImageBaseUrl, ); final tmdbPosterUrl = TmdbImageSelector.posterUrl( tmdbImageSet, tmdbImageBaseUrl, ); final embyBackdropUrl = EmbyImageUrl.detailBanner( baseUrl: session.serverUrl, token: session.token, item: baseItem, ); final String? embyPosterUrl; if (baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false)) { embyPosterUrl = EmbyImageUrl.primaryById( baseUrl: session.serverUrl, token: session.token, itemId: baseItem.SeriesId!, maxHeight: 720, ); } else { embyPosterUrl = EmbyImageUrl.primary( baseUrl: session.serverUrl, token: session.token, item: baseItem, maxHeight: 720, ); } final backdropUrl = tmdbBackdropUrl ?? embyBackdropUrl; final backgroundFallbackUrls = TmdbImageSelector.buildFallbackUrls( primaryUrl: backdropUrl, candidates: [tmdbPosterUrl, embyBackdropUrl, embyPosterUrl], ); final imageModel = DetailImageModel( backdropUrl: backdropUrl, fallbackUrls: backgroundFallbackUrls, embyBaseUrl: session.serverUrl, imageHeaders: imageHeaders, ); final isEpisode = baseItem.Type == 'Episode'; final logoItemId = isEpisode && (baseItem.SeriesId?.isNotEmpty ?? false) ? baseItem.SeriesId! : baseItem.Id; final logoTag = isEpisode ? null : baseItem.ImageTags?['Logo']; final logoUrl = !isEpisode && logoTag == null ? null : EmbyImageUrl.logo( baseUrl: session.serverUrl, token: session.token, itemId: logoItemId, tag: logoTag, ); final canPlay = displayItem.Type != 'Series'; final rawSources = mediaSourcesOfItem(displayItem); final sortedSources = sortMediaSources(rawSources, priorities); final pendingId = pendingMediaSourceId; if (pendingId != null && sortedSources.isNotEmpty) { final idx = resolveDefaultSourceIndex( sortedSources, pendingId, fallback: -1, ); if (idx >= 0 && idx != selectedSourceIdx) { WidgetsBinding.instance.addPostFrameCallback((_) { onApplyPendingMediaSource(idx); }); } else { onClearPendingMediaSource(); } } final selectedSrc = resolveActiveMediaSource( selectedCrossServerCard, sortedSources, selectedSourceIdx, ); final streamSelection = resolveStreamSelection( selectedSrc, selectedSubtitleIdx, selectedAudioIdx, ); final subtitles = streamSelection.subtitles; final audios = streamSelection.audios; final int? effectiveSubtitleIdx = selectedSubtitleIdx; final String? seriesId; if (baseItem.Type == 'Series') { seriesId = baseItem.Id; } else if (baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false)) { seriesId = baseItem.SeriesId; } else { seriesId = null; } final similarItemId = baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false) ? baseItem.SeriesId! : baseItem.Id; final frozenOverview = baseItem.Overview; final embyOverview = (frozenOverview ?? displayItem.Overview)?.trim(); final overviewFallback = (tmdbDetail?.overview ?? tmdbSeriesItem?.Overview ?? (baseItem.Type == 'Series' ? baseItem.Overview : null)) ?.trim(); final overview = embyOverview != null && embyOverview.isNotEmpty ? embyOverview : overviewFallback; final heroActions = isSeedOnly ? null : buildEmbyHeroActions( displayItem: displayItem, canPlay: canPlay, sortedSources: sortedSources, subtitles: subtitles, audios: audios, effectiveSubtitleIdx: effectiveSubtitleIdx, compact: compact, selectedSourceIdx: selectedSourceIdx, selectedSubtitleIdx: selectedSubtitleIdx, selectedAudioIdx: selectedAudioIdx, isPlayed: isPlayed, isFavorite: isFavorite, isLaunchingPlayer: isLaunchingPlayer, onSelectSource: onSelectSourceIndex, onSelectSubtitle: onSelectSubtitleIndex, onSelectAudio: onSelectAudioIndex, onTogglePlayed: onTogglePlayed, onToggleFavorite: onToggleFavorite, onReplay: () => unawaited( onLaunchPlayer( displayItem, sortedSources, fromStart: true, overrideSubtitleIdx: effectiveSubtitleIdx, backdropUrl: backdropUrl, ), ), onPlay: () => unawaited( onLaunchPlayer( displayItem, sortedSources, overrideSubtitleIdx: effectiveSubtitleIdx, backdropUrl: backdropUrl, ), ), ); final sections = buildEmbyDetailSections( ref: ref, baseItem: baseItem, displayItem: displayItem, seriesId: seriesId, similarItemId: similarItemId, tmdbMediaRef: tmdbMediaRef, detailServerId: detailServerId, initialSeasonId: initialSeasonId, tmdbSeriesItem: tmdbSeriesItem, sortedSources: sortedSources, selectedSourceIdx: selectedSourceIdx, selectedCrossServerCard: selectedCrossServerCard, currentServerName: ref.read(activeSessionProvider)?.serverName, onEpisodeTap: onEpisodeTap, onSelectSourceIndex: onSelectSourceIndex, onSelectCrossServerSource: onSelectCrossServerSource, ); if (isSeedOnly) { sections.removeRange(1, sections.length); } final viewState = DetailViewState( image: imageModel, navTitle: detailNavTitle(displayItem, tmdbSeriesItem), hero: DetailHeroModel( title: displayItem.Name, logoUrl: logoUrl, posterUrl: embyPosterUrl, imageHeaders: imageHeaders, rating: displayItem.CommunityRating, metaParts: buildDetailMetaParts(displayItem, source: selectedSrc), genres: extractGenres(baseItem), tagline: tmdbDetail?.tagline, overview: overview, actions: heroActions, ), sections: sections, showLoadingBelowHero: isSeedOnly, ); return Stack( children: [ DetailImmersiveScaffold( scrollController: scrollController, scrollProgress: scrollProgress, backdropUrl: viewState.image.backdropUrl, fallbackUrls: viewState.image.fallbackUrls, embyBaseUrl: viewState.image.embyBaseUrl, imageHeaders: viewState.image.imageHeaders, compact: compact, horizontalPadding: compact ? 16 : 32, child: DetailViewRenderer(state: viewState), ), Positioned( top: 0, left: 0, right: 0, child: DetailNavBar( scrollProgress: scrollProgress, title: viewState.navTitle, onBack: onBack, ), ), ], ); }