feat: restructure client-side code
This commit is contained in:
@@ -15,4 +15,4 @@ ENV NODE_ENV=production \
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "src/server/index.js"]
|
||||
|
||||
@@ -4615,8 +4615,8 @@
|
||||
"title": "I Heard It Through The Grapevine",
|
||||
"artist": "Marvin Gaye",
|
||||
"mbid": "d97b9286-07ce-436f-bb31-2d880b9476ee",
|
||||
"earliestDate": "1988",
|
||||
"year": 1988,
|
||||
"earliestDate": "1968",
|
||||
"year": 1968,
|
||||
"confidence": {
|
||||
"mbScore": 100,
|
||||
"titleSim": 1,
|
||||
@@ -5314,8 +5314,8 @@
|
||||
"title": "La Bamba",
|
||||
"artist": "Ritchie Valens",
|
||||
"mbid": "821f5395-f90f-432f-a807-da8dbb45ceb6",
|
||||
"earliestDate": "1963",
|
||||
"year": 1963,
|
||||
"earliestDate": "1958",
|
||||
"year": 1958,
|
||||
"confidence": {
|
||||
"mbScore": 96,
|
||||
"titleSim": 1,
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Local Hitster-like multiplayer web app using WebSockets and local MP3s",
|
||||
"main": "server.js",
|
||||
"main": "src/server/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"start": "node src/server/index.js",
|
||||
"dev": "nodemon src/server/index.js",
|
||||
"years:resolve": "node scripts/resolve-years.js",
|
||||
"years:resolve:10": "node scripts/resolve-years.js --max 10",
|
||||
"years:force": "node scripts/resolve-years.js --force"
|
||||
|
||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||
|
||||
@@ -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
47
public/js/audio.js
Normal 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
43
public/js/dom.js
Normal 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
261
public/js/main.js
Normal 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
98
public/js/render.js
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
8
public/js/state.js
Normal file
8
public/js/state.js
Normal 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
35
public/js/ws.js
Normal 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
11
public/utils/colors.js
Normal 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%);`;
|
||||
}
|
||||
12
src/server/config.js
Normal file
12
src/server/config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT_DIR = path.resolve(__dirname, '..', '..');
|
||||
|
||||
export const PORT = process.env.PORT || 5173;
|
||||
export const DATA_DIR = path.resolve(ROOT_DIR, 'data');
|
||||
export const PUBLIC_DIR = path.resolve(ROOT_DIR, 'public');
|
||||
export const YEARS_PATH = path.join(DATA_DIR, 'years.json');
|
||||
export const PATHS = { __dirname, ROOT_DIR, DATA_DIR, PUBLIC_DIR, YEARS_PATH };
|
||||
81
src/server/game.js
Normal file
81
src/server/game.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
|
||||
import { loadDeck } from './game/deck.js';
|
||||
import { startSyncTimer, stopSyncTimer } from './game/sync.js';
|
||||
|
||||
function drawNextTrack(room) {
|
||||
const track = room.deck.shift();
|
||||
if (!track) { room.state.status = 'ended'; room.state.winner = null; broadcast(room, 'game_ended', { winner: null }); return; }
|
||||
room.state.currentTrack = { ...track, url: `/audio/${encodeURIComponent(track.file)}` };
|
||||
room.state.phase = 'guess'; room.state.lastResult = null; room.state.paused = false; room.state.pausedPosSec = 0;
|
||||
room.state.trackStartAt = Date.now() + 800;
|
||||
broadcast(room, 'play_track', { track: room.state.currentTrack, startAt: room.state.trackStartAt, serverNow: Date.now() });
|
||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
||||
startSyncTimer(room);
|
||||
}
|
||||
|
||||
export function setupWebSocket(server) {
|
||||
const wss = new WebSocketServer({ server });
|
||||
wss.on('connection', (ws) => {
|
||||
const id = uuidv4();
|
||||
const player = { id, name: `Player-${id.slice(0, 4)}`, ws, connected: true, roomId: null };
|
||||
const send = (type, payload) => { try { ws.send(JSON.stringify({ type, ...payload })); } catch {} };
|
||||
send('connected', { playerId: id });
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||
|
||||
if (msg.type === 'set_name') { player.name = String(msg.name || '').slice(0, 30) || player.name; if (player.roomId && rooms.has(player.roomId)) broadcast(rooms.get(player.roomId), 'room_update', { room: roomSummary(rooms.get(player.roomId)) }); return; }
|
||||
if (msg.type === 'create_room') { const room = createRoom(msg.name, player); player.roomId = room.id; broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
|
||||
if (msg.type === 'join_room') {
|
||||
const code = String(msg.code || '').toUpperCase(); const room = rooms.get(code); if (!room) return send('error', { message: 'Room not found' });
|
||||
room.players.set(player.id, player); player.roomId = room.id; room.state.ready[player.id] = false;
|
||||
if (room.state.status === 'playing' || room.state.status === 'ended') { room.state.spectators[player.id] = true; player.spectator = true; } else { delete room.state.spectators[player.id]; player.spectator = false; }
|
||||
broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
|
||||
if (msg.type === 'leave_room') {
|
||||
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' });
|
||||
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]));
|
||||
room.deck = await loadDeck(); room.discard = []; room.state.phase = 'guess'; room.state.lastResult = null; drawNextTrack(room); return; }
|
||||
if (msg.type === 'player_control') {
|
||||
const room = rooms.get(player.roomId); if (!room) return; const { action } = msg;
|
||||
if (room.state.status !== 'playing') return; if (room.state.phase !== 'guess') return; if (room.state.currentGuesser !== player.id) return; if (!room.state.currentTrack) return;
|
||||
if (action === 'pause') { if (!room.state.paused) { const now = Date.now(); if (room.state.trackStartAt) { room.state.pausedPosSec = Math.max(0, (now - room.state.trackStartAt) / 1000); } room.state.paused = true; stopSyncTimer(room); } broadcast(room, 'control', { action: 'pause' }); }
|
||||
if (action === 'play') { const now = Date.now(); const posSec = room.state.paused ? room.state.pausedPosSec : Math.max(0, (now - (room.state.trackStartAt || now)) / 1000); room.state.trackStartAt = now - Math.floor(posSec * 1000); room.state.paused = false; startSyncTimer(room); broadcast(room, 'control', { action: 'play', startAt: room.state.trackStartAt, serverNow: now }); }
|
||||
return; }
|
||||
if (msg.type === 'place_guess') {
|
||||
const room = rooms.get(player.roomId); if (!room) return; const { position, slot: rawSlot } = msg;
|
||||
if (room.state.status !== 'playing') return send('error', { message: 'Game not playing' });
|
||||
if (room.state.phase !== 'guess') return send('error', { message: 'Not accepting guesses now' });
|
||||
if (room.state.currentGuesser !== player.id) return send('error', { message: 'Not your turn' });
|
||||
const current = room.state.currentTrack; if (!current) return send('error', { message: 'No current track' });
|
||||
const tl = room.state.timeline[player.id] || []; const n = tl.length;
|
||||
let slot = Number.isInteger(rawSlot) ? rawSlot : null; if (slot == null) { if (position === 'before') slot = 0; else if (position === 'after') slot = n; }
|
||||
if (typeof slot !== 'number' || slot < 0 || slot > n) slot = n;
|
||||
let correct = false;
|
||||
if (current.year != null) {
|
||||
if (n === 0) correct = slot === 0;
|
||||
else { const left = slot > 0 ? tl[slot - 1]?.year : null; const right = slot < n ? tl[slot]?.year : null; const leftOk = (left == null) || (current.year >= left); const rightOk = (right == null) || (current.year <= right); correct = leftOk && rightOk; }
|
||||
}
|
||||
if (correct) { const newTl = tl.slice(); newTl.splice(slot, 0, { trackId: current.id, year: current.year, title: current.title, artist: current.artist }); room.state.timeline[player.id] = newTl; }
|
||||
else { room.discard.push(current); }
|
||||
room.state.phase = 'reveal'; room.state.lastResult = { playerId: player.id, correct }; broadcast(room, 'reveal', { result: room.state.lastResult, track: room.state.currentTrack }); broadcast(room, 'room_update', { room: roomSummary(room) });
|
||||
const tlNow = room.state.timeline[player.id] || []; if (correct && tlNow.length >= room.state.goal) { room.state.status = 'ended'; room.state.winner = player.id; broadcast(room, 'game_ended', { winner: player.id }); return; }
|
||||
return; }
|
||||
if (msg.type === 'earn_token') { const room = rooms.get(player.roomId); if (!room) return; const tokens = room.state.tokens[player.id] ?? 0; room.state.tokens[player.id] = Math.min(5, tokens + 1); broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
|
||||
if (msg.type === 'next_track') {
|
||||
const room = rooms.get(player.roomId); if (!room) return; if (room.state.status !== 'playing') return; if (room.state.phase !== 'reveal') return; const isAuthorized = player.id === room.hostId || player.id === room.state.currentGuesser; if (!isAuthorized) return;
|
||||
room.state.currentTrack = null; room.state.trackStartAt = null; room.state.paused = false; room.state.pausedPosSec = 0; stopSyncTimer(room);
|
||||
room.state.currentGuesser = nextPlayer(room.state.turnOrder, room.state.currentGuesser); room.state.phase = 'guess'; broadcast(room, 'room_update', { room: roomSummary(room) }); drawNextTrack(room); return; }
|
||||
});
|
||||
|
||||
ws.on('close', () => { player.connected = false; if (player.roomId && rooms.has(player.roomId)) { const room = rooms.get(player.roomId); broadcast(room, 'room_update', { room: roomSummary(room) }); } });
|
||||
});
|
||||
}
|
||||
18
src/server/game/deck.js
Normal file
18
src/server/game/deck.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parseFile as mmParseFile } from 'music-metadata';
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { loadYearsIndex } from '../years.js';
|
||||
import { shuffle } from './state.js';
|
||||
|
||||
export async function loadDeck() {
|
||||
const years = loadYearsIndex();
|
||||
const files = fs.readdirSync(DATA_DIR).filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f));
|
||||
const tracks = await Promise.all(files.map(async (f) => {
|
||||
const fp = path.join(DATA_DIR, f);
|
||||
let year = null, title = path.parse(f).name, artist = '';
|
||||
try { const meta = await mmParseFile(fp, { duration: false }); title = meta.common.title || title; artist = meta.common.artist || artist; year = meta.common.year || null; } catch {}
|
||||
const y = years[f]?.year ?? year; return { id: f, file: f, title, artist, year: y };
|
||||
}));
|
||||
return shuffle(tracks);
|
||||
}
|
||||
71
src/server/game/state.js
Normal file
71
src/server/game/state.js
Normal file
@@ -0,0 +1,71 @@
|
||||
export const rooms = new Map();
|
||||
|
||||
export function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
export function nextPlayer(turnOrder, currentId) {
|
||||
if (!turnOrder.length) return null;
|
||||
if (!currentId) return turnOrder[0];
|
||||
const idx = turnOrder.indexOf(currentId);
|
||||
return turnOrder[(idx + 1) % turnOrder.length];
|
||||
}
|
||||
|
||||
export function createRoom(name, host) {
|
||||
const id = (Math.random().toString(36).slice(2, 8)).toUpperCase();
|
||||
const room = {
|
||||
id,
|
||||
name: name || `Room ${id}`,
|
||||
hostId: host.id,
|
||||
players: new Map([[host.id, host]]),
|
||||
deck: [],
|
||||
discard: [],
|
||||
revealTimer: null,
|
||||
syncTimer: null,
|
||||
state: {
|
||||
status: 'lobby',
|
||||
turnOrder: [],
|
||||
currentGuesser: null,
|
||||
currentTrack: null,
|
||||
timeline: {},
|
||||
tokens: {},
|
||||
ready: { [host.id]: false },
|
||||
spectators: {},
|
||||
phase: 'guess',
|
||||
lastResult: null,
|
||||
trackStartAt: null,
|
||||
paused: false,
|
||||
pausedPosSec: 0,
|
||||
goal: 10,
|
||||
},
|
||||
};
|
||||
rooms.set(id, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function broadcast(room, type, payload) {
|
||||
for (const p of room.players.values()) {
|
||||
try { p.ws.send(JSON.stringify({ type, ...payload })); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function roomSummary(room) {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
hostId: room.hostId,
|
||||
players: [...room.players.values()].map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
connected: p.connected,
|
||||
ready: !!room.state.ready?.[p.id],
|
||||
spectator: !!room.state.spectators?.[p.id] || !!p.spectator,
|
||||
})),
|
||||
state: room.state,
|
||||
};
|
||||
}
|
||||
13
src/server/game/sync.js
Normal file
13
src/server/game/sync.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { broadcast } from './state.js';
|
||||
|
||||
export function startSyncTimer(room) {
|
||||
if (room.syncTimer) clearInterval(room.syncTimer);
|
||||
room.syncTimer = setInterval(() => {
|
||||
if (room.state.status !== 'playing' || !room.state.currentTrack || !room.state.trackStartAt || room.state.paused) return;
|
||||
broadcast(room, 'sync', { startAt: room.state.trackStartAt, serverNow: Date.now() });
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
export function stopSyncTimer(room) {
|
||||
if (room.syncTimer) { clearInterval(room.syncTimer); room.syncTimer = null; }
|
||||
}
|
||||
37
src/server/index.js
Normal file
37
src/server/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PORT, DATA_DIR, PUBLIC_DIR } from './config.js';
|
||||
import { registerAudioRoutes } from './routes/audio.js';
|
||||
import { registerTracksApi } from './tracks.js';
|
||||
import { loadYearsIndex } from './years.js';
|
||||
import { setupWebSocket } from './game.js';
|
||||
|
||||
// Ensure data dir exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
// Static client
|
||||
app.use(express.static(PUBLIC_DIR));
|
||||
|
||||
// Years reload endpoint
|
||||
app.get('/api/reload-years', (req, res) => {
|
||||
const years = loadYearsIndex();
|
||||
res.json({ ok: true, count: Object.keys(years).length });
|
||||
});
|
||||
|
||||
// Routes
|
||||
registerAudioRoutes(app);
|
||||
registerTracksApi(app);
|
||||
|
||||
const server = http.createServer(app);
|
||||
setupWebSocket(server);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Hitstar server running on http://localhost:${PORT}`);
|
||||
});
|
||||
58
src/server/routes/audio.js
Normal file
58
src/server/routes/audio.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import mime from 'mime';
|
||||
import { DATA_DIR } from '../config.js';
|
||||
|
||||
export function registerAudioRoutes(app) {
|
||||
app.head('/audio/:name', (req, res) => {
|
||||
const name = req.params.name;
|
||||
const filePath = path.join(DATA_DIR, name);
|
||||
if (!filePath.startsWith(DATA_DIR)) return res.status(400).end();
|
||||
if (!fs.existsSync(filePath)) return res.status(404).end();
|
||||
const stat = fs.statSync(filePath);
|
||||
const type = mime.getType(filePath) || 'audio/mpeg';
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Content-Type', type);
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(200).end();
|
||||
});
|
||||
|
||||
app.get('/audio/:name', (req, res) => {
|
||||
const name = req.params.name;
|
||||
const filePath = path.join(DATA_DIR, name);
|
||||
if (!filePath.startsWith(DATA_DIR)) return res.status(400).send('Invalid path');
|
||||
if (!fs.existsSync(filePath)) return res.status(404).send('Not found');
|
||||
const stat = fs.statSync(filePath);
|
||||
const range = req.headers.range;
|
||||
const type = mime.getType(filePath) || 'audio/mpeg';
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (range) {
|
||||
const match = /bytes=(\d+)-(\d+)?/.exec(range);
|
||||
let start = match && match[1] ? parseInt(match[1], 10) : 0;
|
||||
let end = match && match[2] ? parseInt(match[2], 10) : stat.size - 1;
|
||||
if (Number.isNaN(start)) start = 0;
|
||||
if (Number.isNaN(end)) end = stat.size - 1;
|
||||
start = Math.min(Math.max(0, start), Math.max(0, stat.size - 1));
|
||||
end = Math.min(Math.max(start, end), Math.max(0, stat.size - 1));
|
||||
if (start > end || start >= stat.size) {
|
||||
res.setHeader('Content-Range', `bytes */${stat.size}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
const chunkSize = end - start + 1;
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
|
||||
'Content-Length': chunkSize,
|
||||
'Content-Type': type,
|
||||
});
|
||||
fs.createReadStream(filePath, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': stat.size,
|
||||
'Content-Type': type,
|
||||
});
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
});
|
||||
}
|
||||
38
src/server/tracks.js
Normal file
38
src/server/tracks.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parseFile as mmParseFile } from 'music-metadata';
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { loadYearsIndex } from './years.js';
|
||||
|
||||
export async function listTracks() {
|
||||
const years = loadYearsIndex();
|
||||
const files = fs.readdirSync(DATA_DIR).filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f));
|
||||
const tracks = await Promise.all(
|
||||
files.map(async (f) => {
|
||||
const fp = path.join(DATA_DIR, f);
|
||||
let year = null;
|
||||
let title = path.parse(f).name;
|
||||
let artist = '';
|
||||
try {
|
||||
const meta = await mmParseFile(fp, { duration: false });
|
||||
title = meta.common.title || title;
|
||||
artist = meta.common.artist || artist;
|
||||
year = meta.common.year || null;
|
||||
} catch {}
|
||||
const y = years[f]?.year ?? year;
|
||||
return { id: f, file: f, title, artist, year: y };
|
||||
})
|
||||
);
|
||||
return tracks;
|
||||
}
|
||||
|
||||
export function registerTracksApi(app) {
|
||||
app.get('/api/tracks', async (req, res) => {
|
||||
try {
|
||||
const tracks = await listTracks();
|
||||
res.json({ tracks });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
11
src/server/years.js
Normal file
11
src/server/years.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import fs from 'fs';
|
||||
import { YEARS_PATH } from './config.js';
|
||||
|
||||
export function loadYearsIndex() {
|
||||
try {
|
||||
const raw = fs.readFileSync(YEARS_PATH, 'utf8');
|
||||
const j = JSON.parse(raw);
|
||||
if (j && j.byFile && typeof j.byFile === 'object') return j.byFile;
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
Reference in New Issue
Block a user