iPhone: full client timeline + self-diagnosing watchdog

Every step now beacons with millisecond offsets:
branch-decision -> new-hls-ok -> loadsource-called -> attach-ok ->
manifest-loaded -> level-loaded -> frag-loaded(1..2) -> buffer-appended
-> do-play -> play-resolved/play-rejected(name) + EVERY hls.js error
(fatal and silent retries). Plus a watchdog: if not playing within 15s,
it snapshots {readyState, networkState, paused, buffered, hls.state,
overlay text} every 15s (x6) — so a silent death anywhere produces a
state dump instead of nothing.

Verified on desktop: healthy timeline lands end-to-end, playback OK,
18/18 tests. assets v=13.
This commit is contained in:
jozamudi committed 2026-08-31 20:53:18 +00:00
1 parent aba266ee4b
commit aaaf8e9308
3 files changed
+42 -17

No files matched your search

+3 -1
View File
@@ -291,7 +291,9 @@ def api_events(evt: dict = Body(...), request: Request = None):
db.event(c, request.state.user, evt["type"],
{k: evt[k] for k in ("ep_id", "ep", "lang", "msg", "detail",
"jsv", "branch", "hls_lib", "mse", "native_hls", "ua",
"step", "fatal", "details", "buffer", "retry", "http") if k in evt})
"step", "fatal", "details", "buffer", "retry", "http",
"dt", "n", "rs", "ns", "paused", "bufEnd", "ct",
"hls_state", "ov", "name") if k in evt})
c.commit()
c.close()
return {"ok": True}
+37 -14
View File
@@ -769,6 +769,7 @@ function playerFail(msg) {
// ---------- buffering visuals: buffer-ahead gauge + CDN chip ----------
let bufTimer = null;
let wdTimer = null; // play() watchdog (stuck-snapshot interval)
function bufferGauge() {
// thin bar under the video: how much is buffered AHEAD of playhead
// (0-30s scale). Fills while loading, drains as it plays — you can
@@ -864,6 +865,9 @@ async function play(ep_id, ep_no) {
playerBroken = false;
setNowEp(ep_id, ep_no);
localStorage.setItem("aniweb_lang_" + detail.slug_id, lang);
const t0 = performance.now();
const dbg = (step, extra) => { try { postEvent({type: "play-probe", jsv: JSV, dt: Math.round(performance.now() - t0), step, ...(extra || {})}); } catch {} };
if (wdTimer) { clearInterval(wdTimer); wdTimer = null; }
try {
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
stopHls();
@@ -874,15 +878,13 @@ async function play(ep_id, ep_no) {
bufferGauge();
cdnChip();
// DECISIVE PROBE (iPhone debugging): report exactly which branch we're
// about to take + the browser's real capabilities. One retry on the
// phone tells us where play() dies without any guessing.
// about to take + the browser's real capabilities.
try {
const nativeOK = (() => { try { return document.createElement("video").canPlayType("application/vnd.apple.mpegurl") || "none"; } catch { return "err"; } })();
const mse = (() => { try { return (window.Hls && Hls.isSupported) ? Hls.isSupported() : null; } catch { return "err"; } })();
postEvent({type: "play-probe", jsv: JSV,
dbg("branch-decision", {
branch: (!window.Hls || !Hls.isSupported()) ? (nativeOK !== "none" && nativeOK !== "" ? "native" : "none-throw") : "hlsjs",
hls_lib: typeof window.Hls,
mse, native_hls: nativeOK, ua: navigator.userAgent});
hls_lib: typeof window.Hls, mse, native_hls: nativeOK, ua: navigator.userAgent});
} catch {}
if (!window.Hls || !Hls.isSupported()) {
// No MSE (e.g. iOS < 17): fall back to the browser's NATIVE HLS player.
@@ -915,10 +917,11 @@ async function play(ep_id, ep_no) {
timeoutRetry: { maxNumRetry: 6, retryDelayMs: 1000, maxRetryDelayMs: 20000, backoff: "linear" },
errorRetry: { maxNumRetry: 8, retryDelayMs: 1000, maxRetryDelayMs: 20000, backoff: "linear" },
} } });
// iPhone debugging: log EVERY hls.js error (fatal and not) so the
// client-side timeline is visible server-side
// iPhone debugging: log EVERY hls.js error (fatal and not) + the
// success milestones, so the client-side timeline is visible server-side
hls.on(Hls.Events.ERROR, (_, data) => {
try { postEvent({type: "hls-err", jsv: JSV, fatal: data.fatal, details: data.details,
try { postEvent({type: "hls-err", jsv: JSV, dt: Math.round(performance.now() - t0),
fatal: data.fatal, details: data.details,
buffer: data.buffer || null, retry: data.retry ?? null,
http: data.response ? data.response.code : null}); } catch {}
if (data.fatal) {
@@ -926,11 +929,28 @@ async function play(ep_id, ep_no) {
playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : ""));
}
});
try { postEvent({type: "play-probe", jsv: JSV, step: "new-hls-ok"}); } catch {}
hls.on(Hls.Events.MANIFEST_LOADED, () => dbg("manifest-loaded"));
hls.on(Hls.Events.LEVEL_LOADED, () => dbg("level-loaded"));
let _fn = 0; hls.on(Hls.Events.FRAG_LOADED, () => { if (++_fn <= 2) dbg("frag-loaded", {n: _fn}); });
let _ba = false; hls.on(Hls.Events.BUFFER_APPENDED, () => { if (!_ba) { _ba = true; dbg("buffer-appended"); } });
dbg("new-hls-ok");
hls.loadSource("/stream/" + s.token);
try { postEvent({type: "play-probe", jsv: JSV, step: "loadsource-called"}); } catch {}
dbg("loadsource-called");
hls.attachMedia(v);
try { postEvent({type: "play-probe", jsv: JSV, step: "attach-ok"}); } catch {}
dbg("attach-ok");
// self-diagnosing watchdog: if not playing within 15s, snapshot the full
// client state every 15s (max 6) — catches any silent death, anywhere
let _wd = 0;
wdTimer = setInterval(() => {
const m = document.getElementById("player")?.querySelector("video");
if (!m || m.paused === false) { clearInterval(wdTimer); wdTimer = null; return; }
if (++_wd >= 6) { clearInterval(wdTimer); wdTimer = null; return; }
dbg("stuck-snapshot", {
rs: m.readyState, ns: m.networkState, paused: m.paused,
bufEnd: m.buffered.length ? m.buffered.end(m.buffered.length - 1) : 0,
ct: m.currentTime, hls_state: hls ? hls.state : null,
ov: (document.querySelector(".pl-overlay")?.textContent || "").slice(0, 60)});
}, 15000);
}
currentEp = {ep_id, ep_no, v};
lastSavedPos = 0;
@@ -962,9 +982,12 @@ async function play(ep_id, ep_no) {
const blocked = (again) => playerOverlay(
again ? "Playback still blocked — allow autoplay for this site in " +
"the browser's site settings" : "Tap to play", tap, "\u25b6 Play");
const doPlay = () => v.play().catch(err => {
if (err && err.name === "NotAllowedError") blocked(false);
});
const doPlay = () => {
dbg("do-play");
v.play().then(() => dbg("play-resolved"))
.catch(err => { dbg("play-rejected", {name: err && err.name});
if (err && err.name === "NotAllowedError") blocked(false); });
};
if (v.readyState >= 2) doPlay();
else v.addEventListener("canplay", doPlay, {once: true});
watchPos(v);
+2 -2
View File
@@ -6,7 +6,7 @@
<title>ani-web</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23f97316'/%3E%3Ctext x='32' y='45' font-family='sans-serif' font-size='42' font-weight='700' text-anchor='middle' fill='%230e0f13'%3Ea%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css?v=12">
<link rel="stylesheet" href="style.css?v=13">
</head>
<body>
<header>
@@ -30,6 +30,6 @@
max-age=300 in browser AND possibly in your HTTPS proxy, and a stale
copy is exactly how users keep seeing an old UI after a deploy -->
<script src="hls.min.js"></script>
<script src="app.js?v=12"></script>
<script src="app.js?v=13"></script>
</body>
</html>