Files
sm-emby-share/lib/features/detail/tmdb_detail_view.dart
T

905 lines
29 KiB
Dart
Raw Normal View History

2026-07-14 11:11:36 +08:00
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart';
import 'package:go_router/go_router.dart';
import '../../core/contracts/library.dart';
import '../../core/contracts/tmdb.dart';
import '../../providers/di_providers.dart';
import '../../providers/last_played_version_store.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/tmdb_image_url.dart';
import '../../shared/theme/app_theme.dart';
import '../../shared/utils/cross_server_search_key.dart';
import '../../shared/utils/format_utils.dart';
import '../../shared/utils/media_nav.dart';
import '../player/player_launcher.dart';
import '../../shared/utils/tmdb_cta_model.dart';
import '../../shared/utils/tmdb_primary_hit.dart';
import '../../shared/widgets/app_error_state.dart';
import '../../shared/widgets/app_skeleton.dart';
import '../../shared/widgets/empty_state.dart';
import 'widgets/additional_parts_section.dart';
import 'widgets/episode_list_section.dart';
import 'adapter/tmdb_detail_view_builder.dart';
import 'android/android_detail_geometry.dart';
import 'detail_entry.dart';
import 'model/detail_section_model.dart';
import 'model/episode_auto_select_args.dart';
import 'model/stream_selection.dart';
import 'tmdb_detail_body_android.dart';
import 'widgets/detail_immersive_scaffold.dart';
import 'widgets/detail_nav_bar.dart';
import 'widgets/detail_scroll_progress.dart';
import 'widgets/detail_view_renderer.dart';
import 'widgets/hero_action_buttons.dart';
import 'widgets/source_selection_section.dart';
import 'widgets/stream_selector_actions.dart';
const Color _kBg = Color(0xFF08090c);
const double _kHPad = 32.0;
class TmdbDetailView extends ConsumerStatefulWidget {
final TmdbDetailEntry entry;
const TmdbDetailView({super.key, required this.entry});
@override
ConsumerState<TmdbDetailView> createState() => _TmdbDetailViewState();
}
class _TmdbDetailViewState extends ConsumerState<TmdbDetailView> {
static const _sectionDivider = FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.only(top: 24)),
),
);
final _scroll = ScrollController();
final _scrollProgress = ValueNotifier<double>(0);
bool? _favoriteOverride;
bool? _playedOverride;
String? _overrideHitKey;
int _selectedSourceIdx = 0;
CrossServerSourceCard? _selectedCrossServerCard;
EmbyRawItem? _selectedTvEpisode;
int? _selectedTvEpisodeSeasonNumber;
String? _selectedTvEpisodeHitKey;
int? _selectedSubtitleIdx;
int _selectedAudioIdx = 0;
String? _pendingMediaSourceId;
String? _versionMemoryQueriedKey;
String? _playbackOriginServerId;
int? _positionTicksOverride;
String? _positionOverrideItemKey;
int? _positionOverrideFor(String? serverId, String? itemId) {
if (_positionTicksOverride == null) return null;
if (_positionOverrideItemKey != '$serverId|$itemId') return null;
return _positionTicksOverride;
}
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
}
void _onScroll() {
_scrollProgress.value = computeDetailScrollProgress(
context,
_scroll.offset,
collapseExtent: Platform.isAndroid
? computeAndroidDetailCollapseExtent(context)
: null,
);
}
@override
void didUpdateWidget(covariant TmdbDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
final old = oldWidget.entry;
final next = widget.entry;
if (old.mediaType != next.mediaType || old.tmdbId != next.tmdbId) {
setState(() {
_favoriteOverride = null;
_playedOverride = null;
_overrideHitKey = null;
_selectedSourceIdx = 0;
_selectedCrossServerCard = null;
_selectedTvEpisode = null;
_selectedTvEpisodeSeasonNumber = null;
_selectedTvEpisodeHitKey = null;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_pendingMediaSourceId = null;
_versionMemoryQueriedKey = null;
_positionTicksOverride = null;
_positionOverrideItemKey = null;
});
_scrollProgress.value = 0;
if (_scroll.hasClients) _scroll.jumpTo(0);
}
}
@override
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
_scrollProgress.dispose();
super.dispose();
}
void _selectLocalSource(int index) {
setState(() {
_selectedSourceIdx = index;
_selectedCrossServerCard = null;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_pendingMediaSourceId = null;
});
}
void _selectCrossServerSource(CrossServerSourceCard card) {
setState(() {
_selectedCrossServerCard = card;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_pendingMediaSourceId = null;
});
}
void _selectTvEpisode(EmbyRawItem episode, int? seasonNumber, String hitKey) {
setState(() {
_selectedTvEpisode = episode;
_selectedTvEpisodeSeasonNumber = seasonNumber;
_selectedTvEpisodeHitKey = hitKey;
_selectedSourceIdx = 0;
_selectedCrossServerCard = null;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
});
}
Future<void> _playRestoringActiveServer({
required String serverId,
required String itemId,
String? mediaSourceId,
int preferredSubtitleStreamIndex = -1,
int? preferredAudioStreamIndex,
bool fromStart = false,
int? startPositionTicks,
String? backdropUrl,
}) async {
final originServerId = ref.read(activeSessionProvider)?.serverId;
setState(() => _playbackOriginServerId = originServerId);
PlayerExitResult? exitResult;
try {
exitResult = await playFromTmdbOnServer(
context,
ref,
serverId: serverId,
itemId: itemId,
mediaSourceId: mediaSourceId,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
startPositionTicks: startPositionTicks,
fromStart: fromStart,
backdropUrl: backdropUrl,
);
} finally {
if (mounted) {
if (originServerId != null &&
originServerId.isNotEmpty &&
ref.read(activeSessionProvider)?.serverId != originServerId) {
try {
await ref
.read(sessionProvider.notifier)
.switchSession(originServerId);
} catch (_) {}
}
if (mounted) setState(() => _playbackOriginServerId = null);
}
}
final positionTicks = exitResult?.positionTicks;
if (!mounted || positionTicks == null || positionTicks <= 0) return;
final exitServerId = exitResult!.serverId;
final exitItemId = exitResult.itemId;
setState(() {
_positionTicksOverride = positionTicks;
_positionOverrideItemKey = '$exitServerId|$exitItemId';
final ep = _selectedTvEpisode;
if (ep != null && ep.Id == exitItemId) {
_selectedTvEpisode = ep.copyWith(
UserData: (ep.UserData ?? const EmbyRawUserData()).copyWith(
PlaybackPositionTicks: positionTicks,
),
);
}
});
}
void _selectSubtitleIndex(int? index) {
setState(() => _selectedSubtitleIdx = index);
}
void _selectAudioIndex(int index) {
setState(() => _selectedAudioIdx = index);
}
static String _hitKeyOf(TmdbLibraryHit hit) =>
'${hit.serverId}|${hit.itemId}';
Future<void> _toggleTmdbFavorite(TmdbLibraryHit hit, bool current) async {
final next = !current;
setState(() {
_favoriteOverride = next;
_overrideHitKey = _hitKeyOf(hit);
});
try {
final uc = await ref.read(setFavoriteUseCaseProvider.future);
final res = await uc.executeForServer(
serverId: hit.serverId,
input: FavoriteSetReq(itemId: hit.itemId, isFavorite: next),
);
if (!mounted) return;
setState(() => _favoriteOverride = res.isFavorite);
ref.invalidate(
tmdbServerScopedDetailProvider((
serverId: hit.serverId,
itemId: hit.itemId,
)),
);
} catch (_) {
if (!mounted) return;
setState(() => _favoriteOverride = current);
}
}
Future<void> _toggleTmdbPlayed(TmdbLibraryHit hit, bool current) async {
final next = !current;
setState(() {
_playedOverride = next;
_overrideHitKey = _hitKeyOf(hit);
});
try {
final uc = await ref.read(setPlayedUseCaseProvider.future);
final res = await uc.executeForServer(
serverId: hit.serverId,
input: PlayedSetReq(itemId: hit.itemId, played: next),
);
if (!mounted) return;
setState(() => _playedOverride = res.played);
ref.invalidate(
tmdbServerScopedDetailProvider((
serverId: hit.serverId,
itemId: hit.itemId,
)),
);
} catch (_) {
if (!mounted) return;
setState(() => _playedOverride = current);
}
}
@override
Widget build(BuildContext context) {
return _buildTmdbDetail(widget.entry);
}
Widget _buildTmdbDetail(TmdbDetailEntry entry) {
final key = (tmdbId: entry.tmdbId, mediaType: entry.mediaType);
final settings = ref.watch(tmdbSettingsProvider).value;
final imageBaseUrl = settings?.effectiveImageBaseUrl ?? '';
final language = settings?.language ?? 'zh-CN';
final asyncDetail = ref.watch(tmdbEnrichedDetailProvider(key));
if (Platform.isAndroid) {
return Theme(
data: AppTheme.dark(),
child: asyncDetail.when(
loading: () => _buildTmdbContent(
entry: entry,
enriched: null,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: true,
),
error: (_, _) => Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: AppErrorState(
title: '加载失败',
message: '无法获取该内容的 TMDB 详情',
onRetry: () => ref.invalidate(tmdbEnrichedDetailProvider(key)),
),
),
data: (enriched) {
if (enriched == null) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: const Center(
child: EmptyState(
icon: Icons.movie_filter_outlined,
title: '暂无详情',
message: '请检查 TMDB 配置后重试',
),
),
);
}
return _buildTmdbContent(
entry: entry,
enriched: enriched,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: false,
);
},
),
);
}
final detail = asyncDetail.value?.detail;
final title = (detail?.title.isNotEmpty ?? false)
? detail!.title
: (entry.seed?.title ?? '');
return Theme(
data: AppTheme.dark(),
child: Scaffold(
backgroundColor: _kBg,
body: Stack(
children: [
Positioned.fill(
child: asyncDetail.when(
loading: () => _buildTmdbContent(
entry: entry,
enriched: null,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: true,
),
error: (_, _) => AppErrorState(
title: '加载失败',
message: '无法获取该内容的 TMDB 详情',
onRetry: () =>
ref.invalidate(tmdbEnrichedDetailProvider(key)),
),
data: (enriched) {
if (enriched == null) {
return const Center(
child: EmptyState(
icon: Icons.movie_filter_outlined,
title: '暂无详情',
message: '请检查 TMDB 配置后重试',
),
);
}
return _buildTmdbContent(
entry: entry,
enriched: enriched,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: false,
);
},
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: DetailNavBar(
scrollProgress: _scrollProgress,
title: title,
onBack: () => context.pop(),
),
),
],
),
),
);
}
Widget _buildTmdbContent({
required TmdbDetailEntry entry,
required TmdbEnrichedDetail? enriched,
required String imageBaseUrl,
required String language,
required bool showSpinner,
}) {
final mediaType = entry.mediaType;
final tmdbId = entry.tmdbId;
final detail = enriched?.detail;
final title = (detail?.title.isNotEmpty ?? false)
? detail!.title
: (entry.seed?.title ?? '');
final imdbIdForLookup = mediaType == 'movie' ? enriched?.imdbId : null;
final detailBackdropUrl = TmdbImageUrl.backdrop(
imageBaseUrl,
detail?.backdropPath ?? entry.seed?.backdropPath,
);
final availabilityTmdbAsync = ref.watch(
tmdbLibraryAvailabilityProvider((
mediaType: mediaType,
tmdbId: tmdbId,
imdbId: '',
)),
);
final tmdbResultEmpty = availabilityTmdbAsync.value?.isEmpty == true;
final needsImdbFallback = imdbIdForLookup != null && tmdbResultEmpty;
final availabilityImdbAsync = needsImdbFallback
? ref.watch(
tmdbLibraryAvailabilityProvider((
mediaType: mediaType,
tmdbId: tmdbId,
imdbId: imdbIdForLookup,
)),
)
: null;
final availabilityAsync = availabilityImdbAsync ?? availabilityTmdbAsync;
final hits = availabilityAsync.value ?? const <TmdbLibraryHit>[];
final availabilityResolving =
(availabilityTmdbAsync.isLoading &&
availabilityTmdbAsync.value == null) ||
(availabilityImdbAsync?.isLoading == true &&
availabilityImdbAsync?.value == null) ||
(mediaType == 'movie' && enriched == null && tmdbResultEmpty);
final liveActiveServerId = ref.watch(activeSessionProvider)?.serverId;
final activeServerId = _playbackOriginServerId ?? liveActiveServerId;
final rawPrimaryHit = selectPrimaryTmdbHit(
hits,
activeServerId: activeServerId,
);
final tvRecentHit = (rawPrimaryHit != null && mediaType == 'tv')
? ref
.watch(
tmdbTvRecentHitProvider((
mediaType: mediaType,
tmdbId: tmdbId,
imdbId: imdbIdForLookup ?? '',
)),
)
.value
: null;
final primaryHit = tvRecentHit ?? rawPrimaryHit;
AsyncValue<MediaDetailRes>? scopedDetailAsync;
EmbyRawItem? serverScopedItem;
if (primaryHit != null) {
final scopedKey = (
serverId: primaryHit.serverId,
itemId: primaryHit.itemId,
);
scopedDetailAsync = ref.watch(tmdbServerScopedDetailProvider(scopedKey));
serverScopedItem = scopedDetailAsync?.value?.base;
}
if (_overrideHitKey != null &&
(primaryHit == null || _overrideHitKey != _hitKeyOf(primaryHit))) {
_favoriteOverride = null;
_playedOverride = null;
_overrideHitKey = null;
}
final isFavorite =
_favoriteOverride ?? (serverScopedItem?.UserData?.IsFavorite ?? false);
final isPlayed =
_playedOverride ?? (serverScopedItem?.UserData?.Played ?? false);
final isMovieHit = primaryHit != null && mediaType == 'movie';
final isTvHit = primaryHit != null && mediaType == 'tv';
final primaryHitKey = primaryHit == null ? null : _hitKeyOf(primaryHit);
final selectedTvEpisode =
isTvHit && _selectedTvEpisodeHitKey == primaryHitKey
? _selectedTvEpisode
: null;
final selectedTvEpisodeSeasonNumber = selectedTvEpisode == null
? null
: _selectedTvEpisodeSeasonNumber;
final tvAnchorForResume = isTvHit ? primaryHit : null;
final tvResumeAsync = tvAnchorForResume == null
? null
: ref.watch(
seriesResumeTargetProvider((
serverId: tvAnchorForResume.serverId,
seriesId: tvAnchorForResume.itemId,
)),
);
final tvAutoSelect = EpisodeAutoSelectArgs.fromResume(tvResumeAsync);
final priorities =
ref.watch(versionPriorityProvider).value?.activePriorities ??
const <VersionPriority>[];
final localSourceItem = isMovieHit ? serverScopedItem : selectedTvEpisode;
final sortedSources = localSourceItem != null
? sortMediaSources(mediaSourcesOfItem(localSourceItem), priorities)
: const <EmbyRawMediaSource>[];
final memServerId = primaryHit?.serverId;
final memItemId = localSourceItem?.Id;
if (memServerId != null &&
memServerId.isNotEmpty &&
memItemId != null &&
memItemId.isNotEmpty) {
final memKey = '$memServerId:$memItemId';
if (memKey != _versionMemoryQueriedKey) {
_versionMemoryQueriedKey = memKey;
final store = ref.read(lastPlayedVersionStoreProvider);
unawaited(
store
.get(serverId: memServerId, itemId: memItemId)
.then((id) {
if (!mounted || id == null) return;
if (_versionMemoryQueriedKey != memKey) return;
if (_selectedSourceIdx != 0) return;
setState(() => _pendingMediaSourceId = id);
})
.onError((e, s) {}),
);
}
}
final pendingId = _pendingMediaSourceId;
if (pendingId != null && sortedSources.isNotEmpty) {
final idx = resolveDefaultSourceIndex(
sortedSources,
pendingId,
fallback: -1,
);
if (idx >= 0 && idx != _selectedSourceIdx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_selectedSourceIdx = idx;
_pendingMediaSourceId = null;
});
});
} else {
_pendingMediaSourceId = null;
}
}
final effectiveCrossServerCard = isTvHit && selectedTvEpisode == null
? null
: _selectedCrossServerCard;
final localSourceId =
effectiveCrossServerCard == null &&
_selectedSourceIdx < sortedSources.length
? sortedSources[_selectedSourceIdx].Id
: null;
final selectedMediaSource = resolveActiveMediaSource(
effectiveCrossServerCard,
sortedSources,
_selectedSourceIdx,
);
final streamSelection = resolveStreamSelection(
selectedMediaSource,
_selectedSubtitleIdx,
_selectedAudioIdx,
);
final subtitles = streamSelection.subtitles;
final audios = streamSelection.audios;
final preferredSubtitleStreamIndex =
streamSelection.preferredSubtitleStreamIndex;
final preferredAudioStreamIndex = streamSelection.preferredAudioStreamIndex;
void play(bool fromStart) {
final card = effectiveCrossServerCard;
if (card != null) {
final pos =
selectedTvEpisode?.UserData?.PlaybackPositionTicks ??
_positionOverrideFor(card.serverId, card.landingItemId) ??
_positionOverrideFor(primaryHit?.serverId, primaryHit?.itemId) ??
primaryHit?.playbackPositionTicks;
final resumeTicks = (!fromStart && pos != null && pos > 0) ? pos : null;
unawaited(
_playRestoringActiveServer(
serverId: card.serverId,
itemId: card.landingItemId,
mediaSourceId: card.mediaSourceId,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
startPositionTicks: resumeTicks,
fromStart: resumeTicks == null,
backdropUrl: detailBackdropUrl,
),
);
return;
}
if (primaryHit == null) return;
final localItemId = selectedTvEpisode?.Id ?? primaryHit.itemId;
final localResume = fromStart
? null
: _positionOverrideFor(primaryHit.serverId, localItemId);
unawaited(
_playRestoringActiveServer(
serverId: primaryHit.serverId,
itemId: localItemId,
mediaSourceId: localSourceId,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
startPositionTicks: localResume,
fromStart: fromStart,
backdropUrl: detailBackdropUrl,
),
);
}
final imdb = imdbIdForLookup?.trim() ?? '';
final crossServerReq = CrossServerSearchReq(
anchorType: 'Movie',
itemName: title,
providerIds: {'Tmdb': tmdbId, if (imdb.isNotEmpty) 'Imdb': imdb},
);
final tvEpisodeReq = selectedTvEpisode == null || serverScopedItem == null
? null
: buildEpisodeCrossServerReq(
episode: selectedTvEpisode,
seriesItem: serverScopedItem,
seasonNumberOverride: selectedTvEpisodeSeasonNumber,
);
final hitStateSections = <DetailSection>[
if (isMovieHit) ...[
DetailSection(
id: 'tmdb_movie_source',
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
child: SourceSelectionSection(
req: crossServerReq,
localSources: sortedSources,
selectedSourceIdx: _selectedSourceIdx,
selectedCrossServerCard: effectiveCrossServerCard,
currentServerName: primaryHit.serverName,
excludedServerIdOverride: primaryHit.serverId,
localMediaInfoItem: localSourceItem,
showLoadingWhenEmpty: true,
externalLoading: scopedDetailAsync?.isLoading ?? false,
loadingText: '正在查找可用版本…',
onLocalSourceTap: (src, index) => _selectLocalSource(index),
onCrossServerSourceTap: _selectCrossServerSource,
),
),
),
DetailSection(
id: 'tmdb_movie_additional_parts',
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
child: AdditionalPartsSection(
itemId: primaryHit.itemId,
embedded: true,
serverId: primaryHit.serverId,
onItemTap: (item) => unawaited(
_playRestoringActiveServer(
serverId: primaryHit.serverId,
itemId: item.Id,
backdropUrl: detailBackdropUrl,
),
),
),
),
),
const DetailSection(id: 'tmdb_movie_divider', child: _sectionDivider),
],
if (isTvHit) ...[
DetailSection(
id: 'tmdb_tv_episodes',
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
child: EpisodeListSection(
seriesId: primaryHit.itemId,
tmdbSeriesId: tmdbId,
serverId: primaryHit.serverId,
embedded: true,
autoSelectFirstEpisode: tvAutoSelect.autoSelectFirstEpisode,
autoSelectEpisodeId: tvAutoSelect.autoSelectEpisodeId,
autoSelectSeasonId: tvAutoSelect.autoSelectSeasonId,
currentEpisodeId: selectedTvEpisode?.Id,
onEpisodeTap: (episode) =>
_selectTvEpisode(episode, null, primaryHitKey!),
onEpisodeTapWithSeason: (episode, seasonNumber) =>
_selectTvEpisode(episode, seasonNumber, primaryHitKey!),
),
),
),
if (tvEpisodeReq != null)
DetailSection(
id: 'tmdb_tv_source',
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
child: SourceSelectionSection(
req: tvEpisodeReq,
localSources: sortedSources,
selectedSourceIdx: _selectedSourceIdx,
selectedCrossServerCard: effectiveCrossServerCard,
currentServerName: primaryHit.serverName,
excludedServerIdOverride: primaryHit.serverId,
localMediaInfoItem: localSourceItem,
showLoadingWhenEmpty: true,
loadingText: '正在查找该集的可用版本…',
onLocalSourceTap: (src, index) => _selectLocalSource(index),
onCrossServerSourceTap: _selectCrossServerSource,
),
),
),
const DetailSection(id: 'tmdb_tv_divider', child: _sectionDivider),
],
];
final heroActions = _buildHeroActions(
mediaType: mediaType,
primaryHit: primaryHit,
hitCount: hits.length,
availabilityResolving: availabilityResolving,
isFavorite: isFavorite,
isPlayed: isPlayed,
subtitles: subtitles,
audios: audios,
onPlay: () => play(false),
onReplay: () => play(true),
);
final viewState = buildTmdbDetailViewState(
mediaType: mediaType,
enriched: enriched,
seed: entry.seed,
imageBaseUrl: imageBaseUrl,
language: language,
heroActions: heroActions,
hitStateSections: hitStateSections,
showSpinner: showSpinner,
);
if (Platform.isAndroid) {
return TmdbDetailBodyAndroid(
entry: entry,
enriched: enriched,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: showSpinner,
scrollController: _scroll,
scrollProgress: _scrollProgress,
primaryHit: primaryHit,
availabilityResolving: availabilityResolving,
isFavorite: isFavorite,
isPlayed: isPlayed,
subtitles: subtitles,
audios: audios,
selectedSubtitleIdx: _selectedSubtitleIdx,
selectedAudioIdx: _selectedAudioIdx,
onPlay: () => play(false),
onReplay: () => play(true),
onToggleFavorite: primaryHit != null
? () => unawaited(_toggleTmdbFavorite(primaryHit, isFavorite))
: () {},
onTogglePlayed: primaryHit != null
? () => unawaited(_toggleTmdbPlayed(primaryHit, isPlayed))
: () {},
onSelectSubtitleIndex: _selectSubtitleIndex,
onSelectAudioIndex: _selectAudioIndex,
hitCount: hits.length,
hitStateSections: hitStateSections,
);
}
final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
return DetailImmersiveScaffold(
scrollController: _scroll,
scrollProgress: _scrollProgress,
backdropUrl: viewState.image.backdropUrl,
fallbackUrls: viewState.image.fallbackUrls,
embyBaseUrl: viewState.image.embyBaseUrl,
imageHeaders: viewState.image.imageHeaders,
compact: isCompact,
horizontalPadding: isCompact ? 16 : _kHPad,
child: DetailViewRenderer(state: viewState),
);
}
Widget? _buildHeroActions({
required String mediaType,
required TmdbLibraryHit? primaryHit,
required int hitCount,
required bool availabilityResolving,
required bool isFavorite,
required bool isPlayed,
required List<EmbyRawMediaStream> subtitles,
required List<EmbyRawMediaStream> audios,
required VoidCallback onPlay,
required VoidCallback onReplay,
}) {
final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
final btnH = compact ? 44.0 : 52.0;
if (availabilityResolving) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: AppSkeleton(height: btnH, radius: 12),
);
}
final ctaLabel = buildTmdbCtaLabel(
mediaType: mediaType,
primaryHit: primaryHit,
hitCount: hitCount,
);
final hit = primaryHit;
if (ctaLabel == null || hit == null) return null;
final pos =
_positionOverrideFor(hit.serverId, hit.itemId) ??
hit.playbackPositionTicks ??
0;
final runtime = hit.runTimeTicks ?? 0;
final hasProgress = pos > 0;
final progressPercent = (hasProgress && runtime > 0)
? (pos / runtime * 100).clamp(0.0, 100.0)
: null;
final positionClock = hasProgress ? formatTicksAsClock(pos) : null;
final extraActions = buildStreamSelectorPills(
subtitles: subtitles,
audios: audios,
selectedSubtitleIdx: _selectedSubtitleIdx,
selectedAudioIdx: _selectedAudioIdx,
onSelectSubtitle: _selectSubtitleIndex,
onSelectAudio: _selectAudioIndex,
height: btnH,
);
return DetailHeroActions(
showReplay: hasProgress,
onReplay: onReplay,
isPlayed: isPlayed,
onTogglePlayed: () => _toggleTmdbPlayed(hit, isPlayed),
isFavorite: isFavorite,
onToggleFavorite: () => _toggleTmdbFavorite(hit, isFavorite),
extraIconActions: extraActions,
onPlay: onPlay,
playLabel: ctaLabel,
progressPercent: progressPercent,
trailingLabel: positionClock,
compact: compact,
);
}
}