807 lines
26 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|