import { badgeColorForYear } from './utils/colors.js'; let ws; let reconnectAttempts = 0; let reconnectTimer = null; const outbox = []; let sessionId = localStorage.getItem('sessionId') || null; let lastRoomId = localStorage.getItem('lastRoomId') || null; 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; // Try to resume session immediately if (sessionId) { try { ws.send(JSON.stringify({ type: 'resume', sessionId })); } catch {} } // 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; try { if (room?.id) { lastRoomId = room.id; localStorage.setItem('lastRoomId', room.id); } } catch {} 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 ? 'online' : 'offline'; const ready = p.ready ? 'bereit' : '-'; const score = (room.state.timeline?.[p.id]?.length) ?? 0; const isMe = p.id === state.playerId; return `
${escapeHtml(p.name)}${p.spectator ? ' 👻' : ''} ${p.id===room.hostId ? '\u2B50' : ''} ${isMe ? '(du)' : ''}
${connected} ${ready} ${score} `; }).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 `
${year}
${title}
${artist}
`; }).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 = ix.id===id); return p ? p.name : id.slice(0,4); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } // color helper imported from utils/colors.js function handleMessage(ev) { const msg = JSON.parse(ev.data); if (msg.type === 'connected') { state.playerId = msg.playerId; if (msg.sessionId && !sessionId) { sessionId = msg.sessionId; try { localStorage.setItem('sessionId', sessionId); } catch {} } // 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 }); } // If we already have a sessionId, we'll resume instead of auto-joining here if (!sessionId) { const code = state.room?.id || lastRoomId || localStorage.getItem('lastRoomId'); if (code) sendMsg({ type: 'join_room', code }); } } if (msg.type === 'resume_result') { if (msg.ok) { if (msg.playerId) state.playerId = msg.playerId; // If we have a known room, ensure we rejoin it if (msg.roomId) { // Update local roomId (we will receive a room_update shortly) sendMsg({ type: 'join_room', code: msg.roomId }); } else { const code = state.room?.id || lastRoomId || localStorage.getItem('lastRoomId'); if (code) sendMsg({ type: 'join_room', code }); } } else { // If resume failed, try to rejoin by last room code const code = state.room?.id || lastRoomId || localStorage.getItem('lastRoomId'); if (code) sendMsg({ type: 'join_room', code }); } } 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; } })();