Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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,
),
),
],
);
}