Files
sm-emby-share/lib/features/detail/emby_detail_view.dart
T
2026-07-14 11:11:36 +08:00

807 lines
26 KiB
Dart

import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/contracts/library.dart';
import '../../providers/di_providers.dart';
import '../../providers/emby_headers_provider.dart';
import '../../providers/last_played_version_store.dart';
import '../../providers/playback_info_prefetch_provider.dart';
import '../../providers/playback_active_provider.dart';
import '../../providers/session_provider.dart';
import '../../providers/version_priority_provider.dart';
import '../../shared/theme/app_theme.dart';
import '../../shared/utils/cross_server_search_key.dart';
import '../../shared/utils/app_logger.dart';
import '../../shared/widgets/app_snack_bar.dart';
import '../player/player_launcher.dart';
import 'emby_detail_body_android.dart';
import 'emby_detail_body_builder.dart';
import 'emby_detail_helpers.dart';
import 'android/android_detail_geometry.dart';
import 'model/selected_episode_merge.dart';
import 'model/stream_selection.dart';
import 'widgets/detail_scroll_progress.dart';
class EmbyDetailView extends ConsumerStatefulWidget {
final String itemId;
final String? initialSeriesId;
final String? initialSeasonId;
final EmbyRawItem? seedItem;
const EmbyDetailView({
super.key,
required this.itemId,
this.initialSeriesId,
this.initialSeasonId,
this.seedItem,
});
@override
ConsumerState<EmbyDetailView> createState() => _EmbyDetailViewState();
}
class _EmbyDetailViewState extends ConsumerState<EmbyDetailView> {
static const _tag = 'EmbyDetailView';
Future<MediaDetailRes>? _detailFuture;
MediaDetailRes? _data;
EmbyRawItem? _selectedItem;
EmbyRawItem? _tmdbSeriesItem;
bool _isFavorite = false;
bool _isPlayed = false;
int _selectedSourceIdx = 0;
int? _selectedSubtitleIdx;
int _selectedAudioIdx = 0;
bool _isLaunchingPlayer = false;
String? _detailServerId;
String? _originServerId;
bool _serverReloadScheduled = false;
String? _overrideItemId;
String? _pendingMediaSourceId;
bool _isRestoringOriginServer = false;
CrossServerSourceCard? _selectedCrossServerCard;
bool _crossServerPlaybackInFlight = false;
bool _backInFlight = false;
final _scrollController = ScrollController();
final _scrollProgress = ValueNotifier<double>(0.0);
late final DetailPageActivity _detailActivity;
bool _didEnterDetailActivity = false;
String get _detailItemId {
if (_overrideItemId != null && _overrideItemId!.isNotEmpty) {
return _overrideItemId!;
}
return widget.itemId;
}
@override
void initState() {
super.initState();
_detailActivity = ref.read(detailPageActivityProvider.notifier);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_didEnterDetailActivity = true;
_detailActivity.enter();
});
_originServerId = ref.read(activeSessionProvider)?.serverId;
_scrollController.addListener(_onScroll);
_reload();
}
@override
void dispose() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_didEnterDetailActivity) _detailActivity.leave();
});
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_scrollProgress.dispose();
super.dispose();
}
void _onScroll() {
_scrollProgress.value = computeDetailScrollProgress(
context,
_scrollController.offset,
collapseExtent: Platform.isAndroid
? computeAndroidDetailCollapseExtent(context)
: null,
);
}
@override
void didUpdateWidget(covariant EmbyDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemId != widget.itemId ||
oldWidget.initialSeriesId != widget.initialSeriesId ||
oldWidget.initialSeasonId != widget.initialSeasonId) {
_overrideItemId = null;
_reload();
}
}
void _reload({bool invalidateCache = false}) {
_detailServerId = ref.read(activeSessionProvider)?.serverId;
_serverReloadScheduled = false;
_resetScrollState();
if (invalidateCache) {
final serverId = _detailServerId ?? '';
ref.invalidate(
mediaDetailDataProvider((serverId: serverId, itemId: _detailItemId)),
);
}
setState(() {
_data = null;
_selectedItem = null;
_tmdbSeriesItem = null;
_selectedSourceIdx = 0;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_selectedCrossServerCard = null;
_isLaunchingPlayer = false;
_detailFuture = _loadDetail();
});
}
void _resetScrollState() {
_scrollProgress.value = 0.0;
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
}
Future<MediaDetailRes> _loadDetail() async {
final capturedServerId = _detailServerId ?? '';
final initialSeriesId = widget.initialSeriesId;
if (initialSeriesId != null &&
initialSeriesId.isNotEmpty &&
capturedServerId.isNotEmpty) {
unawaited(
ref.read(
mediaDetailDataProvider((
serverId: capturedServerId,
itemId: initialSeriesId,
)).future,
),
);
}
final res = await ref.read(
mediaDetailDataProvider((
serverId: capturedServerId,
itemId: _detailItemId,
)).future,
);
if (!mounted) return res;
AppLogger.debug(
_tag,
'_loadDetail entry itemId=$_detailItemId '
'detailServerId=$capturedServerId '
'activeServerId=${ref.read(activeSessionProvider)?.serverId} '
'base.Id=${res.base.Id} type=${res.base.Type} '
'seriesId=${res.base.SeriesId} seasonId=${res.base.SeasonId} '
'index=${res.base.IndexNumber} '
'sources=${mediaSourcesOfItem(res.base).length}',
);
setState(() {
_data = res;
_selectedItem = res.base;
_isFavorite = res.base.UserData?.IsFavorite ?? false;
_isPlayed = res.base.UserData?.Played ?? false;
});
unawaited(_loadRememberedVersion(res.base));
final seriesId = seriesIdForEpisode(res.base, widget.itemId);
if (seriesId != null) {
unawaited(_loadSeriesInfo(seriesId, capturedServerId, res.base));
} else {
_firePrewarm(res.base);
}
_prefetchPlaybackInfoFor(res.base);
return res;
}
Future<void> _loadSeriesInfo(
String seriesId,
String serverId,
EmbyRawItem episode,
) async {
try {
final seriesRes = await ref.read(
mediaDetailDataProvider((serverId: serverId, itemId: seriesId)).future,
);
if (!mounted) return;
setState(() {
_tmdbSeriesItem = seriesRes.base;
});
_firePrewarm(seriesRes.base);
} catch (_) {
_firePrewarm(episode);
}
}
void _firePrewarm(EmbyRawItem refItem) {
if (refItem.Type != 'Series' && refItem.Type != 'Episode') return;
final pids = providerIdsOfItem(refItem);
if (pids.isEmpty) return;
final activeServerId = ref.read(activeSessionProvider)?.serverId ?? '';
if (activeServerId.isEmpty) return;
unawaited(
ref.read(
seriesPrewarmProvider((
activeServerId: activeServerId,
seriesProviderIdQuery: encodeProviderIdsForKey(pids),
)).future,
),
);
}
void _syncDetailWithActiveServer(String serverId) {
final loadedServerId = _detailServerId;
if (loadedServerId == null) {
_detailServerId = serverId;
return;
}
if (_crossServerPlaybackInFlight) return;
if (loadedServerId == serverId || _serverReloadScheduled) return;
_serverReloadScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_serverReloadScheduled = false;
final currentServerId = ref.read(activeSessionProvider)?.serverId;
if (currentServerId == null || _detailServerId == currentServerId) {
return;
}
_reload(invalidateCache: true);
});
}
void _selectCrossServerSource(CrossServerSourceCard card) {
setState(() {
_selectedCrossServerCard = card;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
});
_prefetchPlaybackInfoForCrossServer(card);
}
void _onEpisodeTap(EmbyRawItem episode) {
AppLogger.debug(
_tag,
'_onEpisodeTap id=${episode.Id} name=${episode.Name} '
'type=${episode.Type} seriesId=${episode.SeriesId} '
'seasonId=${episode.SeasonId} index=${episode.IndexNumber} '
'positionTicks=${episode.UserData?.PlaybackPositionTicks} '
'sources=${mediaSourcesOfItem(episode).length}',
);
setState(() {
_selectedItem = episode;
_isFavorite = episode.UserData?.IsFavorite ?? false;
_isPlayed = episode.UserData?.Played ?? false;
_selectedSourceIdx = 0;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_selectedCrossServerCard = null;
});
unawaited(_loadSelectedEpisodeDetail(episode.Id));
}
Future<void> _loadSelectedEpisodeDetail(String episodeId) async {
final serverId = _detailServerId ?? '';
if (serverId.isEmpty || episodeId.isEmpty) return;
try {
final res = await ref.read(
mediaDetailDataProvider((serverId: serverId, itemId: episodeId)).future,
);
final listItem = _selectedItem;
if (!mounted || listItem?.Id != episodeId) return;
final merged = listItem != null
? mergeSelectedEpisodeDetail(detail: res.base, listItem: listItem)
: res.base;
final mergedSources = mediaSourcesOfItem(merged);
AppLogger.debug(
_tag,
'_loadSelectedEpisodeDetail episodeId=$episodeId '
'listItem.Id=${listItem?.Id} detail.base.Id=${res.base.Id} '
'merged.Id=${merged.Id} mergedSources=${mergedSources.length} '
'firstSourceId=${mergedSources.isNotEmpty ? mergedSources.first.Id : null}',
);
setState(() {
_selectedItem = merged;
_isFavorite = merged.UserData?.IsFavorite ?? false;
_isPlayed = merged.UserData?.Played ?? false;
});
unawaited(_loadRememberedVersion(merged));
_prefetchPlaybackInfoFor(merged);
} catch (_) {}
}
void _selectSourceIndex(int index) {
setState(() {
_selectedSourceIdx = index;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_selectedCrossServerCard = null;
_pendingMediaSourceId = null;
});
final item = _selectedItem;
if (item != null) _prefetchPlaybackInfoFor(item);
}
void _selectSubtitleIndex(int? index) {
setState(() => _selectedSubtitleIdx = index);
}
void _selectAudioIndex(int index) {
setState(() => _selectedAudioIdx = index);
}
void _applyPendingMediaSource(int index) {
if (!mounted) return;
setState(() {
_selectedSourceIdx = index;
_pendingMediaSourceId = null;
});
final item = _selectedItem;
if (item != null) _prefetchPlaybackInfoFor(item);
}
void _clearPendingMediaSource() {
_pendingMediaSourceId = null;
}
int _prefetchStartTimeTicksForCrossServer(CrossServerSourceCard card) {
final selectedTicks = _selectedItem?.UserData?.PlaybackPositionTicks;
if (selectedTicks != null && selectedTicks > 0) return selectedTicks;
return 0;
}
void _prefetchPlaybackInfoFor(EmbyRawItem item) {
final serverId = _detailServerId ?? '';
if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return;
final key = _playbackInfoPrefetchKeyFor(
item,
mediaSourceId: _currentLocalMediaSourceIdFor(item),
fromStart: false,
);
AppLogger.debug(
_tag,
'playbackInfo prefetch request local '
'${describePlaybackInfoPrefetchKey(key)}',
);
unawaited(ref.read(playbackInfoPrefetchProvider(key).future));
}
void _prefetchPlaybackInfoForCrossServer(CrossServerSourceCard card) {
if (card.serverId.isEmpty ||
card.landingItemId.isEmpty ||
card.item.Type == 'Series') {
return;
}
final key = (
serverId: card.serverId,
itemId: card.landingItemId,
mediaSourceId: card.mediaSourceId.isEmpty ? null : card.mediaSourceId,
startTimeTicks: _prefetchStartTimeTicksForCrossServer(card),
);
AppLogger.debug(
_tag,
'playbackInfo prefetch request crossServer '
'${describePlaybackInfoPrefetchKey(key)}',
);
unawaited(ref.read(playbackInfoPrefetchProvider(key).future));
}
PlaybackInfoPrefetchKey _playbackInfoPrefetchKeyFor(
EmbyRawItem item, {
String? mediaSourceId,
required bool fromStart,
}) {
return (
serverId: _detailServerId ?? '',
itemId: item.Id,
mediaSourceId: mediaSourceId,
startTimeTicks: fromStart
? 0
: (item.UserData?.PlaybackPositionTicks ?? 0),
);
}
String? _currentLocalMediaSourceIdFor(EmbyRawItem item) {
if (_selectedCrossServerCard != null) return null;
final priorities =
ref.read(versionPriorityProvider).value?.activePriorities ?? [];
final sources = sortMediaSources(mediaSourcesOfItem(item), priorities);
if (_selectedSourceIdx < 0 || _selectedSourceIdx >= sources.length) {
return null;
}
return sources[_selectedSourceIdx].Id;
}
Future<void> _loadRememberedVersion(EmbyRawItem item) async {
final serverId = _detailServerId ?? '';
if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return;
try {
final remembered = await ref
.read(lastPlayedVersionStoreProvider)
.get(serverId: serverId, itemId: item.Id);
if (!mounted || remembered == null) return;
if (_selectedItem?.Id != item.Id) return;
if (_selectedSourceIdx != 0) return;
setState(() => _pendingMediaSourceId = remembered);
} catch (_) {}
}
void _refreshSelectedItemState(EmbyRawItem item) {
setState(() {
_selectedItem = item;
_isFavorite = item.UserData?.IsFavorite ?? false;
_isPlayed = item.UserData?.Played ?? false;
});
}
Future<void> _toggleFavorite() async {
final item = _selectedItem;
if (item == null) return;
final next = !_isFavorite;
final uc = await ref.read(setFavoriteUseCaseProvider.future);
final res = await uc.execute(
FavoriteSetReq(itemId: item.Id, isFavorite: next),
);
if (!mounted) return;
setState(() => _isFavorite = res.isFavorite);
final serverId = _detailServerId;
if (serverId != null && serverId.isNotEmpty) {
ref.invalidate(
mediaDetailDataProvider((serverId: serverId, itemId: item.Id)),
);
}
}
Future<void> _togglePlayed() async {
final item = _selectedItem;
if (item == null) return;
final next = !_isPlayed;
final uc = await ref.read(setPlayedUseCaseProvider.future);
final res = await uc.execute(PlayedSetReq(itemId: item.Id, played: next));
if (!mounted) return;
setState(() => _isPlayed = res.played);
unawaited(_syncTraktPlayedChange(item, res.played));
final serverId = _detailServerId;
if (serverId != null && serverId.isNotEmpty) {
ref.invalidate(
mediaDetailDataProvider((serverId: serverId, itemId: item.Id)),
);
}
}
Future<void> _syncTraktPlayedChange(EmbyRawItem item, bool played) async {
try {
final service = await ref.read(traktSyncServiceProvider.future);
await service.syncEmbyPlayedChange(
item: item,
played: played,
seriesItem: _tmdbSeriesItem,
);
} catch (_) {}
}
Future<void> _goBack() async {
if (_backInFlight || _isRestoringOriginServer) return;
_backInFlight = true;
final currentServerId = ref.read(activeSessionProvider)?.serverId;
if (_originServerId != null && currentServerId != _originServerId) {
setState(() => _isRestoringOriginServer = true);
try {
await ref
.read(sessionProvider.notifier)
.switchSession(_originServerId!);
} catch (_) {
if (!mounted) return;
setState(() => _isRestoringOriginServer = false);
_backInFlight = false;
showAppSnackBar(context, '恢复原服务器失败', tone: AppSnackTone.error);
return;
}
}
if (!mounted) return;
if (_isRestoringOriginServer) {
setState(() => _isRestoringOriginServer = false);
}
if (context.canPop()) {
context.pop();
} else {
context.go('/');
}
}
Future<void> _launchPlayer(
EmbyRawItem displayItem,
List<EmbyRawMediaSource> sources, {
bool fromStart = false,
int? overrideSubtitleIdx,
String? backdropUrl,
}) async {
if (_isLaunchingPlayer) return;
setState(() => _isLaunchingPlayer = true);
try {
final crossServerCard = _selectedCrossServerCard;
final selectedSrc = _selectedSourceIdx < sources.length
? sources[_selectedSourceIdx]
: null;
final subIdx = overrideSubtitleIdx ?? _selectedSubtitleIdx;
final audioIdx = _selectedAudioIdx;
final streamSelection = resolveStreamSelection(
selectedSrc,
subIdx,
audioIdx,
);
final subStreamIndex = streamSelection.preferredSubtitleStreamIndex;
final audioStreamIndex = streamSelection.preferredAudioStreamIndex;
final crossServerMediaSourceId =
crossServerCard?.mediaSourceId.isEmpty ?? true
? null
: crossServerCard?.mediaSourceId;
final playedItemId = crossServerCard?.landingItemId ?? displayItem.Id;
final playServerId = crossServerCard?.serverId ?? _detailServerId;
final playMediaSourceId = crossServerCard != null
? crossServerMediaSourceId
: selectedSrc?.Id;
final playStartTicks = crossServerCard != null
? _prefetchStartTimeTicksForCrossServer(crossServerCard)
: (displayItem.UserData?.PlaybackPositionTicks ?? 0);
final effectiveFromStart = crossServerCard != null
? (fromStart || playStartTicks <= 0)
: fromStart;
AppLogger.debug(
_tag,
'_launchPlayer displayItem.Id=$playedItemId name=${displayItem.Name} '
'type=${displayItem.Type} selectedSourceIdx=$_selectedSourceIdx '
'serverId=$playServerId crossServer=${crossServerCard != null} '
'selectedSrc.Id=${selectedSrc?.Id} sources=${sources.length} '
'subIdx=$subIdx audioIdx=$audioIdx '
'subStreamIndex=$subStreamIndex audioStreamIndex=$audioStreamIndex '
'fromStart=$effectiveFromStart '
'positionTicks=$playStartTicks',
);
Future<PlayerExitResult?> doLaunch() => PlayerLauncher.launch(
context: context,
itemId: playedItemId,
serverId: playServerId?.isEmpty ?? true ? null : playServerId,
mediaSourceId: playMediaSourceId,
preferredSubtitleStreamIndex: crossServerCard != null
? -1
: subStreamIndex,
preferredAudioStreamIndex: crossServerCard != null
? null
: audioStreamIndex,
startPositionTicks: effectiveFromStart ? null : playStartTicks,
fromStart: effectiveFromStart,
backdropUrl: backdropUrl,
);
final PlayerExitResult? exitResult;
if (crossServerCard != null) {
exitResult = await _withCrossServerSession(
crossServerCard,
action: doLaunch,
);
} else {
exitResult = await doLaunch();
}
final positionTicks = exitResult?.positionTicks;
if (mounted && positionTicks != null && positionTicks > 0) {
final item = _selectedItem;
if (item != null && item.Id == exitResult?.itemId) {
final userData = (item.UserData ?? const EmbyRawUserData()).copyWith(
PlaybackPositionTicks: positionTicks,
);
_refreshSelectedItemState(item.copyWith(UserData: userData));
final exitSourceId = exitResult?.mediaSourceId;
if (exitSourceId != null && exitSourceId.isNotEmpty) {
final idx = sources.indexWhere((s) => s.Id == exitSourceId);
if (idx != -1 && idx != _selectedSourceIdx) {
_selectSourceIndex(idx);
}
}
}
}
unawaited(
_refreshSelectedItemProgress(
displayItem.Id,
optimisticTicks: positionTicks,
),
);
} finally {
if (mounted && _isLaunchingPlayer) {
setState(() => _isLaunchingPlayer = false);
}
}
}
Future<T> _withCrossServerSession<T>(
CrossServerSourceCard card, {
required Future<T> Function() action,
}) async {
_crossServerPlaybackInFlight = true;
final detailServerId = _detailServerId;
try {
if (ref.read(activeSessionProvider)?.serverId != card.serverId) {
await ref.read(sessionProvider.notifier).switchSession(card.serverId);
}
return await action();
} finally {
if (mounted &&
detailServerId != null &&
detailServerId.isNotEmpty &&
ref.read(activeSessionProvider)?.serverId != detailServerId) {
try {
await ref
.read(sessionProvider.notifier)
.switchSession(detailServerId);
} catch (_) {}
}
if (mounted) _crossServerPlaybackInFlight = false;
}
}
Future<void> _refreshSelectedItemProgress(
String itemId, {
int? optimisticTicks,
}) async {
if (!mounted) return;
try {
final capturedServerId = _detailServerId ?? '';
final uc = await ref.read(mediaDetailUseCaseProvider.future);
if (!mounted) return;
final res = await uc.execute(MediaDetailReq(itemId: itemId));
final prev = _selectedItem;
if (!mounted || prev?.Id != itemId) return;
var merged = prev != null
? mergeSelectedEpisodeDetail(detail: res.base, listItem: prev)
: res.base;
if (optimisticTicks != null &&
optimisticTicks > 0 &&
merged.UserData?.Played != true) {
final serverTicks = merged.UserData?.PlaybackPositionTicks ?? 0;
if (optimisticTicks > serverTicks) {
merged = merged.copyWith(
UserData: (merged.UserData ?? const EmbyRawUserData()).copyWith(
PlaybackPositionTicks: optimisticTicks,
),
);
}
}
_refreshSelectedItemState(merged);
if (capturedServerId.isNotEmpty) {
ref.invalidate(
mediaDetailDataProvider((serverId: capturedServerId, itemId: itemId)),
);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final session = ref.watch(activeSessionProvider);
if (session == null) return const SizedBox.shrink();
_syncDetailWithActiveServer(session.serverId);
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
final priorities =
ref.watch(versionPriorityProvider).value?.activePriorities ?? [];
Widget buildBody(AsyncSnapshot<MediaDetailRes> snap) {
if (Platform.isAndroid) {
return EmbyDetailBodyAndroid(
snap: snap,
detailData: _data,
seedItem: widget.seedItem,
selectedItem: _selectedItem,
tmdbSeriesItem: _tmdbSeriesItem,
detailServerId: _detailServerId,
initialSeasonId: widget.initialSeasonId,
pendingMediaSourceId: _pendingMediaSourceId,
scrollController: _scrollController,
scrollProgress: _scrollProgress,
imageHeaders: imageHeaders,
session: session,
priorities: priorities,
selectedSourceIdx: _selectedSourceIdx,
selectedSubtitleIdx: _selectedSubtitleIdx,
selectedAudioIdx: _selectedAudioIdx,
selectedCrossServerCard: _selectedCrossServerCard,
isPlayed: _isPlayed,
isFavorite: _isFavorite,
isLaunchingPlayer: _isLaunchingPlayer,
onBack: () => unawaited(_goBack()),
onRetry: () => _reload(invalidateCache: true),
onTogglePlayed: () => unawaited(_togglePlayed()),
onToggleFavorite: () => unawaited(_toggleFavorite()),
onSelectSourceIndex: _selectSourceIndex,
onSelectSubtitleIndex: _selectSubtitleIndex,
onSelectAudioIndex: _selectAudioIndex,
onEpisodeTap: _onEpisodeTap,
onSelectCrossServerSource: _selectCrossServerSource,
onApplyPendingMediaSource: _applyPendingMediaSource,
onClearPendingMediaSource: _clearPendingMediaSource,
onLaunchPlayer: _launchPlayer,
);
}
return buildEmbyDetailBody(
context: context,
ref: ref,
snap: snap,
detailData: _data,
seedItem: widget.seedItem,
selectedItem: _selectedItem,
tmdbSeriesItem: _tmdbSeriesItem,
detailServerId: _detailServerId,
initialSeasonId: widget.initialSeasonId,
pendingMediaSourceId: _pendingMediaSourceId,
scrollController: _scrollController,
scrollProgress: _scrollProgress,
imageHeaders: imageHeaders,
session: session,
priorities: priorities,
selectedSourceIdx: _selectedSourceIdx,
selectedSubtitleIdx: _selectedSubtitleIdx,
selectedAudioIdx: _selectedAudioIdx,
selectedCrossServerCard: _selectedCrossServerCard,
isPlayed: _isPlayed,
isFavorite: _isFavorite,
isLaunchingPlayer: _isLaunchingPlayer,
onBack: () => unawaited(_goBack()),
onRetry: () => _reload(invalidateCache: true),
onTogglePlayed: () => unawaited(_togglePlayed()),
onToggleFavorite: () => unawaited(_toggleFavorite()),
onSelectSourceIndex: _selectSourceIndex,
onSelectSubtitleIndex: _selectSubtitleIndex,
onSelectAudioIndex: _selectAudioIndex,
onEpisodeTap: _onEpisodeTap,
onSelectCrossServerSource: _selectCrossServerSource,
onApplyPendingMediaSource: _applyPendingMediaSource,
onClearPendingMediaSource: _clearPendingMediaSource,
onLaunchPlayer: _launchPlayer,
);
}
final content = FutureBuilder<MediaDetailRes>(
future: _detailFuture,
builder: (_, snap) => buildBody(snap),
);
if (Platform.isAndroid) {
return Theme(data: AppTheme.dark(), child: content);
}
return Theme(
data: AppTheme.dark(),
child: Scaffold(backgroundColor: Colors.black, body: content),
);
}
}