🎮ArcadeLab

SyncPlayer · 双人视频同步

by HyperShark77
1016 lines30.5 KB
▶ Play
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>SyncPlayer · 双人视频同步</title>
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
<style>
  :root{
    --bg:#06060e;
    --accent:#8b5cf6;
    --accent2:#06b6d4;
    --ok:#34d399;
    --err:#f87171;
    --warn:#fbbf24;
    --text:#e9e9f5;
    --muted:#8a8aa8;
    --dim:#5c5c78;
    --border:rgba(255,255,255,.09);
    --panel:rgba(20,20,34,.72);
  }
  *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
  html,body{height:100%}
  body{
    background:
      radial-gradient(1000px 600px at 15% -10%, #1e1b4b 0%, transparent 55%),
      radial-gradient(900px 600px at 90% 5%, #0c4a6e 0%, transparent 50%),
      var(--bg);
    color:var(--text);
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
    min-height:100%;
    line-height:1.6;
    overflow:hidden;
  }
  ::selection{background:rgba(139,92,246,.4)}

  #app{position:fixed;inset:0}
  .screen{position:absolute;inset:0;display:none;flex-direction:column}
  .screen.active{display:flex}

  /* ===== 主页 ===== */
  #screen-home{
    align-items:center;justify-content:center;padding:20px;
    overflow-y:auto;
  }
  .card{
    width:min(460px,100%);
    background:linear-gradient(165deg,rgba(30,30,58,.94),rgba(14,14,26,.97));
    border:1px solid var(--border);
    border-radius:22px;
    padding:30px 26px 24px;
    box-shadow:0 30px 80px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.06);
    animation:pop .35s cubic-bezier(.2,.9,.3,1.2);
  }
  @keyframes pop{from{opacity:0;transform:translateY(14px) scale(.97)}to{opacity:1;transform:none}}

  .brand{
    text-align:center;margin-bottom:22px;
  }
  .brand-mark{
    width:62px;height:62px;margin:0 auto 12px;border-radius:18px;
    display:grid;place-items:center;
    font-size:26px;
    background:linear-gradient(135deg,var(--accent),var(--accent2));
    box-shadow:0 12px 32px rgba(139,92,246,.4);
  }
  h1{font-size:26px;letter-spacing:.5px;font-weight:800;text-align:center}
  h1 span{background:linear-gradient(90deg,#c4b5fd,#67e8f9);
    -webkit-background-clip:text;background-clip:text;color:transparent}
  .sub{text-align:center;color:var(--muted);font-size:13px;margin-top:6px}

  .tabs{
    display:flex;gap:6px;background:rgba(255,255,255,.05);
    padding:4px;border-radius:13px;margin-bottom:18px;
  }
  .tabs button{
    flex:1;border:0;background:transparent;color:#9a9ab8;font-family:inherit;
    font-size:14px;padding:11px;border-radius:10px;cursor:pointer;transition:.18s;
  }
  .tabs button.on{
    background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;
    box-shadow:0 4px 14px rgba(139,92,246,.35);
  }
  .tab-panel{display:none}
  .tab-panel.active{display:block}

  .field{margin-bottom:14px}
  .field label{display:block;font-size:12px;color:var(--muted);margin-bottom:7px;font-weight:600}
  .field input{
    width:100%;padding:14px 16px;border-radius:12px;font-family:inherit;font-size:15px;
    background:rgba(0,0,0,.4);
    border:1px solid var(--border);
    color:var(--text);outline:none;transition:.18s;
    letter-spacing:1px;
    text-align:center;
  }
  .field input:focus{
    border-color:rgba(139,92,246,.6);
    background:rgba(139,92,246,.06);
    box-shadow:0 0 0 3px rgba(139,92,246,.12);
  }
  .field input::placeholder{color:#4a4a64;letter-spacing:0;font-size:13px}

  .primary{
    width:100%;padding:15px;border:0;border-radius:13px;cursor:pointer;
    font-family:inherit;font-size:15.5px;font-weight:700;letter-spacing:.6px;color:#fff;
    background:linear-gradient(135deg,var(--accent),var(--accent2));
    box-shadow:0 10px 30px rgba(139,92,246,.35);transition:.2s;
    margin-top:6px;
  }
  .primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 14px 38px rgba(139,92,246,.5)}
  .primary:disabled{opacity:.4;cursor:not-allowed;box-shadow:none;transform:none}

  .hint{
    font-size:12px;color:var(--dim);text-align:center;line-height:1.75;
    margin-top:18px;padding-top:16px;border-top:1px solid var(--border);
  }
  .hint b{color:var(--warn);font-weight:700}
  .hint code{
    background:rgba(0,0,0,.4);padding:2px 6px;border-radius:5px;
    color:#7dd3fc;font-size:11px;
  }

  /* ===== 房间 ===== */
  #screen-room{
    background:#000;
  }
  .room-top{
    position:absolute;top:0;left:0;right:0;z-index:10;
    display:flex;align-items:center;justify-content:space-between;gap:12px;
    padding:12px 16px;
    background:linear-gradient(180deg, rgba(0,0,0,.85), rgba(0,0,0,0));
    pointer-events:none;
  }
  .room-top > * { pointer-events:auto; }

  .exit-btn{
    display:inline-flex;align-items:center;gap:7px;
    padding:9px 15px;border-radius:11px;cursor:pointer;
    background:rgba(255,255,255,.08);
    border:1px solid rgba(255,255,255,.15);
    color:#e9e9f5;font-family:inherit;font-size:13px;font-weight:600;
    transition:.18s;backdrop-filter:blur(10px);
    white-space:nowrap;
  }
  .exit-btn:hover{background:rgba(248,113,113,.25);border-color:rgba(248,113,113,.5);color:#fff}

  .room-info{
    display:flex;align-items:center;gap:10px;
    padding:9px 14px;border-radius:11px;
    background:rgba(0,0,0,.55);
    border:1px solid rgba(255,255,255,.12);
    backdrop-filter:blur(10px);
    font-size:13px;
  }
  .room-code{
    font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
    font-size:15px;font-weight:800;letter-spacing:1.5px;
    color:#c4b5fd;
  }
  .copy-btn{
    padding:3px 8px;border-radius:6px;border:0;cursor:pointer;
    background:rgba(139,92,246,.25);color:#ddd6fe;
    font-family:inherit;font-size:11px;font-weight:600;
    transition:.15s;
  }
  .copy-btn:hover{background:rgba(139,92,246,.5);color:#fff}

  .conn-status{
    display:inline-flex;align-items:center;gap:6px;
    font-size:12px;color:var(--muted);
  }
  .dot{
    width:8px;height:8px;border-radius:50%;
    background:#555;flex:none;transition:.2s;
  }
  .dot.connecting{background:var(--warn);box-shadow:0 0 8px var(--warn);animation:pulse 1.2s ease-in-out infinite}
  .dot.online{background:var(--ok);box-shadow:0 0 8px var(--ok)}
  .dot.offline{background:var(--err);box-shadow:0 0 8px var(--err)}
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}

  .video-wrap{
    position:absolute;inset:0;
    display:flex;align-items:center;justify-content:center;
    background:#000;
  }
  #video{
    max-width:100%;max-height:100%;
    width:auto;height:auto;
    background:#000;
  }
  #video:not([src]){
    display:none;
  }

  /* 空状态 */
  .empty-state{
    position:absolute;inset:0;
    display:flex;flex-direction:column;align-items:center;justify-content:center;
    text-align:center;padding:20px;
    pointer-events:none;
  }
  .empty-state.hide{display:none}
  .empty-icon{
    width:88px;height:88px;border-radius:26px;
    display:grid;place-items:center;font-size:38px;
    background:rgba(255,255,255,.05);
    border:1px solid rgba(255,255,255,.08);
    color:var(--dim);
    margin-bottom:18px;
  }
  .empty-title{font-size:16px;font-weight:700;color:#c9c9e0;margin-bottom:6px}
  .empty-sub{font-size:13px;color:var(--muted);max-width:340px;line-height:1.7}

  /* 底部工具栏 */
  .room-bottom{
    position:absolute;bottom:0;left:0;right:0;z-index:10;
    display:flex;align-items:center;justify-content:space-between;gap:12px;
    padding:14px 16px;
    background:linear-gradient(0deg, rgba(0,0,0,.85), rgba(0,0,0,0));
    pointer-events:none;
  }
  .room-bottom > * { pointer-events:auto; }

  .import-btn{
    display:inline-flex;align-items:center;gap:8px;
    padding:12px 18px;border-radius:13px;cursor:pointer;
    background:linear-gradient(135deg,var(--accent),var(--accent2));
    border:0;color:#fff;
    font-family:inherit;font-size:14px;font-weight:700;
    box-shadow:0 8px 24px rgba(139,92,246,.4);
    transition:.2s;white-space:nowrap;
  }
  .import-btn:hover{transform:translateY(-2px);box-shadow:0 12px 32px rgba(139,92,246,.55)}
  .import-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none}

  .status-chips{display:flex;gap:8px;flex-wrap:wrap}
  .chip{
    display:inline-flex;align-items:center;gap:6px;
    padding:8px 13px;border-radius:10px;
    background:rgba(0,0,0,.55);
    border:1px solid rgba(255,255,255,.12);
    font-size:12px;color:var(--muted);
    backdrop-filter:blur(10px);
    transition:.2s;
  }
  .chip.on{
    color:#6ee7b7;border-color:rgba(52,211,153,.4);
    background:rgba(52,211,153,.12);
  }
  .chip .mini-dot{
    width:6px;height:6px;border-radius:50%;background:currentColor;flex:none;
  }

  /* Toast */
  .toast-wrap{
    position:fixed;bottom:100px;left:50%;transform:translateX(-50%);
    z-index:200;display:flex;flex-direction:column;gap:8px;align-items:center;
    pointer-events:none;width:calc(100% - 40px);max-width:380px;
  }
  .toast{
    padding:12px 18px;border-radius:12px;
    background:rgba(24,24,40,.96);
    border:1px solid rgba(255,255,255,.12);
    color:#e9e9f5;font-size:13px;font-weight:500;
    box-shadow:0 14px 40px rgba(0,0,0,.7);
    backdrop-filter:blur(12px);
    animation:toastIn .3s cubic-bezier(.2,.9,.3,1.2);
    max-width:100%;
    text-align:center;
  }
  .toast.ok{border-color:rgba(52,211,153,.4);color:#a7f3d0}
  .toast.err{border-color:rgba(248,113,113,.4);color:#fca5a5}
  @keyframes toastIn{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}

  /* 全屏按钮 */
  .fs-btn{
    display:inline-flex;align-items:center;justify-content:center;
    width:38px;height:38px;border-radius:11px;cursor:pointer;
    background:rgba(255,255,255,.08);
    border:1px solid rgba(255,255,255,.15);
    color:#e9e9f5;font-size:16px;
    transition:.18s;backdrop-filter:blur(10px);
  }
  .fs-btn:hover{background:rgba(255,255,255,.18)}

  @media (max-width:520px){
    .room-info{padding:7px 10px;font-size:12px}
    .room-code{font-size:13px}
    .copy-btn{display:none}
    .import-btn{padding:10px 14px;font-size:13px}
    .exit-btn{padding:8px 12px;font-size:12px}
    .chip{padding:6px 10px;font-size:11px}
  }
</style>
</head>
<body>
<div id="app">

  <!-- ========== 主页 ========== -->
  <div id="screen-home" class="screen active">
    <div class="card">
      <div class="brand">
        <div class="brand-mark">▶</div>
        <h1>Sync<span>Player</span></h1>
        <p class="sub">双人视频同步 · 实时共享进度</p>
      </div>

      <div class="tabs">
        <button data-tab="create" class="on">创建房间</button>
        <button data-tab="join">加入房间</button>
      </div>

      <div id="tab-create" class="tab-panel active">
        <div class="field">
          <label>房间号(4-8 位数字或字母)</label>
          <input type="text" id="createCode" placeholder="例如 8866" maxlength="8" autocomplete="off">
        </div>
        <button id="createBtn" class="primary">创 建 房 间</button>
      </div>

      <div id="tab-join" class="tab-panel">
        <div class="field">
          <label>输入对方提供的房间号</label>
          <input type="text" id="joinCode" placeholder="输入房间号" maxlength="8" autocomplete="off">
        </div>
        <button id="joinBtn" class="primary">加 入 房 间</button>
      </div>

      <div class="hint">
        双方各自导入同一个视频文件 · 播放进度实时同步<br>
        任何一方点击<b>播放/暂停/拖动</b>都会同步给对方<br>
        PeerJS 公共信令 · 无需注册 · 随时退出
      </div>
    </div>
  </div>

  <!-- ========== 房间 ========== -->
  <div id="screen-room" class="screen">
    <div class="video-wrap">
      <video id="video" playsinline controls></video>
      <div class="empty-state" id="emptyState">
        <div class="empty-icon">🎬</div>
        <div class="empty-title">尚未导入视频</div>
        <div class="empty-sub">点击下方「导入视频」按钮,选择本地视频文件。<br>双方请选择<b style="color:#fbbf24">同一个视频</b>以保证同步效果。</div>
      </div>
    </div>

    <div class="room-top">
      <button class="exit-btn" id="exitBtn">← 退出房间</button>
      <div style="display:flex;align-items:center;gap:8px">
        <div class="room-info">
          <span style="color:#8a8aa8;font-size:12px">房间</span>
          <span class="room-code" id="roomCodeDisplay">----</span>
          <button class="copy-btn" id="copyCodeBtn">复制</button>
        </div>
        <div class="room-info">
          <div class="conn-status">
            <span class="dot offline" id="connDot"></span>
            <span id="connText">未连接</span>
          </div>
        </div>
        <button class="fs-btn" id="fsBtn" title="全屏">⛶</button>
      </div>
    </div>

    <div class="room-bottom">
      <button class="import-btn" id="importBtn">📁 导入视频</button>
      <div class="status-chips">
        <div class="chip" id="selfChip">
          <span class="mini-dot"></span>
          <span>我:未导入</span>
        </div>
        <div class="chip" id="remoteChip">
          <span class="mini-dot"></span>
          <span>对方:未导入</span>
        </div>
      </div>
    </div>

    <input type="file" id="fileInput" accept="video/*" hidden>
  </div>

</div>

<div class="toast-wrap" id="toastWrap"></div>

<script>
"use strict";

/* =========================================================
   工具
   ========================================================= */
const $ = id => document.getElementById(id);
const nextTick = () => new Promise(r => setTimeout(r, 0));

function toast(msg, type = '', ms = 2800) {
  const el = document.createElement('div');
  el.className = 'toast' + (type ? ' ' + type : '');
  el.textContent = msg;
  $('toastWrap').appendChild(el);
  setTimeout(() => {
    el.style.transition = 'opacity .3s, transform .3s';
    el.style.opacity = '0';
    el.style.transform = 'translateY(10px)';
    setTimeout(() => el.remove(), 320);
  }, ms);
}

function showScreen(name) {
  document.querySelectorAll('.screen').forEach(s =>
    s.classList.toggle('active', s.id === 'screen-' + name));
}

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, m =>
    ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[m]));
}

/* =========================================================
   全局状态
   ========================================================= */
const State = {
  peer: null,
  conn: null,
  role: null,          // 'host' | 'guest'
  roomCode: '',
  myId: '',
  remoteId: '',
  connected: false,
  remoteVideoReady: false,
  selfVideoReady: false,
  videoEl: null,
  selfVideoUrl: null,

  // 远程事件抑制
  applyingRemote: false,
  applyTimer: null,

  // 心跳
  syncTimer: null,
};

/* =========================================================
   房间 - 创建 / 加入
   ========================================================= */
function validateCode(code) {
  if (!/^[a-zA-Z0-9]{4,8}$/.test(code)) {
    return false;
  }
  return true;
}

function createRoom(code) {
  code = code.trim();
  if (!validateCode(code)) {
    toast('房间号需为 4-8 位数字或字母', 'err');
    return;
  }

  destroyPeer();

  const hostId = 'rf2-' + code.toLowerCase() + '-host';
  const peer = new Peer(hostId, { debug: 1 });
  State.peer = peer;
  State.role = 'host';
  State.roomCode = code.toUpperCase();

  peer.on('open', id => {
    State.myId = id;
    State.connected = false;
    enterRoom();
    updateConnStatus();
    toast('房间已创建 · 把房间号分享给对方', 'ok');
  });

  peer.on('connection', conn => {
    if (State.conn && State.conn.open) {
      // 已有连接,拒绝新的
      conn.close();
      return;
    }
    State.conn = conn;
    setupConnection();
  });

  peer.on('error', err => {
    console.error('Peer error:', err);
    if (err.type === 'unavailable-id') {
      toast('该房间号已被使用,请换一个', 'err');
      destroyPeer();
      State.role = null;
      showScreen('home');
    } else {
      toast('连接出错:' + (err.message || err.type), 'err');
    }
  });
}

function joinRoom(code) {
  code = code.trim();
  if (!validateCode(code)) {
    toast('房间号需为 4-8 位数字或字母', 'err');
    return;
  }

  destroyPeer();

  const peer = new Peer({ debug: 1 });
  State.peer = peer;
  State.role = 'guest';
  State.roomCode = code.toUpperCase();

  peer.on('open', id => {
    State.myId = id;
    const hostId = 'rf2-' + code.toLowerCase() + '-host';
    const conn = peer.connect(hostId, { reliable: true });
    State.conn = conn;
    setupConnection();
    enterRoom();
    updateConnStatus();
  });

  peer.on('error', err => {
    console.error('Peer error:', err);
    if (err.type === 'peer-unavailable') {
      toast('找不到该房间,请确认房间号正确且对方已创建', 'err', 4000);
      destroyPeer();
      State.role = null;
      showScreen('home');
    } else {
      toast('连接出错:' + (err.message || err.type), 'err');
    }
  });
}

function destroyPeer() {
  stopSyncHeartbeat();
  if (State.conn) {
    try { State.conn.close(); } catch (e) {}
    State.conn = null;
  }
  if (State.peer) {
    try { State.peer.destroy(); } catch (e) {}
    State.peer = null;
  }
  State.connected = false;
  State.remoteId = '';
  State.remoteVideoReady = false;
}

/* =========================================================
   进入 / 退出 房间
   ========================================================= */
function enterRoom() {
  showScreen('room');
  $('roomCodeDisplay').textContent = State.roomCode || '----';
  updateVideoStatus();
  updateConnStatus();
  resizeVideoArea();
}

function leaveRoom() {
  // 通知对方
  sendMessage({ type: 'bye' });
  destroyPeer();

  // 清理本地视频
  if (State.videoEl) {
    try { State.videoEl.pause(); } catch (e) {}
    try { State.videoEl.removeAttribute('src'); State.videoEl.load(); } catch (e) {}
  }
  if (State.selfVideoUrl) {
    URL.revokeObjectURL(State.selfVideoUrl);
    State.selfVideoUrl = null;
  }
  State.selfVideoReady = false;
  State.remoteVideoReady = false;
  State.applyingRemote = false;
  clearTimeout(State.applyTimer);

  $('emptyState').classList.remove('hide');

  State.role = null;
  State.roomCode = '';
  showScreen('home');
  updateVideoStatus();
}

/* =========================================================
   连接管理
   ========================================================= */
function setupConnection() {
  const conn = State.conn;
  if (!conn) return;

  conn.on('open', () => {
    State.connected = true;
    State.remoteId = conn.peer;
    updateConnStatus();

    // 打招呼,告知角色
    conn.send({ type: 'hello', role: State.role, ts: Date.now() });

    // 如果本地已有视频,通知对方
    if (State.selfVideoReady) {
      conn.send({ type: 'videoReady', ts: Date.now() });
    }

    // 启动心跳
    startSyncHeartbeat();

    toast('已连接', 'ok');
  });

  conn.on('data', data => {
    handleRemoteMessage(data);
  });

  conn.on('close', () => {
    State.connected = false;
    State.remoteId = '';
    State.remoteVideoReady = false;
    updateConnStatus();
    updateVideoStatus();
    stopSyncHeartbeat();
    toast('对方已断开连接', 'err');
  });

  conn.on('error', err => {
    console.error('Conn error:', err);
  });
}

function sendMessage(data) {
  if (State.conn && State.conn.open) {
    try {
      State.conn.send(data);
    } catch (e) {
      console.error('发送失败:', e);
    }
  }
}

/* =========================================================
   远程消息处理
   ========================================================= */
function handleRemoteMessage(data) {
  if (!data || !data.type) return;

  switch (data.type) {
    case 'hello':
      // 对方介绍自己
      break;

    case 'bye':
      toast('对方已离开房间', 'err');
      State.connected = false;
      State.remoteVideoReady = false;
      updateConnStatus();
      updateVideoStatus();
      stopSyncHeartbeat();
      break;

    case 'videoReady':
      State.remoteVideoReady = true;
      updateVideoStatus();
      toast('对方已导入视频', 'ok', 2000);
      break;

    case 'play':
      applyRemote(() => {
        if (!State.videoEl.src) return;
        const latency = (Date.now() - data.ts) / 1000;
        const target = Math.max(0, data.time + latency);
        if (Math.abs(State.videoEl.currentTime - target) > 0.25) {
          try { State.videoEl.currentTime = target; } catch (e) {}
        }
        State.videoEl.play().catch(() => {});
      });
      break;

    case 'pause':
      applyRemote(() => {
        if (!State.videoEl.src) return;
        try { State.videoEl.currentTime = data.time; } catch (e) {}
        State.videoEl.pause();
      });
      break;

    case 'seek':
      applyRemote(() => {
        if (!State.videoEl.src) return;
        try { State.videoEl.currentTime = data.time; } catch (e) {}
      });
      break;

    case 'sync':
      // 房主定期发送的心跳,访客校正
      if (State.role === 'host') return;
      if (!State.videoEl.src) return;
      {
        const latency = (Date.now() - data.ts) / 1000;
        const target = data.time + latency;
        const diff = Math.abs(State.videoEl.currentTime - target);
        const isPlaying = !State.videoEl.paused && !State.videoEl.ended;

        if (data.playing !== isPlaying) {
          applyRemote(() => {
            try { State.videoEl.currentTime = Math.max(0, target); } catch (e) {}
            if (data.playing) State.videoEl.play().catch(() => {});
            else State.videoEl.pause();
          });
        } else if (data.playing && isPlaying && diff > 0.4) {
          applyRemote(() => {
            try { State.videoEl.currentTime = Math.max(0, target); } catch (e) {}
          });
        }
      }
      break;
  }
}

function applyRemote(fn) {
  State.applyingRemote = true;
  clearTimeout(State.applyTimer);
  try {
    fn();
  } catch (e) {
    console.error(e);
  } finally {
    State.applyTimer = setTimeout(() => {
      State.applyingRemote = false;
    }, 300);
  }
}

/* =========================================================
   心跳同步
   ========================================================= */
function startSyncHeartbeat() {
  stopSyncHeartbeat();
  State.syncTimer = setInterval(() => {
    if (State.role !== 'host') return;
    if (!State.connected) return;
    if (!State.videoEl || !State.videoEl.src) return;

    sendMessage({
      type: 'sync',
      time: State.videoEl.currentTime,
      playing: !State.videoEl.paused && !State.videoEl.ended,
      ts: Date.now(),
    });
  }, 2000);
}

function stopSyncHeartbeat() {
  if (State.syncTimer) {
    clearInterval(State.syncTimer);
    State.syncTimer = null;
  }
}

/* =========================================================
   视频元素
   ========================================================= */
function initVideoEl() {
  if (State.videoEl) return State.videoEl;
  const v = $('video');
  State.videoEl = v;

  v.addEventListener('play', () => {
    if (State.applyingRemote) return;
    if (!v.src) return;
    sendMessage({
      type: 'play',
      time: v.currentTime,
      ts: Date.now(),
    });
  });

  v.addEventListener('pause', () => {
    if (State.applyingRemote) return;
    if (!v.src) return;
    if (v.ended) return;  // ended 时单独处理
    sendMessage({
      type: 'pause',
      time: v.currentTime,
      ts: Date.now(),
    });
  });

  v.addEventListener('seeked', () => {
    if (State.applyingRemote) return;
    if (!v.src) return;
    sendMessage({
      type: 'seek',
      time: v.currentTime,
      ts: Date.now(),
    });
  });

  v.addEventListener('ended', () => {
    if (State.applyingRemote) return;
    sendMessage({
      type: 'pause',
      time: v.duration || 0,
      ts: Date.now(),
    });
  });

  return v;
}

function loadLocalVideo(file) {
  const v = initVideoEl();

  if (State.selfVideoUrl) {
    URL.revokeObjectURL(State.selfVideoUrl);
    State.selfVideoUrl = null;
  }
  State.selfVideoUrl = URL.createObjectURL(file);

  // 暂停当前视频,避免加载过程中出现旧画面
  try { v.pause(); } catch (e) {}

  v.src = State.selfVideoUrl;
  v.load();

  v.onloadedmetadata = () => {
    $('emptyState').classList.add('hide');
    State.selfVideoReady = true;
    updateVideoStatus();
    sendMessage({ type: 'videoReady', ts: Date.now() });
    toast('视频已导入 · ' + formatDuration(v.duration), 'ok');
  };

  v.onerror = () => {
    toast('视频加载失败,请检查文件格式', 'err');
    State.selfVideoReady = false;
    updateVideoStatus();
  };
}

function formatDuration(sec) {
  if (!isFinite(sec) || sec <= 0) return '--:--';
  const m = Math.floor(sec / 60);
  const s = Math.floor(sec % 60);
  return m + ':' + String(s).padStart(2, '0');
}

/* =========================================================
   UI 更新
   ========================================================= */
function updateConnStatus() {
  const dot = $('connDot');
  const text = $('connText');

  if (!State.peer && !State.conn) {
    dot.className = 'dot offline';
    text.textContent = '未连接';
    return;
  }

  if (State.connected) {
    dot.className = 'dot online';
    text.textContent = '已连接';
  } else if (State.peer && !State.peer.destroyed && !State.peer.disconnected) {
    dot.className = 'dot connecting';
    text.textContent = State.role === 'host' ? '等待对方加入…' : '正在连接…';
  } else {
    dot.className = 'dot offline';
    text.textContent = '未连接';
  }
}

function updateVideoStatus() {
  const selfChip = $('selfChip');
  const remoteChip = $('remoteChip');

  selfChip.classList.toggle('on', State.selfVideoReady);
  selfChip.querySelector('span:last-child').textContent =
    State.selfVideoReady ? '我:已导入' : '我:未导入';

  remoteChip.classList.toggle('on', State.remoteVideoReady);
  remoteChip.querySelector('span:last-child').textContent =
    State.remoteVideoReady ? '对方:已导入' : '对方:未导入';
}

function resizeVideoArea() {
  // 视频区域自适应,无需特殊处理
}

/* =========================================================
   事件绑定
   ========================================================= */
function bindEvents() {
  // 主页 Tabs
  document.querySelectorAll('.tabs button').forEach(btn => {
    btn.addEventListener('click', () => {
      const tab = btn.dataset.tab;
      document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('on', b === btn));
      document.querySelectorAll('.tab-panel').forEach(p => {
        p.classList.toggle('active', p.id === 'tab-' + tab);
      });
    });
  });

  // 创建房间
  $('createBtn').addEventListener('click', () => {
    createRoom($('createCode').value);
  });
  $('createCode').addEventListener('keydown', e => {
    if (e.key === 'Enter') createRoom($('createCode').value);
  });

  // 加入房间
  $('joinBtn').addEventListener('click', () => {
    joinRoom($('joinCode').value);
  });
  $('joinCode').addEventListener('keydown', e => {
    if (e.key === 'Enter') joinRoom($('joinCode').value);
  });

  // 导入视频
  $('importBtn').addEventListener('click', () => $('fileInput').click());
  $('fileInput').addEventListener('change', e => {
    const f = e.target.files && e.target.files[0];
    if (f) {
      if (!f.type.startsWith('video')) {
        toast('请选择视频文件', 'err');
        return;
      }
      loadLocalVideo(f);
    }
    e.target.value = '';  // 允许重复选同一个文件
  });

  // 退出房间
  $('exitBtn').addEventListener('click', () => {
    leaveRoom();
    toast('已退出房间');
  });

  // 复制房间号
  $('copyCodeBtn').addEventListener('click', async () => {
    try {
      await navigator.clipboard.writeText(State.roomCode);
      toast('房间号已复制', 'ok', 1500);
    } catch (e) {
      // 降级方案
      const ta = document.createElement('textarea');
      ta.value = State.roomCode;
      document.body.appendChild(ta);
      ta.select();
      try { document.execCommand('copy'); toast('房间号已复制', 'ok', 1500); }
      catch (err) { toast('复制失败,请手动复制', 'err'); }
      document.body.removeChild(ta);
    }
  });

  // 全屏
  $('fsBtn').addEventListener('click', () => {
    const el = document.documentElement;
    if (!document.fullscreenElement) {
      if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
      else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
    } else {
      if (document.exitFullscreen) document.exitFullscreen();
      else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
    }
  });

  // 窗口缩放
  window.addEventListener('resize', resizeVideoArea);

  // 离开页面时通知对方
  window.addEventListener('beforeunload', () => {
    try { sendMessage({ type: 'bye' }); } catch (e) {}
    try { State.peer && State.peer.destroy(); } catch (e) {}
  });

  // 空格键播放/暂停(当焦点不在 input 上时)
  window.addEventListener('keydown', e => {
    if (e.target.tagName === 'INPUT') return;
    if (e.code === 'Space' && State.videoEl && State.videoEl.src) {
      e.preventDefault();
      if (State.videoEl.paused) State.videoEl.play().catch(() => {});
      else State.videoEl.pause();
    }
    if (e.code === 'Escape') {
      if ($('screen-room').classList.contains('active')) {
        leaveRoom();
        toast('已退出房间');
      }
    }
  });
}

/* =========================================================
   启动
   ========================================================= */
(function init() {
  initVideoEl();
  bindEvents();
  updateConnStatus();
  updateVideoStatus();

  // 检查 PeerJS 是否加载
  if (typeof Peer === 'undefined') {
    toast('PeerJS 加载失败,请刷新页面重试', 'err', 6000);
  }
})();
</script>
</body>
</html>

Game Source: SyncPlayer · 双人视频同步

Creator: HyperShark77

Libraries: none

Complexity: complex (1016 lines, 30.5 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: syncplayer-hypershark77" to link back to the original. Then publish at arcadelab.ai/publish.