SyncPlayer · 双人视频同步
by HyperShark771016 lines30.5 KB
<!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 =>
({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[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.