Files
hitstar/public/client.js

458 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let ws;
let reconnectAttempts = 0;
let reconnectTimer = null;
const outbox = [];
function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; }
function sendMsg(obj) {
if (wsIsOpen()) ws.send(JSON.stringify(obj));
else outbox.push(obj);
}
function scheduleReconnect() {
if (reconnectTimer) return;
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts));
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connectWS();
}, delay);
}
function connectWS() {
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host;
ws = new WebSocket(url);
ws.addEventListener('open', () => {
reconnectAttempts = 0;
// Flush queued messages
setTimeout(() => {
while (outbox.length && wsIsOpen()) {
try { ws.send(JSON.stringify(outbox.shift())); } catch { break; }
}
}, 100);
});
ws.addEventListener('message', (ev) => handleMessage(ev));
ws.addEventListener('close', () => { scheduleReconnect(); });
ws.addEventListener('error', () => { try { ws.close(); } catch {} });
}
let state = {
playerId: null,
room: null,
lastTrack: null,
revealed: false,
pendingReady: null,
isBuffering: false,
};
// Elements
const el = (id) => document.getElementById(id);
const $lobby = el('lobby');
const $room = el('room');
// Removed old players chip list; using dashboard only
const $dashboardList = el('dashboardList');
const $roomId = el('roomId');
const $status = el('status');
const $guesser = el('guesser');
const $timeline = el('timeline');
const $tokens = el('tokens');
const $audio = el('audio');
if ($audio) { try { $audio.preload = 'none'; } catch {} }
const $np = document.getElementById('nowPlaying');
const $npTitle = el('npTitle');
const $npArtist = el('npArtist');
const $npYear = el('npYear');
const $readyChk = document.getElementById('readyChk');
const $startGame = document.getElementById('startGame');
const $revealBanner = document.getElementById('revealBanner');
const $placeArea = document.getElementById('placeArea');
const $slotSelect = document.getElementById('slotSelect');
const $placeBtn = document.getElementById('placeBtn');
const $mediaControls = document.getElementById('mediaControls');
const $playBtn = document.getElementById('playBtn');
const $pauseBtn = document.getElementById('pauseBtn');
const $nextArea = document.getElementById('nextArea');
const $nextBtn = document.getElementById('nextBtn');
// Custom player UI
const $recordDisc = document.getElementById('recordDisc');
const $progressFill = document.getElementById('progressFill');
const $volumeSlider = document.getElementById('volumeSlider');
const $bufferBadge = document.getElementById('bufferBadge');
// Copy Room Code button
const $copyRoomCode = document.getElementById('copyRoomCode');
// Name (lobby input + room display)
const $nameLobby = document.getElementById('name');
const $setNameLobby = document.getElementById('setName');
const $nameDisplay = document.getElementById('nameDisplay');
function showLobby() { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); }
function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); }
function renderRoom(room) {
state.room = room;
if (!room) { showLobby(); return; }
showRoom();
$roomId.textContent = room.id;
// Ensure copy button is visible and set up
if ($copyRoomCode) {
$copyRoomCode.style.display = 'inline-block';
$copyRoomCode.onclick = function() {
if (room.id) {
navigator.clipboard.writeText(room.id).then(() => {
$copyRoomCode.textContent = '✔️';
showToast('Code kopiert!');
setTimeout(()=>{$copyRoomCode.textContent = '📋';}, 1200);
});
}
};
}
// Also allow clicking the room code itself to copy
if ($roomId) {
$roomId.onclick = function() {
if (room.id) {
navigator.clipboard.writeText(room.id).then(() => {
$roomId.title = 'Kopiert!';
showToast('Code kopiert!');
setTimeout(()=>{$roomId.title = 'Klicken zum Kopieren';}, 1200);
});
}
};
$roomId.style.cursor = 'pointer';
}
const $toast = document.getElementById('toast');
function showToast(msg) {
if (!$toast) return;
$toast.textContent = msg;
$toast.style.opacity = '1';
setTimeout(() => {
$toast.style.opacity = '0';
}, 1200);
}
$status.textContent = room.state.status;
$guesser.textContent = shortName(room.state.currentGuesser);
// Show my current name (from server if available) or fallback to stored value
const me = room.players.find(p=>p.id===state.playerId);
if ($nameDisplay) $nameDisplay.textContent = (me?.name || localStorage.getItem('playerName') || '-');
// Old players chip list removed
// Dashboard rows
if ($dashboardList) {
$dashboardList.innerHTML = room.players.map(p => {
const connected = p.connected ? '<span class="text-emerald-600">online</span>' : '<span class="text-rose-600">offline</span>';
const ready = p.ready ? '<span class="text-emerald-600">bereit</span>' : '<span class="text-slate-400">-</span>';
const score = (room.state.timeline?.[p.id]?.length) ?? 0;
const isMe = p.id === state.playerId;
return `
<tr class="align-top">
<td class="py-2 pr-3">
<div class="inline-flex items-center gap-1">
<span>${escapeHtml(p.name)}</span>${p.spectator ? ' <span title="Zuschauer">👻</span>' : ''}
${p.id===room.hostId ? '<span title="Host" class="text-amber-600">\u2B50</span>' : ''}
${isMe ? '<span title="Du" class="text-indigo-600">(du)</span>' : ''}
</div>
</td>
<td class="py-2 pr-3">${connected}</td>
<td class="py-2 pr-3">${ready}</td>
<td class="py-2 pr-3 font-semibold tabular-nums">${score}</td>
</tr>`;
}).join('');
}
const myTl = room.state.timeline?.[state.playerId] || [];
$timeline.innerHTML = myTl.map(t => {
const title = escapeHtml(t.title || t.trackId || 'Unbekannt');
const artist = t.artist ? escapeHtml(t.artist) : '';
const year = (t.year ?? '?');
const badgeStyle = badgeColorForYear(year);
return `
<div class="flex items-center gap-2 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 bg-white text-slate-900 dark:bg-slate-800 dark:text-slate-100 shadow-sm" title="${title}${artist? ' — '+artist : ''} (${year})">
<div class="font-bold tabular-nums text-white rounded-md px-2 py-0.5 min-w-[3ch] text-center" style="${badgeStyle}">${year}</div>
<div class="leading-tight">
<div class="font-semibold">${title}</div>
<div class="text-sm text-slate-600 dark:text-slate-300">${artist}</div>
</div>
</div>
`;
}).join('');
$tokens.textContent = room.state.tokens?.[state.playerId] ?? 0;
// Ready control visibility
if ($readyChk) {
const serverReady = !!me?.ready;
// If user recently toggled, keep local visual state until server matches
if (state.pendingReady === null || state.pendingReady === undefined) {
$readyChk.checked = serverReady;
} else {
$readyChk.checked = !!state.pendingReady;
// Clear pending once it matches server
if (serverReady === state.pendingReady) state.pendingReady = null;
}
$readyChk.parentElement.classList.toggle('hidden', room.state.status !== 'lobby');
}
// Host start button when all ready
const isHost = state.playerId === room.hostId;
const allReady = room.players.length>0 && room.players.every(p=>p.ready);
if ($startGame) $startGame.classList.toggle('hidden', !(isHost && room.state.status==='lobby' && allReady));
// Show guess buttons only when it's my turn and a track is active
const isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack;
const canGuess = isMyTurn;
// Build slot options: 0..n
if ($placeArea && $slotSelect) {
if (canGuess) {
const tl = room.state.timeline?.[state.playerId] || [];
$slotSelect.innerHTML = '';
for (let i = 0; i <= tl.length; i++) {
const left = i>0 ? (tl[i-1]?.year ?? '?') : null;
const right = i<tl.length ? (tl[i]?.year ?? '?') : null;
let label = '';
if (tl.length === 0) label = 'Einsetzen';
else if (i === 0) label = `Vor (${right})`;
else if (i === tl.length) label = `Nach (${left})`;
else label = `Zwischen (${left} / ${right})`;
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = label;
$slotSelect.appendChild(opt);
}
}
$placeArea.classList.toggle('hidden', !canGuess);
}
// Show now playing if track exists
$np.classList.toggle('hidden', !room.state.currentTrack);
// Keep reveal banner visible during reveal; otherwise hide/reset it
if ($revealBanner) {
const inReveal = room.state.phase === 'reveal';
if (!inReveal) { $revealBanner.className = 'hidden'; $revealBanner.textContent=''; }
}
// Media controls only for current guesser during guess phase
if ($mediaControls) $mediaControls.classList.toggle('hidden', !isMyTurn);
// Next button visible in reveal to host or current guesser
const canNext = room.state.status==='playing' && room.state.phase==='reveal' && (isHost || room.state.currentGuesser===state.playerId);
if ($nextArea) $nextArea.classList.toggle('hidden', !canNext);
}
function shortName(id) {
if (!id) return '-';
const p = state.room?.players.find(x=>x.id===id);
return p ? p.name : id.slice(0,4);
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
// Stable distinct color per year for the year badge
function badgeColorForYear(y) {
const val = (y === undefined || y === null) ? '?' : y;
if (val === '?' || Number.isNaN(Number(val))) {
// Neutral slate for unknown years
return 'background-color: hsl(215 16% 34%);';
}
const n = Number(val);
const hue = ((n * 23) % 360 + 360) % 360; // spread hues deterministically
return `background-color: hsl(${hue} 70% 42%);`;
}
function handleMessage(ev) {
const msg = JSON.parse(ev.data);
if (msg.type === 'connected') {
state.playerId = msg.playerId;
// Try to auto-apply stored name
const stored = localStorage.getItem('playerName');
if (stored) {
if ($nameLobby && $nameLobby.value !== stored) {
$nameLobby.value = stored;
}
if ($nameDisplay) {
$nameDisplay.textContent = stored;
}
sendMsg({ type: 'set_name', name: stored });
}
// Try to rejoin room if known
if (state.room?.id) sendMsg({ type: 'join_room', code: state.room.id });
}
if (msg.type === 'room_update') {
renderRoom(msg.room);
}
if (msg.type === 'play_track') {
const t = msg.track;
state.lastTrack = t;
state.revealed = false;
// Hide metadata until a guess is placed
$npTitle.textContent = '???';
$npArtist.textContent = '';
$npYear.textContent = '';
try { $audio.preload = 'auto'; } catch {}
$audio.src = t.url;
// Reset custom UI
if ($progressFill) $progressFill.style.width = '0%';
if ($recordDisc) $recordDisc.classList.remove('spin-record');
// Sync start using server-provided timestamp
const { startAt, serverNow } = msg;
if (startAt && serverNow) {
const now = Date.now();
const offsetMs = startAt - serverNow; // server delay until start
const localStart = now + offsetMs;
const delay = Math.max(0, localStart - now);
setTimeout(() => {
$audio.currentTime = 0;
$audio.play().catch(()=>{});
if ($recordDisc) $recordDisc.classList.add('spin-record');
}, delay);
} else {
$audio.play().catch(()=>{});
if ($recordDisc) $recordDisc.classList.add('spin-record');
}
if (state.room) renderRoom(state.room);
}
if (msg.type === 'sync') {
const { startAt, serverNow } = msg;
if (!state.room?.state?.currentTrack || !startAt || !serverNow) return;
if (state.room?.state?.paused) return; // don't auto-resume while paused
if (state.isBuffering) return; // avoid corrections while buffering
const now = Date.now();
const elapsed = (now - startAt) / 1000; // seconds
const drift = ($audio.currentTime || 0) - elapsed;
// Soft sync via playbackRate adjustments; hard seek if way off
const abs = Math.abs(drift);
if (abs > 1.0) {
// Hard correct when over 1s
$audio.currentTime = Math.max(0, elapsed);
if ($audio.paused) $audio.play().catch(()=>{});
$audio.playbackRate = 1.0;
} else if (abs > 0.12) {
// Gently nudge speed up to +/-3%
const maxNudge = 0.03;
const sign = drift > 0 ? -1 : 1; // if ahead (positive drift), slow down
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate));
} else {
// Close enough
if (Math.abs($audio.playbackRate - 1) > 0.001) {
$audio.playbackRate = 1.0;
}
}
}
if (msg.type === 'control') {
const { action, startAt, serverNow } = msg;
if (action === 'pause') {
$audio.pause();
if ($recordDisc) $recordDisc.classList.remove('spin-record');
$audio.playbackRate = 1.0;
} else if (action === 'play') {
if (startAt && serverNow) {
const now = Date.now();
const elapsed = (now - startAt) / 1000;
$audio.currentTime = Math.max(0, elapsed);
}
$audio.play().catch(()=>{});
if ($recordDisc) $recordDisc.classList.add('spin-record');
}
}
if (msg.type === 'reveal') {
const { result, track } = msg;
// Reveal metadata
$npTitle.textContent = track.title || track.id || 'Track';
$npArtist.textContent = track.artist ? ` ${track.artist}` : '';
$npYear.textContent = track.year ? ` (${track.year})` : '';
state.revealed = true;
// Show banner
if ($revealBanner) {
if (result.correct) {
$revealBanner.textContent = 'Richtig!';
$revealBanner.className = 'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
} else {
$revealBanner.textContent = 'Falsch!';
$revealBanner.className = 'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
}
}
// Hide placement during reveal
if ($placeArea) $placeArea.classList.add('hidden');
}
if (msg.type === 'game_ended') {
alert(`Gewinner: ${shortName(msg.winner)}`);
}
}
// Start connection
connectWS();
window.addEventListener('online', () => {
if (!wsIsOpen()) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
connectWS();
}
});
// UI events
el('setName').onclick = () => {
const name = $nameLobby.value.trim();
if (!name) return;
localStorage.setItem('playerName', name);
if ($nameDisplay) $nameDisplay.textContent = name;
sendMsg({ type: 'set_name', name });
};
el('createRoom').onclick = () => { sendMsg({ type: 'create_room' }); };
el('joinRoom').onclick = () => {
const code = el('roomCode').value.trim();
if (code) sendMsg({ type: 'join_room', code });
};
el('leaveRoom').onclick = () => {
sendMsg({ type: 'leave_room' });
state.room = null; showLobby();
};
el('startGame').onclick = () => sendMsg({ type: 'start_game' });
document.getElementById('readyChk').onchange = (e) => {
const val = !!e.target.checked;
state.pendingReady = val;
sendMsg({ type: 'set_ready', ready: val });
};
el('earnToken').onclick = () => sendMsg({ type: 'earn_token' });
if ($placeBtn) {
$placeBtn.onclick = () => {
const slot = parseInt($slotSelect.value, 10);
sendMsg({ type: 'place_guess', slot });
};
}
if ($playBtn) $playBtn.onclick = () => sendMsg({ type: 'player_control', action: 'play' });
if ($pauseBtn) $pauseBtn.onclick = () => sendMsg({ type: 'player_control', action: 'pause' });
if ($nextBtn) $nextBtn.onclick = () => sendMsg({ type: 'next_track' });
// Progress + volume updates
if ($audio) {
// Try to preserve pitch during slight playbackRate changes if supported
try {
if ('preservesPitch' in $audio) $audio.preservesPitch = true;
if ('mozPreservesPitch' in $audio) $audio.mozPreservesPitch = true;
if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true;
} catch {}
$audio.addEventListener('timeupdate', () => {
const dur = $audio.duration || 0;
if (!dur || !$progressFill) return;
const pct = Math.min(100, Math.max(0, ($audio.currentTime / dur) * 100));
$progressFill.style.width = pct + '%';
});
const showBuffer = (v) => {
state.isBuffering = v;
if ($bufferBadge) $bufferBadge.classList.toggle('hidden', !v);
if ($recordDisc) $recordDisc.classList.toggle('spin-record', !v && !$audio.paused);
};
$audio.addEventListener('waiting', () => showBuffer(true));
$audio.addEventListener('stalled', () => showBuffer(true));
$audio.addEventListener('canplay', () => showBuffer(false));
$audio.addEventListener('playing', () => showBuffer(false));
$audio.addEventListener('ended', () => {
if ($recordDisc) $recordDisc.classList.remove('spin-record');
$audio.playbackRate = 1.0;
});
}
if ($volumeSlider && $audio) {
// Initialize from current volume
try { $volumeSlider.value = String($audio.volume ?? 1); } catch {}
$volumeSlider.addEventListener('input', () => {
$audio.volume = parseFloat($volumeSlider.value);
});
}
// Try to restore room view if server restarts won't preserve sessions; basic behavior only
(() => {
const saved = localStorage.getItem('playerName');
if (saved) {
if ($nameLobby && $nameLobby.value !== saved) $nameLobby.value = saved;
if ($nameDisplay) $nameDisplay.textContent = saved;
}
})();