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 // 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 // stale-cache phone is detectable server-side
// BASE = this page's directory, so the app works at / or behind a // BASE = this page's directory, so the app works at / or behind a
// prefixing reverse proxy (e.g. https://host/ani/) // 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.MANIFEST_LOADED, () => dbg("manifest-loaded"));
hls.on(Hls.Events.LEVEL_LOADED, () => dbg("level-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 _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"); dbg("new-hls-ok");
hls.loadSource("/stream/" + s.token); hls.loadSource("/stream/" + s.token);
dbg("loadsource-called"); dbg("loadsource-called");
@@ -972,24 +972,26 @@ async function play(ep_id, ep_no) {
v.addEventListener("ended", onEnded); v.addEventListener("ended", onEnded);
// ponytail: play() before first data can reject with NotAllowedError in // ponytail: play() before first data can reject with NotAllowedError in
// Chrome — the same error a real autoplay block produces — so it used to // 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 // show a scary "browser blocked" overlay on every initial play. Start as
// first data (canplay; hls.js's recommended pattern), THEN play(). A // soon as data exists (first BUFFER_APPENDED; canplay too) — but NEVER
// NotAllowedError at that point is a genuine policy block (common on // wait on canplay alone: the iPhone (iOS 18.7) once sat with 513s
// phones) — offer a simple tap-to-play; escalate to the settings hint // buffered, readyState stuck at 1, and canplay never fired — so the
// only if the tap itself is rejected. 'playing' still clears the // video never started. 3s safety net covers any remaining variant.
// Loading… overlay either way. let started = false;
const tap = () => v.play().catch(() => blocked(true)); const tap = () => v.play().catch(() => blocked(true));
const blocked = (again) => playerOverlay( const blocked = (again) => playerOverlay(
again ? "Playback still blocked — allow autoplay for this site in " + again ? "Playback still blocked — allow autoplay for this site in " +
"the browser's site settings" : "Tap to play", tap, "\u25b6 Play"); "the browser's site settings" : "Tap to play", tap, "\u25b6 Play");
const doPlay = () => { const doPlay = () => {
dbg("do-play"); if (started) return;
v.play().then(() => dbg("play-resolved")) 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}); .catch(err => { dbg("play-rejected", {name: err && err.name});
if (err && err.name === "NotAllowedError") blocked(false); }); if (err && err.name === "NotAllowedError") blocked(false); });
}; };
if (v.readyState >= 2) doPlay(); if (v.readyState >= 2) doPlay();
else v.addEventListener("canplay", doPlay, {once: true}); else v.addEventListener("canplay", doPlay, {once: true});
setTimeout(() => { if (v.paused && v.buffered.length) doPlay(); }, 3000);
watchPos(v); watchPos(v);
setupIntro(v, ep_no); setupIntro(v, ep_no);
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId, postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
+2 -2
View File
@@ -6,7 +6,7 @@
<title>ani-web</title> <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="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=13"> <link rel="stylesheet" href="style.css?v=14">
</head> </head>
<body> <body>
<header> <header>
@@ -30,6 +30,6 @@
max-age=300 in browser AND possibly in your HTTPS proxy, and a stale 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 --> copy is exactly how users keep seeing an old UI after a deploy -->
<script src="hls.min.js"></script> <script src="hls.min.js"></script>
<script src="app.js?v=13"></script> <script src="app.js?v=14"></script>
</body> </body>
</html> </html>