Files

382 lines
11 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
part of 'player_page.dart';
extension _PlayerPageSession on _PlayerPageState {
Future<void> _switchTo(
PlaybackRequest req, {
PlayerEngineKind? forcedEngineKind,
}) async {
if (_switchInFlight) {
_pendingResync = true;
AppLogger.debug(_tag, '_switchTo busy → will resync to current');
return;
}
final builder = _builder;
if (builder == null) {
AppLogger.warn(_tag, '_switchTo before initialize');
return;
}
_switchInFlight = true;
final stickyOverride =
ref.read(playerEngineOverrideProvider).value ??
PlayerEngineOverride.auto;
final effectiveForcedEngineKind =
forcedEngineKind ??
(stickyOverride == PlayerEngineOverride.auto
? _forcedEngineBySource[_fallbackKeyForRequest(req)]
: null);
PlayerEngineKind? resolvedEngineKind;
var resolvedIsDolbyVision = false;
_relink.cancelPending();
AppLogger.debug(
_tag,
'_switchTo itemId=${req.itemId} '
'mediaSourceId=${req.mediaSourceId} '
'forced=${effectiveForcedEngineKind?.name ?? '<none>'}',
);
final old = _session;
if (old != null) {
old.phase.removeListener(_onSessionPhaseChanged);
}
_attachWakelockSession(null);
if (mounted) {
setState(() {
_session = null;
_videoVisible = false;
_errorMessage = null;
});
_drawer.close();
_versionBarOpen.value = false;
_invalidateBottomBar();
}
PlaybackSession? next;
try {
_lastRequest = req;
final priorDisposeFuture = old?.dispose();
if (!mounted) return;
final prefetched = _prefetchedPlaybackInfo;
_prefetchedPlaybackInfo = null;
final expectedStartTimeTicks = _prefetchStartTimeTicksFor(req);
final usablePrefetch =
prefetched != null &&
prefetched.info.isNotEmpty &&
req.itemId == widget.itemId &&
prefetched.startTimeTicks == expectedStartTimeTicks
? prefetched.info
: null;
if (prefetched != null && usablePrefetch == null) {
AppLogger.debug(
_tag,
'drop prefetched PlaybackInfo: item=${req.itemId} '
'prefetchTicks=${prefetched.startTimeTicks} '
'expectedTicks=$expectedStartTimeTicks',
);
}
next = await builder.build(
request: req,
feeder: _feeder,
priorDisposeFuture: priorDisposeFuture,
preloadedPlaybackInfo: usablePrefetch,
forcedEngineKind: effectiveForcedEngineKind,
onEngineResolved: (kind, isDolbyVision) {
resolvedEngineKind = kind;
resolvedIsDolbyVision = isDolbyVision;
},
);
if (!mounted) {
await next.dispose();
return;
}
next.phase.addListener(_onSessionPhaseChanged);
next.onStreamFailure = _handleStreamFailure;
next.onExternalSubtitleError = _showExternalSubtitleError;
setState(() => _session = next);
unawaited(
next.engine.stream.position
.firstWhere((pos) => pos > Duration.zero)
.then((_) {
if (!mounted || !identical(_session, next)) return;
setState(() => _videoVisible = true);
})
.catchError((_) {}),
);
_attachWakelockSession(next);
_attachAndroidPipPlayingListener(next);
unawaited(
ref
.read(danmakuSessionProvider.notifier)
.loadFor(next.context.danmakuMatchContext),
);
await next.start();
if (!mounted) return;
final manuallyConfirmedMedia3 =
stickyOverride == PlayerEngineOverride.media3 &&
playerEngineKindFromDisplayName(next.engine.displayName) ==
PlayerEngineKind.media3;
if (manuallyConfirmedMedia3) {
_forcedEngineBySource.remove(_fallbackKeyForRequest(req));
}
if (req.targetServer == null) {
final ctx = next.context;
final msid = ctx.mediaSourceId;
if (msid != null && msid.isNotEmpty) {
unawaited(
_lastPlayedVersionStore.save(
serverId: ctx.serverId,
itemId: ctx.itemId,
mediaSourceId: msid,
),
);
}
}
if (_playbackRate != 1.0) {
await next.setRate(_playbackRate);
}
_configureAndroidPip();
_armRelinkSustain();
} catch (e, s) {
if (e is CancelledException || !mounted) {
AppLogger.debug(_tag, '_switchTo aborted (unmounted/cancelled)');
if (next != null) {
next.phase.removeListener(_onSessionPhaseChanged);
try {
await next.dispose();
} catch (_) {}
}
return;
}
AppLogger.error(_tag, '_switchTo failed', e, s);
final failedKind = next == null
? resolvedEngineKind
: playerEngineKindFromDisplayName(next.engine.displayName);
if (mounted &&
await _tryFallbackFromSwitchFailure(
request: req,
failedKind: failedKind,
failedSession: next,
isDolbyVision: next?.context.isDolbyVision ?? resolvedIsDolbyVision,
)) {
return;
}
if (next != null) {
next.phase.removeListener(_onSessionPhaseChanged);
try {
await next.dispose();
} catch (disposeErr) {
AppLogger.warn(
_tag,
'_switchTo failed: dispose next failed',
disposeErr,
);
}
}
if (mounted) {
setState(() {
_session = null;
_errorMessage = formatUserError(e);
});
}
} finally {
_switchInFlight = false;
if (mounted && _session?.phase.value == PlaybackPhase.failed) {
_onSessionPhaseChanged();
}
if (mounted) {
setState(() {});
_invalidateBottomBar();
}
}
await _drainPendingResync();
}
Future<void> _drainPendingResync() async {
if (!_pendingResync || !mounted) return;
_pendingResync = false;
final current = _playlist?.current.value;
if (current == null || current.itemId.isEmpty) return;
if (current.itemId == _session?.context.itemId) return;
AppLogger.debug(_tag, '_drainPendingResync → realign to ${current.itemId}');
_onCurrentItemChanged();
}
void _onSessionPhaseChanged() {
final phase = _session?.phase.value;
AppLogger.debug(_tag, 'session phase: $phase');
_syncWakelockForSession(_session);
if (phase == PlaybackPhase.ended && !_switchInFlight) {
final pl = _playlist;
if (pl != null && pl.hasNext) {
unawaited(pl.next());
}
} else if (phase == PlaybackPhase.failed && mounted) {
if (_switchInFlight) return;
final failed = _session;
failed?.phase.removeListener(_onSessionPhaseChanged);
_attachWakelockSession(null);
if (_tryFallbackFromFailedSession(failed)) {
return;
}
if (failed != null) {
_rememberFailurePosition(ticksFromDuration(failed.engine.state.position));
}
setState(() {
_session = null;
_errorMessage = formatUserError(failed?.lastError);
});
if (failed != null) {
unawaited(failed.dispose());
}
_invalidateBottomBar();
}
}
Future<void> _retry() async {
final req = _lastRequest;
if (req == null) return;
_relink.reset();
if (mounted) setState(() => _errorMessage = null);
await _switchTo(req);
}
Future<bool> _tryFallbackFromSwitchFailure({
required PlaybackRequest request,
required PlayerEngineKind? failedKind,
required bool isDolbyVision,
PlaybackSession? failedSession,
}) async {
if (_engineFallbackInFlight || failedKind == null) return false;
final override =
ref.read(playerEngineOverrideProvider).value ??
PlayerEngineOverride.auto;
final key = _fallbackKeyForRequest(request);
final fallbackTarget = nextAutoFallbackEngine(
override: override,
failedEngineKind: failedKind,
isDolbyVision: isDolbyVision,
);
if (fallbackTarget == null) return false;
_forcedEngineBySource[key] = fallbackTarget;
_engineFallbackInFlight = true;
_relink.cancelPending();
failedSession?.phase.removeListener(_onSessionPhaseChanged);
AppLogger.warn(
_tag,
'${failedKind.name} switch failed for ${request.itemId} '
'mediaSource=${request.mediaSourceId ?? '<auto>'}; '
'fallback to ${fallbackTarget.name}',
);
if (failedSession != null) {
await failedSession.dispose();
}
if (!mounted) {
_engineFallbackInFlight = false;
return true;
}
setState(() {
_session = null;
_errorMessage = null;
});
unawaited(
Future<void>.microtask(() async {
try {
await _switchTo(request, forcedEngineKind: fallbackTarget);
} finally {
_engineFallbackInFlight = false;
}
}),
);
_invalidateBottomBar();
return true;
}
bool _tryFallbackFromFailedSession(PlaybackSession? failed) {
if (failed == null || _switchInFlight || _engineFallbackInFlight) {
return false;
}
final base = _lastRequest;
if (base == null) return false;
final override =
ref.read(playerEngineOverrideProvider).value ??
PlayerEngineOverride.auto;
final failedKind = playerEngineKindFromDisplayName(
failed.engine.displayName,
);
final key = _fallbackKeyForRequest(base);
final fallbackTarget = nextAutoFallbackEngine(
override: override,
failedEngineKind: failedKind,
isDolbyVision: failed.context.isDolbyVision,
);
if (fallbackTarget == null) return false;
_forcedEngineBySource[key] = fallbackTarget;
_engineFallbackInFlight = true;
_relink.cancelPending();
final ticks = ticksFromDuration(failed.engine.state.position);
final req = ticks > 0
? base.copyWith(startPositionTicks: ticks, playFromStart: false)
: base;
AppLogger.warn(
_tag,
'${failedKind?.name ?? "unknown"} failed for ${failed.context.itemId} '
'mediaSource=${failed.context.mediaSourceId ?? '<auto>'}; '
'fallback to ${fallbackTarget.name}',
);
setState(() {
_session = null;
_errorMessage = null;
});
unawaited(
failed.dispose().whenComplete(() async {
try {
await _switchTo(req, forcedEngineKind: fallbackTarget);
} finally {
_engineFallbackInFlight = false;
}
}),
);
_invalidateBottomBar();
return true;
}
String _fallbackKeyForRequest(PlaybackRequest request) {
return [
request.targetServer?.serverId ?? '',
request.itemId,
request.mediaSourceId ?? '',
].join(':');
}
void _showExternalSubtitleError(String message) {
if (!mounted) return;
showAppSnackBar(context, message, tone: AppSnackTone.warning);
}
}