Refactor code structure for improved readability and maintainability
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s

This commit is contained in:
2025-09-04 21:53:54 +02:00
parent 80f8c4ca90
commit 8c5ca0044f
27 changed files with 3398 additions and 326 deletions

View File

@@ -8,7 +8,8 @@ export function initAudioUI() {
if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true;
} catch {}
$audio.addEventListener('timeupdate', () => {
const dur = $audio.duration || 0; if (!dur || !$progressFill) return;
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 + '%';
});
@@ -21,7 +22,10 @@ export function initAudioUI() {
$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; });
$audio.addEventListener('ended', () => {
if ($recordDisc) $recordDisc.classList.remove('spin-record');
$audio.playbackRate = 1.0;
});
}
export function applySync(startAt, serverNow) {
@@ -34,7 +38,7 @@ export function applySync(startAt, serverNow) {
const abs = Math.abs(drift);
if (abs > 1.0) {
$audio.currentTime = Math.max(0, elapsed);
if ($audio.paused) $audio.play().catch(()=>{});
if ($audio.paused) $audio.play().catch(() => {});
$audio.playbackRate = 1.0;
} else if (abs > 0.12) {
const maxNudge = 0.03;
@@ -42,16 +46,32 @@ export function applySync(startAt, serverNow) {
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; }
if (Math.abs($audio.playbackRate - 1) > 0.001) {
$audio.playbackRate = 1.0;
}
}
}
export function stopAudioPlayback() {
try { $audio.pause(); } catch {}
try { $audio.currentTime = 0; } catch {}
try { $audio.src = ''; } catch {}
try { $audio.playbackRate = 1.0; } catch {}
try { if ($recordDisc) $recordDisc.classList.remove('spin-record'); } catch {}
try { if ($progressFill) $progressFill.style.width = '0%'; } catch {}
try { if ($bufferBadge) $bufferBadge.classList.add('hidden'); } catch {}
try {
$audio.pause();
} catch {}
try {
$audio.currentTime = 0;
} catch {}
try {
$audio.src = '';
} catch {}
try {
$audio.playbackRate = 1.0;
} catch {}
try {
if ($recordDisc) $recordDisc.classList.remove('spin-record');
} catch {}
try {
if ($progressFill) $progressFill.style.width = '0%';
} catch {}
try {
if ($bufferBadge) $bufferBadge.classList.add('hidden');
} catch {}
}

View File

@@ -44,5 +44,11 @@ export const $guessTitle = el('guessTitle');
export const $guessArtist = el('guessArtist');
export const $answerResult = el('answerResult');
export function showLobby() { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); }
export function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); }
export function showLobby() {
$lobby.classList.remove('hidden');
$room.classList.add('hidden');
}
export function showRoom() {
$lobby.classList.add('hidden');
$room.classList.remove('hidden');
}

View File

@@ -1,4 +1,31 @@
import { $audio, $copyRoomCode, $leaveRoom, $nameDisplay, $nameLobby, $npArtist, $npTitle, $npYear, $pauseBtn, $placeBtn, $readyChk, $roomId, $roomCode, $slotSelect, $startGame, $volumeSlider, $playBtn, $nextBtn, $createRoom, $joinRoom, $lobby, $room, $setNameLobby, $guessTitle, $guessArtist, $answerResult } from './dom.js';
import {
$audio,
$copyRoomCode,
$leaveRoom,
$nameDisplay,
$nameLobby,
$npArtist,
$npTitle,
$npYear,
$pauseBtn,
$placeBtn,
$readyChk,
$roomId,
$roomCode,
$slotSelect,
$startGame,
$volumeSlider,
$playBtn,
$nextBtn,
$createRoom,
$joinRoom,
$lobby,
$room,
$setNameLobby,
$guessTitle,
$guessArtist,
$answerResult,
} from './dom.js';
import { state } from './state.js';
import { connectWS, sendMsg, cacheSessionId, cacheLastRoomId } from './ws.js';
import { renderRoom } from './render.js';
@@ -56,8 +83,13 @@ function handlePlayTrack(msg) {
// reset answer UI
if ($guessTitle) $guessTitle.value = '';
if ($guessArtist) $guessArtist.value = '';
if ($answerResult) { $answerResult.textContent=''; $answerResult.className='mt-1 text-sm'; }
try { $audio.preload = 'auto'; } catch {}
if ($answerResult) {
$answerResult.textContent = '';
$answerResult.className = 'mt-1 text-sm';
}
try {
$audio.preload = 'auto';
} catch {}
$audio.src = t.url;
const pf = document.getElementById('progressFill');
if (pf) {
@@ -124,10 +156,12 @@ function handleReveal(msg) {
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';
$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';
$rb.className =
'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
}
}
const $placeArea = document.getElementById('placeArea');
@@ -139,8 +173,12 @@ function handleReveal(msg) {
if (rd && track?.id) {
const coverUrl = `/cover/${encodeURIComponent(track.id)}`;
const img = new Image();
img.onload = () => { rd.src = coverUrl; };
img.onerror = () => { /* keep default logo */ };
img.onload = () => {
rd.src = coverUrl;
};
img.onerror = () => {
/* keep default logo */
};
img.src = coverUrl + `?t=${Date.now()}`; // bypass cache just in case
}
}
@@ -197,7 +235,9 @@ function onMessage(ev) {
if (msg.awarded) coin = ' +1 Token';
else if (msg.alreadyAwarded) coin = ' (bereits erhalten)';
$answerResult.textContent = `${parts.join(' · ')}${coin}`;
$answerResult.className = okBoth ? 'mt-1 text-sm text-emerald-600' : 'mt-1 text-sm text-amber-600';
$answerResult.className = okBoth
? 'mt-1 text-sm text-emerald-600'
: 'mt-1 text-sm text-amber-600';
}
}
break;
@@ -239,14 +279,26 @@ function wireUi() {
});
wire($leaveRoom, 'click', () => {
sendMsg({ type: 'leave_room' });
// Clear all local storage entries on leave
try { localStorage.clear(); } catch {}
stopAudioPlayback();
// Clear all local storage entries on leave
try {
localStorage.clear();
} catch {}
stopAudioPlayback();
state.room = null;
// Reset visible name inputs/labels
if ($nameLobby) { try { $nameLobby.value = ''; } catch {} }
if ($nameDisplay) { $nameDisplay.textContent = ''; }
if ($readyChk) { try { $readyChk.checked = false; } catch {} }
// Reset visible name inputs/labels
if ($nameLobby) {
try {
$nameLobby.value = '';
} catch {}
}
if ($nameDisplay) {
$nameDisplay.textContent = '';
}
if ($readyChk) {
try {
$readyChk.checked = false;
} catch {}
}
$lobby.classList.remove('hidden');
$room.classList.add('hidden');
});
@@ -307,7 +359,10 @@ function wireUi() {
const title = ($guessTitle?.value || '').trim();
const artist = ($guessArtist?.value || '').trim();
if (!title || !artist) {
if ($answerResult) { $answerResult.textContent = 'Bitte Titel und Künstler eingeben'; $answerResult.className = 'mt-1 text-sm text-amber-600'; }
if ($answerResult) {
$answerResult.textContent = 'Bitte Titel und Künstler eingeben';
$answerResult.className = 'mt-1 text-sm text-amber-600';
}
return;
}
sendMsg({ type: 'submit_answer', guess: { title, artist } });

View File

@@ -1,28 +1,62 @@
import { state } from './state.js';
import { badgeColorForYear } from '../utils/colors.js';
import { $answerForm, $answerResult, $dashboardList, $guesser, $lobby, $mediaControls, $nameDisplay, $nextArea, $np, $placeArea, $readyChk, $revealBanner, $room, $roomId, $slotSelect, $startGame, $status, $timeline, $tokens } from './dom.js';
import {
$answerForm,
$answerResult,
$dashboardList,
$guesser,
$lobby,
$mediaControls,
$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; }
try { localStorage.setItem('lastRoomId', room.id); } catch {}
$lobby.classList.add('hidden'); $room.classList.remove('hidden');
state.room = room;
if (!room) {
$lobby.classList.remove('hidden');
$room.classList.add('hidden');
return;
}
try {
localStorage.setItem('lastRoomId', room.id);
} catch {}
$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') || '-');
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 `
$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>' : ''}
${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>
@@ -30,16 +64,18 @@ export function renderRoom(room) {
<td class="py-2 pr-3">${ready}</td>
<td class="py-2 pr-3 font-semibold tabular-nums">${score}</td>
</tr>`;
}).join('');
})
.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})">
$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>
@@ -47,20 +83,29 @@ export function renderRoom(room) {
</div>
</div>
`;
}).join('');
})
.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; }
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 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;
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 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
if ($mediaControls) $mediaControls.classList.toggle('hidden', !isMyTurn);
@@ -89,19 +134,37 @@ export function renderRoom(room) {
$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 ($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);
// Answer form visible during guess phase while a track is active
const showAnswer = room.state.status==='playing' && room.state.phase==='guess' && !!room.state.currentTrack;
const showAnswer =
room.state.status === 'playing' && room.state.phase === 'guess' && !!room.state.currentTrack;
if ($answerForm) $answerForm.classList.toggle('hidden', !showAnswer);
if ($answerResult && !showAnswer) { $answerResult.textContent=''; $answerResult.className='mt-1 text-sm'; }
if ($answerResult && !showAnswer) {
$answerResult.textContent = '';
$answerResult.className = 'mt-1 text-sm';
}
}
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);
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])); }
export function escapeHtml(s) {
return String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}

View File

@@ -1,53 +1,58 @@
import { state } from './state.js';
let ws;
let reconnectAttempts = 0;
let reconnectTimer = null;
// Assumes socket.io client library is loaded globally as io
let socket;
const outbox = [];
let sessionId = localStorage.getItem('sessionId') || null;
let lastRoomId = localStorage.getItem('lastRoomId') || null;
let _lastRoomId = localStorage.getItem('lastRoomId') || null;
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 wsIsOpen() {
return !!socket?.connected;
}
export function sendMsg(obj) {
if (wsIsOpen()) socket.emit('message', obj);
else outbox.push(obj);
}
export function connectWS(onMessage) {
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host;
ws = new WebSocket(url);
ws.addEventListener('open', () => {
reconnectAttempts = 0;
// Establish Socket.IO connection in websocket-only mode
socket = window.io({ transports: ['websocket'] });
socket.on('connect', () => {
// Try to resume session immediately on (re)connect
if (sessionId) {
try { ws.send(JSON.stringify({ type: 'resume', sessionId })); } catch {}
try {
socket.emit('message', { type: 'resume', sessionId });
} catch {}
}
setTimeout(() => { while (outbox.length && wsIsOpen()) { try { ws.send(JSON.stringify(outbox.shift())); } catch { break; } } }, 100);
// flush queued
setTimeout(() => {
while (outbox.length && wsIsOpen()) {
try {
socket.emit('message', outbox.shift());
} catch {
break;
}
}
}, 50);
});
ws.addEventListener('message', (ev) => onMessage(ev));
ws.addEventListener('close', () => { scheduleReconnect(() => connectWS(onMessage)); });
ws.addEventListener('error', () => { try { ws.close(); } catch {} });
socket.on('message', (msg) => {
// Adapt to previous onmessage(ev) signature used by main.js
const ev = { data: JSON.stringify(msg) };
onMessage(ev);
});
// Socket.IO handles reconnection internally; no manual timers required
}
window.addEventListener('online', () => {
if (!wsIsOpen()) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
// Kick off a reconnect by calling connectWS from app main again
}
});
// Helpers to update cached ids from other modules
export function cacheSessionId(id) {
if (!id) return;
sessionId = id;
try { localStorage.setItem('sessionId', id); } catch {}
try {
localStorage.setItem('sessionId', id);
} catch {}
}
export function cacheLastRoomId(id) {
if (!id) return;
lastRoomId = id;
try { localStorage.setItem('lastRoomId', id); } catch {}
_lastRoomId = id;
try {
localStorage.setItem('lastRoomId', id);
} catch {}
}