feat: restructure client-side code

This commit is contained in:
2025-09-04 12:33:17 +02:00
parent edaf9ea94e
commit bbce3cbadf
21 changed files with 854 additions and 20 deletions

View File

@@ -1,3 +1,5 @@
import { badgeColorForYear } from './utils/colors.js';
let ws;
let reconnectAttempts = 0;
let reconnectTimer = null;
@@ -233,17 +235,7 @@ function shortName(id) {
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%);`;
}
// color helper imported from utils/colors.js
function handleMessage(ev) {
const msg = JSON.parse(ev.data);

View File

@@ -152,6 +152,6 @@
</div>
</div>
<script src="/client.js" type="module"></script>
<script src="/js/main.js" type="module"></script>
</body>
</html>

47
public/js/audio.js Normal file
View File

@@ -0,0 +1,47 @@
import { $audio, $bufferBadge, $progressFill, $recordDisc } from './dom.js';
import { state } from './state.js';
export function initAudioUI() {
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; });
}
export function applySync(startAt, serverNow) {
if (!startAt || !serverNow) return;
if (state.room?.state?.paused) return;
if (state.isBuffering) return;
const now = Date.now();
const elapsed = (now - startAt) / 1000;
const drift = ($audio.currentTime || 0) - elapsed;
const abs = Math.abs(drift);
if (abs > 1.0) {
$audio.currentTime = Math.max(0, elapsed);
if ($audio.paused) $audio.play().catch(()=>{});
$audio.playbackRate = 1.0;
} else if (abs > 0.12) {
const maxNudge = 0.03;
const sign = drift > 0 ? -1 : 1;
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate));
} else {
if (Math.abs($audio.playbackRate - 1) > 0.001) { $audio.playbackRate = 1.0; }
}
}

43
public/js/dom.js Normal file
View File

@@ -0,0 +1,43 @@
const el = (id) => document.getElementById(id);
export const $lobby = el('lobby');
export const $room = el('room');
export const $roomId = el('roomId');
export const $status = el('status');
export const $guesser = el('guesser');
export const $timeline = el('timeline');
export const $tokens = el('tokens');
export const $audio = el('audio');
export const $np = el('nowPlaying');
export const $npTitle = el('npTitle');
export const $npArtist = el('npArtist');
export const $npYear = el('npYear');
export const $readyChk = el('readyChk');
export const $startGame = el('startGame');
export const $revealBanner = el('revealBanner');
export const $placeArea = el('placeArea');
export const $slotSelect = el('slotSelect');
export const $placeBtn = el('placeBtn');
export const $mediaControls = el('mediaControls');
export const $playBtn = el('playBtn');
export const $pauseBtn = el('pauseBtn');
export const $nextArea = el('nextArea');
export const $nextBtn = el('nextBtn');
export const $recordDisc = el('recordDisc');
export const $progressFill = el('progressFill');
export const $volumeSlider = el('volumeSlider');
export const $bufferBadge = el('bufferBadge');
export const $copyRoomCode = el('copyRoomCode');
export const $nameLobby = el('name');
export const $setNameLobby = el('setName');
export const $nameDisplay = el('nameDisplay');
export const $createRoom = el('createRoom');
export const $joinRoom = el('joinRoom');
export const $roomCode = el('roomCode');
export const $leaveRoom = el('leaveRoom');
export const $earnToken = el('earnToken');
export const $dashboardList = el('dashboardList');
export const $toast = el('toast');
export function showLobby() { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); }
export function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); }

261
public/js/main.js Normal file
View File

@@ -0,0 +1,261 @@
import { $audio, $copyRoomCode, $leaveRoom, $nameDisplay, $nameLobby, $npArtist, $npTitle, $npYear, $pauseBtn, $placeBtn, $readyChk, $roomId, $roomCode, $slotSelect, $startGame, $volumeSlider, $playBtn, $nextBtn, $createRoom, $joinRoom, $lobby, $room, $setNameLobby } from './dom.js';
import { state } from './state.js';
import { connectWS, sendMsg } from './ws.js';
import { renderRoom } from './render.js';
import { initAudioUI, applySync } from './audio.js';
function showToast(msg) {
const el = document.getElementById('toast');
if (!el) {
return;
}
el.textContent = msg;
el.style.opacity = '1';
setTimeout(() => {
el.style.opacity = '0';
}, 1200);
}
function handleConnected(msg) {
state.playerId = msg.playerId;
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 (state.room?.id) {
sendMsg({ type: 'join_room', code: state.room.id });
}
}
function handleRoomUpdate(msg) {
renderRoom(msg.room);
}
function handlePlayTrack(msg) {
const t = msg.track;
state.lastTrack = t;
state.revealed = false;
$npTitle.textContent = '???';
$npArtist.textContent = '';
$npYear.textContent = '';
try {
$audio.preload = 'auto';
} catch {}
$audio.src = t.url;
const pf = document.getElementById('progressFill');
if (pf) {
pf.style.width = '0%';
}
const rd = document.getElementById('recordDisc');
if (rd) {
rd.classList.remove('spin-record');
}
const { startAt, serverNow } = msg;
const now = Date.now();
const offsetMs = startAt - serverNow;
const localStart = now + offsetMs;
const delay = Math.max(0, localStart - now);
setTimeout(() => {
$audio.currentTime = 0;
$audio.play().catch(() => {});
const disc = document.getElementById('recordDisc');
if (disc) {
disc.classList.add('spin-record');
}
}, delay);
if (state.room) {
renderRoom(state.room);
}
}
function handleSync(msg) {
applySync(msg.startAt, msg.serverNow);
}
function handleControl(msg) {
const { action, startAt, serverNow } = msg;
if (action === 'pause') {
$audio.pause();
const disc = document.getElementById('recordDisc');
if (disc) {
disc.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(() => {});
const disc = document.getElementById('recordDisc');
if (disc) {
disc.classList.add('spin-record');
}
}
}
function handleReveal(msg) {
const { result, track } = msg;
$npTitle.textContent = track.title || track.id || 'Track';
$npArtist.textContent = track.artist ? ` ${track.artist}` : '';
$npYear.textContent = track.year ? ` (${track.year})` : '';
state.revealed = true;
const $rb = document.getElementById('revealBanner');
if ($rb) {
if (result.correct) {
$rb.textContent = 'Richtig!';
$rb.className = 'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
} else {
$rb.textContent = 'Falsch!';
$rb.className = 'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
}
}
const $placeArea = document.getElementById('placeArea');
if ($placeArea) {
$placeArea.classList.add('hidden');
}
}
function handleGameEnded(msg) {
alert(`Gewinner: ${shortName(msg.winner)}`);
}
function onMessage(ev) {
const msg = JSON.parse(ev.data);
switch (msg.type) {
case 'connected':
handleConnected(msg);
break;
case 'room_update':
handleRoomUpdate(msg);
break;
case 'play_track':
handlePlayTrack(msg);
break;
case 'sync':
handleSync(msg);
break;
case 'control':
handleControl(msg);
break;
case 'reveal':
handleReveal(msg);
break;
case 'game_ended':
handleGameEnded(msg);
break;
default:
break;
}
}
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 wire(el, type, handler) {
if (el) {
el.addEventListener(type, handler);
}
}
function wireUi() {
initAudioUI();
wire($setNameLobby, 'click', () => {
const name = $nameLobby.value.trim();
if (!name) return;
localStorage.setItem('playerName', name);
if ($nameDisplay) {
$nameDisplay.textContent = name;
}
sendMsg({ type: 'set_name', name });
});
wire($createRoom, 'click', () => sendMsg({ type: 'create_room' }));
wire($joinRoom, 'click', () => {
const code = $roomCode.value.trim();
if (code) {
sendMsg({ type: 'join_room', code });
}
});
wire($leaveRoom, 'click', () => {
sendMsg({ type: 'leave_room' });
state.room = null;
$lobby.classList.remove('hidden');
$room.classList.add('hidden');
});
wire($startGame, 'click', () => sendMsg({ type: 'start_game' }));
wire($readyChk, 'change', (e) => {
const val = !!e.target.checked;
state.pendingReady = val;
sendMsg({ type: 'set_ready', ready: val });
});
wire($placeBtn, 'click', () => {
const slot = parseInt($slotSelect.value, 10);
sendMsg({ type: 'place_guess', slot });
});
wire($playBtn, 'click', () => sendMsg({ type: 'player_control', action: 'play' }));
wire($pauseBtn, 'click', () => sendMsg({ type: 'player_control', action: 'pause' }));
wire($nextBtn, 'click', () => sendMsg({ type: 'next_track' }));
if ($volumeSlider && $audio) {
try {
$volumeSlider.value = String($audio.volume ?? 1);
} catch {}
$volumeSlider.addEventListener('input', () => {
$audio.volume = parseFloat($volumeSlider.value);
});
}
if ($copyRoomCode) {
$copyRoomCode.style.display = 'inline-block';
wire($copyRoomCode, 'click', () => {
if (state.room?.id) {
navigator.clipboard.writeText(state.room.id).then(() => {
$copyRoomCode.textContent = '✔️';
showToast('Code kopiert!');
setTimeout(() => {
$copyRoomCode.textContent = '📋';
}, 1200);
});
}
});
}
if ($roomId) {
wire($roomId, 'click', () => {
if (state.room?.id) {
navigator.clipboard.writeText(state.room.id).then(() => {
$roomId.title = 'Kopiert!';
showToast('Code kopiert!');
setTimeout(() => {
$roomId.title = 'Klicken zum Kopieren';
}, 1200);
});
}
});
$roomId.style.cursor = 'pointer';
}
}
// boot
wireUi();
connectWS(onMessage);
// restore name immediately if present
(() => {
const saved = localStorage.getItem('playerName');
if (saved) {
if ($nameLobby && $nameLobby.value !== saved) {
$nameLobby.value = saved;
}
if ($nameDisplay) {
$nameDisplay.textContent = saved;
}
}
})();

98
public/js/render.js Normal file
View File

@@ -0,0 +1,98 @@
import { state } from './state.js';
import { badgeColorForYear } from '../utils/colors.js';
import { $dashboardList, $guesser, $lobby, $nameDisplay, $nextArea, $np, $placeArea, $readyChk, $revealBanner, $room, $roomId, $slotSelect, $startGame, $status, $timeline, $tokens } from './dom.js';
export function renderRoom(room) {
state.room = room; if (!room) { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); return; }
$lobby.classList.add('hidden'); $room.classList.remove('hidden');
$roomId.textContent = room.id;
$status.textContent = room.state.status;
$guesser.textContent = shortName(room.state.currentGuesser);
const me = room.players.find(p=>p.id===state.playerId);
if ($nameDisplay) $nameDisplay.textContent = (me?.name || localStorage.getItem('playerName') || '-');
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;
if ($readyChk) {
const serverReady = !!me?.ready;
if (state.pendingReady === null || state.pendingReady === undefined) { $readyChk.checked = serverReady; }
else { $readyChk.checked = !!state.pendingReady; if (serverReady === state.pendingReady) state.pendingReady = null; }
$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 isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack;
const canGuess = isMyTurn;
// Build slot options for insertion positions when it's my turn
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);
}
} else {
// Clear options when not guessing
$slotSelect.innerHTML = '';
}
$placeArea.classList.toggle('hidden', !canGuess);
}
$np.classList.toggle('hidden', !room.state.currentTrack);
if ($revealBanner) { const inReveal = room.state.phase === 'reveal'; if (!inReveal) { $revealBanner.className = 'hidden'; $revealBanner.textContent=''; } }
const canNext = room.state.status==='playing' && room.state.phase==='reveal' && (isHost || room.state.currentGuesser===state.playerId);
if ($nextArea) $nextArea.classList.toggle('hidden', !canNext);
}
export function shortName(id) {
if (!id) return '-';
const p = state.room?.players.find(x=>x.id===id);
return p ? p.name : id.slice(0,4);
}
export function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }

8
public/js/state.js Normal file
View File

@@ -0,0 +1,8 @@
export const state = {
playerId: null,
room: null,
lastTrack: null,
revealed: false,
pendingReady: null,
isBuffering: false,
};

35
public/js/ws.js Normal file
View File

@@ -0,0 +1,35 @@
import { state } from './state.js';
let ws;
let reconnectAttempts = 0;
let reconnectTimer = null;
const outbox = [];
export function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; }
export function sendMsg(obj) { if (wsIsOpen()) ws.send(JSON.stringify(obj)); else outbox.push(obj); }
function scheduleReconnect(connect) {
if (reconnectTimer) return;
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts));
reconnectAttempts++;
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);
}
export function connectWS(onMessage) {
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host;
ws = new WebSocket(url);
ws.addEventListener('open', () => {
reconnectAttempts = 0;
setTimeout(() => { while (outbox.length && wsIsOpen()) { try { ws.send(JSON.stringify(outbox.shift())); } catch { break; } } }, 100);
});
ws.addEventListener('message', (ev) => onMessage(ev));
ws.addEventListener('close', () => { scheduleReconnect(() => connectWS(onMessage)); });
ws.addEventListener('error', () => { try { ws.close(); } catch {} });
}
window.addEventListener('online', () => {
if (!wsIsOpen()) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
// Kick off a reconnect by calling connectWS from app main again
}
});

11
public/utils/colors.js Normal file
View File

@@ -0,0 +1,11 @@
// Stable distinct color per year for the year badge
export 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%);`;
}