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