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 createState() => _TmdbDetailViewState(); } class _TmdbDetailViewState extends ConsumerState { static const _sectionDivider = FDivider( style: .delta( color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.only(top: 24)), ), ); final _scroll = ScrollController(); final _scrollProgress = ValueNotifier(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 _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 _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 _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 []; 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? 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 []; final localSourceItem = isMovieHit ? serverScopedItem : selectedTvEpisode; final sortedSources = localSourceItem != null ? sortMediaSources(mediaSourcesOfItem(localSourceItem), priorities) : const []; 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 = [ 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 subtitles, required List 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, ); } }