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