Initial commit
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user