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