diff --git a/public/client.js b/public/client.js
deleted file mode 100644
index 8cd9688..0000000
--- a/public/client.js
+++ /dev/null
@@ -1,480 +0,0 @@
-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 `
-
- `;
- }).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;
- }
-})();
diff --git a/public/js/render.js b/public/js/render.js
index b0a95ab..062b207 100644
--- a/public/js/render.js
+++ b/public/js/render.js
@@ -56,8 +56,10 @@ export function renderRoom(room) {
$readyChk.parentElement.classList.toggle('hidden', room.state.status !== 'lobby');
}
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));
+ const activePlayers = room.players.filter(p => !p.spectator && p.connected);
+ const allReady = activePlayers.length>0 && activePlayers.every(p=>p.ready);
+ const canStart = room.state.status==='lobby' && isHost && allReady;
+ if ($startGame) $startGame.classList.toggle('hidden', !canStart);
const isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack;
const canGuess = isMyTurn;
// Media controls (play/pause) only for current guesser while guessing and a track is active
diff --git a/src/server/game.js b/src/server/game.js
index 09c38d7..4d9dd7e 100644
--- a/src/server/game.js
+++ b/src/server/game.js
@@ -145,9 +145,12 @@ export function setupWebSocket(server) {
if (!player.roomId) return; const room = rooms.get(player.roomId); if (!room) return; room.players.delete(player.id); player.roomId = null; if (room.state.ready) delete room.state.ready[player.id]; if (room.state.spectators) delete room.state.spectators[player.id]; if (room.players.size === 0) rooms.delete(room.id); else broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
if (msg.type === 'set_ready') { const room = rooms.get(player.roomId); if (!room) return; const value = !!msg.ready; room.state.ready[player.id] = value; broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
if (msg.type === 'start_game') {
- const room = rooms.get(player.roomId); if (!room) return; if (room.hostId !== player.id) return send('error', { message: 'Only host can start' });
- const pids = [...room.players.values()].filter(p => !room.state.spectators?.[p.id]).map(p => p.id);
- const allReady = pids.every((pid) => !!room.state.ready?.[pid]); if (!allReady) return send('error', { message: 'All players must be ready' });
+ const room = rooms.get(player.roomId); if (!room) return;
+ if (room.hostId !== player.id) return send('error', { message: 'Only host can start' });
+ const active = [...room.players.values()].filter(p => !room.state.spectators?.[p.id] && p.connected);
+ const allReady = active.length>0 && active.every(p => !!room.state.ready?.[p.id]);
+ if (!allReady) return send('error', { message: 'All active players must be ready' });
+ const pids = active.map(p => p.id);
room.state.status = 'playing'; room.state.turnOrder = shuffle(pids); room.state.currentGuesser = room.state.turnOrder[0];
room.state.timeline = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, []]));
room.state.tokens = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, 2]));