live.html 9.4 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">接下来想看什么</div>
      <div class="say-panel">
        <textarea id="sayInput" placeholder="接下来想看什么…" maxlength="300"></textarea>
        <button id="sayBtn">发送</button>
        <div class="say-hint" id="sayHint"></div>
      </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>

<script>
let mode = "live";
let clips = []; // browse mode: full list
let currentIndex = -1;
let currentClipId = null;
let livePollTimer = 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 sayInput = document.getElementById("sayInput");
const sayBtn = document.getElementById("sayBtn");
const sayHint = document.getElementById("sayHint");
const watchingCount = document.getElementById("watchingCount");
const muteBtn = document.getElementById("muteBtn");
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;
}

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);
}

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">${statusLabel[q.status] || q.status}</div></div>`)
    .join("");
  const upcomingHtml = upcoming
    .map((c) => `<div class="clip-row"><div class="prompt">${escapeHtml(c.prompt)}</div><div class="meta">${fmtMeta(c)}</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
  }
}

async function submitPrompt() {
  const prompt = sayInput.value.trim();
  if (!prompt) return;
  sayBtn.disabled = true;
  try {
    const res = await fetch("/api/prompt", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt }),
    });
    const data = await res.json();
    if (res.status === 429) {
      sayHint.textContent = data.error;
      return;
    }
    sayInput.value = "";
    sayHint.textContent = data.status === "matched" ? "已匹配片段,即将播放" : data.status === "generating" ? "库里没有,已提交生成,请稍候…" : "";
  } finally {
    sayBtn.disabled = false;
  }
}
sayBtn.addEventListener("click", submitPrompt);
sayInput.addEventListener("keydown", (e) => {
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    submitPrompt();
  }
});

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);
  clearTimeout(advanceTimer);
  currentClipId = null;
  if (newMode === "live") {
    liveDot.hidden = false;
    rightCol.hidden = false;
    mobileSubtabs.hidden = false;
    applyMobileSub();
    await pollLive();
    livePollTimer = setInterval(pollLive, 4000);
  } 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>