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