372 lines
11 KiB
Dart
372 lines
11 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/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<MediaDetailRes> 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<double> scrollProgress,
|
||
|
|
required Map<String, String>? imageHeaders,
|
||
|
|
required AuthedSession session,
|
||
|
|
required List<VersionPriority> 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<int> onSelectSourceIndex,
|
||
|
|
required ValueChanged<int?> onSelectSubtitleIndex,
|
||
|
|
required ValueChanged<int> onSelectAudioIndex,
|
||
|
|
required ValueChanged<EmbyRawItem> onEpisodeTap,
|
||
|
|
required ValueChanged<CrossServerSourceCard> onSelectCrossServerSource,
|
||
|
|
required ValueChanged<int> onApplyPendingMediaSource,
|
||
|
|
required VoidCallback onClearPendingMediaSource,
|
||
|
|
required Future<void> Function(
|
||
|
|
EmbyRawItem displayItem,
|
||
|
|
List<EmbyRawMediaSource> 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,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|