Files

471 lines
16 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
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/detail_palette_provider.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/mappers/media_image_url.dart';
import '../../shared/mappers/tmdb_image_selector.dart';
import '../../shared/utils/format_utils.dart';
import '../../shared/utils/user_error_formatter.dart';
import '../../shared/widgets/app_error_state.dart';
import '../../shared/widgets/app_loading_ring.dart';
import 'android/android_detail_layout.dart';
import 'android/android_detail_colors.dart';
import 'android/android_external_links_section.dart';
import 'android/android_hero_actions.dart';
import 'android/android_hero_info.dart';
import 'android/android_overview_section.dart';
import 'android/android_section_divider.dart';
import 'android/android_stream_selector_row.dart';
import 'emby_detail_helpers.dart';
import 'emby_detail_sections_builder.dart';
import 'model/stream_selection.dart';
class EmbyDetailBodyAndroid extends ConsumerWidget {
const EmbyDetailBodyAndroid({
super.key,
required this.snap,
required this.detailData,
required this.seedItem,
required this.selectedItem,
required this.tmdbSeriesItem,
required this.detailServerId,
required this.initialSeasonId,
required this.pendingMediaSourceId,
required this.scrollController,
required this.scrollProgress,
required this.imageHeaders,
required this.session,
required this.priorities,
required this.selectedSourceIdx,
required this.selectedSubtitleIdx,
required this.selectedAudioIdx,
required this.selectedCrossServerCard,
required this.isPlayed,
required this.isFavorite,
required this.isLaunchingPlayer,
required this.onBack,
required this.onRetry,
required this.onTogglePlayed,
required this.onToggleFavorite,
required this.onSelectSourceIndex,
required this.onSelectSubtitleIndex,
required this.onSelectAudioIndex,
required this.onEpisodeTap,
required this.onSelectCrossServerSource,
required this.onApplyPendingMediaSource,
required this.onClearPendingMediaSource,
required this.onLaunchPlayer,
});
final AsyncSnapshot<MediaDetailRes> snap;
final MediaDetailRes? detailData;
final EmbyRawItem? seedItem;
final EmbyRawItem? selectedItem;
final EmbyRawItem? tmdbSeriesItem;
final String? detailServerId;
final String? initialSeasonId;
final String? pendingMediaSourceId;
final ScrollController scrollController;
final ValueNotifier<double> scrollProgress;
final Map<String, String>? imageHeaders;
final AuthedSession session;
final List<VersionPriority> priorities;
final int selectedSourceIdx;
final int? selectedSubtitleIdx;
final int selectedAudioIdx;
final CrossServerSourceCard? selectedCrossServerCard;
final bool isPlayed;
final bool isFavorite;
final bool isLaunchingPlayer;
final VoidCallback onBack;
final VoidCallback onRetry;
final VoidCallback onTogglePlayed;
final VoidCallback onToggleFavorite;
final ValueChanged<int> onSelectSourceIndex;
final ValueChanged<int?> onSelectSubtitleIndex;
final ValueChanged<int> onSelectAudioIndex;
final ValueChanged<EmbyRawItem> onEpisodeTap;
final ValueChanged<CrossServerSourceCard> onSelectCrossServerSource;
final ValueChanged<int> onApplyPendingMediaSource;
final VoidCallback onClearPendingMediaSource;
final Future<void> Function(
EmbyRawItem displayItem,
List<EmbyRawMediaSource> sources, {
bool fromStart,
int? overrideSubtitleIdx,
String? backdropUrl,
})
onLaunchPlayer;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (snap.connectionState != ConnectionState.done &&
detailData == null &&
seedItem == null) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
),
body: const Center(child: AppLoadingRing()),
);
}
final data = detailData ?? snap.data;
if (snap.hasError && data == null) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
),
body: AppErrorState(
message: formatUserError(snap.error, fallback: '详情加载失败'),
onRetry: onRetry,
),
);
}
final baseItem = data?.base ?? seedItem;
if (baseItem == null) return const Center(child: Text('未找到数据'));
final isSeedOnly = data == null;
final displayItem = selectedItem ?? baseItem;
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 posterUrl = tmdbPosterUrl ?? embyPosterUrl;
final backgroundFallbackUrls = TmdbImageSelector.buildFallbackUrls(
primaryUrl: backdropUrl,
candidates: [tmdbPosterUrl, embyBackdropUrl, embyPosterUrl],
);
final paletteImageUrl = posterUrl ?? backdropUrl;
final paletteImageHeaders = paletteImageUrl.startsWith(session.serverUrl)
? imageHeaders
: null;
final extractedBackgroundColor = ref
.watch(
detailPaletteProvider(
DetailPaletteRequest(
url: paletteImageUrl,
headers: paletteImageHeaders,
),
),
)
.value;
final detailBackgroundColor =
extractedBackgroundColor ?? AndroidDetailColors.background;
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 int? effectiveSubtitleIdx = selectedSubtitleIdx;
final streamSelection = resolveStreamSelection(
selectedSrc,
selectedSubtitleIdx,
selectedAudioIdx,
);
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 metaParts = buildDetailMetaParts(displayItem, source: selectedSrc);
final genres = extractGenres(baseItem);
final progressPercent = detailProgressPercent(displayItem);
final hasProgress = progressPercent != null && progressPercent > 0;
final pos = displayItem.UserData?.PlaybackPositionTicks;
final positionClock = pos != null && pos > 0
? formatTicksAsClock(pos)
: null;
final studiosRaw = baseItem.extra['Studios'];
final studioNames = studiosRaw is List
? studiosRaw
.whereType<Map>()
.map((s) => s['Name']?.toString() ?? '')
.where((n) => n.isNotEmpty)
.toList()
: <String>[];
final peopleRaw = baseItem.extra['People'];
final directors = peopleRaw is List
? peopleRaw
.whereType<Map>()
.where((p) => p['Type'] == 'Director')
.map((p) => p['Name']?.toString() ?? '')
.where((n) => n.isNotEmpty)
.toList()
: <String>[];
final mediaInfoTags = _buildMediaInfoTags(selectedSrc);
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,
activeSource: selectedSrc,
studioNames: studioNames,
directors: directors,
sectionLeading: const AndroidSectionDivider(),
);
if (isSeedOnly) sections.removeRange(1, sections.length);
final providerIds = providerIdsOfItem(baseItem);
final imdbId =
providerIds['Imdb'] ?? providerIds['IMDB'] ?? providerIds['imdb'];
final tmdbIdRaw =
providerIds['Tmdb'] ?? providerIds['TMDB'] ?? providerIds['tmdb'];
final tmdbIdInt = tmdbIdRaw != null ? int.tryParse(tmdbIdRaw) : null;
final traktRaw =
providerIds['TraktTv'] ?? providerIds['Trakt'] ?? providerIds['trakt'];
final traktIdInt = traktRaw != null ? int.tryParse(traktRaw) : null;
final sectionWidgets = <Widget>[
for (final section in sections) section.child,
];
sectionWidgets.add(
ExternalLinksSection(
imdbId: imdbId,
tmdbId: tmdbIdInt,
tmdbMediaType: tmdbMediaRef?.mediaType,
traktId: traktIdInt,
leading: const AndroidSectionDivider(),
),
);
return AndroidDetailLayout(
scrollController: scrollController,
scrollProgress: scrollProgress,
backdropUrl: backdropUrl,
fallbackUrls: backgroundFallbackUrls,
embyBaseUrl: session.serverUrl,
imageHeaders: imageHeaders,
backgroundColor: detailBackgroundColor,
navigationColor: createDetailNavigationColor(detailBackgroundColor),
title: detailNavTitle(displayItem, tmdbSeriesItem),
onBack: onBack,
heroInfo: AndroidHeroInfo(
posterUrl: posterUrl,
embyBaseUrl: session.serverUrl,
imageHeaders: imageHeaders,
title: displayItem.Name,
rating: displayItem.CommunityRating,
metaParts: metaParts,
genres: genres,
mediaInfoTags: mediaInfoTags,
),
heroActions: isSeedOnly
? null
: Column(
mainAxisSize: MainAxisSize.min,
children: [
AndroidHeroActions(
playLabel: hasProgress ? '继续播放' : '播放',
playTrailingLabel: positionClock,
progressPercent: progressPercent,
isLoading: isLaunchingPlayer,
canPlay: canPlay,
onPlay: canPlay
? () => unawaited(
onLaunchPlayer(
displayItem,
sortedSources,
overrideSubtitleIdx: effectiveSubtitleIdx,
backdropUrl: backdropUrl,
),
)
: null,
showReplay: hasProgress && canPlay,
onReplay: canPlay
? () => unawaited(
onLaunchPlayer(
displayItem,
sortedSources,
fromStart: true,
overrideSubtitleIdx: effectiveSubtitleIdx,
backdropUrl: backdropUrl,
),
)
: null,
isPlayed: isPlayed,
onTogglePlayed: onTogglePlayed,
isFavorite: isFavorite,
onToggleFavorite: onToggleFavorite,
),
if (canPlay)
AndroidStreamSelectorRow(
sources: sortedSources,
selectedSourceIdx: selectedSourceIdx,
onSelectSourceIndex: onSelectSourceIndex,
subtitles: streamSelection.subtitles,
audios: streamSelection.audios,
selectedSubtitleIdx: selectedSubtitleIdx,
selectedAudioIdx: selectedAudioIdx,
onSelectSubtitleIndex: onSelectSubtitleIndex,
onSelectAudioIndex: onSelectAudioIndex,
),
],
),
overview: overview != null && overview.isNotEmpty
? AndroidOverviewSection(overview: overview)
: null,
belowOverview: null,
sections: sectionWidgets,
);
}
static List<String> _buildMediaInfoTags(EmbyRawMediaSource? source) {
if (source == null) return const [];
final tags = <String>[];
final streams = source.MediaStreams ?? [];
final video = streams.where((s) => s.Type == 'Video').firstOrNull;
if (video != null) {
final codec = video.Codec?.toUpperCase();
if (codec != null && codec.isNotEmpty) tags.add(codec);
final range = video.extra['VideoRange']?.toString();
if (range != null && range.isNotEmpty && range != 'SDR') {
tags.add(range);
}
}
final audio = streams.where((s) => s.Type == 'Audio').firstOrNull;
if (audio != null) {
final codec = audio.Codec?.toUpperCase();
if (codec != null && codec.isNotEmpty) tags.add(codec);
}
if (source.Size != null && source.Size! > 0) {
tags.add(formatFileSize(source.Size!));
}
return tags;
}
}