iOS root cause: start playback on BUFFER_APPENDED, not canplay

The live timeline caught it: iPhone buffered 513s, readyState stuck at
1, canplay NEVER fired -> play() never called -> eternal Loading.
Now: first BUFFER_APPENDED (hls.js's own 'data is in the pipe' signal)
triggers play, canplay still works, plus a 3s safety retry if still
paused with a non-empty buffer. play() calls are guarded + idempotent.
Desktop + mobile emu regression: playing. assets v=14.
This commit is contained in:
jozamudi committed 2026-08-31 21:01:26 +00:00
1 parent daac8d5256
commit 0315356f89
2 files changed
+14 -12

No files matched your search

+12 -10
View File
@@ -1,5 +1,5 @@
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
const JSV = 13; // bump with every app.js change; play-probe reports it so a
const JSV = 14; // bump with every app.js change; play-probe reports it so a
// stale-cache phone is detectable server-side
// BASE = this page's directory, so the app works at / or behind a
// prefixing reverse proxy (e.g. https://host/ani/)
@@ -932,7 +932,7 @@ async function play(ep_id, ep_no) {
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"); } });
let _ba = false; hls.on(Hls.Events.BUFFER_APPENDED, () => { if (!_ba) { _ba = true; dbg("buffer-appended"); doPlay(); } });
dbg("new-hls-ok");
hls.loadSource("/stream/" + s.token);
dbg("loadsource-called");
@@ -972,24 +972,26 @@ async function play(ep_id, ep_no) {
v.addEventListener("ended", onEnded);
// ponytail: play() before first data can reject with NotAllowedError in
// Chrome — the same error a real autoplay block produces — so it used to
// show a scary "browser blocked" overlay on every initial play. Wait for
// first data (canplay; hls.js's recommended pattern), THEN play(). A
// NotAllowedError at that point is a genuine policy block (common on
// phones) — offer a simple tap-to-play; escalate to the settings hint
// only if the tap itself is rejected. 'playing' still clears the
// Loading… overlay either way.
// show a scary "browser blocked" overlay on every initial play. Start as
// soon as data exists (first BUFFER_APPENDED; canplay too) — but NEVER
// wait on canplay alone: the iPhone (iOS 18.7) once sat with 513s
// buffered, readyState stuck at 1, and canplay never fired — so the
// video never started. 3s safety net covers any remaining variant.
let started = false;
const tap = () => v.play().catch(() => blocked(true));
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 = () => {
dbg("do-play");
v.play().then(() => dbg("play-resolved"))
if (started) return;
dbg("do-play", {rs: v.readyState, buf: v.buffered.length});
v.play().then(() => { started = true; 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});
setTimeout(() => { if (v.paused && v.buffered.length) doPlay(); }, 3000);
watchPos(v);
setupIntro(v, ep_no);
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,