From 3c2c0c4222d472aafb2cfbb2f779791a31fd0be8 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 9 Sep 2026 12:54:54 +0800 Subject: [PATCH] Fix dance.html playback: slow H3 video proxy left rooms stuck/muted/choppy --- .gitignore | 5 +++++ dance-channel.test.js | 11 ++++++++++- dance.html | 39 +++++++++++++++++++++++++++++---------- server.js | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------- 4 files changed, 152 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 73e5c28..5b8a907 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ uploads/ # local raw asset library (persona/clothes/musicvideo/withclothes source # material, ~11GB) — way too large for git, lives on disk only H3assest/ + +# server.js's disk cache of H3-console-proxied dance clip bytes +# (ensureVideoCached) — fully rebuildable from the console on demand, not +# source +video-cache/ diff --git a/dance-channel.test.js b/dance-channel.test.js index e7edf9e..8021abe 100644 --- a/dance-channel.test.js +++ b/dance-channel.test.js @@ -62,7 +62,16 @@ function startMockH3() { res.end(JSON.stringify({ id })); return; } - const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/); + // /lib/video/:id's cache-warming (ensureVideoCached, server.js) hits + // this same mock's GET /v1/videos/:id/file — must not match the + // status-poll regex below, or its background requests get counted + // as extra "job polls" and trip the dedupe test's job-count assert. + if (req.method === "GET" && /^\/v1\/videos\/[^/]+\/file$/.test(req.url)) { + res.writeHead(200, { "Content-Type": "video/mp4" }); + res.end("fake-mp4-bytes"); + return; + } + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/([^/]+)$/); if (pollMatch) { const id = pollMatch[1]; const seen = (videoPollCounts.get(id) || 0) + 1; diff --git a/dance.html b/dance.html index 2e05f93..d46cfde 100644 --- a/dance.html +++ b/dance.html @@ -47,7 +47,6 @@ @@ -142,7 +141,6 @@ const roomTitle = document.getElementById("roomTitle"); const nowPlayingBox = document.getElementById("nowPlayingBox"); const nowPlayingNick = document.getElementById("nowPlayingNick"); const nowPlayingTime = document.getElementById("nowPlayingTime"); -const nowPlayingText = document.getElementById("nowPlayingText"); const likeBtn = document.getElementById("likeBtn"); const likeCount = document.getElementById("likeCount"); const watchingCount = document.getElementById("watchingCount"); @@ -393,7 +391,14 @@ muteBtn.addEventListener("click", () => { // 有声自动播放,unmuted play() 被拒绝就退回静音自动播放,再靠下面的点击进入 // 蒙版拿到那次手势,切回有声。 function tryPlay() { - player.play().catch(() => { + player.play().catch((err) => { + // Only fall back to muted autoplay for an actual autoplay-policy + // rejection. tryPlay() re-runs on every clip switch (every ~10s), and a + // switch mid-load commonly throws AbortError ("interrupted by a new + // load request") — treating that the same as a policy block used to + // silently flip sound back off even after the viewer had explicitly + // unmuted via the splash click or 🔈 button. + if (err && err.name !== "NotAllowedError") return; player.muted = true; muteBtn.textContent = "🔇 静音"; player.play().catch(() => {}); @@ -404,8 +409,14 @@ splash.addEventListener("click", () => { splash.remove(); const desktop = matchMedia("(min-width: 769px)").matches; player.muted = !desktop; // 手机进入后仍保持静音,同 live.html 的理由 - player.play().catch(() => { - player.muted = true; + player.play().catch((err) => { + // Same guard as tryPlay(): only a genuine autoplay-policy rejection + // should force muted — this click IS the user gesture, so a rejection + // here is almost always something else (e.g. the clip's src still + // mid-switch from a just-arrived pollState) and shouldn't silently + // override the sound-on choice this click just made. + if (!err || err.name === "NotAllowedError") player.muted = true; + muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声"; }); muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声"; }); @@ -719,13 +730,12 @@ async function voteQueueItem(id, btn) { } // ---- 播放器:跟 live.html 一样,服务端 endAt 是权威时钟,轮询只是纠偏 ---- -function setNowPlaying(nick, time, text, clip) { +function setNowPlaying(nick, time, clip) { nowPlayingBox.hidden = false; nowPlayingNick.hidden = !nick; nowPlayingNick.textContent = nick || ""; nowPlayingTime.hidden = !time; nowPlayingTime.textContent = time || ""; - nowPlayingText.textContent = text; likeBtn.hidden = !clip; if (clip) { likeCount.textContent = clip.likes || 0; @@ -736,6 +746,16 @@ function setNowPlaying(nick, time, text, clip) { } } +// A clip whose /lib/video request errors out (e.g. a transient 401/502 from +// the upstream H3 console) would otherwise just sit there frozen until the +// server-clock advanceTimer eventually fires — skip ahead immediately +// instead of leaving the room looking dead. +player.addEventListener("error", () => { + if (player.currentSrc && currentClipId && player.currentSrc.endsWith(`/lib/video/${currentClipId}`) && upNextCache.length) { + switchToClip(upNextCache[0], 0, upNextCache[0].requestedBy); + } +}); + function switchToClip(clip, offsetSec, requestedBy) { currentClipId = clip.id; player.src = `/lib/video/${clip.id}`; @@ -749,7 +769,7 @@ function switchToClip(clip, offsetSec, requestedBy) { ); } tryPlay(); - setNowPlaying(requestedBy, fmtClock(Date.now()), clip.prompt, clip); + setNowPlaying(requestedBy, fmtClock(Date.now()), clip); } async function pollState() { @@ -781,8 +801,7 @@ async function pollState() { advanceTimer = setTimeout(() => switchToClip(upNextCache[0], 0, upNextCache[0].requestedBy), Math.max(0, msUntilEnd)); } } else if (!currentClipId) { - nowPlayingText.textContent = "还没有可播放的内容,去右边选一件衣服开播吧"; - nowPlayingBox.hidden = false; + setNowPlaying("还没有可播放的内容,去右边选一件衣服开播吧", null, null); } } catch { // transient — next poll retries diff --git a/server.js b/server.js index 75d1aa3..62be458 100644 --- a/server.js +++ b/server.js @@ -930,6 +930,93 @@ function h3ConsoleFetch(pathAndQuery, options) { }); } +// Local disk cache for H3-console-proxied clip video bytes. The shared H3 +// cluster is slow enough at serving already-finished files (confirmed +// 2026-09: ~5s to first byte, ~50s total for a 1.7MB/10s clip, ~35KB/s) that +// a dance room's