dance.html 14.4 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>换装跳舞直播间</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Shrikhand&display=swap" />
<link rel="stylesheet" href="live.css" />
<link rel="stylesheet" href="dance.css" />
<style>
  .like-btn { margin-top: 6px; font: bold 12px Tahoma, sans-serif; cursor: pointer; border: 3px solid #000; border-radius: 10px; background: #fff; color: var(--ink); padding: 4px 10px; box-shadow: var(--shadow-sm); }
  .like-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 #000; }
</style>
</head>
<body>
<div class="channel">
  <header class="topbar">
    <h1 class="logo">换装跳舞直播间</h1>
    <div class="spacer"></div>
    <div class="watching" id="watchingCount" hidden></div>
    <button class="topbar-pill" id="muteBtn">🔇 静音</button>
    <a class="topbar-pill" href="live.html">AI 短剧频道 →</a>
  </header>

  <div class="body" id="body">
    <aside class="left-col" id="leftCol">
      <div class="wardrobe-tabs" id="wardrobeTabs">
        <button data-tab="wardrobe" class="active">衣柜</button>
        <button data-tab="garment">传衣服</button>
        <button data-tab="character">传人</button>
      </div>
      <div class="clip-list" id="wardrobePanel"></div>
      <div class="site-credit">🍋 <span>换装跳舞直播间</span></div>
    </aside>

    <div class="player-col">
      <div class="player-wrap">
        <div class="live-pill" id="liveDot"><span class="dot"></span>直播中</div>
        <div class="now-playing-box" id="nowPlayingBox" hidden>
          <span class="np-nick" id="nowPlayingNick"></span>
          <span class="np-time" id="nowPlayingTime"></span>
          <div class="np-text" id="nowPlayingText">加载中…</div>
          <div><button class="like-btn" id="likeBtn" hidden>❤️ <span id="likeCount">0</span></button></div>
        </div>
        <video id="player" autoplay playsinline muted></video>
      </div>
    </div>

    <aside class="right-col" id="rightCol">
      <div class="section-title">互动</div>
      <div class="clip-list" id="queueList"></div>
    </aside>

    <div class="mobile-subtabs" id="mobileSubtabs">
      <button data-sub="wardrobe" class="active">衣柜</button>
      <button data-sub="queue">互动</button>
    </div>
  </div>
</div>

<!-- 5.1 节:首次进入强制年龄确认,非模态(点背景不关),一次性 cookie 记忆 -->
<div class="nickname-modal" id="ageGateModal" hidden>
  <div class="nickname-box age-box">
    <div class="nickname-title">进入前确认</div>
    <div class="age-disclaimer">
      本频道播放 AI 生成的换装舞蹈内容,服饰类别可能包含泳装/舞台表演装等,全部角色与画面均由 AI 生成,不代表真实人物行为。<br /><br />
      继续访问即表示您确认已年满 18 周岁。
    </div>
    <button id="ageConfirmBtn">我已满18岁,继续</button>
  </div>
</div>

<div class="nickname-modal" id="nicknameModal" hidden>
  <div class="nickname-box">
    <div class="nickname-title">先起个昵称才能点赞</div>
    <input type="text" id="nicknameInput" placeholder="你的昵称" maxlength="20" />
    <button id="nicknameConfirmBtn">确定</button>
  </div>
</div>

<script>
let wardrobe = [];
let leftTab = "wardrobe";
let mobileSub = "wardrobe";
let currentClipId = null;
let statePollTimer = null;
let advanceTimer = null;
let upNextCache = [];
const clientId = Math.random().toString(36).slice(2);

const player = document.getElementById("player");
const wardrobePanel = document.getElementById("wardrobePanel");
const queueList = document.getElementById("queueList");
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");
const muteBtn = document.getElementById("muteBtn");
const leftCol = document.getElementById("leftCol");
const rightCol = document.getElementById("rightCol");
const mobileSubtabs = document.getElementById("mobileSubtabs");
const ageGateModal = document.getElementById("ageGateModal");
const ageConfirmBtn = document.getElementById("ageConfirmBtn");
const nicknameModal = document.getElementById("nicknameModal");
const nicknameInput = document.getElementById("nicknameInput");
const nicknameConfirmBtn = document.getElementById("nicknameConfirmBtn");

function escapeHtml(s) {
  const div = document.createElement("div");
  div.textContent = s || "";
  return div.innerHTML;
}
function fmtClock(ts) {
  return new Date(ts).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
function getCookie(name) {
  const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
  return m ? decodeURIComponent(m[1]) : null;
}
function setCookie(name, value, days) {
  document.cookie = `${name}=${encodeURIComponent(value)}; max-age=${days * 86400}; path=/; samesite=lax`;
}

// ---- 5.1 年龄确认:非模态强制,一次性记忆,不能点背景关闭(没有绑定 backdrop click) ----
if (!getCookie("dance_age_confirmed")) ageGateModal.hidden = false;
ageConfirmBtn.addEventListener("click", () => {
  setCookie("dance_age_confirmed", "1", 365);
  ageGateModal.hidden = true;
});

// ---- viewer identity: same cookie pair as live.html, only needed to vote ----
function getIdentity() {
  const uid = getCookie("viewer_uid");
  const nickname = getCookie("viewer_nickname");
  return uid && nickname ? { uid, nickname } : null;
}
function openNicknameModal(prefill) {
  return new Promise((resolve) => {
    nicknameModal.hidden = false;
    nicknameInput.value = prefill || "";
    nicknameInput.focus();
    const onConfirm = () => {
      const nickname = nicknameInput.value.trim().slice(0, 20);
      if (!nickname) return;
      nicknameModal.hidden = true;
      nicknameConfirmBtn.removeEventListener("click", onConfirm);
      nicknameInput.removeEventListener("keydown", onKeydown);
      resolve(nickname);
    };
    const onKeydown = (e) => {
      if (e.key === "Enter") onConfirm();
    };
    nicknameConfirmBtn.addEventListener("click", onConfirm);
    nicknameInput.addEventListener("keydown", onKeydown);
  });
}
function ensureIdentity() {
  const existing = getIdentity();
  if (existing) return Promise.resolve(existing);
  return openNicknameModal("").then((nickname) => {
    const uid = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
    setCookie("viewer_uid", uid, 365);
    setCookie("viewer_nickname", nickname, 365);
    return { uid, nickname };
  });
}

muteBtn.addEventListener("click", () => {
  player.muted = !player.muted;
  muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声";
  if (!player.muted) player.play().catch(() => {});
});

// ---- 左侧栏:衣柜 / 传衣服 / 传人 三个 tab(3.1 节,本期只做衣柜) ----
function garmentName(id) {
  const g = wardrobe.find((x) => x.id === id);
  return g ? g.name : id;
}

function renderWardrobe() {
  document.querySelectorAll("#wardrobeTabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === leftTab));
  if (leftTab !== "wardrobe") {
    wardrobePanel.innerHTML = `<div class="coming-soon">${leftTab === "garment" ? "传衣服" : "传人"}功能开发中,敬请期待</div>`;
    return;
  }
  if (wardrobe.length === 0) {
    wardrobePanel.innerHTML = `<div class="empty-hint">衣柜还是空的</div>`;
    return;
  }
  wardrobePanel.innerHTML = `<div class="wardrobe-grid">${wardrobe
    .map(
      (g) => `
      <div class="wardrobe-card" data-garment="${g.id}">
        <img src="${escapeHtml(g.thumbnailUrl || g.imageUrl)}" />
        <div class="name">${escapeHtml(g.name)}</div>
      </div>`
    )
    .join("")}</div>`;
  wardrobePanel.querySelectorAll("[data-garment]").forEach((card) =>
    card.addEventListener("click", () => pickGarment(card.dataset.garment, card))
  );
}

document.querySelectorAll("#wardrobeTabs button").forEach((b) =>
  b.addEventListener("click", () => {
    leftTab = b.dataset.tab;
    renderWardrobe();
  })
);

async function loadWardrobe() {
  const res = await fetch("/api/dance/wardrobe");
  wardrobe = await res.json();
  renderWardrobe();
}

async function pickGarment(garmentId, card) {
  if (card.classList.contains("disabled")) return;
  card.classList.add("disabled");
  try {
    const identity = getIdentity(); // 免费模式一不强制登录(架构文档 2.7 节)— 有 cookie 就带上昵称,没有也能提交
    const res = await fetch("/api/dance/pick", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ garmentId, nickname: identity ? identity.nickname : undefined }),
    });
    const data = await res.json();
    if (!res.ok) {
      alert(data.error || "提交失败");
      return;
    }
    pollState();
  } finally {
    card.classList.remove("disabled");
  }
}

// ---- 右侧栏:接下来播放 / 生成中 / 排队中(可点赞),复用 live.html 的分组思路 ----
function renderQueue(state) {
  const upcoming = state.upNext || [];
  const queue = state.queue || [];
  const generating = queue.filter((q) => q.status === "generating");
  const failed = queue.filter((q) => q.status === "failed");

  const upcomingHtml = upcoming
    .map(
      (c) => `
      <div class="clip-row">
        <div class="prompt">${escapeHtml(c.prompt || garmentName(c.garmentId))}</div>
        ${c.requestedBy ? `<div class="meta">by ${escapeHtml(c.requestedBy)}</div>` : ""}
      </div>`
    )
    .join("");
  const generatingHtml = generating
    .map(
      (q) => `
      <div class="qrow">
        <div class="clip-row gen">
          <div class="prompt">👗 ${escapeHtml(garmentName(q.garmentId))}</div>
          <div class="meta">${q.nickname ? `${escapeHtml(q.nickname)} · ` : ""}生成中…</div>
        </div>
        <button class="up-btn ${q.voted ? "voted" : ""}" data-vote="${q.id}">▲<span>${q.votes || 0}</span></button>
      </div>`
    )
    .join("");
  const failedHtml = failed
    .map(
      (q) => `
      <div class="clip-row failed">
        <div class="prompt">👗 ${escapeHtml(garmentName(q.garmentId))}</div>
        <div class="meta">${q.nickname ? `${escapeHtml(q.nickname)} · ` : ""}❌ 生成失败</div>
      </div>`
    )
    .join("");

  queueList.innerHTML =
    (upcomingHtml ? `<div class="section-head">接下来播放</div>${upcomingHtml}` : "") +
    (generatingHtml ? `<div class="section-head">生成中(可点赞)</div>${generatingHtml}` : "") +
    (failedHtml ? `<div class="section-head">生成失败</div>${failedHtml}` : "") ||
    `<div class="empty-hint">暂无排队,去左边衣柜选一件衣服吧</div>`;

  queueList.querySelectorAll("[data-vote]").forEach((btn) => {
    btn.addEventListener("click", () => voteQueueItem(btn.dataset.vote, btn));
  });
}

async function voteQueueItem(id, btn) {
  const identity = await ensureIdentity();
  btn.disabled = true;
  try {
    const res = await fetch(`/api/dance/queue/vote/${id}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ uid: identity.uid }),
    });
    if (!res.ok) return;
    const data = await res.json();
    btn.classList.toggle("voted", data.voted);
    btn.querySelector("span").textContent = data.votes;
  } finally {
    btn.disabled = false;
  }
}

// ---- 播放器:跟 live.html 一样,服务端 endAt 是权威时钟,轮询只是纠偏 ----
function setNowPlaying(nick, time, text, 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;
    likeBtn.onclick = async () => {
      const res = await fetch(`/api/like/${clip.id}`, { method: "POST" });
      if (res.ok) likeCount.textContent = (await res.json()).likes;
    };
  }
}

function switchToClip(clip, offsetSec, requestedBy) {
  currentClipId = clip.id;
  player.src = `/lib/video/${clip.id}`;
  if (offsetSec > 0) {
    player.addEventListener(
      "loadedmetadata",
      () => {
        player.currentTime = Math.min(offsetSec, player.duration || offsetSec);
      },
      { once: true }
    );
  }
  player.play().catch(() => {});
  setNowPlaying(requestedBy, fmtClock(Date.now()), clip.prompt, clip);
}

async function pollState() {
  try {
    const identity = getIdentity();
    const res = await fetch(`/api/dance/live/state?cid=${clientId}${identity ? `&uid=${encodeURIComponent(identity.uid)}` : ""}`);
    const state = await res.json();
    watchingCount.hidden = false;
    watchingCount.textContent = `👀 ${state.watching}`;
    renderQueue(state);
    upNextCache = state.upNext || [];

    clearTimeout(advanceTimer);
    if (state.current && state.current.clip) {
      if (state.current.clip.id !== currentClipId) {
        const offsetSec = Math.max(0, (state.now - state.current.startAt) / 1000);
        switchToClip(state.current.clip, offsetSec, state.current.requestedBy);
      }
      if (upNextCache.length) {
        const msUntilEnd = state.current.endAt - Date.now();
        advanceTimer = setTimeout(() => switchToClip(upNextCache[0], 0, upNextCache[0].requestedBy), Math.max(0, msUntilEnd));
      }
    } else if (!currentClipId) {
      nowPlayingText.textContent = "衣柜还没有内容,去左边选一件衣服开播吧";
      nowPlayingBox.hidden = false;
    }
  } catch {
    // transient — next poll retries
  }
}

function applyMobileSub() {
  leftCol.classList.toggle("mobile-inactive", mobileSub !== "wardrobe");
  rightCol.classList.toggle("mobile-inactive", mobileSub !== "queue");
  mobileSubtabs.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.sub === mobileSub));
}
mobileSubtabs.querySelectorAll("button").forEach((b) =>
  b.addEventListener("click", () => {
    mobileSub = b.dataset.sub;
    applyMobileSub();
  })
);
applyMobileSub();

loadWardrobe();
pollState();
statePollTimer = setInterval(pollState, 4000);
</script>
</body>
</html>