init commit
This commit is contained in:
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
package-lock.json
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Build/output
|
||||
/dist/
|
||||
/build/
|
||||
/.cache/
|
||||
/.parcel-cache/
|
||||
coverage/
|
||||
|
||||
# Audio files (exclude all audio from Git)
|
||||
*.3gp
|
||||
*.aac
|
||||
*.ac3
|
||||
*.aif
|
||||
*.aiff
|
||||
*.alac
|
||||
*.amr
|
||||
*.ape
|
||||
*.au
|
||||
*.caf
|
||||
*.flac
|
||||
*.m4a
|
||||
*.m4b
|
||||
*.m4p
|
||||
*.mid
|
||||
*.midi
|
||||
*.mp1
|
||||
*.mp2
|
||||
*.mp3
|
||||
*.mpga
|
||||
*.oga
|
||||
*.ogg
|
||||
*.opus
|
||||
*.ra
|
||||
*.rm
|
||||
*.snd
|
||||
*.wav
|
||||
*.wma
|
||||
*.wv
|
||||
|
||||
# Project-specific: keep local music only on your machine
|
||||
/data/*.mp3
|
||||
/data/*.wav
|
||||
/data/*.flac
|
||||
/data/*.m4a
|
||||
/data/*.aac
|
||||
/data/*.ogg
|
||||
/data/*.opus
|
||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Hitstar – lokale Web-App (Prototyp)
|
||||
|
||||
Lokales Multiplayer-Webspiel inspiriert von HITSTER. Nutzt eure MP3-Dateien im Ordner `data/`, eine Lobby mit Raum-Code sowie WebSockets für den Mehrspieler-Modus.
|
||||
|
||||
## Features
|
||||
- Lobby mit Raum-Erstellung und -Beitritt (Code)
|
||||
- Mehrere Spieler pro Raum, Host startet das Spiel
|
||||
- Lokale MP3-Wiedergabe via Browser-Audio (`/audio/<dateiname>`) – keine externen Dienste
|
||||
- Einfache Rundenlogik: DJ scannt Lied, Spieler raten vor/nach (vereinfachte Chronologie)
|
||||
- Token-Zähler (Basis); Gewinnbedingung: 10 korrekt platzierte Karten
|
||||
|
||||
Hinweis: Regeln sind vereinfacht; „HITSTER!“-Challenges und exakter Zwischenplatzierungsmodus sind als Ausbaustufe geplant.
|
||||
|
||||
## Setup
|
||||
1. MP3-Dateien in `data/` legen (Dateiname wird als Fallback-Titel genutzt; falls Tags vorhanden, werden Titel/Künstler/Jahr ausgelesen).
|
||||
2. Abhängigkeiten installieren und Server starten.
|
||||
|
||||
### PowerShell-Befehle
|
||||
```powershell
|
||||
# In den Projektordner wechseln
|
||||
Set-Location e:\git\hitstar
|
||||
|
||||
# Abhängigkeiten installieren
|
||||
npm install
|
||||
|
||||
# Server starten
|
||||
npm start
|
||||
```
|
||||
|
||||
Dann im Browser öffnen: http://localhost:5173
|
||||
|
||||
## Nutzung
|
||||
- Namen setzen, Raum erstellen oder mit Code beitreten (Code wird angezeigt).
|
||||
- Host klickt „Spiel starten“.
|
||||
- DJ klickt „Lied scannen“; der Track spielt bei allen.
|
||||
- Aktiver Spieler wählt „Vor“ oder „Nach“. Bei Erfolg wandert das Lied in seine Zeitleiste.
|
||||
|
||||
## Ordnerstruktur
|
||||
- `public/` – Client (HTML/CSS/JS)
|
||||
- `server.js` – Express + WebSocket Server, Game-State
|
||||
- `data/` – eure MP3-Dateien
|
||||
|
||||
## Git & Audio-Dateien
|
||||
- In `.gitignore` sind alle gängigen Audio-Dateitypen ausgeschlossen (z. B. .mp3, .wav, .flac, .m4a, .ogg, …).
|
||||
- Legt eure Musik lokal in `data/`. Diese Dateien werden nicht ins Git-Repo eingecheckt und bleiben nur auf eurem Rechner.
|
||||
|
||||
## Nächste Schritte (optional)
|
||||
- „HITSTER!“-Challenges per Token mit Positionsauswahl (zwischen zwei Karten)
|
||||
- Team-Modus, Pro-/Expert-Regeln, exaktes Jahr
|
||||
- Persistenz (Räume/Spielstände), Reconnect
|
||||
- Drag & Drop-Zeitleiste, visuelle Platzierung
|
||||
|
||||
## Hinweis
|
||||
Nur für privaten Gebrauch. Musikdateien bleiben lokal bei euch.
|
||||
3767
data/.mb_cache.json
Normal file
3767
data/.mb_cache.json
Normal file
File diff suppressed because it is too large
Load Diff
6453
data/years.json
Normal file
6453
data/years.json
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "hitstar-webapp",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Local Hitster-like multiplayer web app using WebSockets and local MP3s",
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.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"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"mime": "^3.0.0",
|
||||
"music-metadata": "^7.14.0",
|
||||
"undici": "^6.19.8",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
428
public/client.js
Normal file
428
public/client.js
Normal file
@@ -0,0 +1,428 @@
|
||||
let ws;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer = null;
|
||||
const outbox = [];
|
||||
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;
|
||||
// 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');
|
||||
const $players = el('players');
|
||||
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;
|
||||
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') || '-');
|
||||
$players.innerHTML = room.players.map(p => {
|
||||
const badges = [
|
||||
p.id===room.hostId ? '<span class="ml-1 text-amber-600">⭐</span>' : '',
|
||||
p.ready ? '<span class="ml-1 text-emerald-600">✓</span>' : '',
|
||||
!p.connected ? '<span class="ml-1 text-rose-600">(off)</span>' : '',
|
||||
].join('');
|
||||
return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm">${escapeHtml(p.name)}${badges}</span>`;
|
||||
}).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 ?? '?');
|
||||
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 bg-indigo-600 text-white rounded-md px-2 py-0.5 min-w-[3ch] text-center">${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;
|
||||
// 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 = 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);
|
||||
}
|
||||
}
|
||||
$placeArea.classList.toggle('hidden', !canGuess);
|
||||
}
|
||||
// Show now playing if track exists
|
||||
$np.classList.toggle('hidden', !room.state.currentTrack);
|
||||
// Keep reveal banner visible during reveal; otherwise hide/reset it
|
||||
if ($revealBanner) {
|
||||
const inReveal = room.state.phase === 'reveal';
|
||||
if (!inReveal) { $revealBanner.className = 'hidden'; $revealBanner.textContent=''; }
|
||||
}
|
||||
// Media controls only for current guesser during guess phase
|
||||
if ($mediaControls) $mediaControls.classList.toggle('hidden', !isMyTurn);
|
||||
// Next button visible in reveal to host or current guesser
|
||||
const canNext = room.state.status==='playing' && room.state.phase==='reveal' && (isHost || room.state.currentGuesser===state.playerId);
|
||||
if ($nextArea) $nextArea.classList.toggle('hidden', !canNext);
|
||||
}
|
||||
|
||||
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 escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
|
||||
function handleMessage(ev) {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'connected') {
|
||||
state.playerId = msg.playerId;
|
||||
// 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 });
|
||||
}
|
||||
// Try to rejoin room if known
|
||||
if (state.room?.id) sendMsg({ type: 'join_room', code: state.room.id });
|
||||
}
|
||||
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;
|
||||
}
|
||||
})();
|
||||
BIN
public/hitstar.png
Normal file
BIN
public/hitstar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
137
public/index.html
Normal file
137
public/index.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hitstar Web</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@keyframes record-spin { from { transform: rotate(0deg);} to { transform: rotate(360deg);} }
|
||||
.spin-record { animation: record-spin 3.2s linear infinite; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div id="app" class="max-w-5xl mx-auto p-4 md:p-6">
|
||||
<header class="mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight">Hitstar</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 mt-1">Lokales Multiplayer-Spiel mit deiner eigenen Musik</p>
|
||||
</header>
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="fixed top-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium shadow-lg opacity-0 pointer-events-none transition-opacity duration-500">Code kopiert!</div>
|
||||
|
||||
<!-- Lobby Card -->
|
||||
<div id="lobby" class="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-4 md:p-6 space-y-4">
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||
<label class="flex-1 text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
Dein Name
|
||||
<input id="name" placeholder="Name" class="mt-1 w-full rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 py-2 h-11 outline-none focus:ring-2 focus:ring-indigo-500"/>
|
||||
</label>
|
||||
<button id="setName" class="h-11 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium">Setzen</button>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button id="createRoom" class="h-11 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium">Raum erstellen</button>
|
||||
<input id="roomCode" placeholder="Code" class="flex-1 h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500"/>
|
||||
<button id="joinRoom" class="h-11 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600">Beitreten</button>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">MP3-Dateien in den Ordner <code class="px-1 rounded bg-slate-100 dark:bg-slate-800">data/</code> legen und Server starten.</p>
|
||||
</div>
|
||||
|
||||
<!-- Room Card -->
|
||||
<div id="room" class="hidden rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-4 md:p-6 space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-xl md:text-2xl font-semibold flex items-center gap-2">Raum <span id="roomId" class="font-mono tracking-wider cursor-pointer" title="Klicken zum Kopieren"></span>
|
||||
</h2>
|
||||
<button id="leaveRoom" class="h-10 px-4 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">Verlassen</button>
|
||||
</div>
|
||||
|
||||
<div class="text-slate-700 dark:text-slate-300">Dein Name: <strong id="nameDisplay" class="font-semibold"></strong></div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-500 uppercase tracking-wide">Spieler</h3>
|
||||
<div id="players" class="mt-2 flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div class="space-y-1">
|
||||
<div class="text-slate-700 dark:text-slate-300">Status: <span id="status" class="font-medium"></span></div>
|
||||
<div class="text-slate-700 dark:text-slate-300">Am Zug: <span id="guesser" class="font-medium"></span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 justify-start md:justify-end">
|
||||
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">
|
||||
<input type="checkbox" id="readyChk" class="peer sr-only" aria-label="Bereit um zu starten" />
|
||||
<span class="relative inline-flex h-6 w-10 shrink-0 cursor-pointer rounded-full bg-slate-300 transition-colors duration-200 dark:bg-slate-700 peer-checked:bg-emerald-500 peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 before:absolute before:top-1 before:left-1 before:h-4 before:w-4 before:rounded-full before:bg-white before:shadow before:transition-transform before:duration-200 peer-checked:before:translate-x-4"></span>
|
||||
<span>Bereit</span>
|
||||
</label>
|
||||
<button id="startGame" class="hidden h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium">Spiel starten (Host)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="nowPlaying" class="hidden rounded-lg border border-slate-200 dark:border-slate-800 p-4 bg-slate-50/60 dark:bg-slate-800/60">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div class="text-lg font-semibold">
|
||||
<strong id="npTitle"> </strong><span id="npArtist"></span><span id="npYear" class="text-slate-500"></span>
|
||||
</div>
|
||||
<div id="revealBanner" class="hidden"></div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<audio id="audio" preload="none" class="hidden"></audio>
|
||||
<div class="flex flex-col items-center">
|
||||
<!-- Record Disc -->
|
||||
<div class="relative" style="width: 200px; height: 200px;">
|
||||
<img id="recordDisc" src="/hitstar.png" alt="Record" class="w-full h-full rounded-full object-cover shadow-lg ring-2 ring-slate-300 dark:ring-slate-700" />
|
||||
<!-- center hole overlay -->
|
||||
<div class="pointer-events-none absolute inset-0 rounded-full" style="background: radial-gradient(circle at center, transparent 0 14px, rgba(0,0,0,0.22) 14px, transparent 16px);"></div>
|
||||
<!-- buffering badge -->
|
||||
<div id="bufferBadge" class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-slate-900/80 text-white text-xs px-2 py-1 hidden">Buffering…</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mt-4 w-full">
|
||||
<div class="relative h-2 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden">
|
||||
<div id="progressFill" class="absolute left-0 top-0 h-full bg-indigo-600" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls (Play/Pause restricted to guesser) -->
|
||||
<div id="mediaControls" class="hidden mt-4 flex items-center gap-3">
|
||||
<button id="playBtn" class="h-10 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium">Play</button>
|
||||
<button id="pauseBtn" class="h-10 px-4 rounded-lg bg-rose-600 hover:bg-rose-700 text-white font-medium">Pause</button>
|
||||
</div>
|
||||
|
||||
<!-- Volume (available to all players) -->
|
||||
<div class="mt-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
Lautstärke
|
||||
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-40 accent-indigo-600" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<div id="placeArea" class="hidden flex items-center gap-2">
|
||||
<label class="text-sm text-slate-600 dark:text-slate-300">Position:
|
||||
<select id="slotSelect" class="ml-2 h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3"></select>
|
||||
</label>
|
||||
<button id="placeBtn" class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600">Platzieren</button>
|
||||
</div>
|
||||
<div id="nextArea" class="hidden">
|
||||
<button id="nextBtn" class="h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mt-2">Deine Zeitleiste</h3>
|
||||
<div id="timeline" class="mt-2 flex flex-wrap gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-800 bg-white/60 dark:bg-slate-900/40 min-h-[64px]"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-slate-700 dark:text-slate-300">
|
||||
Tokens: <span id="tokens" class="font-semibold">0</span>
|
||||
<button id="earnToken" class="h-9 px-3 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 text-sm">+1 (Titel & Künstler richtig)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/client.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
public/style.css
Normal file
37
public/style.css
Normal file
@@ -0,0 +1,37 @@
|
||||
:root { color-scheme: light dark; }
|
||||
html { -webkit-text-size-adjust: 100%; touch-action: manipulation; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0 auto; padding: 1rem; padding-bottom: calc(1rem + env(safe-area-inset-bottom)); max-width: 960px; }
|
||||
h1 { margin-top: 0; }
|
||||
.card { border: 1px solid #8884; padding: 1rem; border-radius: 12px; margin-bottom: 1rem; }
|
||||
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
|
||||
.row.space { justify-content: space-between; }
|
||||
.hidden { display: none; }
|
||||
.muted { opacity: .7; font-size: .9em; }
|
||||
button, input, select { padding: .7rem 1rem; min-height: 44px; font-size: 1rem; border-radius: 10px; }
|
||||
button { cursor: pointer; }
|
||||
input, select { border: 1px solid #8884; background: inherit; color: inherit; }
|
||||
.timeline { display: flex; gap: .75rem; flex-wrap: wrap; padding: .75rem; border: 1px dashed #8886; min-height: 64px; border-radius: 12px; }
|
||||
.chip { padding: .25rem .5rem; border-radius: 999px; border: 1px solid #8886; }
|
||||
.np { display: grid; grid-template-columns: 1fr; gap: .5rem; align-items: center; margin: .5rem 0; }
|
||||
.track-card { display: flex; align-items: center; gap: .5rem; border: 1px solid #8885; border-radius: 8px; padding: .4rem .6rem; background: #fff; color: #000; box-shadow: 0 1px 2px #0001; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.track-card { background: #1b1b1b; color: #eee; border-color: #ffffff22; box-shadow: 0 1px 2px #0005; }
|
||||
}
|
||||
.year-badge { font-weight: 700; font-variant-numeric: tabular-nums; background: #6200ee; color: white; border-radius: 6px; padding: .15rem .4rem; min-width: 3ch; text-align: center; }
|
||||
.track-info { display: grid; line-height: 1.2; }
|
||||
.track-title { font-weight: 600; }
|
||||
.track-artist { opacity: .8; font-size: .9em; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
body { padding: .75rem; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
.row { gap: .5rem; }
|
||||
.timeline { flex-wrap: nowrap; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; }
|
||||
.track-card { flex: 0 0 auto; scroll-snap-align: start; padding: .6rem .8rem; min-width: 220px; }
|
||||
.year-badge { padding: .2rem .5rem; }
|
||||
#placeArea { position: sticky; bottom: 0; left: 0; right: 0; padding: .5rem; gap: .5rem; background: color-mix(in srgb, Canvas 92%, transparent); backdrop-filter: blur(6px); border: 1px solid #8883; border-radius: 12px; box-shadow: 0 -4px 12px #0002; z-index: 10; }
|
||||
#placeArea button { flex: 1 1 auto; }
|
||||
#placeArea select { flex: 2 1 auto; min-width: 40vw; }
|
||||
}
|
||||
.banner-ok { background: #1b5e20; color: white; padding: .5rem .75rem; border-radius: 6px; }
|
||||
.banner-bad { background: #b71c1c; color: white; padding: .5rem .75rem; border-radius: 6px; }
|
||||
309
scripts/resolve-years.js
Normal file
309
scripts/resolve-years.js
Normal file
@@ -0,0 +1,309 @@
|
||||
// Resolve earliest release year for songs in data/ using MusicBrainz
|
||||
// Usage: node scripts/resolve-years.js [--max N] [--force]
|
||||
// Respects MusicBrainz 1 req/sec guideline and caches results.
|
||||
|
||||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { parseFile as mmParseFile } from 'music-metadata';
|
||||
import { setTimeout as wait } from 'timers/promises';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DATA_DIR = path.join(ROOT, 'data');
|
||||
const OUT_JSON = path.join(DATA_DIR, 'years.json');
|
||||
const CACHE_JSON = path.join(DATA_DIR, '.mb_cache.json');
|
||||
|
||||
const CONTACT = process.env.MB_CONTACT || 'local';
|
||||
const USER_AGENT = `hitstar-years/0.1.0 (${CONTACT})`;
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
function getArgValue(name, defVal) {
|
||||
const i = process.argv.findIndex((a) => a === name || a.startsWith(name + '='));
|
||||
if (i === -1) return defVal;
|
||||
const a = process.argv[i];
|
||||
if (a.includes('=')) return a.split('=')[1];
|
||||
return process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : defVal;
|
||||
}
|
||||
const MAX = parseInt(getArgValue('--max', '0'), 10) || 0;
|
||||
const FORCE = args.has('--force');
|
||||
const FILE_FILTER = getArgValue('--file', '').toLowerCase();
|
||||
|
||||
function normalize(str) {
|
||||
if (!str) return '';
|
||||
let s = String(str)
|
||||
.replace(/\s*\([^)]*(feat\.|ft\.|featuring)[^)]*\)/gi, '') // remove (feat. ...)
|
||||
.replace(/\s*\[(?:radio edit|remaster(?:ed)?(?: \d{2,4})?|single version|album version|mono|stereo|live|version)\]/gi, '')
|
||||
.replace(/\s*-\s*(?:radio edit|remaster(?:ed)?(?: \d{2,4})?|single version|album version|mono|stereo|live|version)\b/gi, '')
|
||||
.replace(/\s*\((?:radio edit|remaster(?:ed)?(?: \d{2,4})?|single version|album version|mono|stereo|live|version|short mix|original mix|201\d remaster|20\d\d remaster)\)/gi, '')
|
||||
.replace(/\s*&\s*/g, ' and ')
|
||||
.replace(/\s+feat\.?\s+/gi, ' ')
|
||||
.replace(/\s+ft\.?\s+/gi, ' ')
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[’‘']/g, "'")
|
||||
.replace(/[^a-z0-9'"\s]/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
// remove trailing quotes or hyphens
|
||||
// trim leading/trailing dashes/spaces
|
||||
s = s.replace(/^[-\s]+/, '').replace(/[-\s]+$/, '');
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseFromFilename(file) {
|
||||
const base = path.parse(file).name;
|
||||
const m = base.match(/^(.*?)\s+-\s+(.*)$/); // Artist - Title
|
||||
if (m) {
|
||||
return { artist: m[1].trim(), title: m[2].trim() };
|
||||
}
|
||||
return { artist: '', title: base };
|
||||
}
|
||||
|
||||
async function getMeta(fp) {
|
||||
try {
|
||||
const meta = await mmParseFile(fp, { duration: true });
|
||||
return {
|
||||
title: meta.common.title || '',
|
||||
artist: meta.common.artist || '',
|
||||
durationMs: Number.isFinite(meta.format.duration) ? Math.round(meta.format.duration * 1000) : null,
|
||||
yearTag: meta.common.year || null,
|
||||
};
|
||||
} catch {
|
||||
return { title: '', artist: '', durationMs: null, yearTag: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function readCache() {
|
||||
try {
|
||||
const j = JSON.parse(await fsp.readFile(CACHE_JSON, 'utf8'));
|
||||
return j || {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
async function writeCache(cache) {
|
||||
await fsp.writeFile(CACHE_JSON, JSON.stringify(cache, null, 2));
|
||||
}
|
||||
|
||||
function similar(a, b) {
|
||||
a = normalize(a); b = normalize(b);
|
||||
if (!a || !b) return 0;
|
||||
if (a === b) return 1;
|
||||
// simple token overlap Jaccard
|
||||
const as = new Set(a.split(' '));
|
||||
const bs = new Set(b.split(' '));
|
||||
const inter = [...as].filter((x) => bs.has(x)).length;
|
||||
const union = new Set([...as, ...bs]).size;
|
||||
return inter / union;
|
||||
}
|
||||
|
||||
async function mbFetchJson(url, retries = 3) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' } });
|
||||
if (res.status === 503 || res.status === 429) {
|
||||
const ra = Number(res.headers.get('Retry-After')) || 2;
|
||||
await wait(ra * 1000);
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText} - ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
throw new Error('Failed after retries');
|
||||
}
|
||||
|
||||
async function searchRecording(artist, title) {
|
||||
const q = `recording:"${title}" AND artist:"${artist}"`;
|
||||
const url = `https://musicbrainz.org/ws/2/recording?fmt=json&limit=25&query=${encodeURIComponent(q)}`;
|
||||
const json = await mbFetchJson(url);
|
||||
await wait(1300); // rate limit
|
||||
return json.recordings || [];
|
||||
}
|
||||
|
||||
async function getRecordingDetails(mbid) {
|
||||
const url = `https://musicbrainz.org/ws/2/recording/${encodeURIComponent(mbid)}?fmt=json&inc=releases+artist-credits`;
|
||||
const json = await mbFetchJson(url);
|
||||
await wait(1300); // rate limit
|
||||
return json;
|
||||
}
|
||||
|
||||
function pickBestRecording(candidates, artist, title, durationMs) {
|
||||
const nArtist = normalize(artist);
|
||||
const nTitle = normalize(title);
|
||||
let best = null;
|
||||
let bestScore = -Infinity;
|
||||
const viable = [];
|
||||
for (const r of candidates) {
|
||||
const rTitle = r.title || '';
|
||||
const rArtists = (r['artist-credit'] || []).map((ac) => ac.name || ac.artist?.name).filter(Boolean).join(' ');
|
||||
const titleSim = similar(rTitle, nTitle);
|
||||
const artistSim = similar(rArtists, nArtist);
|
||||
let score = (r.score || 0) / 100 + titleSim * 1.5 + artistSim * 1.2;
|
||||
if (durationMs && r.length) {
|
||||
const diff = Math.abs(r.length - durationMs);
|
||||
const durScore = Math.max(0, 1 - Math.min(diff, 15000) / 15000); // within 15s window
|
||||
score += durScore * 0.8;
|
||||
}
|
||||
// Prefer those with more releases (more evidence)
|
||||
if (Array.isArray(r.releases)) score += Math.min(5, r.releases.length) * 0.05;
|
||||
const firstYear = parseDateToYear(r['first-release-date']);
|
||||
if (firstYear) {
|
||||
// Tiny bias towards older original releases
|
||||
const ageBias = Math.max(0, 2100 - firstYear) / 2100; // ~0.5 for 1050, ~0.95 for 100
|
||||
score += ageBias * 0.3;
|
||||
}
|
||||
if (score > bestScore) { bestScore = score; best = r; }
|
||||
if (titleSim >= 0.55 && artistSim >= 0.55) {
|
||||
viable.push({ r, firstYear: firstYear || null, titleSim, artistSim, score });
|
||||
}
|
||||
}
|
||||
// Among viable matches, prefer the one with the earliest known first release year
|
||||
const withYear = viable.filter((v) => v.firstYear);
|
||||
if (withYear.length) {
|
||||
withYear.sort((a, b) => a.firstYear - b.firstYear || b.score - a.score);
|
||||
return withYear[0].r;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function parseDateToYear(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const re = /^(\d{4})/;
|
||||
const m = re.exec(String(dateStr));
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
function earliestDate(dates) {
|
||||
const valid = dates.filter(Boolean).map((d) => ({ d, y: parseDateToYear(d) })).filter((x) => x.y);
|
||||
if (!valid.length) return { date: null, year: null };
|
||||
valid.sort((a, b) => {
|
||||
if (a.d < b.d) return -1;
|
||||
if (a.d > b.d) return 1;
|
||||
return 0;
|
||||
});
|
||||
return { date: valid[0].d, year: valid[0].y };
|
||||
}
|
||||
|
||||
async function resolveOne(file, meta, cache) {
|
||||
const key = `${normalize(meta.artist)}|${normalize(meta.title)}`;
|
||||
if (!FORCE && cache[key]) return { ...cache[key], fromCache: true };
|
||||
if (!meta.artist || !meta.title) throw new Error('Missing artist/title');
|
||||
|
||||
const recs = await searchRecording(meta.artist, meta.title);
|
||||
if (!recs.length) throw new Error('No recordings found');
|
||||
const best = pickBestRecording(recs, meta.artist, meta.title, meta.durationMs);
|
||||
if (!best) throw new Error('No suitable match');
|
||||
let firstDate = best['first-release-date'] || null;
|
||||
let year = parseDateToYear(firstDate);
|
||||
// If no year on best, or if best appears to be a later reissue, inspect more candidates
|
||||
const viable = recs
|
||||
.map((r) => ({
|
||||
r,
|
||||
titleSim: similar(r.title || '', meta.title),
|
||||
artistSim: similar((r['artist-credit'] || []).map((ac) => ac.name || ac.artist?.name).filter(Boolean).join(' '), meta.artist),
|
||||
firstYear: parseDateToYear(r['first-release-date']) || null,
|
||||
}))
|
||||
.filter((v) => v.titleSim >= 0.5 && v.artistSim >= 0.5);
|
||||
|
||||
// Determine earliest among top candidates, fetching details when missing
|
||||
let earliest = { year: year || Infinity, date: firstDate || null, id: best.id };
|
||||
const detailsBudget = 5; // limit extra calls per track
|
||||
let detailsUsed = 0;
|
||||
for (const v of viable.slice(0, 10)) {
|
||||
let y = v.firstYear;
|
||||
let d = v.r['first-release-date'] || null;
|
||||
if (!y && detailsUsed < detailsBudget) {
|
||||
try {
|
||||
const details = await getRecordingDetails(v.r.id);
|
||||
detailsUsed++;
|
||||
const dates = (details.releases || []).map((re) => re.date || re['release-events']?.[0]?.date || null);
|
||||
const er = earliestDate(dates);
|
||||
y = er.year;
|
||||
d = er.date;
|
||||
} catch {}
|
||||
}
|
||||
if (y && y < (earliest.year || Infinity)) {
|
||||
earliest = { year: y, date: d, id: v.r.id };
|
||||
}
|
||||
}
|
||||
if (earliest.year && earliest.year !== year) {
|
||||
year = earliest.year;
|
||||
firstDate = earliest.date;
|
||||
}
|
||||
const result = {
|
||||
file,
|
||||
title: meta.title,
|
||||
artist: meta.artist,
|
||||
mbid: earliest.id || best.id,
|
||||
earliestDate: firstDate,
|
||||
year,
|
||||
confidence: {
|
||||
mbScore: best.score || null,
|
||||
titleSim: similar(best.title || '', meta.title),
|
||||
artistSim: similar((best['artist-credit'] || []).map((ac) => ac.name || ac.artist?.name).filter(Boolean).join(' '), meta.artist),
|
||||
durationMs: meta.durationMs,
|
||||
matchedDurationMs: best.length || null,
|
||||
},
|
||||
};
|
||||
cache[key] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Scanning data dir:', DATA_DIR);
|
||||
let files = fs.readdirSync(DATA_DIR).filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f));
|
||||
if (FILE_FILTER) {
|
||||
files = files.filter((f) => f.toLowerCase().includes(FILE_FILTER));
|
||||
}
|
||||
if (!files.length) {
|
||||
console.error('No audio files in data/.');
|
||||
process.exit(1);
|
||||
}
|
||||
const cache = await readCache();
|
||||
const results = [];
|
||||
|
||||
let count = 0;
|
||||
for (const f of files) {
|
||||
if (MAX && count >= MAX) break;
|
||||
const fp = path.join(DATA_DIR, f);
|
||||
const fromName = parseFromFilename(f);
|
||||
const tags = await getMeta(fp);
|
||||
const artist = tags.artist || fromName.artist;
|
||||
const title = tags.title || fromName.title;
|
||||
const meta = { artist, title, durationMs: tags.durationMs };
|
||||
count++;
|
||||
console.log(`\n[${count}/${MAX || files.length}] ${f}`);
|
||||
console.log(` -> ${artist} — ${title}`);
|
||||
try {
|
||||
const r = await resolveOne(f, meta, cache);
|
||||
results.push(r);
|
||||
console.log(` ✓ Earliest: ${r.earliestDate || 'n/a'} (year=${r.year || 'n/a'}) [${r.fromCache ? 'cache' : 'MB'}]`);
|
||||
} catch (e) {
|
||||
console.warn(' ! Failed:', e.message);
|
||||
results.push({ file: f, title, artist, mbid: null, earliestDate: null, year: null, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Build index by file
|
||||
const byFile = Object.fromEntries(results.map((r) => [r.file, { year: r.year, date: r.earliestDate, title: r.title, artist: r.artist, mbid: r.mbid }]));
|
||||
const out = { generatedAt: new Date().toISOString(), total: results.length, byFile, results };
|
||||
await fsp.writeFile(OUT_JSON, JSON.stringify(out, null, 2));
|
||||
await writeCache(cache);
|
||||
console.log(`\nWritten ${OUT_JSON} with ${results.length} entries.`);
|
||||
console.log('Cache saved:', path.basename(CACHE_JSON));
|
||||
}
|
||||
|
||||
// Ensure fetch exists in Node <18
|
||||
if (typeof fetch === 'undefined') {
|
||||
const { default: undici } = await import('undici');
|
||||
global.fetch = undici.fetch;
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
482
server.js
Normal file
482
server.js
Normal file
@@ -0,0 +1,482 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import mime from 'mime';
|
||||
import { parseFile as mmParseFile } from 'music-metadata';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Config
|
||||
const PORT = process.env.PORT || 5173;
|
||||
const DATA_DIR = path.resolve(__dirname, 'data');
|
||||
const PUBLIC_DIR = path.resolve(__dirname, 'public');
|
||||
const YEARS_PATH = path.join(DATA_DIR, 'years.json');
|
||||
|
||||
// Ensure data dir exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const app = express();
|
||||
// Load years.json helper
|
||||
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 {};
|
||||
}
|
||||
let YEARS_INDEX = loadYearsIndex();
|
||||
|
||||
app.get('/api/reload-years', (req, res) => {
|
||||
YEARS_INDEX = loadYearsIndex();
|
||||
res.json({ ok: true, count: Object.keys(YEARS_INDEX).length });
|
||||
});
|
||||
|
||||
// Static files
|
||||
app.use(express.static(PUBLIC_DIR));
|
||||
|
||||
// Serve audio files safely from data folder with byte-range support
|
||||
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;
|
||||
// Clamp and validate
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// List tracks with minimal metadata
|
||||
app.get('/api/tracks', async (req, res) => {
|
||||
try {
|
||||
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_INDEX[f]?.year ?? year;
|
||||
return { id: f, file: f, title, artist, year: y };
|
||||
})
|
||||
);
|
||||
res.json({ tracks });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
// --- Game State ---
|
||||
const rooms = new Map(); // roomId -> { id, name, hostId, players: Map, state }
|
||||
|
||||
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', // lobby | playing | ended
|
||||
turnOrder: [],
|
||||
currentGuesser: null,
|
||||
currentTrack: null,
|
||||
timeline: {}, // playerId -> [{trackId, year, title, artist}]
|
||||
tokens: {}, // playerId -> number
|
||||
ready: { [host.id]: false }, // playerId -> boolean
|
||||
phase: 'guess', // 'guess' | 'reveal'
|
||||
lastResult: null, // { playerId, correct }
|
||||
trackStartAt: null, // ms epoch for synced start time
|
||||
paused: false,
|
||||
pausedPosSec: 0,
|
||||
goal: 10,
|
||||
},
|
||||
};
|
||||
rooms.set(id, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
function broadcast(room, type, payload) {
|
||||
for (const p of room.players.values()) {
|
||||
try {
|
||||
p.ws.send(JSON.stringify({ type, ...payload }));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
})),
|
||||
state: room.state,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDeck() {
|
||||
// Load directly from DATA_DIR to avoid HTTP dependency
|
||||
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_INDEX[f]?.year ?? year;
|
||||
return { id: f, file: f, title, artist, year: y };
|
||||
})
|
||||
);
|
||||
return tracks;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function stopSyncTimer(room) {
|
||||
if (room.syncTimer) { clearInterval(room.syncTimer); room.syncTimer = null; }
|
||||
}
|
||||
|
||||
function drawNextTrack(room) {
|
||||
const track = room.deck.shift();
|
||||
if (!track) {
|
||||
room.state.status = 'ended';
|
||||
room.state.winner = null; // deck exhausted
|
||||
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; // synchronized start in ~0.8s
|
||||
broadcast(room, 'play_track', { track: room.state.currentTrack, startAt: room.state.trackStartAt, serverNow: Date.now() });
|
||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
||||
startSyncTimer(room);
|
||||
}
|
||||
|
||||
// WebSocket handling
|
||||
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 };
|
||||
|
||||
function 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 === 'player_control') {
|
||||
const room = rooms.get(player.roomId);
|
||||
if (!room) return;
|
||||
const { action } = msg; // 'play' | 'pause'
|
||||
if (room.state.status !== 'playing') return;
|
||||
if (room.state.phase !== 'guess') return; // control only while guessing
|
||||
if (room.state.currentGuesser !== player.id) return; // only current guesser controls
|
||||
if (!room.state.currentTrack) return;
|
||||
if (action !== 'play' && action !== 'pause') 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' });
|
||||
} else 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 === '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;
|
||||
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.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' });
|
||||
// All players must be ready
|
||||
const pids = [...room.players.keys()];
|
||||
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 = shuffle(await loadDeck());
|
||||
room.discard = [];
|
||||
room.state.phase = 'guess';
|
||||
room.state.lastResult = null;
|
||||
// draw first track automatically
|
||||
drawNextTrack(room);
|
||||
return;
|
||||
}
|
||||
|
||||
// 'scan_track' is no longer used; server auto draws.
|
||||
|
||||
if (msg.type === 'place_guess') {
|
||||
const room = rooms.get(player.roomId);
|
||||
if (!room) return;
|
||||
const { position, slot: rawSlot } = msg; // 'before' | 'after' | slot index
|
||||
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' });
|
||||
// Simplified: if year known, check relative placement against player's timeline
|
||||
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; // default to after
|
||||
let correct = false;
|
||||
if (current.year != null) {
|
||||
if (n === 0) {
|
||||
correct = slot === 0; // only one slot
|
||||
} 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) {
|
||||
// Insert at chosen slot; equal years allowed anywhere
|
||||
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);
|
||||
}
|
||||
// Enter reveal phase (song keeps playing), announce result
|
||||
room.state.phase = 'reveal';
|
||||
room.state.lastResult = { playerId: player.id, correct };
|
||||
broadcast(room, 'reveal', { result: room.state.lastResult, track: room.state.currentTrack });
|
||||
// Also push a room update so clients know we're in 'reveal' (for Next button visibility)
|
||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
||||
// Win check after revealing
|
||||
const tlNow = room.state.timeline[player.id] || [];
|
||||
if (correct && tlNow.length >= room.state.goal) {
|
||||
room.state.status = 'ended';
|
||||
room.state.winner = player.id;
|
||||
// Inform game ended immediately
|
||||
broadcast(room, 'game_ended', { winner: player.id });
|
||||
return;
|
||||
}
|
||||
// Manual advance: wait for 'next_track'
|
||||
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; // can only advance during reveal
|
||||
const isAuthorized = player.id === room.hostId || player.id === room.state.currentGuesser;
|
||||
if (!isAuthorized) return;
|
||||
// Advance to next round
|
||||
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);
|
||||
// Keep player in room but mark disconnected
|
||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Hitstar server running on http://localhost:${PORT}`);
|
||||
});
|
||||
1
tmp_tracks.json
Normal file
1
tmp_tracks.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user