Player: buffering visuals — spinner ring, buffer-ahead gauge, CDN chip
- Loading/Buffering overlays now show a rotating ring, not just text - thin gauge under the video: how much is buffered ahead (0-30s scale), fills while loading, drains as it plays — you can see a stall building - corner chip: live CDN KB/s from /api/cdn, green dot OK / amber degraded, refreshed on every stall — explains WHY it's buffering Verified in-browser (CDN-independent): chip reflects live state (0 KB/s degraded), gauge fills from video.buffered, stall overlay + ring, playing clears. 18/18 API tests. assets v=5.
This commit is contained in:
1 parent
a61e7dd0e7
commit
733fa673c2
3 files changed
+66
-7
No files matched your search
+48
-5
@@ -723,15 +723,17 @@ function videoEl() {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
|
function stopHls() { if (hls) { hls.destroy(); hls = null; } if (bufTimer) { clearInterval(bufTimer); bufTimer = null; } }
|
||||||
|
|
||||||
let currentEp = null, lastSavedPos = 0, playerBroken = false, nextCta = null;
|
let currentEp = null, lastSavedPos = 0, playerBroken = false, nextCta = null;
|
||||||
|
|
||||||
function playerOverlay(text, onAction, actionLabel = "Retry") {
|
function playerOverlay(text, onAction, actionLabel = "Retry", spin = false) {
|
||||||
const p = document.getElementById("player");
|
const p = document.getElementById("player");
|
||||||
let o = p.querySelector(".pl-overlay");
|
let o = p.querySelector(".pl-overlay");
|
||||||
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
|
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
|
||||||
o.replaceChildren(el("span", null, text));
|
o.replaceChildren();
|
||||||
|
if (spin) o.append(el("span", "pl-spin")); // rotating ring: buffering shown, not told
|
||||||
|
o.append(el("span", null, text));
|
||||||
if (onAction) {
|
if (onAction) {
|
||||||
const b = el("button", "btn", actionLabel);
|
const b = el("button", "btn", actionLabel);
|
||||||
b.onclick = onAction;
|
b.onclick = onAction;
|
||||||
@@ -754,6 +756,42 @@ function playerFail(msg) {
|
|||||||
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
|
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- buffering visuals: buffer-ahead gauge + CDN chip ----------
|
||||||
|
let bufTimer = null;
|
||||||
|
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
|
||||||
|
// watch a stall build before it hits.
|
||||||
|
const p = document.getElementById("player");
|
||||||
|
let g = p.querySelector(".pl-buffer");
|
||||||
|
if (!g) { g = el("div", "pl-buffer"); g.append(el("i")); p.append(g); }
|
||||||
|
const bar = g.querySelector("i");
|
||||||
|
const tick = () => {
|
||||||
|
const m = p.querySelector("video");
|
||||||
|
if (!m || !m.duration) { bar.style.width = "0%"; return; }
|
||||||
|
let ahead = 0;
|
||||||
|
for (let i = 0; i < m.buffered.length; i++)
|
||||||
|
if (m.buffered.end(i) > m.currentTime) { ahead = m.buffered.end(i) - m.currentTime; break; }
|
||||||
|
bar.style.width = (Math.min(ahead / 30, 1) * 100).toFixed(1) + "%";
|
||||||
|
g.title = "buffered " + ahead.toFixed(0) + "s ahead (full = 30s)";
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
if (bufTimer) clearInterval(bufTimer);
|
||||||
|
bufTimer = setInterval(tick, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdnChip() {
|
||||||
|
// corner badge: live CDN throughput from /api/cdn (dot: green=OK, amber=degraded)
|
||||||
|
const p = document.getElementById("player");
|
||||||
|
let c = p.querySelector(".pl-cdn");
|
||||||
|
if (!c) { c = el("div", "pl-cdn"); c.append(el("i")); p.append(c); }
|
||||||
|
api("/api/cdn").then(d => {
|
||||||
|
c.className = "pl-cdn " + (d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "");
|
||||||
|
c.lastChild.textContent = d.kbps + " KB/s";
|
||||||
|
c.title = "CDN " + d.state + " — " + d.kbps + " KB/s";
|
||||||
|
}).catch(() => { c.className = "pl-cdn"; c.lastChild.textContent = "CDN ?"; });
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- auto-next on episode end (5s countdown) ----------
|
// ---------- auto-next on episode end (5s countdown) ----------
|
||||||
let nextTimer = null;
|
let nextTimer = null;
|
||||||
function onEnded() {
|
function onEnded() {
|
||||||
@@ -821,7 +859,9 @@ async function play(ep_id, ep_no) {
|
|||||||
const v = videoEl();
|
const v = videoEl();
|
||||||
v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1);
|
v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1);
|
||||||
document.getElementById("player").replaceChildren(v);
|
document.getElementById("player").replaceChildren(v);
|
||||||
playerOverlay("Loading\u2026");
|
playerOverlay("Loading\u2026", null, null, true);
|
||||||
|
bufferGauge();
|
||||||
|
cdnChip();
|
||||||
if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser");
|
if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser");
|
||||||
// the upstream CDN occasionally stalls on segment bytes (0 bytes for
|
// the upstream CDN occasionally stalls on segment bytes (0 bytes for
|
||||||
// 10s+). hls.js's default 5s time-to-first-byte abort would drop the XHR
|
// 10s+). hls.js's default 5s time-to-first-byte abort would drop the XHR
|
||||||
@@ -865,7 +905,10 @@ async function play(ep_id, ep_no) {
|
|||||||
v.onerror = () => playerFail("media error");
|
v.onerror = () => playerFail("media error");
|
||||||
v.addEventListener("playing", playerHideOverlay);
|
v.addEventListener("playing", playerHideOverlay);
|
||||||
v.addEventListener("waiting", () => {
|
v.addEventListener("waiting", () => {
|
||||||
if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026");
|
if (currentEp && !playerBroken && !v.ended) {
|
||||||
|
playerOverlay("Buffering\u2026", null, null, true);
|
||||||
|
cdnChip(); // refresh CDN readout exactly when it matters
|
||||||
|
}
|
||||||
});
|
});
|
||||||
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
|
||||||
|
|||||||
+2
-2
@@ -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=4">
|
<link rel="stylesheet" href="style.css?v=5">
|
||||||
</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=4"></script>
|
<script src="app.js?v=5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -145,6 +145,22 @@ button.tiny.on { border-color: var(--acc); color: var(--acc); }
|
|||||||
.pl-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; gap: .6rem;
|
.pl-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; gap: .6rem;
|
||||||
align-items: center; justify-content: center; background: rgba(0, 0, 0, .72);
|
align-items: center; justify-content: center; background: rgba(0, 0, 0, .72);
|
||||||
border-radius: 10px; color: var(--fg); font-size: .95rem; z-index: 1; }
|
border-radius: 10px; color: var(--fg); font-size: .95rem; z-index: 1; }
|
||||||
|
/* buffering visuals: spinner ring + buffer-ahead gauge + CDN chip */
|
||||||
|
.pl-spin { width: 48px; height: 48px; flex: none; border-radius: 50%;
|
||||||
|
border: 4px solid rgba(255, 255, 255, .18); border-top-color: var(--acc);
|
||||||
|
animation: plspin .8s linear infinite; }
|
||||||
|
@keyframes plspin { to { transform: rotate(1turn); } }
|
||||||
|
.pl-buffer { height: 4px; margin-top: 6px; border-radius: 2px; overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, .1); }
|
||||||
|
.pl-buffer i { display: block; height: 100%; width: 0;
|
||||||
|
background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; }
|
||||||
|
.pl-cdn { position: absolute; top: 10px; right: 10px; z-index: 2;
|
||||||
|
display: flex; align-items: center; gap: .4rem;
|
||||||
|
font: 600 .72rem/1 system-ui, sans-serif; padding: .3rem .55rem; border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, .55); color: #fff; pointer-events: none; }
|
||||||
|
.pl-cdn i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
|
||||||
|
.pl-cdn.ok i { background: #22c55e; }
|
||||||
|
.pl-cdn.degraded i { background: #f59e0b; }
|
||||||
#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||||
display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
|
display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
|
||||||
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
|
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
|
||||||
|
|||||||
Reference in new issue
Block a user