mobile: recover from mid-playback decode/seek errors (doply NAL)

Bug f6c86847/b1b5e1a2: doply/playmogo plays fine but seeking throws
"source error, invalid NAL length" in ExoPlayer. Investigation (cross-IP,
2026-06-01) showed the stream is well-formed — faststart MP4 (moov before
mdat) on cloudatacdn.com which fully supports HTTP range (206, correct
content-range, repeatable token, no redirect). So it is an ExoPlayer-internal
seek failure, not an HTTP/container problem, and expo-video exposes no
extractor/MIME hint to influence it.

Mitigation: when the native player errors *after* it had already loaded
(i.e. a mid-playback/seek failure, not an initial-load failure) and the error
is not a 404/410, recreate the source via player.replace() and resume at the
last known position — this opens a fresh connection and re-parses moov, which
typically clears the transient decode error. Hard-capped at 2 attempts per
mount to avoid any auto-reload loop; if it still fails it falls through to the
existing proxy/WebView fallback and error UI. Initial-load errors are
untouched, so the resolver and the ~59k working doply sources are unaffected.

Also thread playbackId/entityKind through the resolved-hoster and proxy/WebView
nav.replace calls so those paths get the 404 "Mark broken" affordance too, and
complete the local RouteParams type with headers/fallbackProxyUrl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-01 11:15:21 +02:00
parent 967123d5d6
commit 7b2f093d85

View file

@ -39,6 +39,8 @@ interface RouteParams {
refererHost?: string; refererHost?: string;
title?: string; title?: string;
mode?: 'video' | 'webview'; mode?: 'video' | 'webview';
headers?: Record<string, string>;
fallbackProxyUrl?: string;
fallbackEmbedUrl?: string; fallbackEmbedUrl?: string;
} }
@ -179,14 +181,53 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
// Każdy step ma osobną ref żeby nie loopować. // Każdy step ma osobną ref żeby nie loopować.
const didFallbackProxyRef = React.useRef(false); const didFallbackProxyRef = React.useRef(false);
const didFallbackWebViewRef = React.useRef(false); const didFallbackWebViewRef = React.useRef(false);
// Seek/decode recovery (bug f6c86847: doply/playmogo „invalid NAL length” przy
// przewijaniu). Stream jest poprawny — faststart MP4, CDN wspiera Range 206
// (zweryfikowane 2026-06-01 cross-IP) — więc to wewnętrzny błąd seeka ExoPlayera,
// nie HTTP. `replace(source)` otwiera świeże połączenie + re-parsuje moov; resume
// na ostatniej dobrej pozycji ratuje playback. Twardy limit 2 prób / mount żeby
// NIE wpaść w auto-loop. Tylko gdy player JUŻ grał (post-load, czyli seek-error,
// nie initial-load) i błąd nie jest 404/410.
const loadedOnceRef = React.useRef(false);
const lastGoodPositionRef = React.useRef(0);
const seekRecoveryRef = React.useRef(0);
React.useEffect(() => {
if (status === 'readyToPlay') loadedOnceRef.current = true;
}, [status]);
React.useEffect(() => { React.useEffect(() => {
if (status !== 'error') return; if (status !== 'error') return;
// Step 0: post-load decode/seek error → recover in-place (przed proxy/WebView,
// które są dla INITIAL-load errorów IP-bound CDN).
if (
loadedOnceRef.current &&
seekRecoveryRef.current < 2 &&
!isGoneError(playerError?.message)
) {
seekRecoveryRef.current += 1;
const resumeAt = lastGoodPositionRef.current;
try {
player.replace(source);
setTimeout(() => {
try {
if (resumeAt > 0) player.currentTime = resumeAt;
player.play();
} catch {
// disposed/za wcześnie — następny error tick spróbuje znów (do limitu)
}
}, 700);
} catch {
// replace failed — przepuść do fallback chain niżej
}
return;
}
// Step 1 → 2: direct fail (403/410/etc), spróbuj proxy URL. // Step 1 → 2: direct fail (403/410/etc), spróbuj proxy URL.
if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) { if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) {
didFallbackProxyRef.current = true; didFallbackProxyRef.current = true;
nav.replace('Player', { nav.replace('Player', {
url: fallbackProxyUrl, url: fallbackProxyUrl,
sceneId, sceneId,
playbackId: params.playbackId,
entityKind,
durationSec, durationSec,
refererHost, refererHost,
title, title,
@ -203,13 +244,15 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
nav.replace('Player', { nav.replace('Player', {
url: fallbackEmbedUrl, url: fallbackEmbedUrl,
sceneId, sceneId,
playbackId: params.playbackId,
entityKind,
durationSec, durationSec,
refererHost, refererHost,
title, title,
mode: 'webview', mode: 'webview',
}); });
} }
}, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title]); }, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError]);
const lastReportedRef = React.useRef(0); const lastReportedRef = React.useRef(0);
// Lokalny tick co 500ms — driver dla custom scrubber + time labels. expo-video // Lokalny tick co 500ms — driver dla custom scrubber + time labels. expo-video
@ -221,6 +264,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
try { try {
const pos = player.currentTime || 0; const pos = player.currentTime || 0;
setPosition(pos); setPosition(pos);
// Ostatnia „dobra” pozycja dla seek-recovery (tylko gdy faktycznie gra).
if (player.playing && pos > 0) lastGoodPositionRef.current = pos;
const dur = player.duration || 0; const dur = player.duration || 0;
if (dur > 0 && Math.abs(dur - knownDuration) > 0.5) setKnownDuration(dur); if (dur > 0 && Math.abs(dur - knownDuration) > 0.5) setKnownDuration(dur);
const posInt = Math.floor(pos); const posInt = Math.floor(pos);
@ -1124,6 +1169,8 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
nav.replace('Player', { nav.replace('Player', {
url: result.url, url: result.url,
sceneId, sceneId,
playbackId: params.playbackId,
entityKind,
durationSec, durationSec,
refererHost, refererHost,
headers: result.headers, headers: result.headers,