382 lines
11 KiB
Dart
382 lines
11 KiB
Dart
|
|
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
}
|