part of 'player_page.dart'; extension _PlayerPageSession on _PlayerPageState { Future _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 ?? ''}', ); 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 _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 _retry() async { final req = _lastRequest; if (req == null) return; _relink.reset(); if (mounted) setState(() => _errorMessage = null); await _switchTo(req); } Future _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 ?? ''}; ' 'fallback to ${fallbackTarget.name}', ); if (failedSession != null) { await failedSession.dispose(); } if (!mounted) { _engineFallbackInFlight = false; return true; } setState(() { _session = null; _errorMessage = null; }); unawaited( Future.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 ?? ''}; ' '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); } }