Refactor code structure for improved readability and maintainability
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
This commit is contained in:
27
.eslintrc.cjs
Normal file
27
.eslintrc.cjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
es2023: true,
|
||||||
|
browser: true,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2023,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
ignores: ['data/**', 'public/**/vendor/**', 'scripts/**/tmp/**', 'tmp/**'],
|
||||||
|
plugins: ['import'],
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:import/recommended',
|
||||||
|
'plugin:import/errors',
|
||||||
|
'plugin:import/warnings',
|
||||||
|
'plugin:import/typescript',
|
||||||
|
'prettier',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'no-console': 'off',
|
||||||
|
'import/no-unresolved': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
8
.prettierignore
Normal file
8
.prettierignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
data/
|
||||||
|
public/audio/
|
||||||
|
public/cover/
|
||||||
|
**/*.mp3
|
||||||
|
node_modules/
|
||||||
|
.tmp/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"files.eol": "\n",
|
||||||
|
"eslint.validate": ["javascript"],
|
||||||
|
"eslint.format.enable": true
|
||||||
|
}
|
||||||
13
.vscode/tasks.json
vendored
Normal file
13
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "start-dev",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm run start",
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": ["$eslint-stylish"],
|
||||||
|
"group": "build"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
15
README.md
15
README.md
@@ -4,27 +4,29 @@
|
|||||||
|
|
||||||
Run the app in a container while using your local `data/` music folder:
|
Run the app in a container while using your local `data/` music folder:
|
||||||
|
|
||||||
1) Build the image
|
1. Build the image
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose build
|
docker compose build
|
||||||
```
|
```
|
||||||
|
|
||||||
2) Start the service
|
2. Start the service
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
3) Open http://localhost:5173
|
3. Open http://localhost:5173
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Your local `data/` is mounted read/write at `/app/data` inside the container, so you can manage tracks on the host.
|
- Your local `data/` is mounted read/write at `/app/data` inside the container, so you can manage tracks on the host.
|
||||||
- To rebuild after changes: `docker compose build --no-cache && docker compose up -d`.
|
- To rebuild after changes: `docker compose build --no-cache && docker compose up -d`.
|
||||||
|
|
||||||
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.
|
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
|
## Features
|
||||||
|
|
||||||
- Lobby mit Raum-Erstellung und -Beitritt (Code)
|
- Lobby mit Raum-Erstellung und -Beitritt (Code)
|
||||||
- Mehrere Spieler pro Raum, Host startet das Spiel
|
- Mehrere Spieler pro Raum, Host startet das Spiel
|
||||||
- Lokale MP3-Wiedergabe via Browser-Audio (`/audio/<dateiname>`) – keine externen Dienste
|
- Lokale MP3-Wiedergabe via Browser-Audio (`/audio/<dateiname>`) – keine externen Dienste
|
||||||
@@ -34,10 +36,12 @@ Lokales Multiplayer-Webspiel inspiriert von HITSTER. Nutzt eure MP3-Dateien im O
|
|||||||
Hinweis: Regeln sind vereinfacht; „HITSTER!“-Challenges und exakter Zwischenplatzierungsmodus sind als Ausbaustufe geplant.
|
Hinweis: Regeln sind vereinfacht; „HITSTER!“-Challenges und exakter Zwischenplatzierungsmodus sind als Ausbaustufe geplant.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. MP3-Dateien in `data/` legen (Dateiname wird als Fallback-Titel genutzt; falls Tags vorhanden, werden Titel/Künstler/Jahr ausgelesen).
|
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.
|
2. Abhängigkeiten installieren und Server starten.
|
||||||
|
|
||||||
### PowerShell-Befehle
|
### PowerShell-Befehle
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# In den Projektordner wechseln
|
# In den Projektordner wechseln
|
||||||
Set-Location e:\git\hitstar
|
Set-Location e:\git\hitstar
|
||||||
@@ -52,25 +56,30 @@ npm start
|
|||||||
Dann im Browser öffnen: http://localhost:5173
|
Dann im Browser öffnen: http://localhost:5173
|
||||||
|
|
||||||
## Nutzung
|
## Nutzung
|
||||||
|
|
||||||
- Namen setzen, Raum erstellen oder mit Code beitreten (Code wird angezeigt).
|
- Namen setzen, Raum erstellen oder mit Code beitreten (Code wird angezeigt).
|
||||||
- Host klickt „Spiel starten“.
|
- Host klickt „Spiel starten“.
|
||||||
- DJ klickt „Lied scannen“; der Track spielt bei allen.
|
- DJ klickt „Lied scannen“; der Track spielt bei allen.
|
||||||
- Aktiver Spieler wählt „Vor“ oder „Nach“. Bei Erfolg wandert das Lied in seine Zeitleiste.
|
- Aktiver Spieler wählt „Vor“ oder „Nach“. Bei Erfolg wandert das Lied in seine Zeitleiste.
|
||||||
|
|
||||||
## Ordnerstruktur
|
## Ordnerstruktur
|
||||||
|
|
||||||
- `public/` – Client (HTML/CSS/JS)
|
- `public/` – Client (HTML/CSS/JS)
|
||||||
- `server.js` – Express + WebSocket Server, Game-State
|
- `server.js` – Express + WebSocket Server, Game-State
|
||||||
- `data/` – eure MP3-Dateien
|
- `data/` – eure MP3-Dateien
|
||||||
|
|
||||||
## Git & Audio-Dateien
|
## Git & Audio-Dateien
|
||||||
|
|
||||||
- In `.gitignore` sind alle gängigen Audio-Dateitypen ausgeschlossen (z. B. .mp3, .wav, .flac, .m4a, .ogg, …).
|
- 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.
|
- Legt eure Musik lokal in `data/`. Diese Dateien werden nicht ins Git-Repo eingecheckt und bleiben nur auf eurem Rechner.
|
||||||
|
|
||||||
## Nächste Schritte (optional)
|
## Nächste Schritte (optional)
|
||||||
|
|
||||||
- „HITSTER!“-Challenges per Token mit Positionsauswahl (zwischen zwei Karten)
|
- „HITSTER!“-Challenges per Token mit Positionsauswahl (zwischen zwei Karten)
|
||||||
- Team-Modus, Pro-/Expert-Regeln, exaktes Jahr
|
- Team-Modus, Pro-/Expert-Regeln, exaktes Jahr
|
||||||
- Persistenz (Räume/Spielstände), Reconnect
|
- Persistenz (Räume/Spielstände), Reconnect
|
||||||
- Drag & Drop-Zeitleiste, visuelle Platzierung
|
- Drag & Drop-Zeitleiste, visuelle Platzierung
|
||||||
|
|
||||||
## Hinweis
|
## Hinweis
|
||||||
|
|
||||||
Nur für privaten Gebrauch. Musikdateien bleiben lokal bei euch.
|
Nur für privaten Gebrauch. Musikdateien bleiben lokal bei euch.
|
||||||
|
|||||||
@@ -1816,8 +1816,8 @@
|
|||||||
"mbid": "e9eb684a-5c5a-485e-ac76-ce799aeba7a0"
|
"mbid": "e9eb684a-5c5a-485e-ac76-ce799aeba7a0"
|
||||||
},
|
},
|
||||||
"The Beach Boys - Surfin' U.S.A. (Mono).mp3": {
|
"The Beach Boys - Surfin' U.S.A. (Mono).mp3": {
|
||||||
"year": 2008,
|
"year": 1963,
|
||||||
"date": "2008-06-10",
|
"date": "1963",
|
||||||
"title": "Surfin' U.S.A. (Mono)",
|
"title": "Surfin' U.S.A. (Mono)",
|
||||||
"artist": "The Beach Boys",
|
"artist": "The Beach Boys",
|
||||||
"mbid": "fa52f01e-6c8c-46ff-860d-daa4930f93a4"
|
"mbid": "fa52f01e-6c8c-46ff-860d-daa4930f93a4"
|
||||||
@@ -5176,8 +5176,8 @@
|
|||||||
"title": "Under Pressure",
|
"title": "Under Pressure",
|
||||||
"artist": "Queen, David Bowie",
|
"artist": "Queen, David Bowie",
|
||||||
"mbid": null,
|
"mbid": null,
|
||||||
"earliestDate": null,
|
"earliestDate": "1981",
|
||||||
"year": null,
|
"year": 1981,
|
||||||
"error": "No recordings found"
|
"error": "No recordings found"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -6151,8 +6151,8 @@
|
|||||||
"title": "China In Your Hand (Single Version)",
|
"title": "China In Your Hand (Single Version)",
|
||||||
"artist": "T'pau",
|
"artist": "T'pau",
|
||||||
"mbid": "56a5d0fc-a0c3-4e07-91b5-0c8c77b43ab2",
|
"mbid": "56a5d0fc-a0c3-4e07-91b5-0c8c77b43ab2",
|
||||||
"earliestDate": "2009-06-08",
|
"earliestDate": "1987",
|
||||||
"year": 2009,
|
"year": 1987,
|
||||||
"confidence": {
|
"confidence": {
|
||||||
"mbScore": 100,
|
"mbScore": 100,
|
||||||
"titleSim": 1,
|
"titleSim": 1,
|
||||||
|
|||||||
32
eslint.config.mjs
Normal file
32
eslint.config.mjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
files: ['**/*.js'],
|
||||||
|
ignores: [
|
||||||
|
'node_modules/**',
|
||||||
|
'data/**',
|
||||||
|
'public/audio/**',
|
||||||
|
'public/cover/**',
|
||||||
|
'**/*.mp3',
|
||||||
|
'.tmp/**',
|
||||||
|
'dist/**',
|
||||||
|
'coverage/**',
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2023,
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'no-console': 'off',
|
||||||
|
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
16
package.json
16
package.json
@@ -10,7 +10,11 @@
|
|||||||
"dev": "nodemon src/server/index.js",
|
"dev": "nodemon src/server/index.js",
|
||||||
"years:resolve": "node scripts/resolve-years.js",
|
"years:resolve": "node scripts/resolve-years.js",
|
||||||
"years:resolve:10": "node scripts/resolve-years.js --max 10",
|
"years:resolve:10": "node scripts/resolve-years.js --max 10",
|
||||||
"years:force": "node scripts/resolve-years.js --force"
|
"years:force": "node scripts/resolve-years.js --force",
|
||||||
|
"lint": "eslint . --ext .js",
|
||||||
|
"lint:fix": "eslint . --ext .js --fix",
|
||||||
|
"format": "prettier --write \"**/*.{js,json,md,css,html}\"",
|
||||||
|
"format:check": "prettier --check \"**/*.{js,json,md,css,html}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
@@ -18,9 +22,15 @@
|
|||||||
"music-metadata": "^7.14.0",
|
"music-metadata": "^7.14.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"ws": "^8.18.0"
|
"socket.io": "^4.7.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.1.0",
|
||||||
|
"eslint": "^9.11.1",
|
||||||
|
"@eslint/js": "^9.11.1",
|
||||||
|
"globals": "^13.24.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
"prettier": "^3.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,53 +5,118 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Hitstar Web</title>
|
<title>Hitstar Web</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@keyframes record-spin { from { transform: rotate(0deg);} to { transform: rotate(360deg);} }
|
@keyframes record-spin {
|
||||||
.spin-record { animation: record-spin 3.2s linear infinite; }
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.spin-record {
|
||||||
|
animation: record-spin 3.2s linear infinite;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
<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">
|
<div id="app" class="max-w-5xl mx-auto p-4 md:p-6">
|
||||||
<header class="mb-6 md:mb-8">
|
<header class="mb-6 md:mb-8">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight">Hitstar</h1>
|
<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>
|
<p class="text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Lokales Multiplayer-Spiel mit deiner eigenen Musik
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<!-- Toast Notification -->
|
<!-- 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>
|
<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 -->
|
<!-- 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
|
||||||
|
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">
|
<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">
|
<label class="flex-1 text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||||
Dein Name
|
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"/>
|
<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>
|
</label>
|
||||||
<button id="setName" class="h-11 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium">Setzen</button>
|
<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>
|
||||||
<div class="flex flex-col sm:flex-row gap-3">
|
<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>
|
<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"/>
|
id="createRoom"
|
||||||
<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>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Room Card -->
|
<!-- 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
|
||||||
|
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">
|
<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 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>
|
</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>
|
<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>
|
||||||
|
|
||||||
<div class="text-slate-700 dark:text-slate-300">Dein Name: <strong id="nameDisplay" class="font-semibold"></strong></div>
|
<div class="text-slate-700 dark:text-slate-300">
|
||||||
|
Dein Name: <strong id="nameDisplay" class="font-semibold"></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Expandable Dashboard: player statuses and scores -->
|
<!-- Expandable Dashboard: player statuses and scores -->
|
||||||
<div>
|
<div>
|
||||||
<details id="dashboard" class="rounded-lg border border-slate-200 dark:border-slate-800 bg-white/60 dark:bg-slate-900/40 p-3">
|
<details
|
||||||
<summary class="cursor-pointer select-none text-sm font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-2">
|
id="dashboard"
|
||||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white text-[11px] font-bold">i</span>
|
class="rounded-lg border border-slate-200 dark:border-slate-800 bg-white/60 dark:bg-slate-900/40 p-3"
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer select-none text-sm font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white text-[11px] font-bold"
|
||||||
|
>i</span
|
||||||
|
>
|
||||||
Dashboard: Spielerstatus & Punkte
|
Dashboard: Spielerstatus & Punkte
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-3 overflow-x-auto">
|
<div class="mt-3 overflow-x-auto">
|
||||||
@@ -64,7 +129,10 @@
|
|||||||
<th class="py-2 pr-3">Score</th>
|
<th class="py-2 pr-3">Score</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="dashboardList" class="divide-y divide-slate-200 dark:divide-slate-800"></tbody>
|
<tbody
|
||||||
|
id="dashboardList"
|
||||||
|
class="divide-y divide-slate-200 dark:divide-slate-800"
|
||||||
|
></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -72,23 +140,45 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||||
<div class="space-y-1">
|
<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">
|
||||||
<div class="text-slate-700 dark:text-slate-300">Am Zug: <span id="guesser" class="font-medium"></span></div>
|
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>
|
||||||
<div class="flex flex-wrap items-center gap-3 justify-start md:justify-end">
|
<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">
|
<label
|
||||||
<input type="checkbox" id="readyChk" class="peer sr-only" aria-label="Bereit um zu starten" />
|
class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none"
|
||||||
<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>
|
>
|
||||||
|
<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>
|
<span>Bereit</span>
|
||||||
</label>
|
</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>
|
<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>
|
</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
|
||||||
|
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="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||||
<div class="text-lg font-semibold">
|
<div class="text-lg font-semibold">
|
||||||
<strong id="npTitle"> </strong><span id="npArtist"></span><span id="npYear" class="text-slate-500"></span>
|
<strong id="npTitle"> </strong><span id="npArtist"></span
|
||||||
|
><span id="npYear" class="text-slate-500"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="revealBanner" class="hidden"></div>
|
<div id="revealBanner" class="hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,66 +186,149 @@
|
|||||||
<audio id="audio" preload="none" class="hidden"></audio>
|
<audio id="audio" preload="none" class="hidden"></audio>
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<!-- Record Disc -->
|
<!-- Record Disc -->
|
||||||
<div class="relative" style="width: 200px; height: 200px;">
|
<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" />
|
<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 -->
|
<!-- 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>
|
<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 -->
|
<!-- 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
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<div class="mt-4 w-full">
|
<div class="mt-4 w-full">
|
||||||
<div class="relative h-2 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden">
|
<div
|
||||||
<div id="progressFill" class="absolute left-0 top-0 h-full bg-indigo-600" style="width:0%"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls (Play/Pause restricted to guesser) -->
|
<!-- Controls (Play/Pause restricted to guesser) -->
|
||||||
<div id="mediaControls" class="hidden mt-4 flex items-center gap-3">
|
<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
|
||||||
<button id="pauseBtn" class="h-10 px-4 rounded-lg bg-rose-600 hover:bg-rose-700 text-white font-medium">Pause</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>
|
</div>
|
||||||
|
|
||||||
<!-- Volume (available to all players) -->
|
<!-- Volume (available to all players) -->
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
<label
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
Lautstärke
|
Lautstärke
|
||||||
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-40 accent-indigo-600" />
|
<input
|
||||||
|
id="volumeSlider"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
class="w-40 accent-indigo-600"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Answer form: everyone can try to guess title & artist for a coin -->
|
<!-- Answer form: everyone can try to guess title & artist for a coin -->
|
||||||
<form id="answerForm" class="mt-4 w-full flex flex-col md:flex-row gap-2 md:items-end">
|
<form
|
||||||
|
id="answerForm"
|
||||||
|
class="mt-4 w-full flex flex-col md:flex-row gap-2 md:items-end"
|
||||||
|
>
|
||||||
<label class="flex-1 text-sm">
|
<label class="flex-1 text-sm">
|
||||||
<span class="text-slate-700 dark:text-slate-300">Titel</span>
|
<span class="text-slate-700 dark:text-slate-300">Titel</span>
|
||||||
<input id="guessTitle" name="title" placeholder="Songtitel" autocomplete="off" class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3" />
|
<input
|
||||||
|
id="guessTitle"
|
||||||
|
name="title"
|
||||||
|
placeholder="Songtitel"
|
||||||
|
autocomplete="off"
|
||||||
|
class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex-1 text-sm">
|
<label class="flex-1 text-sm">
|
||||||
<span class="text-slate-700 dark:text-slate-300">Künstler</span>
|
<span class="text-slate-700 dark:text-slate-300">Künstler</span>
|
||||||
<input id="guessArtist" name="artist" placeholder="Künstler" autocomplete="off" class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3" />
|
<input
|
||||||
|
id="guessArtist"
|
||||||
|
name="artist"
|
||||||
|
placeholder="Künstler"
|
||||||
|
autocomplete="off"
|
||||||
|
class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button id="submitAnswer" type="submit" class="h-10 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium">Abschicken</button>
|
<button
|
||||||
|
id="submitAnswer"
|
||||||
|
type="submit"
|
||||||
|
class="h-10 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium"
|
||||||
|
>
|
||||||
|
Abschicken
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="answerResult" class="mt-1 text-sm"></div>
|
<div id="answerResult" class="mt-1 text-sm"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
<div id="placeArea" class="hidden flex items-center gap-2">
|
<div id="placeArea" class="hidden flex items-center gap-2">
|
||||||
<label class="text-sm text-slate-600 dark:text-slate-300">Position:
|
<label class="text-sm text-slate-600 dark:text-slate-300"
|
||||||
<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>
|
>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>
|
</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>
|
<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>
|
||||||
<div id="nextArea" class="hidden">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold mt-2">Deine Zeitleiste</h3>
|
<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
|
||||||
|
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>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 text-slate-700 dark:text-slate-300">
|
<div class="flex items-center gap-3 text-slate-700 dark:text-slate-300">
|
||||||
@@ -166,4 +339,4 @@
|
|||||||
|
|
||||||
<script src="/js/main.js" type="module"></script>
|
<script src="/js/main.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export function initAudioUI() {
|
|||||||
if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true;
|
if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true;
|
||||||
} catch {}
|
} catch {}
|
||||||
$audio.addEventListener('timeupdate', () => {
|
$audio.addEventListener('timeupdate', () => {
|
||||||
const dur = $audio.duration || 0; if (!dur || !$progressFill) return;
|
const dur = $audio.duration || 0;
|
||||||
|
if (!dur || !$progressFill) return;
|
||||||
const pct = Math.min(100, Math.max(0, ($audio.currentTime / dur) * 100));
|
const pct = Math.min(100, Math.max(0, ($audio.currentTime / dur) * 100));
|
||||||
$progressFill.style.width = pct + '%';
|
$progressFill.style.width = pct + '%';
|
||||||
});
|
});
|
||||||
@@ -21,7 +22,10 @@ export function initAudioUI() {
|
|||||||
$audio.addEventListener('stalled', () => showBuffer(true));
|
$audio.addEventListener('stalled', () => showBuffer(true));
|
||||||
$audio.addEventListener('canplay', () => showBuffer(false));
|
$audio.addEventListener('canplay', () => showBuffer(false));
|
||||||
$audio.addEventListener('playing', () => showBuffer(false));
|
$audio.addEventListener('playing', () => showBuffer(false));
|
||||||
$audio.addEventListener('ended', () => { if ($recordDisc) $recordDisc.classList.remove('spin-record'); $audio.playbackRate = 1.0; });
|
$audio.addEventListener('ended', () => {
|
||||||
|
if ($recordDisc) $recordDisc.classList.remove('spin-record');
|
||||||
|
$audio.playbackRate = 1.0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applySync(startAt, serverNow) {
|
export function applySync(startAt, serverNow) {
|
||||||
@@ -34,7 +38,7 @@ export function applySync(startAt, serverNow) {
|
|||||||
const abs = Math.abs(drift);
|
const abs = Math.abs(drift);
|
||||||
if (abs > 1.0) {
|
if (abs > 1.0) {
|
||||||
$audio.currentTime = Math.max(0, elapsed);
|
$audio.currentTime = Math.max(0, elapsed);
|
||||||
if ($audio.paused) $audio.play().catch(()=>{});
|
if ($audio.paused) $audio.play().catch(() => {});
|
||||||
$audio.playbackRate = 1.0;
|
$audio.playbackRate = 1.0;
|
||||||
} else if (abs > 0.12) {
|
} else if (abs > 0.12) {
|
||||||
const maxNudge = 0.03;
|
const maxNudge = 0.03;
|
||||||
@@ -42,16 +46,32 @@ export function applySync(startAt, serverNow) {
|
|||||||
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
|
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
|
||||||
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate));
|
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate));
|
||||||
} else {
|
} else {
|
||||||
if (Math.abs($audio.playbackRate - 1) > 0.001) { $audio.playbackRate = 1.0; }
|
if (Math.abs($audio.playbackRate - 1) > 0.001) {
|
||||||
|
$audio.playbackRate = 1.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopAudioPlayback() {
|
export function stopAudioPlayback() {
|
||||||
try { $audio.pause(); } catch {}
|
try {
|
||||||
try { $audio.currentTime = 0; } catch {}
|
$audio.pause();
|
||||||
try { $audio.src = ''; } catch {}
|
} catch {}
|
||||||
try { $audio.playbackRate = 1.0; } catch {}
|
try {
|
||||||
try { if ($recordDisc) $recordDisc.classList.remove('spin-record'); } catch {}
|
$audio.currentTime = 0;
|
||||||
try { if ($progressFill) $progressFill.style.width = '0%'; } catch {}
|
} catch {}
|
||||||
try { if ($bufferBadge) $bufferBadge.classList.add('hidden'); } catch {}
|
try {
|
||||||
|
$audio.src = '';
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
$audio.playbackRate = 1.0;
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
if ($recordDisc) $recordDisc.classList.remove('spin-record');
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
if ($progressFill) $progressFill.style.width = '0%';
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
if ($bufferBadge) $bufferBadge.classList.add('hidden');
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,11 @@ export const $guessTitle = el('guessTitle');
|
|||||||
export const $guessArtist = el('guessArtist');
|
export const $guessArtist = el('guessArtist');
|
||||||
export const $answerResult = el('answerResult');
|
export const $answerResult = el('answerResult');
|
||||||
|
|
||||||
export function showLobby() { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); }
|
export function showLobby() {
|
||||||
export function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); }
|
$lobby.classList.remove('hidden');
|
||||||
|
$room.classList.add('hidden');
|
||||||
|
}
|
||||||
|
export function showRoom() {
|
||||||
|
$lobby.classList.add('hidden');
|
||||||
|
$room.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,31 @@
|
|||||||
import { $audio, $copyRoomCode, $leaveRoom, $nameDisplay, $nameLobby, $npArtist, $npTitle, $npYear, $pauseBtn, $placeBtn, $readyChk, $roomId, $roomCode, $slotSelect, $startGame, $volumeSlider, $playBtn, $nextBtn, $createRoom, $joinRoom, $lobby, $room, $setNameLobby, $guessTitle, $guessArtist, $answerResult } from './dom.js';
|
import {
|
||||||
|
$audio,
|
||||||
|
$copyRoomCode,
|
||||||
|
$leaveRoom,
|
||||||
|
$nameDisplay,
|
||||||
|
$nameLobby,
|
||||||
|
$npArtist,
|
||||||
|
$npTitle,
|
||||||
|
$npYear,
|
||||||
|
$pauseBtn,
|
||||||
|
$placeBtn,
|
||||||
|
$readyChk,
|
||||||
|
$roomId,
|
||||||
|
$roomCode,
|
||||||
|
$slotSelect,
|
||||||
|
$startGame,
|
||||||
|
$volumeSlider,
|
||||||
|
$playBtn,
|
||||||
|
$nextBtn,
|
||||||
|
$createRoom,
|
||||||
|
$joinRoom,
|
||||||
|
$lobby,
|
||||||
|
$room,
|
||||||
|
$setNameLobby,
|
||||||
|
$guessTitle,
|
||||||
|
$guessArtist,
|
||||||
|
$answerResult,
|
||||||
|
} from './dom.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { connectWS, sendMsg, cacheSessionId, cacheLastRoomId } from './ws.js';
|
import { connectWS, sendMsg, cacheSessionId, cacheLastRoomId } from './ws.js';
|
||||||
import { renderRoom } from './render.js';
|
import { renderRoom } from './render.js';
|
||||||
@@ -56,8 +83,13 @@ function handlePlayTrack(msg) {
|
|||||||
// reset answer UI
|
// reset answer UI
|
||||||
if ($guessTitle) $guessTitle.value = '';
|
if ($guessTitle) $guessTitle.value = '';
|
||||||
if ($guessArtist) $guessArtist.value = '';
|
if ($guessArtist) $guessArtist.value = '';
|
||||||
if ($answerResult) { $answerResult.textContent=''; $answerResult.className='mt-1 text-sm'; }
|
if ($answerResult) {
|
||||||
try { $audio.preload = 'auto'; } catch {}
|
$answerResult.textContent = '';
|
||||||
|
$answerResult.className = 'mt-1 text-sm';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$audio.preload = 'auto';
|
||||||
|
} catch {}
|
||||||
$audio.src = t.url;
|
$audio.src = t.url;
|
||||||
const pf = document.getElementById('progressFill');
|
const pf = document.getElementById('progressFill');
|
||||||
if (pf) {
|
if (pf) {
|
||||||
@@ -124,10 +156,12 @@ function handleReveal(msg) {
|
|||||||
if ($rb) {
|
if ($rb) {
|
||||||
if (result.correct) {
|
if (result.correct) {
|
||||||
$rb.textContent = 'Richtig!';
|
$rb.textContent = 'Richtig!';
|
||||||
$rb.className = 'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
|
$rb.className =
|
||||||
|
'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
|
||||||
} else {
|
} else {
|
||||||
$rb.textContent = 'Falsch!';
|
$rb.textContent = 'Falsch!';
|
||||||
$rb.className = 'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
|
$rb.className =
|
||||||
|
'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const $placeArea = document.getElementById('placeArea');
|
const $placeArea = document.getElementById('placeArea');
|
||||||
@@ -139,8 +173,12 @@ function handleReveal(msg) {
|
|||||||
if (rd && track?.id) {
|
if (rd && track?.id) {
|
||||||
const coverUrl = `/cover/${encodeURIComponent(track.id)}`;
|
const coverUrl = `/cover/${encodeURIComponent(track.id)}`;
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => { rd.src = coverUrl; };
|
img.onload = () => {
|
||||||
img.onerror = () => { /* keep default logo */ };
|
rd.src = coverUrl;
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
/* keep default logo */
|
||||||
|
};
|
||||||
img.src = coverUrl + `?t=${Date.now()}`; // bypass cache just in case
|
img.src = coverUrl + `?t=${Date.now()}`; // bypass cache just in case
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +235,9 @@ function onMessage(ev) {
|
|||||||
if (msg.awarded) coin = ' +1 Token';
|
if (msg.awarded) coin = ' +1 Token';
|
||||||
else if (msg.alreadyAwarded) coin = ' (bereits erhalten)';
|
else if (msg.alreadyAwarded) coin = ' (bereits erhalten)';
|
||||||
$answerResult.textContent = `${parts.join(' · ')}${coin}`;
|
$answerResult.textContent = `${parts.join(' · ')}${coin}`;
|
||||||
$answerResult.className = okBoth ? 'mt-1 text-sm text-emerald-600' : 'mt-1 text-sm text-amber-600';
|
$answerResult.className = okBoth
|
||||||
|
? 'mt-1 text-sm text-emerald-600'
|
||||||
|
: 'mt-1 text-sm text-amber-600';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -240,13 +280,25 @@ function wireUi() {
|
|||||||
wire($leaveRoom, 'click', () => {
|
wire($leaveRoom, 'click', () => {
|
||||||
sendMsg({ type: 'leave_room' });
|
sendMsg({ type: 'leave_room' });
|
||||||
// Clear all local storage entries on leave
|
// Clear all local storage entries on leave
|
||||||
try { localStorage.clear(); } catch {}
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
} catch {}
|
||||||
stopAudioPlayback();
|
stopAudioPlayback();
|
||||||
state.room = null;
|
state.room = null;
|
||||||
// Reset visible name inputs/labels
|
// Reset visible name inputs/labels
|
||||||
if ($nameLobby) { try { $nameLobby.value = ''; } catch {} }
|
if ($nameLobby) {
|
||||||
if ($nameDisplay) { $nameDisplay.textContent = ''; }
|
try {
|
||||||
if ($readyChk) { try { $readyChk.checked = false; } catch {} }
|
$nameLobby.value = '';
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if ($nameDisplay) {
|
||||||
|
$nameDisplay.textContent = '';
|
||||||
|
}
|
||||||
|
if ($readyChk) {
|
||||||
|
try {
|
||||||
|
$readyChk.checked = false;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
$lobby.classList.remove('hidden');
|
$lobby.classList.remove('hidden');
|
||||||
$room.classList.add('hidden');
|
$room.classList.add('hidden');
|
||||||
});
|
});
|
||||||
@@ -307,7 +359,10 @@ function wireUi() {
|
|||||||
const title = ($guessTitle?.value || '').trim();
|
const title = ($guessTitle?.value || '').trim();
|
||||||
const artist = ($guessArtist?.value || '').trim();
|
const artist = ($guessArtist?.value || '').trim();
|
||||||
if (!title || !artist) {
|
if (!title || !artist) {
|
||||||
if ($answerResult) { $answerResult.textContent = 'Bitte Titel und Künstler eingeben'; $answerResult.className = 'mt-1 text-sm text-amber-600'; }
|
if ($answerResult) {
|
||||||
|
$answerResult.textContent = 'Bitte Titel und Künstler eingeben';
|
||||||
|
$answerResult.className = 'mt-1 text-sm text-amber-600';
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendMsg({ type: 'submit_answer', guess: { title, artist } });
|
sendMsg({ type: 'submit_answer', guess: { title, artist } });
|
||||||
|
|||||||
@@ -1,28 +1,62 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { badgeColorForYear } from '../utils/colors.js';
|
import { badgeColorForYear } from '../utils/colors.js';
|
||||||
import { $answerForm, $answerResult, $dashboardList, $guesser, $lobby, $mediaControls, $nameDisplay, $nextArea, $np, $placeArea, $readyChk, $revealBanner, $room, $roomId, $slotSelect, $startGame, $status, $timeline, $tokens } from './dom.js';
|
import {
|
||||||
|
$answerForm,
|
||||||
|
$answerResult,
|
||||||
|
$dashboardList,
|
||||||
|
$guesser,
|
||||||
|
$lobby,
|
||||||
|
$mediaControls,
|
||||||
|
$nameDisplay,
|
||||||
|
$nextArea,
|
||||||
|
$np,
|
||||||
|
$placeArea,
|
||||||
|
$readyChk,
|
||||||
|
$revealBanner,
|
||||||
|
$room,
|
||||||
|
$roomId,
|
||||||
|
$slotSelect,
|
||||||
|
$startGame,
|
||||||
|
$status,
|
||||||
|
$timeline,
|
||||||
|
$tokens,
|
||||||
|
} from './dom.js';
|
||||||
|
|
||||||
export function renderRoom(room) {
|
export function renderRoom(room) {
|
||||||
state.room = room; if (!room) { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); return; }
|
state.room = room;
|
||||||
try { localStorage.setItem('lastRoomId', room.id); } catch {}
|
if (!room) {
|
||||||
$lobby.classList.add('hidden'); $room.classList.remove('hidden');
|
$lobby.classList.remove('hidden');
|
||||||
|
$room.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem('lastRoomId', room.id);
|
||||||
|
} catch {}
|
||||||
|
$lobby.classList.add('hidden');
|
||||||
|
$room.classList.remove('hidden');
|
||||||
$roomId.textContent = room.id;
|
$roomId.textContent = room.id;
|
||||||
$status.textContent = room.state.status;
|
$status.textContent = room.state.status;
|
||||||
$guesser.textContent = shortName(room.state.currentGuesser);
|
$guesser.textContent = shortName(room.state.currentGuesser);
|
||||||
const me = room.players.find(p=>p.id===state.playerId);
|
const me = room.players.find((p) => p.id === state.playerId);
|
||||||
if ($nameDisplay) $nameDisplay.textContent = (me?.name || localStorage.getItem('playerName') || '-');
|
if ($nameDisplay)
|
||||||
|
$nameDisplay.textContent = me?.name || localStorage.getItem('playerName') || '-';
|
||||||
if ($dashboardList) {
|
if ($dashboardList) {
|
||||||
$dashboardList.innerHTML = room.players.map(p => {
|
$dashboardList.innerHTML = room.players
|
||||||
const connected = p.connected ? '<span class="text-emerald-600">online</span>' : '<span class="text-rose-600">offline</span>';
|
.map((p) => {
|
||||||
const ready = p.ready ? '<span class="text-emerald-600">bereit</span>' : '<span class="text-slate-400">-</span>';
|
const connected = p.connected
|
||||||
const score = (room.state.timeline?.[p.id]?.length) ?? 0;
|
? '<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;
|
const isMe = p.id === state.playerId;
|
||||||
return `
|
return `
|
||||||
<tr class="align-top">
|
<tr class="align-top">
|
||||||
<td class="py-2 pr-3">
|
<td class="py-2 pr-3">
|
||||||
<div class="inline-flex items-center gap-1">
|
<div class="inline-flex items-center gap-1">
|
||||||
<span>${escapeHtml(p.name)}</span>${p.spectator ? ' <span title="Zuschauer">👻</span>' : ''}
|
<span>${escapeHtml(p.name)}</span>${p.spectator ? ' <span title="Zuschauer">👻</span>' : ''}
|
||||||
${p.id===room.hostId ? '<span title="Host" class="text-amber-600">\u2B50</span>' : ''}
|
${p.id === room.hostId ? '<span title="Host" class="text-amber-600">\u2B50</span>' : ''}
|
||||||
${isMe ? '<span title="Du" class="text-indigo-600">(du)</span>' : ''}
|
${isMe ? '<span title="Du" class="text-indigo-600">(du)</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -30,16 +64,18 @@ export function renderRoom(room) {
|
|||||||
<td class="py-2 pr-3">${ready}</td>
|
<td class="py-2 pr-3">${ready}</td>
|
||||||
<td class="py-2 pr-3 font-semibold tabular-nums">${score}</td>
|
<td class="py-2 pr-3 font-semibold tabular-nums">${score}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
})
|
||||||
|
.join('');
|
||||||
}
|
}
|
||||||
const myTl = room.state.timeline?.[state.playerId] || [];
|
const myTl = room.state.timeline?.[state.playerId] || [];
|
||||||
$timeline.innerHTML = myTl.map(t => {
|
$timeline.innerHTML = myTl
|
||||||
|
.map((t) => {
|
||||||
const title = escapeHtml(t.title || t.trackId || 'Unbekannt');
|
const title = escapeHtml(t.title || t.trackId || 'Unbekannt');
|
||||||
const artist = t.artist ? escapeHtml(t.artist) : '';
|
const artist = t.artist ? escapeHtml(t.artist) : '';
|
||||||
const year = (t.year ?? '?');
|
const year = t.year ?? '?';
|
||||||
const badgeStyle = badgeColorForYear(year);
|
const badgeStyle = badgeColorForYear(year);
|
||||||
return `
|
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="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="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="leading-tight">
|
||||||
<div class="font-semibold">${title}</div>
|
<div class="font-semibold">${title}</div>
|
||||||
@@ -47,20 +83,29 @@ export function renderRoom(room) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
})
|
||||||
|
.join('');
|
||||||
$tokens.textContent = room.state.tokens?.[state.playerId] ?? 0;
|
$tokens.textContent = room.state.tokens?.[state.playerId] ?? 0;
|
||||||
if ($readyChk) {
|
if ($readyChk) {
|
||||||
const serverReady = !!me?.ready;
|
const serverReady = !!me?.ready;
|
||||||
if (state.pendingReady === null || state.pendingReady === undefined) { $readyChk.checked = serverReady; }
|
if (state.pendingReady === null || state.pendingReady === undefined) {
|
||||||
else { $readyChk.checked = !!state.pendingReady; if (serverReady === state.pendingReady) state.pendingReady = null; }
|
$readyChk.checked = serverReady;
|
||||||
|
} else {
|
||||||
|
$readyChk.checked = !!state.pendingReady;
|
||||||
|
if (serverReady === state.pendingReady) state.pendingReady = null;
|
||||||
|
}
|
||||||
$readyChk.parentElement.classList.toggle('hidden', room.state.status !== 'lobby');
|
$readyChk.parentElement.classList.toggle('hidden', room.state.status !== 'lobby');
|
||||||
}
|
}
|
||||||
const isHost = state.playerId === room.hostId;
|
const isHost = state.playerId === room.hostId;
|
||||||
const activePlayers = room.players.filter(p => !p.spectator && p.connected);
|
const activePlayers = room.players.filter((p) => !p.spectator && p.connected);
|
||||||
const allReady = activePlayers.length>0 && activePlayers.every(p=>p.ready);
|
const allReady = activePlayers.length > 0 && activePlayers.every((p) => p.ready);
|
||||||
const canStart = room.state.status==='lobby' && isHost && allReady;
|
const canStart = room.state.status === 'lobby' && isHost && allReady;
|
||||||
if ($startGame) $startGame.classList.toggle('hidden', !canStart);
|
if ($startGame) $startGame.classList.toggle('hidden', !canStart);
|
||||||
const isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack;
|
const isMyTurn =
|
||||||
|
room.state.status === 'playing' &&
|
||||||
|
room.state.phase === 'guess' &&
|
||||||
|
room.state.currentGuesser === state.playerId &&
|
||||||
|
room.state.currentTrack;
|
||||||
const canGuess = isMyTurn;
|
const canGuess = isMyTurn;
|
||||||
// Media controls (play/pause) only for current guesser while guessing and a track is active
|
// Media controls (play/pause) only for current guesser while guessing and a track is active
|
||||||
if ($mediaControls) $mediaControls.classList.toggle('hidden', !isMyTurn);
|
if ($mediaControls) $mediaControls.classList.toggle('hidden', !isMyTurn);
|
||||||
@@ -89,19 +134,37 @@ export function renderRoom(room) {
|
|||||||
$placeArea.classList.toggle('hidden', !canGuess);
|
$placeArea.classList.toggle('hidden', !canGuess);
|
||||||
}
|
}
|
||||||
$np.classList.toggle('hidden', !room.state.currentTrack);
|
$np.classList.toggle('hidden', !room.state.currentTrack);
|
||||||
if ($revealBanner) { const inReveal = room.state.phase === 'reveal'; if (!inReveal) { $revealBanner.className = 'hidden'; $revealBanner.textContent=''; } }
|
if ($revealBanner) {
|
||||||
const canNext = room.state.status==='playing' && room.state.phase==='reveal' && (isHost || room.state.currentGuesser===state.playerId);
|
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);
|
if ($nextArea) $nextArea.classList.toggle('hidden', !canNext);
|
||||||
// Answer form visible during guess phase while a track is active
|
// Answer form visible during guess phase while a track is active
|
||||||
const showAnswer = room.state.status==='playing' && room.state.phase==='guess' && !!room.state.currentTrack;
|
const showAnswer =
|
||||||
|
room.state.status === 'playing' && room.state.phase === 'guess' && !!room.state.currentTrack;
|
||||||
if ($answerForm) $answerForm.classList.toggle('hidden', !showAnswer);
|
if ($answerForm) $answerForm.classList.toggle('hidden', !showAnswer);
|
||||||
if ($answerResult && !showAnswer) { $answerResult.textContent=''; $answerResult.className='mt-1 text-sm'; }
|
if ($answerResult && !showAnswer) {
|
||||||
|
$answerResult.textContent = '';
|
||||||
|
$answerResult.className = 'mt-1 text-sm';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shortName(id) {
|
export function shortName(id) {
|
||||||
if (!id) return '-';
|
if (!id) return '-';
|
||||||
const p = state.room?.players.find(x=>x.id===id);
|
const p = state.room?.players.find((x) => x.id === id);
|
||||||
return p ? p.name : id.slice(0,4);
|
return p ? p.name : id.slice(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
export function escapeHtml(s) {
|
||||||
|
return String(s).replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,53 +1,58 @@
|
|||||||
import { state } from './state.js';
|
// Assumes socket.io client library is loaded globally as io
|
||||||
|
let socket;
|
||||||
let ws;
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
let reconnectTimer = null;
|
|
||||||
const outbox = [];
|
const outbox = [];
|
||||||
let sessionId = localStorage.getItem('sessionId') || null;
|
let sessionId = localStorage.getItem('sessionId') || null;
|
||||||
let lastRoomId = localStorage.getItem('lastRoomId') || null;
|
let _lastRoomId = localStorage.getItem('lastRoomId') || null;
|
||||||
|
|
||||||
export function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; }
|
export function wsIsOpen() {
|
||||||
export function sendMsg(obj) { if (wsIsOpen()) ws.send(JSON.stringify(obj)); else outbox.push(obj); }
|
return !!socket?.connected;
|
||||||
|
}
|
||||||
function scheduleReconnect(connect) {
|
export function sendMsg(obj) {
|
||||||
if (reconnectTimer) return;
|
if (wsIsOpen()) socket.emit('message', obj);
|
||||||
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts));
|
else outbox.push(obj);
|
||||||
reconnectAttempts++;
|
|
||||||
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connectWS(onMessage) {
|
export function connectWS(onMessage) {
|
||||||
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host;
|
// Establish Socket.IO connection in websocket-only mode
|
||||||
ws = new WebSocket(url);
|
socket = window.io({ transports: ['websocket'] });
|
||||||
ws.addEventListener('open', () => {
|
socket.on('connect', () => {
|
||||||
reconnectAttempts = 0;
|
|
||||||
// Try to resume session immediately on (re)connect
|
// Try to resume session immediately on (re)connect
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
try { ws.send(JSON.stringify({ type: 'resume', sessionId })); } catch {}
|
try {
|
||||||
|
socket.emit('message', { type: 'resume', sessionId });
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
setTimeout(() => { while (outbox.length && wsIsOpen()) { try { ws.send(JSON.stringify(outbox.shift())); } catch { break; } } }, 100);
|
// flush queued
|
||||||
|
setTimeout(() => {
|
||||||
|
while (outbox.length && wsIsOpen()) {
|
||||||
|
try {
|
||||||
|
socket.emit('message', outbox.shift());
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
});
|
});
|
||||||
ws.addEventListener('message', (ev) => onMessage(ev));
|
socket.on('message', (msg) => {
|
||||||
ws.addEventListener('close', () => { scheduleReconnect(() => connectWS(onMessage)); });
|
// Adapt to previous onmessage(ev) signature used by main.js
|
||||||
ws.addEventListener('error', () => { try { ws.close(); } catch {} });
|
const ev = { data: JSON.stringify(msg) };
|
||||||
|
onMessage(ev);
|
||||||
|
});
|
||||||
|
// Socket.IO handles reconnection internally; no manual timers required
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('online', () => {
|
|
||||||
if (!wsIsOpen()) {
|
|
||||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
||||||
// Kick off a reconnect by calling connectWS from app main again
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helpers to update cached ids from other modules
|
// Helpers to update cached ids from other modules
|
||||||
export function cacheSessionId(id) {
|
export function cacheSessionId(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
sessionId = id;
|
sessionId = id;
|
||||||
try { localStorage.setItem('sessionId', id); } catch {}
|
try {
|
||||||
|
localStorage.setItem('sessionId', id);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
export function cacheLastRoomId(id) {
|
export function cacheLastRoomId(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
lastRoomId = id;
|
_lastRoomId = id;
|
||||||
try { localStorage.setItem('lastRoomId', id); } catch {}
|
try {
|
||||||
|
localStorage.setItem('lastRoomId', id);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
206
public/style.css
206
public/style.css
@@ -1,37 +1,177 @@
|
|||||||
:root { color-scheme: light dark; }
|
:root {
|
||||||
html { -webkit-text-size-adjust: 100%; touch-action: manipulation; }
|
color-scheme: light dark;
|
||||||
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; }
|
html {
|
||||||
.card { border: 1px solid #8884; padding: 1rem; border-radius: 12px; margin-bottom: 1rem; }
|
-webkit-text-size-adjust: 100%;
|
||||||
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
|
touch-action: manipulation;
|
||||||
.row.space { justify-content: space-between; }
|
}
|
||||||
.hidden { display: none; }
|
body {
|
||||||
.muted { opacity: .7; font-size: .9em; }
|
font-family: system-ui, sans-serif;
|
||||||
button, input, select { padding: .7rem 1rem; min-height: 44px; font-size: 1rem; border-radius: 10px; }
|
margin: 0 auto;
|
||||||
button { cursor: pointer; }
|
padding: 1rem;
|
||||||
input, select { border: 1px solid #8884; background: inherit; color: inherit; }
|
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||||
.timeline { display: flex; gap: .75rem; flex-wrap: wrap; padding: .75rem; border: 1px dashed #8886; min-height: 64px; border-radius: 12px; }
|
max-width: 960px;
|
||||||
.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; }
|
h1 {
|
||||||
.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; }
|
margin-top: 0;
|
||||||
@media (prefers-color-scheme: dark) {
|
}
|
||||||
.track-card { background: #1b1b1b; color: #eee; border-color: #ffffff22; box-shadow: 0 1px 2px #0005; }
|
.card {
|
||||||
|
border: 1px solid #8884;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.row.space {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 0.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: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px dashed #8886;
|
||||||
|
min-height: 64px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #8886;
|
||||||
|
}
|
||||||
|
.np {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.track-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid #8885;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.4rem 0.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: 0.15rem 0.4rem;
|
||||||
|
min-width: 3ch;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.track-info {
|
||||||
|
display: grid;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.track-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.track-artist {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
.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) {
|
@media (max-width: 800px) {
|
||||||
body { padding: .75rem; }
|
body {
|
||||||
h1 { font-size: 1.5rem; }
|
padding: 0.75rem;
|
||||||
.row { gap: .5rem; }
|
}
|
||||||
.timeline { flex-wrap: nowrap; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; }
|
h1 {
|
||||||
.track-card { flex: 0 0 auto; scroll-snap-align: start; padding: .6rem .8rem; min-width: 220px; }
|
font-size: 1.5rem;
|
||||||
.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; }
|
.row {
|
||||||
#placeArea button { flex: 1 1 auto; }
|
gap: 0.5rem;
|
||||||
#placeArea select { flex: 2 1 auto; min-width: 40vw; }
|
}
|
||||||
|
.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: 0.6rem 0.8rem;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.year-badge {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
#placeArea {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
gap: 0.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: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.banner-bad {
|
||||||
|
background: #b71c1c;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.banner-ok { background: #1b5e20; color: white; padding: .5rem .75rem; border-radius: 6px; }
|
|
||||||
.banner-bad { background: #b71c1c; color: white; padding: .5rem .75rem; border-radius: 6px; }
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// Stable distinct color per year for the year badge
|
// Stable distinct color per year for the year badge
|
||||||
export function badgeColorForYear(y) {
|
export function badgeColorForYear(y) {
|
||||||
const val = (y === undefined || y === null) ? '?' : y;
|
const val = y === undefined || y === null ? '?' : y;
|
||||||
if (val === '?' || Number.isNaN(Number(val))) {
|
if (val === '?' || Number.isNaN(Number(val))) {
|
||||||
// Neutral slate for unknown years
|
// Neutral slate for unknown years
|
||||||
return 'background-color: hsl(215 16% 34%);';
|
return 'background-color: hsl(215 16% 34%);';
|
||||||
}
|
}
|
||||||
const n = Number(val);
|
const n = Number(val);
|
||||||
const hue = ((n * 23) % 360 + 360) % 360; // spread hues deterministically
|
const hue = (((n * 23) % 360) + 360) % 360; // spread hues deterministically
|
||||||
return `background-color: hsl(${hue} 70% 42%);`;
|
return `background-color: hsl(${hue} 70% 42%);`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ function getArgValue(name, defVal) {
|
|||||||
if (i === -1) return defVal;
|
if (i === -1) return defVal;
|
||||||
const a = process.argv[i];
|
const a = process.argv[i];
|
||||||
if (a.includes('=')) return a.split('=')[1];
|
if (a.includes('=')) return a.split('=')[1];
|
||||||
return process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : defVal;
|
return process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
|
||||||
|
? process.argv[i + 1]
|
||||||
|
: defVal;
|
||||||
}
|
}
|
||||||
const MAX = parseInt(getArgValue('--max', '0'), 10) || 0;
|
const MAX = parseInt(getArgValue('--max', '0'), 10) || 0;
|
||||||
const FORCE = args.has('--force');
|
const FORCE = args.has('--force');
|
||||||
@@ -35,9 +37,18 @@ function normalize(str) {
|
|||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
let s = String(str)
|
let s = String(str)
|
||||||
.replace(/\s*\([^)]*(feat\.|ft\.|featuring)[^)]*\)/gi, '') // remove (feat. ...)
|
.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(
|
||||||
.replace(/\s*-\s*(?:radio edit|remaster(?:ed)?(?: \d{2,4})?|single version|album version|mono|stereo|live|version)\b/gi, '')
|
/\s*\[(?:radio edit|remaster(?:ed)?(?: \d{2,4})?|single version|album version|mono|stereo|live|version)\]/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*(?: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*&\s*/g, ' and ')
|
||||||
.replace(/\s+feat\.?\s+/gi, ' ')
|
.replace(/\s+feat\.?\s+/gi, ' ')
|
||||||
.replace(/\s+ft\.?\s+/gi, ' ')
|
.replace(/\s+ft\.?\s+/gi, ' ')
|
||||||
@@ -68,7 +79,9 @@ async function getMeta(fp) {
|
|||||||
return {
|
return {
|
||||||
title: meta.common.title || '',
|
title: meta.common.title || '',
|
||||||
artist: meta.common.artist || '',
|
artist: meta.common.artist || '',
|
||||||
durationMs: Number.isFinite(meta.format.duration) ? Math.round(meta.format.duration * 1000) : null,
|
durationMs: Number.isFinite(meta.format.duration)
|
||||||
|
? Math.round(meta.format.duration * 1000)
|
||||||
|
: null,
|
||||||
yearTag: meta.common.year || null,
|
yearTag: meta.common.year || null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -80,7 +93,9 @@ async function readCache() {
|
|||||||
try {
|
try {
|
||||||
const j = JSON.parse(await fsp.readFile(CACHE_JSON, 'utf8'));
|
const j = JSON.parse(await fsp.readFile(CACHE_JSON, 'utf8'));
|
||||||
return j || {};
|
return j || {};
|
||||||
} catch { return {}; }
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeCache(cache) {
|
async function writeCache(cache) {
|
||||||
@@ -88,7 +103,8 @@ async function writeCache(cache) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function similar(a, b) {
|
function similar(a, b) {
|
||||||
a = normalize(a); b = normalize(b);
|
a = normalize(a);
|
||||||
|
b = normalize(b);
|
||||||
if (!a || !b) return 0;
|
if (!a || !b) return 0;
|
||||||
if (a === b) return 1;
|
if (a === b) return 1;
|
||||||
// simple token overlap Jaccard
|
// simple token overlap Jaccard
|
||||||
@@ -101,7 +117,9 @@ function similar(a, b) {
|
|||||||
|
|
||||||
async function mbFetchJson(url, retries = 3) {
|
async function mbFetchJson(url, retries = 3) {
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' } });
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||||
|
});
|
||||||
if (res.status === 503 || res.status === 429) {
|
if (res.status === 503 || res.status === 429) {
|
||||||
const ra = Number(res.headers.get('Retry-After')) || 2;
|
const ra = Number(res.headers.get('Retry-After')) || 2;
|
||||||
await wait(ra * 1000);
|
await wait(ra * 1000);
|
||||||
@@ -139,7 +157,10 @@ function pickBestRecording(candidates, artist, title, durationMs) {
|
|||||||
const viable = [];
|
const viable = [];
|
||||||
for (const r of candidates) {
|
for (const r of candidates) {
|
||||||
const rTitle = r.title || '';
|
const rTitle = r.title || '';
|
||||||
const rArtists = (r['artist-credit'] || []).map((ac) => ac.name || ac.artist?.name).filter(Boolean).join(' ');
|
const rArtists = (r['artist-credit'] || [])
|
||||||
|
.map((ac) => ac.name || ac.artist?.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
const titleSim = similar(rTitle, nTitle);
|
const titleSim = similar(rTitle, nTitle);
|
||||||
const artistSim = similar(rArtists, nArtist);
|
const artistSim = similar(rArtists, nArtist);
|
||||||
let score = (r.score || 0) / 100 + titleSim * 1.5 + artistSim * 1.2;
|
let score = (r.score || 0) / 100 + titleSim * 1.5 + artistSim * 1.2;
|
||||||
@@ -156,7 +177,10 @@ function pickBestRecording(candidates, artist, title, durationMs) {
|
|||||||
const ageBias = Math.max(0, 2100 - firstYear) / 2100; // ~0.5 for 1050, ~0.95 for 100
|
const ageBias = Math.max(0, 2100 - firstYear) / 2100; // ~0.5 for 1050, ~0.95 for 100
|
||||||
score += ageBias * 0.3;
|
score += ageBias * 0.3;
|
||||||
}
|
}
|
||||||
if (score > bestScore) { bestScore = score; best = r; }
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = r;
|
||||||
|
}
|
||||||
if (titleSim >= 0.55 && artistSim >= 0.55) {
|
if (titleSim >= 0.55 && artistSim >= 0.55) {
|
||||||
viable.push({ r, firstYear: firstYear || null, titleSim, artistSim, score });
|
viable.push({ r, firstYear: firstYear || null, titleSim, artistSim, score });
|
||||||
}
|
}
|
||||||
@@ -178,7 +202,10 @@ function parseDateToYear(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function earliestDate(dates) {
|
function earliestDate(dates) {
|
||||||
const valid = dates.filter(Boolean).map((d) => ({ d, y: parseDateToYear(d) })).filter((x) => x.y);
|
const valid = dates
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((d) => ({ d, y: parseDateToYear(d) }))
|
||||||
|
.filter((x) => x.y);
|
||||||
if (!valid.length) return { date: null, year: null };
|
if (!valid.length) return { date: null, year: null };
|
||||||
valid.sort((a, b) => {
|
valid.sort((a, b) => {
|
||||||
if (a.d < b.d) return -1;
|
if (a.d < b.d) return -1;
|
||||||
@@ -204,7 +231,13 @@ async function resolveOne(file, meta, cache) {
|
|||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
r,
|
r,
|
||||||
titleSim: similar(r.title || '', meta.title),
|
titleSim: similar(r.title || '', meta.title),
|
||||||
artistSim: similar((r['artist-credit'] || []).map((ac) => ac.name || ac.artist?.name).filter(Boolean).join(' '), meta.artist),
|
artistSim: similar(
|
||||||
|
(r['artist-credit'] || [])
|
||||||
|
.map((ac) => ac.name || ac.artist?.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
meta.artist
|
||||||
|
),
|
||||||
firstYear: parseDateToYear(r['first-release-date']) || null,
|
firstYear: parseDateToYear(r['first-release-date']) || null,
|
||||||
}))
|
}))
|
||||||
.filter((v) => v.titleSim >= 0.5 && v.artistSim >= 0.5);
|
.filter((v) => v.titleSim >= 0.5 && v.artistSim >= 0.5);
|
||||||
@@ -220,7 +253,9 @@ async function resolveOne(file, meta, cache) {
|
|||||||
try {
|
try {
|
||||||
const details = await getRecordingDetails(v.r.id);
|
const details = await getRecordingDetails(v.r.id);
|
||||||
detailsUsed++;
|
detailsUsed++;
|
||||||
const dates = (details.releases || []).map((re) => re.date || re['release-events']?.[0]?.date || null);
|
const dates = (details.releases || []).map(
|
||||||
|
(re) => re.date || re['release-events']?.[0]?.date || null
|
||||||
|
);
|
||||||
const er = earliestDate(dates);
|
const er = earliestDate(dates);
|
||||||
y = er.year;
|
y = er.year;
|
||||||
d = er.date;
|
d = er.date;
|
||||||
@@ -244,7 +279,13 @@ async function resolveOne(file, meta, cache) {
|
|||||||
confidence: {
|
confidence: {
|
||||||
mbScore: best.score || null,
|
mbScore: best.score || null,
|
||||||
titleSim: similar(best.title || '', meta.title),
|
titleSim: similar(best.title || '', meta.title),
|
||||||
artistSim: similar((best['artist-credit'] || []).map((ac) => ac.name || ac.artist?.name).filter(Boolean).join(' '), meta.artist),
|
artistSim: similar(
|
||||||
|
(best['artist-credit'] || [])
|
||||||
|
.map((ac) => ac.name || ac.artist?.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
meta.artist
|
||||||
|
),
|
||||||
durationMs: meta.durationMs,
|
durationMs: meta.durationMs,
|
||||||
matchedDurationMs: best.length || null,
|
matchedDurationMs: best.length || null,
|
||||||
},
|
},
|
||||||
@@ -281,15 +322,30 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
const r = await resolveOne(f, meta, cache);
|
const r = await resolveOne(f, meta, cache);
|
||||||
results.push(r);
|
results.push(r);
|
||||||
console.log(` ✓ Earliest: ${r.earliestDate || 'n/a'} (year=${r.year || 'n/a'}) [${r.fromCache ? 'cache' : 'MB'}]`);
|
console.log(
|
||||||
|
` ✓ Earliest: ${r.earliestDate || 'n/a'} (year=${r.year || 'n/a'}) [${r.fromCache ? 'cache' : 'MB'}]`
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(' ! Failed:', e.message);
|
console.warn(' ! Failed:', e.message);
|
||||||
results.push({ file: f, title, artist, mbid: null, earliestDate: null, year: null, error: e.message });
|
results.push({
|
||||||
|
file: f,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
mbid: null,
|
||||||
|
earliestDate: null,
|
||||||
|
year: null,
|
||||||
|
error: e.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build index by file
|
// 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 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 };
|
const out = { generatedAt: new Date().toISOString(), total: results.length, byFile, results };
|
||||||
await fsp.writeFile(OUT_JSON, JSON.stringify(out, null, 2));
|
await fsp.writeFile(OUT_JSON, JSON.stringify(out, null, 2));
|
||||||
await writeCache(cache);
|
await writeCache(cache);
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ const cases = [
|
|||||||
['World Hold On', 'World Hold on (Children of the Sky) [Radio Edit]'],
|
['World Hold On', 'World Hold on (Children of the Sky) [Radio Edit]'],
|
||||||
['Respect', 'Respect (2019 Remaster)'],
|
['Respect', 'Respect (2019 Remaster)'],
|
||||||
['No Woman No Cry', 'No Woman, No Cry (Live)'],
|
['No Woman No Cry', 'No Woman, No Cry (Live)'],
|
||||||
['It\'s My Life', 'It\'s My Life (Single Version)'],
|
["It's My Life", "It's My Life (Single Version)"],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [guess, truth] of cases) {
|
for (const [guess, truth] of cases) {
|
||||||
const r = scoreTitle(guess, truth);
|
const r = scoreTitle(guess, truth);
|
||||||
console.log(JSON.stringify({ guess, truth, pass: r.pass, sim: +r.sim.toFixed(3), jac: +r.jac.toFixed(3), g: r.g, t: r.t }));
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
guess,
|
||||||
|
truth,
|
||||||
|
pass: r.pass,
|
||||||
|
sim: +r.sim.toFixed(3),
|
||||||
|
jac: +r.jac.toFixed(3),
|
||||||
|
g: r.g,
|
||||||
|
t: r.t,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { WebSocketServer } from 'ws';
|
import { Server as SocketIOServer } from 'socket.io';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
|
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
|
||||||
import { loadDeck } from './game/deck.js';
|
import { loadDeck } from './game/deck.js';
|
||||||
@@ -7,24 +7,50 @@ import { scoreTitle, scoreArtist, splitArtists } from './game/answerCheck.js';
|
|||||||
|
|
||||||
function drawNextTrack(room) {
|
function drawNextTrack(room) {
|
||||||
const track = room.deck.shift();
|
const track = room.deck.shift();
|
||||||
if (!track) { room.state.status = 'ended'; room.state.winner = null; broadcast(room, 'game_ended', { winner: null }); return; }
|
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.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.phase = 'guess';
|
||||||
|
room.state.lastResult = null;
|
||||||
|
room.state.paused = false;
|
||||||
|
room.state.pausedPosSec = 0;
|
||||||
room.state.awardedThisRound = {}; // reset per-round coin awards
|
room.state.awardedThisRound = {}; // reset per-round coin awards
|
||||||
room.state.trackStartAt = Date.now() + 800;
|
room.state.trackStartAt = Date.now() + 800;
|
||||||
broadcast(room, 'play_track', { track: room.state.currentTrack, startAt: room.state.trackStartAt, serverNow: Date.now() });
|
broadcast(room, 'play_track', {
|
||||||
|
track: room.state.currentTrack,
|
||||||
|
startAt: room.state.trackStartAt,
|
||||||
|
serverNow: Date.now(),
|
||||||
|
});
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
broadcast(room, 'room_update', { room: roomSummary(room) });
|
||||||
startSyncTimer(room);
|
startSyncTimer(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupWebSocket(server) {
|
export function setupWebSocket(server) {
|
||||||
const wss = new WebSocketServer({ server });
|
const io = new SocketIOServer(server, {
|
||||||
wss.on('connection', (ws) => {
|
transports: ['websocket'],
|
||||||
|
cors: { origin: true, methods: ['GET', 'POST'] },
|
||||||
|
});
|
||||||
|
io.on('connection', (socket) => {
|
||||||
// Create a tentative player identity, but don't immediately commit or send it.
|
// Create a tentative player identity, but don't immediately commit or send it.
|
||||||
const newId = uuidv4();
|
const newId = uuidv4();
|
||||||
const newSessionId = uuidv4();
|
const newSessionId = uuidv4();
|
||||||
let player = { id: newId, sessionId: newSessionId, name: `Player-${newId.slice(0, 4)}`, ws, connected: true, roomId: null };
|
let player = {
|
||||||
const send = (type, payload) => { try { ws.send(JSON.stringify({ type, ...payload })); } catch {} };
|
id: newId,
|
||||||
|
sessionId: newSessionId,
|
||||||
|
name: `Player-${newId.slice(0, 4)}`,
|
||||||
|
ws: socket,
|
||||||
|
connected: true,
|
||||||
|
roomId: null,
|
||||||
|
};
|
||||||
|
const send = (type, payload) => {
|
||||||
|
try {
|
||||||
|
socket.emit('message', { type, ...payload });
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
// To avoid overwriting an existing session on reconnect, delay the initial
|
// To avoid overwriting an existing session on reconnect, delay the initial
|
||||||
// welcome until we see if the client sends a resume message.
|
// welcome until we see if the client sends a resume message.
|
||||||
@@ -42,30 +68,49 @@ export function setupWebSocket(server) {
|
|||||||
function isParticipant(room, pid) {
|
function isParticipant(room, pid) {
|
||||||
if (!room) return false;
|
if (!room) return false;
|
||||||
if (room.state.turnOrder?.includes(pid)) return true;
|
if (room.state.turnOrder?.includes(pid)) return true;
|
||||||
if (room.state.timeline && Object.prototype.hasOwnProperty.call(room.state.timeline, pid)) return true;
|
if (room.state.timeline && Object.hasOwn(room.state.timeline, pid)) return true;
|
||||||
if (room.state.tokens && Object.prototype.hasOwnProperty.call(room.state.tokens, pid)) return true;
|
if (room.state.tokens && Object.hasOwn(room.state.tokens, pid)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.on('message', async (raw) => {
|
socket.on('message', async (msg) => {
|
||||||
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
|
||||||
// Allow client to resume by session token
|
// Allow client to resume by session token
|
||||||
if (msg.type === 'resume') {
|
if (msg.type === 'resume') {
|
||||||
clearTimeout(helloTimer);
|
clearTimeout(helloTimer);
|
||||||
const reqSession = String(msg.sessionId || '');
|
const reqSession = String(msg.sessionId || '');
|
||||||
if (!reqSession) { send('resume_result', { ok: false, reason: 'no_session' }); return; }
|
if (!reqSession) {
|
||||||
let found = null; let foundRoom = null;
|
send('resume_result', { ok: false, reason: 'no_session' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let found = null;
|
||||||
|
let foundRoom = null;
|
||||||
for (const room of rooms.values()) {
|
for (const room of rooms.values()) {
|
||||||
for (const p of room.players.values()) {
|
for (const p of room.players.values()) {
|
||||||
if (p.sessionId === reqSession) { found = p; foundRoom = room; break; }
|
if (p.sessionId === reqSession) {
|
||||||
|
found = p;
|
||||||
|
foundRoom = room;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (found) break;
|
if (found) break;
|
||||||
}
|
}
|
||||||
if (!found) { send('resume_result', { ok: false, reason: 'not_found' }); return; }
|
if (!found) {
|
||||||
|
send('resume_result', { ok: false, reason: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Rebind socket and mark connected
|
// Rebind socket and mark connected
|
||||||
try { if (found.ws && found.ws !== ws) { try { found.ws.terminate(); } catch {} } } catch {}
|
try {
|
||||||
found.ws = ws; found.connected = true; player = found; // switch our local reference to the existing player
|
if (found.ws?.id && found.ws.id !== socket.id) {
|
||||||
|
try {
|
||||||
|
found.ws.disconnect(true);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
found.ws = socket;
|
||||||
|
found.connected = true;
|
||||||
|
player = found; // switch our local reference to the existing player
|
||||||
// If they were a participant, ensure they are not marked spectator
|
// If they were a participant, ensure they are not marked spectator
|
||||||
if (foundRoom) {
|
if (foundRoom) {
|
||||||
if (isParticipant(foundRoom, found.id)) {
|
if (isParticipant(foundRoom, found.id)) {
|
||||||
@@ -87,21 +132,35 @@ export function setupWebSocket(server) {
|
|||||||
const room = rooms.get(player.roomId);
|
const room = rooms.get(player.roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
const current = room.state.currentTrack;
|
const current = room.state.currentTrack;
|
||||||
if (!current) { send('answer_result', { ok: false, error: 'no_track' }); return; }
|
if (!current) {
|
||||||
if (room.state.status !== 'playing' || room.state.phase !== 'guess') { send('answer_result', { ok: false, error: 'not_accepting' }); return; }
|
send('answer_result', { ok: false, error: 'no_track' });
|
||||||
if (room.state.spectators?.[player.id]) { send('answer_result', { ok: false, error: 'spectator' }); return; }
|
return;
|
||||||
|
}
|
||||||
|
if (room.state.status !== 'playing' || room.state.phase !== 'guess') {
|
||||||
|
send('answer_result', { ok: false, error: 'not_accepting' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (room.state.spectators?.[player.id]) {
|
||||||
|
send('answer_result', { ok: false, error: 'spectator' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const guess = msg.guess || {};
|
const guess = msg.guess || {};
|
||||||
const guessTitle = String(guess.title || '').slice(0, 200);
|
const guessTitle = String(guess.title || '').slice(0, 200);
|
||||||
const guessArtist = String(guess.artist || '').slice(0, 200);
|
const guessArtist = String(guess.artist || '').slice(0, 200);
|
||||||
if (!guessTitle || !guessArtist) { send('answer_result', { ok: false, error: 'invalid' }); return; }
|
if (!guessTitle || !guessArtist) {
|
||||||
|
send('answer_result', { ok: false, error: 'invalid' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const titleScore = scoreTitle(guessTitle, current.title || current.id || '');
|
const titleScore = scoreTitle(guessTitle, current.title || current.id || '');
|
||||||
const artistScore = scoreArtist(guessArtist, splitArtists(current.artist || ''), 1);
|
const artistScore = scoreArtist(guessArtist, splitArtists(current.artist || ''), 1);
|
||||||
const correct = !!(titleScore.pass && artistScore.pass);
|
const correct = !!(titleScore.pass && artistScore.pass);
|
||||||
let awarded = false; let alreadyAwarded = false;
|
let awarded = false;
|
||||||
|
let alreadyAwarded = false;
|
||||||
if (correct) {
|
if (correct) {
|
||||||
room.state.awardedThisRound = room.state.awardedThisRound || {};
|
room.state.awardedThisRound = room.state.awardedThisRound || {};
|
||||||
if (room.state.awardedThisRound[player.id]) { alreadyAwarded = true; }
|
if (room.state.awardedThisRound[player.id]) {
|
||||||
else {
|
alreadyAwarded = true;
|
||||||
|
} else {
|
||||||
const currentTokens = room.state.tokens[player.id] ?? 0;
|
const currentTokens = room.state.tokens[player.id] ?? 0;
|
||||||
room.state.tokens[player.id] = Math.min(5, currentTokens + 1);
|
room.state.tokens[player.id] = Math.min(5, currentTokens + 1);
|
||||||
room.state.awardedThisRound[player.id] = true;
|
room.state.awardedThisRound[player.id] = true;
|
||||||
@@ -114,80 +173,244 @@ export function setupWebSocket(server) {
|
|||||||
correctArtist: artistScore.pass,
|
correctArtist: artistScore.pass,
|
||||||
scoreTitle: { sim: +titleScore.sim.toFixed(3), jaccard: +titleScore.jac.toFixed(3) },
|
scoreTitle: { sim: +titleScore.sim.toFixed(3), jaccard: +titleScore.jac.toFixed(3) },
|
||||||
scoreArtist: +artistScore.best.toFixed(3),
|
scoreArtist: +artistScore.best.toFixed(3),
|
||||||
normalized: { guessTitle: titleScore.g, truthTitle: titleScore.t, guessArtists: artistScore.guessArtists, truthArtists: artistScore.truthArtists },
|
normalized: {
|
||||||
|
guessTitle: titleScore.g,
|
||||||
|
truthTitle: titleScore.t,
|
||||||
|
guessArtists: artistScore.guessArtists,
|
||||||
|
truthArtists: artistScore.truthArtists,
|
||||||
|
},
|
||||||
awarded,
|
awarded,
|
||||||
alreadyAwarded,
|
alreadyAwarded,
|
||||||
});
|
});
|
||||||
if (awarded) { broadcast(room, 'room_update', { room: roomSummary(room) }); }
|
if (awarded) {
|
||||||
|
broadcast(room, 'room_update', { room: roomSummary(room) });
|
||||||
|
}
|
||||||
return;
|
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 === 'set_name') {
|
||||||
if (msg.type === 'create_room') { const room = createRoom(msg.name, player); player.roomId = room.id; broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
|
player.name = String(msg.name || '').slice(0, 30) || player.name;
|
||||||
|
if (player.roomId && rooms.has(player.roomId)) {
|
||||||
|
const r = rooms.get(player.roomId);
|
||||||
|
broadcast(r, 'room_update', { room: roomSummary(r) });
|
||||||
|
}
|
||||||
|
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') {
|
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' });
|
const code = String(msg.code || '').toUpperCase();
|
||||||
|
const room = rooms.get(code);
|
||||||
|
if (!room) return send('error', { message: 'Room not found' });
|
||||||
// If there's an existing player in this room with the same session, merge to avoid duplicates.
|
// If there's an existing player in this room with the same session, merge to avoid duplicates.
|
||||||
let existing = null;
|
let existing = null;
|
||||||
for (const p of room.players.values()) { if (p.sessionId && p.sessionId === player.sessionId && p.id !== player.id) { existing = p; break; } }
|
for (const p of room.players.values()) {
|
||||||
if (existing) {
|
if (p.sessionId && p.sessionId === player.sessionId && p.id !== player.id) {
|
||||||
try { if (existing.ws && existing.ws !== ws) { try { existing.ws.terminate(); } catch {} } } catch {}
|
existing = p;
|
||||||
existing.ws = ws; existing.connected = true; existing.roomId = room.id; player = existing;
|
break;
|
||||||
}
|
}
|
||||||
room.players.set(player.id, player); player.roomId = room.id; if (!room.state.ready) room.state.ready = {}; if (room.state.ready[player.id] == null) room.state.ready[player.id] = false;
|
}
|
||||||
|
if (existing) {
|
||||||
|
try {
|
||||||
|
if (existing.ws?.id && existing.ws.id !== socket.id) {
|
||||||
|
try {
|
||||||
|
existing.ws.disconnect(true);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
existing.ws = socket;
|
||||||
|
existing.connected = true;
|
||||||
|
existing.roomId = room.id;
|
||||||
|
player = existing;
|
||||||
|
}
|
||||||
|
room.players.set(player.id, player);
|
||||||
|
player.roomId = room.id;
|
||||||
|
if (!room.state.ready) room.state.ready = {};
|
||||||
|
if (room.state.ready[player.id] == null) room.state.ready[player.id] = false;
|
||||||
const inProgress = room.state.status === 'playing' || room.state.status === 'ended';
|
const inProgress = room.state.status === 'playing' || room.state.status === 'ended';
|
||||||
const wasParticipant = isParticipant(room, player.id);
|
const wasParticipant = isParticipant(room, player.id);
|
||||||
if (inProgress) {
|
if (inProgress) {
|
||||||
if (wasParticipant) { if (room.state.spectators) delete room.state.spectators[player.id]; player.spectator = false; }
|
if (wasParticipant) {
|
||||||
else { room.state.spectators[player.id] = true; player.spectator = true; }
|
if (room.state.spectators) delete room.state.spectators[player.id];
|
||||||
} else { delete room.state.spectators[player.id]; player.spectator = false; }
|
player.spectator = false;
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
|
} else {
|
||||||
|
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 (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 (!player.roomId) 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; }
|
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') {
|
if (msg.type === 'start_game') {
|
||||||
const room = rooms.get(player.roomId); if (!room) return;
|
const room = rooms.get(player.roomId);
|
||||||
|
if (!room) return;
|
||||||
if (room.hostId !== player.id) return send('error', { message: 'Only host can start' });
|
if (room.hostId !== player.id) return send('error', { message: 'Only host can start' });
|
||||||
const active = [...room.players.values()].filter(p => !room.state.spectators?.[p.id] && p.connected);
|
const active = [...room.players.values()].filter(
|
||||||
const allReady = active.length>0 && active.every(p => !!room.state.ready?.[p.id]);
|
(p) => !room.state.spectators?.[p.id] && p.connected
|
||||||
|
);
|
||||||
|
const allReady = active.length > 0 && active.every((p) => !!room.state.ready?.[p.id]);
|
||||||
if (!allReady) return send('error', { message: 'All active players must be ready' });
|
if (!allReady) return send('error', { message: 'All active players must be ready' });
|
||||||
const pids = active.map(p => p.id);
|
const pids = active.map((p) => p.id);
|
||||||
room.state.status = 'playing'; room.state.turnOrder = shuffle(pids); room.state.currentGuesser = room.state.turnOrder[0];
|
room.state.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.timeline = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, []]));
|
||||||
room.state.tokens = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, 2]));
|
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; }
|
room.deck = await loadDeck();
|
||||||
|
room.discard = [];
|
||||||
|
room.state.phase = 'guess';
|
||||||
|
room.state.lastResult = null;
|
||||||
|
drawNextTrack(room);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === 'player_control') {
|
if (msg.type === 'player_control') {
|
||||||
const room = rooms.get(player.roomId); if (!room) return; const { action } = msg;
|
const room = rooms.get(player.roomId);
|
||||||
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 (!room) 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' }); }
|
const { action } = msg;
|
||||||
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 }); }
|
if (room.state.status !== 'playing') return;
|
||||||
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') {
|
if (msg.type === 'place_guess') {
|
||||||
const room = rooms.get(player.roomId); if (!room) return; const { position, slot: rawSlot } = msg;
|
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.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.phase !== 'guess')
|
||||||
if (room.state.currentGuesser !== player.id) return send('error', { message: 'Not your turn' });
|
return send('error', { message: 'Not accepting guesses now' });
|
||||||
const current = room.state.currentTrack; if (!current) return send('error', { message: 'No current track' });
|
if (room.state.currentGuesser !== player.id)
|
||||||
const tl = room.state.timeline[player.id] || []; const n = tl.length;
|
return send('error', { message: 'Not your turn' });
|
||||||
let slot = Number.isInteger(rawSlot) ? rawSlot : null; if (slot == null) { if (position === 'before') slot = 0; else if (position === 'after') slot = n; }
|
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;
|
if (typeof slot !== 'number' || slot < 0 || slot > n) slot = n;
|
||||||
let correct = false;
|
let correct = false;
|
||||||
if (current.year != null) {
|
if (current.year != null) {
|
||||||
if (n === 0) correct = slot === 0;
|
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; }
|
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 (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') {
|
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;
|
const room = rooms.get(player.roomId);
|
||||||
room.state.currentTrack = null; room.state.trackStartAt = null; room.state.paused = false; room.state.pausedPosSec = 0; stopSyncTimer(room);
|
if (!room) return;
|
||||||
room.state.currentGuesser = nextPlayer(room.state.turnOrder, room.state.currentGuesser); room.state.phase = 'guess'; broadcast(room, 'room_update', { room: roomSummary(room) }); drawNextTrack(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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
socket.on('disconnect', () => {
|
||||||
clearTimeout(helloTimer);
|
clearTimeout(helloTimer);
|
||||||
// Mark player disconnected but keep them in the room for resume
|
// Mark player disconnected but keep them in the room for resume
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Fuzzy matching helpers for title/artist guessing
|
// Fuzzy matching helpers for title/artist guessing
|
||||||
|
|
||||||
function stripDiacritics(s) {
|
function stripDiacritics(s) {
|
||||||
return String(s).normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
return String(s)
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCommon(s) {
|
function normalizeCommon(s) {
|
||||||
@@ -15,18 +17,29 @@ function normalizeCommon(s) {
|
|||||||
|
|
||||||
function cleanTitleNoise(raw) {
|
function cleanTitleNoise(raw) {
|
||||||
let s = String(raw);
|
let s = String(raw);
|
||||||
s = s.replace(/\(([^)]*remaster[^)]*)\)/gi, '')
|
s = s
|
||||||
|
.replace(/\(([^)]*remaster[^)]*)\)/gi, '')
|
||||||
.replace(/\(([^)]*radio edit[^)]*)\)/gi, '')
|
.replace(/\(([^)]*radio edit[^)]*)\)/gi, '')
|
||||||
.replace(/\(([^)]*edit[^)]*)\)/gi, '')
|
.replace(/\(([^)]*edit[^)]*)\)/gi, '')
|
||||||
.replace(/\(([^)]*version[^)]*)\)/gi, '')
|
.replace(/\(([^)]*version[^)]*)\)/gi, '')
|
||||||
.replace(/\(([^)]*live[^)]*)\)/gi, '')
|
.replace(/\(([^)]*live[^)]*)\)/gi, '')
|
||||||
.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
|
.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
|
||||||
s = s.replace(/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi, '');
|
s = s.replace(
|
||||||
|
/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi,
|
||||||
|
''
|
||||||
|
);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTitle(s) { return normalizeCommon(cleanTitleNoise(s)); }
|
function normalizeTitle(s) {
|
||||||
function normalizeArtist(s) { return normalizeCommon(s).replace(/\bthe\b/g, ' ').replace(/\s+/g, ' ').trim(); }
|
return normalizeCommon(cleanTitleNoise(s));
|
||||||
|
}
|
||||||
|
function normalizeArtist(s) {
|
||||||
|
return normalizeCommon(s)
|
||||||
|
.replace(/\bthe\b/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// Produce a variant with anything inside parentheses (...) or double quotes "..." removed.
|
// Produce a variant with anything inside parentheses (...) or double quotes "..." removed.
|
||||||
function stripOptionalSegments(raw) {
|
function stripOptionalSegments(raw) {
|
||||||
@@ -45,12 +58,18 @@ function normalizeTitleBaseOptional(s) {
|
|||||||
return normalizeCommon(stripOptionalSegments(cleanTitleNoise(s)));
|
return normalizeCommon(stripOptionalSegments(cleanTitleNoise(s)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function tokenize(s) { return s ? String(s).split(' ').filter(Boolean) : []; }
|
function tokenize(s) {
|
||||||
function tokenSet(s) { return new Set(tokenize(s)); }
|
return s ? String(s).split(' ').filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
function tokenSet(s) {
|
||||||
|
return new Set(tokenize(s));
|
||||||
|
}
|
||||||
function jaccard(a, b) {
|
function jaccard(a, b) {
|
||||||
const A = tokenSet(a), B = tokenSet(b);
|
const A = tokenSet(a),
|
||||||
|
B = tokenSet(b);
|
||||||
if (A.size === 0 && B.size === 0) return 1;
|
if (A.size === 0 && B.size === 0) return 1;
|
||||||
let inter = 0; for (const t of A) if (B.has(t)) inter++;
|
let inter = 0;
|
||||||
|
for (const t of A) if (B.has(t)) inter++;
|
||||||
const union = A.size + B.size - inter;
|
const union = A.size + B.size - inter;
|
||||||
return union ? inter / union : 0;
|
return union ? inter / union : 0;
|
||||||
}
|
}
|
||||||
@@ -58,7 +77,8 @@ function jaccard(a, b) {
|
|||||||
function levenshtein(a, b) {
|
function levenshtein(a, b) {
|
||||||
a = String(a);
|
a = String(a);
|
||||||
b = String(b);
|
b = String(b);
|
||||||
const m = a.length, n = b.length;
|
const m = a.length,
|
||||||
|
n = b.length;
|
||||||
if (!m) return n;
|
if (!m) return n;
|
||||||
if (!n) return m;
|
if (!n) return m;
|
||||||
const dp = new Array(n + 1);
|
const dp = new Array(n + 1);
|
||||||
@@ -110,12 +130,19 @@ export function scoreTitle(guessRaw, truthRaw) {
|
|||||||
[gBase, tBase],
|
[gBase, tBase],
|
||||||
];
|
];
|
||||||
|
|
||||||
let bestSim = 0; let bestJac = 0; let pass = false; let bestPair = pairs[0];
|
let bestSim = 0;
|
||||||
|
let bestJac = 0;
|
||||||
|
let pass = false;
|
||||||
|
let bestPair = pairs[0];
|
||||||
for (const [g, t] of pairs) {
|
for (const [g, t] of pairs) {
|
||||||
const sim = simRatio(g, t);
|
const sim = simRatio(g, t);
|
||||||
const jac = jaccard(g, t);
|
const jac = jaccard(g, t);
|
||||||
if (sim >= TITLE_SIM_THRESHOLD || jac >= TITLE_JACCARD_THRESHOLD) pass = true;
|
if (sim >= TITLE_SIM_THRESHOLD || jac >= TITLE_JACCARD_THRESHOLD) pass = true;
|
||||||
if (sim > bestSim || (sim === bestSim && jac > bestJac)) { bestSim = sim; bestJac = jac; bestPair = [g, t]; }
|
if (sim > bestSim || (sim === bestSim && jac > bestJac)) {
|
||||||
|
bestSim = sim;
|
||||||
|
bestJac = jac;
|
||||||
|
bestPair = [g, t];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { pass, sim: bestSim, jac: bestJac, g: bestPair[0], t: bestPair[1] };
|
return { pass, sim: bestSim, jac: bestJac, g: bestPair[0], t: bestPair[1] };
|
||||||
@@ -134,7 +161,10 @@ export function scoreArtist(guessRaw, truthArtistsRaw, primaryCount) {
|
|||||||
}
|
}
|
||||||
const primary = truthArtists.slice(0, primaryCount || truthArtists.length);
|
const primary = truthArtists.slice(0, primaryCount || truthArtists.length);
|
||||||
const pass = primary.some((p) => matches.has(p)); // accept any one artist
|
const pass = primary.some((p) => matches.has(p)); // accept any one artist
|
||||||
let best = 0; for (const ga of guessArtists) { for (const ta of truthSet) best = Math.max(best, simRatio(ga, ta)); }
|
let best = 0;
|
||||||
|
for (const ga of guessArtists) {
|
||||||
|
for (const ta of truthSet) best = Math.max(best, simRatio(ga, ta));
|
||||||
|
}
|
||||||
return { pass, best, matched: Array.from(matches), guessArtists, truthArtists };
|
return { pass, best, matched: Array.from(matches), guessArtists, truthArtists };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,21 @@ import { shuffle } from './state.js';
|
|||||||
export async function loadDeck() {
|
export async function loadDeck() {
|
||||||
const years = loadYearsIndex();
|
const years = loadYearsIndex();
|
||||||
const files = fs.readdirSync(DATA_DIR).filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f));
|
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 tracks = await Promise.all(
|
||||||
|
files.map(async (f) => {
|
||||||
const fp = path.join(DATA_DIR, f);
|
const fp = path.join(DATA_DIR, f);
|
||||||
let year = null, title = path.parse(f).name, artist = '';
|
let year = null,
|
||||||
try { const meta = await mmParseFile(fp, { duration: false }); title = meta.common.title || title; artist = meta.common.artist || artist; year = meta.common.year || null; } catch {}
|
title = path.parse(f).name,
|
||||||
const y = years[f]?.year ?? year; return { id: f, file: f, title, artist, year: y };
|
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);
|
return shuffle(tracks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function nextPlayer(turnOrder, currentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createRoom(name, host) {
|
export function createRoom(name, host) {
|
||||||
const id = (Math.random().toString(36).slice(2, 8)).toUpperCase();
|
const id = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||||
const room = {
|
const room = {
|
||||||
id,
|
id,
|
||||||
name: name || `Room ${id}`,
|
name: name || `Room ${id}`,
|
||||||
@@ -50,7 +50,9 @@ export function createRoom(name, host) {
|
|||||||
|
|
||||||
export function broadcast(room, type, payload) {
|
export function broadcast(room, type, payload) {
|
||||||
for (const p of room.players.values()) {
|
for (const p of room.players.values()) {
|
||||||
try { p.ws.send(JSON.stringify({ type, ...payload })); } catch {}
|
try {
|
||||||
|
p.ws?.emit?.('message', { type, ...payload });
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,20 @@ import { broadcast } from './state.js';
|
|||||||
export function startSyncTimer(room) {
|
export function startSyncTimer(room) {
|
||||||
if (room.syncTimer) clearInterval(room.syncTimer);
|
if (room.syncTimer) clearInterval(room.syncTimer);
|
||||||
room.syncTimer = setInterval(() => {
|
room.syncTimer = setInterval(() => {
|
||||||
if (room.state.status !== 'playing' || !room.state.currentTrack || !room.state.trackStartAt || room.state.paused) return;
|
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() });
|
broadcast(room, 'sync', { startAt: room.state.trackStartAt, serverNow: Date.now() });
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopSyncTimer(room) {
|
export function stopSyncTimer(room) {
|
||||||
if (room.syncTimer) { clearInterval(room.syncTimer); room.syncTimer = null; }
|
if (room.syncTimer) {
|
||||||
|
clearInterval(room.syncTimer);
|
||||||
|
room.syncTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
|
||||||
import { PORT, DATA_DIR, PUBLIC_DIR } from './config.js';
|
import { PORT, DATA_DIR, PUBLIC_DIR } from './config.js';
|
||||||
import { registerAudioRoutes } from './routes/audio.js';
|
import { registerAudioRoutes } from './routes/audio.js';
|
||||||
import { registerTracksApi } from './tracks.js';
|
import { registerTracksApi } from './tracks.js';
|
||||||
@@ -32,6 +31,5 @@ const server = http.createServer(app);
|
|||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`Hitstar server running on http://localhost:${PORT}`);
|
console.log(`Hitstar server running on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ export function registerAudioRoutes(app) {
|
|||||||
res.setHeader('Content-Type', mimeType);
|
res.setHeader('Content-Type', mimeType);
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
return res.status(200).end(buf);
|
return res.status(200).end(buf);
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
|
console.error('Error reading cover:', error);
|
||||||
return res.status(500).send('Error reading cover');
|
return res.status(500).send('Error reading cover');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
2161
tmp_tracks.json
2161
tmp_tracks.json
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user