live.html 14 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI 短剧频道</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" />
</head>
<body>
<div class="channel">
  <header class="topbar">
    <h1 class="logo">AI 短剧频道</h1>
    <div class="tabs" id="tabs">
      <button data-mode="live" class="active">🔴 直播</button>
      <button data-mode="newest">🆕 最新</button>
      <button data-mode="oldest">⏮ 最早</button>
      <button data-mode="top">❤️ 最热</button>
    </div>
    <div class="spacer"></div>
    <div class="watching" id="watchingCount" hidden></div>
  </header>

  <div class="body" id="body">
    <aside class="left-col" id="leftCol">
      <div class="section-title" id="listTitle">片段列表</div>
      <div class="clip-list" id="clipList"></div>
    </aside>

    <div class="player-col">
      <div class="player-wrap">
        <div class="now-playing-bar">
          <span id="liveDot" hidden>🔴 直播中 ·</span>
          <span id="nowPlayingText">加载中…</span>
        </div>
        <video id="player" autoplay muted playsinline></video>
        <button class="mute-btn" id="muteBtn">🔇</button>
      </div>
    </div>

    <aside class="right-col" id="rightCol" hidden>
      <div class="section-title chat-title-row">
        <span>聊天</span>
        <button class="nickname-edit-btn" id="nicknameEditBtn">昵称 <span id="currentNickname">未设置</span> ✎</button>
      </div>
      <div class="chat-list" id="chatList"></div>
      <div class="chat-input-row">
        <input type="text" id="chatInput" placeholder="说点什么,或者想看什么…" maxlength="300" />
        <button id="chatSendBtn">发送</button>
      </div>
      <div class="say-hint" id="chatHint"></div>
    </aside>

    <!-- flex item of #body (not .channel) so `order` in the mobile media
         query actually places it between the player and left/right-col. -->
    <div class="mobile-subtabs" id="mobileSubtabs" hidden>
      <button data-sub="queue" class="active">排队队列</button>
      <button data-sub="input">聊天</button>
    </div>
  </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 mode = "live";
let clips = []; // browse mode: full list
let currentIndex = -1;
let currentClipId = null;
let livePollTimer = null;
let chatPollTimer = null;
let advanceTimer = null; // precise, server-endAt-driven switch to the next clip — the 4s poll is just a resync/fallback
let upNextCache = [];
let mobileSub = "queue";

const player = document.getElementById("player");
const clipList = document.getElementById("clipList");
const listTitle = document.getElementById("listTitle");
const nowPlayingText = document.getElementById("nowPlayingText");
const liveDot = document.getElementById("liveDot");
const leftCol = document.getElementById("leftCol");
const rightCol = document.getElementById("rightCol");
const mobileSubtabs = document.getElementById("mobileSubtabs");
const chatList = document.getElementById("chatList");
const chatInput = document.getElementById("chatInput");
const chatSendBtn = document.getElementById("chatSendBtn");
const chatHint = document.getElementById("chatHint");
const watchingCount = document.getElementById("watchingCount");
const muteBtn = document.getElementById("muteBtn");
const nicknameModal = document.getElementById("nicknameModal");
const nicknameInput = document.getElementById("nicknameInput");
const nicknameConfirmBtn = document.getElementById("nicknameConfirmBtn");
const nicknameEditBtn = document.getElementById("nicknameEditBtn");
const currentNickname = document.getElementById("currentNickname");
const clientId = Math.random().toString(36).slice(2);

muteBtn.addEventListener("click", () => {
  player.muted = !player.muted;
  muteBtn.textContent = player.muted ? "🔇" : "🔈";
});

function fmtMeta(c) {
  return `${c.width}x${c.height} · ${c.duration}s · ${new Date(c.createdAt).toLocaleString()}`;
}
function escapeHtml(s) {
  const div = document.createElement("div");
  div.textContent = s || "";
  return div.innerHTML;
}

// ---- viewer identity: nickname + uid, stored in cookies ----
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`;
}
function getIdentity() {
  const uid = getCookie("viewer_uid");
  const nickname = getCookie("viewer_nickname");
  return uid && nickname ? { uid, nickname } : null;
}
function updateNicknameLabel() {
  const existing = getIdentity();
  currentNickname.textContent = existing ? existing.nickname : "未设置";
}

// Shared by first-time setup and the always-visible "昵称 ✎" edit button.
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);
    updateNicknameLabel();
    return { uid, nickname };
  });
}

nicknameEditBtn.addEventListener("click", async () => {
  const existing = getIdentity();
  const nickname = await openNicknameModal(existing ? existing.nickname : "");
  const uid = existing ? existing.uid : crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
  setCookie("viewer_uid", uid, 365);
  setCookie("viewer_nickname", nickname, 365);
  updateNicknameLabel();
});

updateNicknameLabel();

function renderBrowseList() {
  if (clips.length === 0) {
    clipList.innerHTML = `<div class="empty-hint">片段库为空</div>`;
    return;
  }
  clipList.innerHTML = clips
    .map(
      (c, i) => `
    <div class="clip-row ${i === currentIndex ? "active" : ""}" data-i="${i}">
      <div class="prompt">${escapeHtml(c.prompt)}</div>
      <div class="meta">${fmtMeta(c)} · ❤️ <span data-likes="${c.id}">${c.likes || 0}</span></div>
    </div>`
    )
    .join("");
  clipList.querySelectorAll(".clip-row").forEach((row) => {
    row.addEventListener("click", (e) => {
      if (e.target.dataset.likes) return;
      playBrowse(Number(row.dataset.i));
    });
  });
  clipList.querySelectorAll("[data-likes]").forEach((el) => {
    el.style.cursor = "pointer";
    el.addEventListener("click", async (e) => {
      e.stopPropagation();
      const res = await fetch(`/api/like/${el.dataset.likes}`, { method: "POST" });
      if (res.ok) el.textContent = (await res.json()).likes;
    });
  });
}

function playBrowse(index) {
  if (index < 0 || index >= clips.length) return;
  currentIndex = index;
  const c = clips[index];
  player.src = `/lib/video/${c.id}`;
  player.play().catch(() => {});
  nowPlayingText.textContent = c.prompt;
  renderBrowseList();
}

player.addEventListener("ended", () => {
  if (mode !== "live" && clips.length) playBrowse((currentIndex + 1) % clips.length);
});

async function loadBrowse(sort) {
  const res = await fetch(`/api/library?sort=${sort}`);
  clips = await res.json();
  currentIndex = -1;
  renderBrowseList();
  if (clips.length) playBrowse(0);
}

// Left queue: prompt + who asked for it only — no resolution/duration/date.
function renderLiveList(state) {
  listTitle.textContent = "排队队列";
  const upcoming = state.upNext || [];
  const queue = state.queue || [];
  const statusLabel = { generating: "生成中…", matched: "即将播放", failed: "❌ 生成失败" };
  const queueHtml = queue
    .map(
      (q) => `
      <div class="clip-row">
        <div class="prompt">💬 ${escapeHtml(q.prompt)}</div>
        <div class="meta">${q.nickname ? `${escapeHtml(q.nickname)} · ` : ""}${statusLabel[q.status] || q.status}</div>
      </div>`
    )
    .join("");
  const upcomingHtml = upcoming
    .map(
      (c) => `
      <div class="clip-row">
        <div class="prompt">${escapeHtml(c.prompt)}</div>
        ${c.requestedBy ? `<div class="meta">by ${escapeHtml(c.requestedBy)}</div>` : ""}
      </div>`
    )
    .join("");
  clipList.innerHTML = queueHtml + upcomingHtml || `<div class="empty-hint">暂无排队</div>`;
}

function switchToClip(clip, offsetSec) {
  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(() => {});
  nowPlayingText.textContent = clip.prompt;
}

async function pollLive() {
  try {
    const res = await fetch(`/api/live/state?cid=${clientId}`);
    const state = await res.json();
    if (mode !== "live") return;
    watchingCount.hidden = false;
    watchingCount.textContent = `👀 ${state.watching}`;
    renderLiveList(state);
    upNextCache = state.upNext || [];

    clearTimeout(advanceTimer);
    if (state.current) {
      if (state.current.clip.id !== currentClipId) {
        const offsetSec = Math.max(0, (state.now - state.current.startAt) / 1000);
        switchToClip(state.current.clip, offsetSec);
      }
      // Fire the switch to the next clip exactly when this one should end,
      // instead of waiting for the next 4s poll to notice — the poll still
      // runs on schedule and will correct any drift.
      if (upNextCache.length) {
        const msUntilEnd = state.current.endAt - Date.now();
        advanceTimer = setTimeout(() => switchToClip(upNextCache[0], 0), Math.max(0, msUntilEnd));
      }
    }
  } catch {
    // transient — next poll retries
  }
}

const chatStatusLabel = { rejected: "❌ 未通过审核", generating: "生成中…", matched: "即将播放", scheduled: "即将播放", ready: "已排播放", failed: "❌ 生成失败" };
function renderChat(messages) {
  const atBottom = chatList.scrollHeight - chatList.scrollTop - chatList.clientHeight < 40;
  chatList.innerHTML = messages
    .map((m) => {
      const status = m.status && chatStatusLabel[m.status] ? `<span class="chat-status">${chatStatusLabel[m.status]}</span>` : "";
      return `<div class="chat-msg"><span class="chat-nick">${escapeHtml(m.nickname)}</span><span class="chat-text">${escapeHtml(m.text)}</span>${status}</div>`;
    })
    .join("");
  if (atBottom) chatList.scrollTop = chatList.scrollHeight;
}

async function pollChat() {
  try {
    const res = await fetch(`/api/chat?cid=${clientId}`);
    renderChat(await res.json());
  } catch {
    // transient — next poll retries
  }
}

async function sendChat() {
  const text = chatInput.value.trim();
  if (!text) return;
  const identity = await ensureIdentity();
  chatSendBtn.disabled = true;
  try {
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ uid: identity.uid, nickname: identity.nickname, text }),
    });
    const data = await res.json();
    if (res.status === 429) {
      chatHint.textContent = data.error;
      return;
    }
    chatInput.value = "";
    chatHint.textContent =
      data.status === "rejected" ? `未通过审核:${data.reason}` : data.status === "matched" ? "已匹配片段,即将播放" : data.status === "generating" ? "库里没有,已提交生成,请稍候…" : "";
    pollChat();
  } finally {
    chatSendBtn.disabled = false;
  }
}
chatSendBtn.addEventListener("click", sendChat);
chatInput.addEventListener("keydown", (e) => {
  if (e.key === "Enter") sendChat();
});

function applyMobileSub() {
  leftCol.classList.toggle("mobile-inactive", mobileSub !== "queue");
  rightCol.classList.toggle("mobile-inactive", mobileSub !== "input");
  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();
  })
);

async function setMode(newMode) {
  mode = newMode;
  document.querySelectorAll("#tabs button").forEach((b) => b.classList.toggle("active", b.dataset.mode === newMode));
  clearInterval(livePollTimer);
  clearInterval(chatPollTimer);
  clearTimeout(advanceTimer);
  currentClipId = null;
  if (newMode === "live") {
    liveDot.hidden = false;
    rightCol.hidden = false;
    mobileSubtabs.hidden = false;
    applyMobileSub();
    await Promise.all([pollLive(), pollChat()]);
    livePollTimer = setInterval(pollLive, 4000);
    chatPollTimer = setInterval(pollChat, 3000);
  } else {
    liveDot.hidden = true;
    rightCol.hidden = true;
    mobileSubtabs.hidden = true;
    watchingCount.hidden = true;
    leftCol.classList.remove("mobile-inactive");
    listTitle.textContent = "片段列表";
    await loadBrowse(newMode);
  }
}

document.querySelectorAll("#tabs button").forEach((b) => b.addEventListener("click", () => setMode(b.dataset.mode)));

setMode("live");
</script>
</body>
</html>