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