471 lines
16 KiB
Dart
471 lines
16 KiB
Dart
|
|
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;
|
||
|
|
}
|
||
|
|
}
|