905 lines
29 KiB
Dart
905 lines
29 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|