309 lines
10 KiB
Dart
309 lines
10 KiB
Dart
|
|
|
||
|
|
|
||
|
|
part of 'player_page.dart';
|
||
|
|
|
||
|
|
extension _PlayerPageLifecycle on _PlayerPageState {
|
||
|
|
void _onPanelChanged() {
|
||
|
|
if (_drawer.isOpen && _drawer.category == PlayerPanelCategory.episode) {
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
||
|
|
(_) => _episodeBodyKey.currentState?.ensureLoaded(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (mounted) setState(() {});
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _initialize() async {
|
||
|
|
try {
|
||
|
|
final directStream = widget.directStream;
|
||
|
|
final activeSession = ref.read(activeSessionProvider);
|
||
|
|
if (activeSession == null && directStream == null) {
|
||
|
|
if (mounted) {
|
||
|
|
setState(() => _errorMessage = '没有活跃的会话');
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
AppLogger.debug(
|
||
|
|
_tag,
|
||
|
|
directStream != null
|
||
|
|
? 'init direct stream'
|
||
|
|
: 'init server=${activeSession!.serverUrl} '
|
||
|
|
'userId=${activeSession.userId}',
|
||
|
|
);
|
||
|
|
|
||
|
|
_prewarmPlayerEngine();
|
||
|
|
|
||
|
|
final bootstrap = await (
|
||
|
|
ref.read(deviceIdProvider.future),
|
||
|
|
ref.read(appVersionProvider.future),
|
||
|
|
ref.read(playbackResolverProvider.future),
|
||
|
|
ref.read(embyGatewayProvider.future),
|
||
|
|
).wait;
|
||
|
|
final deviceId = bootstrap.$1;
|
||
|
|
final appVersion = bootstrap.$2;
|
||
|
|
final resolver = bootstrap.$3;
|
||
|
|
final gateway = bootstrap.$4;
|
||
|
|
if (!mounted) return;
|
||
|
|
|
||
|
|
final traktDepsFuture = directStream == null
|
||
|
|
? _loadTraktDeps()
|
||
|
|
: Future.value(null);
|
||
|
|
final prefetchFuture = directStream == null
|
||
|
|
? _loadPrefetchedPlaybackInfo(activeSession!)
|
||
|
|
: Future.value(null);
|
||
|
|
|
||
|
|
final scrobbleStatus = ref.read(traktScrobbleStatusProvider.notifier);
|
||
|
|
|
||
|
|
final pendingSeq = ref.read(pendingCrossServerSequenceProvider);
|
||
|
|
if (pendingSeq != null) {
|
||
|
|
ref.read(pendingCrossServerSequenceProvider.notifier).state = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
final settingsFuture = (
|
||
|
|
ref
|
||
|
|
.read(subtitleSettingsProvider.future)
|
||
|
|
.then(
|
||
|
|
(value) => value,
|
||
|
|
onError: (Object e) {
|
||
|
|
AppLogger.warn(_tag, 'load subtitle settings failed', e);
|
||
|
|
return const SubtitleStyleSettings();
|
||
|
|
},
|
||
|
|
),
|
||
|
|
Future.value(
|
||
|
|
ref.read(playerSettingsProvider).value ?? const PlayerSettingsState(),
|
||
|
|
),
|
||
|
|
ref
|
||
|
|
.read(danmakuSettingsProvider.future)
|
||
|
|
.then(
|
||
|
|
(value) => value,
|
||
|
|
onError: (Object e) {
|
||
|
|
AppLogger.warn(_tag, 'load danmaku settings failed', e);
|
||
|
|
return const DanmakuSettingsState();
|
||
|
|
},
|
||
|
|
),
|
||
|
|
ref
|
||
|
|
.read(audioSettingsProvider.future)
|
||
|
|
.then(
|
||
|
|
(value) => value,
|
||
|
|
onError: (Object e) {
|
||
|
|
AppLogger.warn(_tag, 'load audio settings failed', e);
|
||
|
|
return const AudioSettingsState();
|
||
|
|
},
|
||
|
|
),
|
||
|
|
).wait;
|
||
|
|
final Future<Playlist> playlistFuture;
|
||
|
|
if (directStream != null) {
|
||
|
|
final itemType = directStream.episode != null ? 'Episode' : 'Movie';
|
||
|
|
final syntheticItem = EmbyRawItem(
|
||
|
|
Id: widget.itemId,
|
||
|
|
Name: directStream.title,
|
||
|
|
Type: itemType,
|
||
|
|
SeriesName: directStream.seriesName,
|
||
|
|
IndexNumber: directStream.episode,
|
||
|
|
RunTimeTicks: directStream.durationSeconds == null
|
||
|
|
? null
|
||
|
|
: directStream.durationSeconds! * kTicksPerSecond,
|
||
|
|
);
|
||
|
|
playlistFuture = Future<Playlist>.value(
|
||
|
|
SingleItemPlaylist(
|
||
|
|
item: PlaylistItem(
|
||
|
|
itemId: widget.itemId,
|
||
|
|
displayLabel: directStream.title,
|
||
|
|
playFromStart: true,
|
||
|
|
preloadedItem: syntheticItem,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
playlistFuture = playlistFromLaunch(
|
||
|
|
itemId: widget.itemId,
|
||
|
|
mediaSourceId: widget.mediaSourceId,
|
||
|
|
startPositionTicks: widget.startPositionTicks,
|
||
|
|
preferredAudioStreamIndex: widget.preferredAudioStreamIndex,
|
||
|
|
preferredSubtitleStreamIndex: widget.preferredSubtitleStreamIndex,
|
||
|
|
playFromStart: widget.fromStart,
|
||
|
|
crossServerSequence: pendingSeq,
|
||
|
|
loadDetail: (id) {
|
||
|
|
final serverId = ref.read(activeSessionProvider)?.serverId ?? '';
|
||
|
|
return ref.read(
|
||
|
|
mediaDetailDataProvider((serverId: serverId, itemId: id)).future,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
loadEpisodes: ({required seriesId, required seasonId}) {
|
||
|
|
final serverId = ref.read(activeSessionProvider)?.serverId ?? '';
|
||
|
|
return ref.read(
|
||
|
|
seriesEpisodesDataProvider((
|
||
|
|
serverId: serverId,
|
||
|
|
seriesId: seriesId,
|
||
|
|
seasonId: seasonId,
|
||
|
|
)).future,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
loadSeasons: (seriesId) {
|
||
|
|
final serverId = ref.read(activeSessionProvider)?.serverId ?? '';
|
||
|
|
return ref.read(
|
||
|
|
seriesSeasonsDataProvider((
|
||
|
|
serverId: serverId,
|
||
|
|
seriesId: seriesId,
|
||
|
|
)).future,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
final ready = await (settingsFuture, playlistFuture).wait;
|
||
|
|
final settings = ready.$1;
|
||
|
|
_subtitleSettings = settings.$1;
|
||
|
|
final playerSettings =
|
||
|
|
ref.read(playerSettingsProvider).value ?? settings.$2;
|
||
|
|
final danmakuSettings = settings.$3;
|
||
|
|
if (!mounted) return;
|
||
|
|
|
||
|
|
final audioSettings = settings.$4;
|
||
|
|
_prefetchedPlaybackInfo = await prefetchFuture;
|
||
|
|
final traktDeps = await traktDepsFuture;
|
||
|
|
if (!mounted) return;
|
||
|
|
|
||
|
|
_builder = PlaybackSessionBuilder(
|
||
|
|
resolver: resolver,
|
||
|
|
gateway: gateway,
|
||
|
|
metadataResolver: MediaSourceMetadataResolver(
|
||
|
|
probeService: MediaProbeService.instance,
|
||
|
|
),
|
||
|
|
engineHolder: _engineHolder,
|
||
|
|
readActiveSession: () {
|
||
|
|
|
||
|
|
|
||
|
|
if (!mounted) throw const CancelledException('player unmounted');
|
||
|
|
return ref.read(activeSessionProvider)!;
|
||
|
|
},
|
||
|
|
deviceId: deviceId,
|
||
|
|
deviceName: kEmbyDeviceName,
|
||
|
|
playerSettingsNotifier: _playerSettingsNotifier,
|
||
|
|
audioSettingsNotifier: _audioSettingsNotifier,
|
||
|
|
subtitleSettingsNotifier: ref.read(subtitleSettingsProvider.notifier),
|
||
|
|
loadItemDetail: (serverId, itemId) async {
|
||
|
|
if (!mounted) throw const CancelledException('player unmounted');
|
||
|
|
final res = await ref.read(
|
||
|
|
mediaDetailDataProvider((
|
||
|
|
serverId: serverId,
|
||
|
|
itemId: itemId,
|
||
|
|
)).future,
|
||
|
|
);
|
||
|
|
return res.base;
|
||
|
|
},
|
||
|
|
readEngineOverride: () {
|
||
|
|
if (!mounted) throw const CancelledException('player unmounted');
|
||
|
|
return ref.read(playerEngineOverrideProvider).value ??
|
||
|
|
PlayerEngineOverride.auto;
|
||
|
|
},
|
||
|
|
traktGateway: traktDeps?.gateway,
|
||
|
|
traktQueue: traktDeps?.queue,
|
||
|
|
traktAppVersion: appVersion,
|
||
|
|
traktOnSuccess: scrobbleStatus.recordSuccess,
|
||
|
|
traktOnError: scrobbleStatus.recordError,
|
||
|
|
);
|
||
|
|
|
||
|
|
_initialPlayerSettings = playerSettings;
|
||
|
|
_initialDanmakuSettings = danmakuSettings;
|
||
|
|
_initialAudioSettings = audioSettings;
|
||
|
|
_panelSettings = danmakuSettings.panelSettings;
|
||
|
|
|
||
|
|
_playlist = ready.$2;
|
||
|
|
if (!mounted) {
|
||
|
|
_playlist?.dispose();
|
||
|
|
_playlist = null;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
_playlist!.current.addListener(_onCurrentItemChanged);
|
||
|
|
_onCurrentItemChanged();
|
||
|
|
} catch (e, s) {
|
||
|
|
AppLogger.error(_tag, 'initialize failed', e, s);
|
||
|
|
if (mounted) setState(() => _errorMessage = formatUserError(e));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
void _prewarmPlayerEngine() {
|
||
|
|
if (_engineHolder.current != null) return;
|
||
|
|
final override =
|
||
|
|
ref.read(playerEngineOverrideProvider).value ??
|
||
|
|
PlayerEngineOverride.auto;
|
||
|
|
final kind = resolvePlayerEngineKind(
|
||
|
|
override,
|
||
|
|
isDolbyVision: false,
|
||
|
|
videoCodec: null,
|
||
|
|
);
|
||
|
|
unawaited(() async {
|
||
|
|
try {
|
||
|
|
final engine = await _engineHolder.acquire(
|
||
|
|
kind,
|
||
|
|
() => PlaybackSessionBuilder.createEngine(kind),
|
||
|
|
);
|
||
|
|
AppLogger.debug(_tag, 'engine prewarmed kind=${kind.name}');
|
||
|
|
if (engine is Media3PlayerEngine) {
|
||
|
|
await engine.prewarmNative();
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
AppLogger.debug(_tag, 'engine prewarm failed', e);
|
||
|
|
}
|
||
|
|
}());
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<({AuthedTraktGateway gateway, TraktPendingSyncQueue queue})?>
|
||
|
|
_loadTraktDeps() async {
|
||
|
|
try {
|
||
|
|
final traktGateway = ref.read(authedTraktGatewayProvider);
|
||
|
|
final queue = await ref.read(traktSyncQueueProvider.future);
|
||
|
|
unawaited(queue.drain());
|
||
|
|
return (gateway: traktGateway, queue: queue);
|
||
|
|
} catch (e) {
|
||
|
|
AppLogger.warn(_tag, 'trakt deps unavailable, scrobble disabled', e);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<({int startTimeTicks, Map<String, dynamic> info})?>
|
||
|
|
_loadPrefetchedPlaybackInfo(AuthedSession activeSession) async {
|
||
|
|
try {
|
||
|
|
final startTimeTicks = widget.fromStart
|
||
|
|
? 0
|
||
|
|
: (widget.startPositionTicks ?? 0);
|
||
|
|
final serverId = widget.serverId ?? activeSession.serverId;
|
||
|
|
final key = (
|
||
|
|
serverId: serverId,
|
||
|
|
itemId: widget.itemId,
|
||
|
|
mediaSourceId: widget.mediaSourceId,
|
||
|
|
startTimeTicks: startTimeTicks,
|
||
|
|
);
|
||
|
|
AppLogger.debug(
|
||
|
|
_tag,
|
||
|
|
'playbackInfo prefetch await ${describePlaybackInfoPrefetchKey(key)}',
|
||
|
|
);
|
||
|
|
final prefetched = await ref.read(
|
||
|
|
playbackInfoPrefetchProvider(key).future,
|
||
|
|
);
|
||
|
|
if (prefetched == null || prefetched.info.isEmpty) {
|
||
|
|
AppLogger.debug(
|
||
|
|
_tag,
|
||
|
|
'playbackInfo prefetch miss ${describePlaybackInfoPrefetchKey(key)}',
|
||
|
|
);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
AppLogger.debug(
|
||
|
|
_tag,
|
||
|
|
'playbackInfo prefetch hit ${describePlaybackInfoPrefetchKey(key)}',
|
||
|
|
);
|
||
|
|
return (startTimeTicks: prefetched.startTimeTicks, info: prefetched.info);
|
||
|
|
} catch (e) {
|
||
|
|
AppLogger.debug(_tag, 'playbackInfo prefetch failed; fallback', e);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
int _prefetchStartTimeTicksFor(PlaybackRequest req) {
|
||
|
|
if (req.playFromStart) return 0;
|
||
|
|
final explicitTicks = req.startPositionTicks;
|
||
|
|
if (explicitTicks != null && explicitTicks > 0) return explicitTicks;
|
||
|
|
return req.preloadedItem?.UserData?.PlaybackPositionTicks ?? 0;
|
||
|
|
}
|
||
|
|
}
|