Compare commits
12 Commits
d17c78fa68
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
9373726347
|
|||
|
a1f1b41987
|
|||
|
8ca744cd5b
|
|||
|
c9be49d988
|
|||
|
70be1e7e39
|
|||
|
18d14b097d
|
|||
|
1dbae8b62b
|
|||
| 47b0caa52b | |||
|
445f522fa8
|
|||
|
bec0a3b72f
|
|||
|
ab87e65e18
|
|||
|
58c668de63
|
@@ -12,3 +12,10 @@ data/*
|
|||||||
*.m4a
|
*.m4a
|
||||||
*.ogg
|
*.ogg
|
||||||
*.flac
|
*.flac
|
||||||
|
|
||||||
|
**/data/*.mp3
|
||||||
|
**/data/*.wav
|
||||||
|
**/data/*.m4a
|
||||||
|
**/data/*.opus
|
||||||
|
**/data/*.ogg
|
||||||
|
**/data/*.flac
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -165,6 +165,7 @@ jobs:
|
|||||||
echo "Building $IMAGE_FULL with tags: $TAG_ARGS"
|
echo "Building $IMAGE_FULL with tags: $TAG_ARGS"
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
|
--target production \
|
||||||
-f "$DOCKERFILE" \
|
-f "$DOCKERFILE" \
|
||||||
$TAG_ARGS \
|
$TAG_ARGS \
|
||||||
--cache-from type=registry,ref="$IMAGE_FULL:buildcache" \
|
--cache-from type=registry,ref="$IMAGE_FULL:buildcache" \
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
data/
|
|
||||||
public/audio/
|
|
||||||
public/cover/
|
|
||||||
**/*.mp3
|
|
||||||
node_modules/
|
|
||||||
.tmp/
|
|
||||||
dist/
|
|
||||||
coverage/
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"semi": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
57
Dockerfile
57
Dockerfile
@@ -1,23 +1,52 @@
|
|||||||
# Lightweight production image for the Hitstar Node app
|
# Multi-stage Dockerfile for Hitstar Deno Server
|
||||||
FROM node:22-alpine
|
# Supports both development and production environments
|
||||||
|
|
||||||
|
# Base stage with common dependencies
|
||||||
|
FROM denoland/deno:latest AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
# Copy all source files first for dependency resolution
|
||||||
COPY package*.json ./
|
COPY src/server-deno/ .
|
||||||
# Use npm ci when a lockfile is present, otherwise fallback to npm install without throwing an error
|
|
||||||
RUN if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then \
|
|
||||||
npm ci --omit=dev; \
|
|
||||||
else \
|
|
||||||
npm install --omit=dev; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Copy app source (media lives outside via volume)
|
# Cache all dependencies based on deno.json imports
|
||||||
COPY . .
|
RUN deno cache main.ts
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
# Development stage
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
|
ENV DENO_ENV=development \
|
||||||
PORT=5173
|
PORT=5173
|
||||||
|
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
CMD ["node", "src/server/index.js", "--host", "0.0.0.0"]
|
# Copy all source files
|
||||||
|
COPY src/server-deno/ .
|
||||||
|
|
||||||
|
# Run with watch mode and all necessary permissions
|
||||||
|
CMD ["deno", "run", \
|
||||||
|
"--allow-net", \
|
||||||
|
"--allow-read", \
|
||||||
|
"--allow-env", \
|
||||||
|
"--allow-write", \
|
||||||
|
"--watch", \
|
||||||
|
"main.ts"]
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM base AS production
|
||||||
|
|
||||||
|
ENV DENO_ENV=production \
|
||||||
|
PORT=5173
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Copy only necessary source files for production
|
||||||
|
COPY src/server-deno/ .
|
||||||
|
|
||||||
|
# Run optimized production server
|
||||||
|
CMD ["deno", "run", \
|
||||||
|
"--allow-net", \
|
||||||
|
"--allow-read=/app/data,/app/public", \
|
||||||
|
"--allow-env", \
|
||||||
|
"--allow-write=/app/data", \
|
||||||
|
"main.ts"]
|
||||||
|
|||||||
160
README.md
160
README.md
@@ -1,115 +1,75 @@
|
|||||||
# Hitstar – lokale Web-App (Prototyp)
|
# Hitstar Backend - Deno 2 + TypeScript
|
||||||
|
|
||||||
## Docker
|
Modern backend rewrite using Deno 2 and TypeScript with clean architecture principles.
|
||||||
|
|
||||||
Run the app in a container while using your local `data/` music folder:
|
## Architecture
|
||||||
|
|
||||||
1. Build the image
|
This backend follows **Clean Architecture** principles with clear separation of concerns:
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the service
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Open http://localhost:5173
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- 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`.
|
|
||||||
|
|
||||||
Lokales Multiplayer-Webspiel inspiriert von HITSTER. Nutzt eure MP3-Dateien im Ordner `data/`, eine Lobby mit Raum-Code sowie WebSockets für den Mehrspieler-Modus.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Lobby mit Raum-Erstellung und -Beitritt (Code)
|
|
||||||
- Mehrere Spieler pro Raum, Host startet das Spiel
|
|
||||||
- Lokale MP3-Wiedergabe via Browser-Audio (`/audio/<dateiname>`) – keine externen Dienste
|
|
||||||
- Einfache Rundenlogik: DJ scannt Lied, Spieler raten vor/nach (vereinfachte Chronologie)
|
|
||||||
- Token-Zähler (Basis); Gewinnbedingung: 10 korrekt platzierte Karten
|
|
||||||
|
|
||||||
Hinweis: Regeln sind vereinfacht; „HITSTER!“-Challenges und exakter Zwischenplatzierungsmodus sind als Ausbaustufe geplant.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. MP3-Dateien in `data/` legen (Dateiname wird als Fallback-Titel genutzt; falls Tags vorhanden, werden Titel/Künstler/Jahr ausgelesen).
|
|
||||||
2. Abhängigkeiten installieren und Server starten.
|
|
||||||
|
|
||||||
### PowerShell-Befehle
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# In den Projektordner wechseln
|
|
||||||
Set-Location e:\git\hitstar
|
|
||||||
|
|
||||||
# Abhängigkeiten installieren
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Server starten
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
Dann im Browser öffnen: http://localhost:5173
|
|
||||||
|
|
||||||
## Nutzung
|
|
||||||
|
|
||||||
- Namen eingeben (speichert automatisch), Raum erstellen oder mit Code beitreten (Code wird angezeigt).
|
|
||||||
- Host klickt „Spiel starten“.
|
|
||||||
- DJ klickt „Lied scannen“; der Track spielt bei allen.
|
|
||||||
- Aktiver Spieler wählt „Vor“ oder „Nach“. Bei Erfolg wandert das Lied in seine Zeitleiste.
|
|
||||||
|
|
||||||
## Ordnerstruktur
|
|
||||||
|
|
||||||
- `public/` – Client (HTML/CSS/JS)
|
|
||||||
- `src/server/` – Express + WebSocket Server, Game-State
|
|
||||||
- `data/` – eure Audio-Dateien und Playlists
|
|
||||||
|
|
||||||
### Playlist-Unterstützung
|
|
||||||
|
|
||||||
Die App unterstützt jetzt mehrere Playlists! Du kannst verschiedene Playlists für verschiedene Spielsessions erstellen:
|
|
||||||
|
|
||||||
**Ordnerstruktur für Playlists:**
|
|
||||||
|
|
||||||
```
|
```
|
||||||
data/
|
src/server-deno/
|
||||||
├── (Audio-Dateien hier = "Default" Playlist)
|
├── domain/ # Domain models, types, and business rules (no dependencies)
|
||||||
├── 80s-Hits/
|
├── application/ # Use cases and application services
|
||||||
│ ├── Song1.opus
|
├── infrastructure/ # External concerns (file system, networking, etc.)
|
||||||
│ ├── Song2.opus
|
├── presentation/ # HTTP routes, WebSocket handlers
|
||||||
│ └── ...
|
├── shared/ # Shared utilities, constants, types
|
||||||
├── Rock-Classics/
|
└── main.ts # Application entry point and DI setup
|
||||||
│ ├── Song1.opus
|
|
||||||
│ └── ...
|
|
||||||
└── Party-Mix/
|
|
||||||
├── Song1.opus
|
|
||||||
└── ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**So funktioniert's:**
|
### Layers
|
||||||
|
|
||||||
1. **Standard-Playlist**: Audio-Dateien direkt im `data/`-Ordner werden als "Default"-Playlist erkannt
|
1. **Domain Layer**: Pure business logic and types
|
||||||
2. **Eigene Playlists**: Erstelle Unterordner im `data/`-Verzeichnis, z.B. `data/80s-Hits/`
|
- No external dependencies
|
||||||
3. **Playlist-Auswahl**: Als Raum-Host kannst du in der Lobby die gewünschte Playlist auswählen, bevor das Spiel startet
|
- Models: Player, Room, Track, GameState
|
||||||
4. **Unterstützte Formate**: .mp3, .wav, .m4a, .ogg, .opus
|
- Domain services for core game logic
|
||||||
|
|
||||||
**Empfehlung**: Nutze das `.opus`-Format für optimale Streaming-Performance und geringeren Speicherverbrauch. Das Konvertierungsskript `npm run audio:convert` wandelt automatisch alle Audio-Dateien in Opus um.
|
2. **Application Layer**: Use cases and orchestration
|
||||||
|
- Game service (start game, process guesses, etc.)
|
||||||
|
- Track service (load tracks, manage playlists)
|
||||||
|
- Room service (create/join rooms, manage players)
|
||||||
|
|
||||||
## Git & Audio-Dateien
|
3. **Infrastructure Layer**: External concerns
|
||||||
|
- File system operations
|
||||||
|
- Audio streaming
|
||||||
|
- Token management
|
||||||
|
- Metadata parsing
|
||||||
|
|
||||||
- In `.gitignore` sind alle gängigen Audio-Dateitypen ausgeschlossen (z. B. .mp3, .wav, .flac, .m4a, .ogg, …).
|
4. **Presentation Layer**: API and WebSocket
|
||||||
- Legt eure Musik lokal in `data/`. Diese Dateien werden nicht ins Git-Repo eingecheckt und bleiben nur auf eurem Rechner.
|
- REST routes for HTTP endpoints
|
||||||
|
- Socket.IO handlers for real-time game
|
||||||
|
|
||||||
## Nächste Schritte (optional)
|
## Running
|
||||||
|
|
||||||
- „HITSTER!“-Challenges per Token mit Positionsauswahl (zwischen zwei Karten)
|
### Development
|
||||||
- Team-Modus, Pro-/Expert-Regeln, exaktes Jahr
|
```bash
|
||||||
- Persistenz (Räume/Spielstände), Reconnect
|
deno task dev
|
||||||
- Drag & Drop-Zeitleiste, visuelle Platzierung
|
```
|
||||||
|
|
||||||
## Hinweis
|
### Production
|
||||||
|
```bash
|
||||||
|
deno task start
|
||||||
|
```
|
||||||
|
|
||||||
Nur für privaten Gebrauch. Musikdateien bleiben lokal bei euch.
|
### Testing
|
||||||
|
```bash
|
||||||
|
deno task test
|
||||||
|
deno task test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
```bash
|
||||||
|
deno task lint
|
||||||
|
deno task fmt
|
||||||
|
deno task check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **@oak/oak**: Modern HTTP framework for Deno
|
||||||
|
- **socket.io**: Real-time bidirectional event-based communication
|
||||||
|
- **music-metadata**: Audio file metadata parsing
|
||||||
|
- **lru-cache**: Token and cover art caching
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` for all available configuration options.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 288 KiB |
@@ -689,8 +689,8 @@
|
|||||||
"mbid": "289cc14d-a64d-472a-b865-937874233dfb"
|
"mbid": "289cc14d-a64d-472a-b865-937874233dfb"
|
||||||
},
|
},
|
||||||
"Eurythmics - Sweet Dreams (Are Made of This).opus": {
|
"Eurythmics - Sweet Dreams (Are Made of This).opus": {
|
||||||
"year": 2007,
|
"year": 1983,
|
||||||
"date": "2007-12-10",
|
"date": "1983",
|
||||||
"title": "Sweet Dreams (Are Made of This)",
|
"title": "Sweet Dreams (Are Made of This)",
|
||||||
"artist": "Eurythmics, Annie Lennox, Dave Stewart",
|
"artist": "Eurythmics, Annie Lennox, Dave Stewart",
|
||||||
"mbid": "cf4c57df-da7f-4336-9c9f-090dfc98afeb"
|
"mbid": "cf4c57df-da7f-4336-9c9f-090dfc98afeb"
|
||||||
@@ -934,8 +934,8 @@
|
|||||||
"mbid": "d5548b1d-63db-4aa7-b3d5-3f864f248eee"
|
"mbid": "d5548b1d-63db-4aa7-b3d5-3f864f248eee"
|
||||||
},
|
},
|
||||||
"Johnny Cash - Ring of Fire.opus": {
|
"Johnny Cash - Ring of Fire.opus": {
|
||||||
"year": 1981,
|
"year": 1963,
|
||||||
"date": "1981-04-19",
|
"date": "1963",
|
||||||
"title": "Ring of Fire",
|
"title": "Ring of Fire",
|
||||||
"artist": "Johnny Cash",
|
"artist": "Johnny Cash",
|
||||||
"mbid": "5611c424-d0db-4e30-bae2-2db6d72177f7"
|
"mbid": "5611c424-d0db-4e30-bae2-2db6d72177f7"
|
||||||
@@ -1640,10 +1640,10 @@
|
|||||||
"artist": "Rupert Holmes",
|
"artist": "Rupert Holmes",
|
||||||
"mbid": "71709e8c-fb6b-48c6-972a-851ecff0a60d"
|
"mbid": "71709e8c-fb6b-48c6-972a-851ecff0a60d"
|
||||||
},
|
},
|
||||||
"Sam Cooke - (What A) Wonderful World (Mono).opus": {
|
"Sam Cooke - (What A) Wonderful World (Mono).opus": {
|
||||||
"year": 1960,
|
"year": 1960,
|
||||||
"date": "1960",
|
"date": "1960",
|
||||||
"title": "(What A) Wonderful World (Mono)",
|
"title": "(What A) Wonderful World (Mono)",
|
||||||
"artist": "Sam Cooke",
|
"artist": "Sam Cooke",
|
||||||
"mbid": null
|
"mbid": null
|
||||||
},
|
},
|
||||||
@@ -1837,8 +1837,8 @@
|
|||||||
"mbid": "ed626d61-415e-4a4c-bcc0-89805243ab8b"
|
"mbid": "ed626d61-415e-4a4c-bcc0-89805243ab8b"
|
||||||
},
|
},
|
||||||
"The Commodores - Easy.opus": {
|
"The Commodores - Easy.opus": {
|
||||||
"year": 2002,
|
"year": 1977,
|
||||||
"date": "2002",
|
"date": "1977",
|
||||||
"title": "Easy",
|
"title": "Easy",
|
||||||
"artist": "The Commodores",
|
"artist": "The Commodores",
|
||||||
"mbid": "0b0e1974-584b-44c3-90f1-ed43ac8b920d"
|
"mbid": "0b0e1974-584b-44c3-90f1-ed43ac8b920d"
|
||||||
|
|||||||
44
docker-compose.dev.yml
Normal file
44
docker-compose.dev.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Development Docker Compose for Hitstar
|
||||||
|
# Enables hot reload and debugging for local development
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.dev.yml up --build
|
||||||
|
#
|
||||||
|
# Debugging:
|
||||||
|
# Connect to chrome://inspect or VS Code debugger at localhost:9229
|
||||||
|
|
||||||
|
services:
|
||||||
|
hitstar-dev:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: development
|
||||||
|
image: hitstar-deno:dev
|
||||||
|
container_name: hitstar-dev
|
||||||
|
environment:
|
||||||
|
- DENO_ENV=development
|
||||||
|
- PORT=5173
|
||||||
|
ports:
|
||||||
|
# Application port
|
||||||
|
- "5173:5173"
|
||||||
|
# Deno inspector/debugger port
|
||||||
|
- "9229:9229"
|
||||||
|
volumes:
|
||||||
|
# Mount source code for hot reload
|
||||||
|
- ./src/server-deno:/app:cached
|
||||||
|
# Mount data directory
|
||||||
|
- ./data:/app/data
|
||||||
|
# Override CMD to enable debugging with inspector
|
||||||
|
command: >
|
||||||
|
deno run --allow-net --allow-read --allow-env --allow-write --watch --inspect=0.0.0.0:9229 main.ts
|
||||||
|
networks:
|
||||||
|
- hitstar-dev-network
|
||||||
|
# Restart on crash during development
|
||||||
|
restart: unless-stopped
|
||||||
|
# Enable stdin for interactive debugging
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
|
||||||
|
networks:
|
||||||
|
hitstar-dev-network:
|
||||||
|
driver: bridge
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
|
# Production service
|
||||||
hitstar:
|
hitstar:
|
||||||
build: .
|
build:
|
||||||
image: hitstar-webapp:latest
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: production
|
||||||
|
image: hitstar-deno:prod
|
||||||
container_name: hitstar
|
container_name: hitstar
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- DENO_ENV=production
|
||||||
- PORT=5173
|
- PORT=5173
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "0.0.0.0:5173:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data:rw
|
- ./data:/app/data:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- hitstar-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
hitstar-network:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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 }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
40
package.json
40
package.json
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "hitstar-webapp",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "Local Hitster-like multiplayer web app using WebSockets and local MP3s",
|
|
||||||
"main": "src/server/index.js",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node src/server/index.js",
|
|
||||||
"dev": "nodemon src/server/index.js",
|
|
||||||
"audio:convert": "node scripts/convert-to-opus.js",
|
|
||||||
"audio:convert:dry": "node scripts/convert-to-opus.js --dry-run",
|
|
||||||
"years:resolve": "node scripts/resolve-years.js",
|
|
||||||
"years:resolve:10": "node scripts/resolve-years.js --max 10",
|
|
||||||
"years:force": "node scripts/resolve-years.js --force",
|
|
||||||
"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": {
|
|
||||||
"express": "^4.19.2",
|
|
||||||
"lru-cache": "^11.0.0",
|
|
||||||
"mime": "^3.0.0",
|
|
||||||
"music-metadata": "^7.14.0",
|
|
||||||
"socket.io": "^4.7.5",
|
|
||||||
"undici": "^6.19.8",
|
|
||||||
"uuid": "^9.0.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.11.1",
|
|
||||||
"ffmpeg-static": "^5.2.0",
|
|
||||||
"eslint": "^9.11.1",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
|
||||||
"eslint-plugin-import": "^2.29.1",
|
|
||||||
"globals": "^13.24.0",
|
|
||||||
"nodemon": "^3.1.0",
|
|
||||||
"prettier": "^3.3.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { $audio, $bufferBadge, $progressFill, $recordDisc } from './dom.js';
|
|
||||||
import { state } from './state.js';
|
|
||||||
|
|
||||||
export function initAudioUI() {
|
|
||||||
try {
|
|
||||||
if ('preservesPitch' in $audio) $audio.preservesPitch = true;
|
|
||||||
if ('mozPreservesPitch' in $audio) $audio.mozPreservesPitch = true;
|
|
||||||
if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true;
|
|
||||||
} catch {}
|
|
||||||
$audio.addEventListener('timeupdate', () => {
|
|
||||||
const dur = $audio.duration || 0;
|
|
||||||
if (!dur || !$progressFill) return;
|
|
||||||
const pct = Math.min(100, Math.max(0, ($audio.currentTime / dur) * 100));
|
|
||||||
$progressFill.style.width = pct + '%';
|
|
||||||
});
|
|
||||||
const showBuffer = (v) => {
|
|
||||||
state.isBuffering = v;
|
|
||||||
if ($bufferBadge) $bufferBadge.classList.toggle('hidden', !v);
|
|
||||||
if ($recordDisc) $recordDisc.classList.toggle('spin-record', !v && !$audio.paused);
|
|
||||||
};
|
|
||||||
$audio.addEventListener('waiting', () => showBuffer(true));
|
|
||||||
$audio.addEventListener('stalled', () => showBuffer(true));
|
|
||||||
$audio.addEventListener('canplay', () => showBuffer(false));
|
|
||||||
$audio.addEventListener('playing', () => showBuffer(false));
|
|
||||||
$audio.addEventListener('ended', () => {
|
|
||||||
if ($recordDisc) $recordDisc.classList.remove('spin-record');
|
|
||||||
$audio.playbackRate = 1.0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applySync(startAt, serverNow) {
|
|
||||||
if (!startAt || !serverNow) return;
|
|
||||||
if (state.room?.state?.paused) return;
|
|
||||||
if (state.isBuffering) return;
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = (now - startAt) / 1000;
|
|
||||||
const drift = ($audio.currentTime || 0) - elapsed;
|
|
||||||
const abs = Math.abs(drift);
|
|
||||||
if (abs > 1.0) {
|
|
||||||
$audio.currentTime = Math.max(0, elapsed);
|
|
||||||
if ($audio.paused) $audio.play().catch(() => {});
|
|
||||||
$audio.playbackRate = 1.0;
|
|
||||||
} else if (abs > 0.12) {
|
|
||||||
const maxNudge = 0.03;
|
|
||||||
const sign = drift > 0 ? -1 : 1;
|
|
||||||
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
|
|
||||||
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate));
|
|
||||||
} else {
|
|
||||||
if (Math.abs($audio.playbackRate - 1) > 0.001) {
|
|
||||||
$audio.playbackRate = 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopAudioPlayback() {
|
|
||||||
try {
|
|
||||||
$audio.pause();
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
$audio.currentTime = 0;
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
$audio.src = '';
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
$audio.playbackRate = 1.0;
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
if ($recordDisc) $recordDisc.classList.remove('spin-record');
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
if ($progressFill) $progressFill.style.width = '0%';
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
if ($bufferBadge) $bufferBadge.classList.add('hidden');
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
const el = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
export const $lobby = el('lobby');
|
|
||||||
export const $room = el('room');
|
|
||||||
export const $roomId = el('roomId');
|
|
||||||
export const $nameDisplay = el('nameDisplay');
|
|
||||||
export const $status = el('status');
|
|
||||||
export const $guesser = el('guesser');
|
|
||||||
export const $timeline = el('timeline');
|
|
||||||
export const $audio = el('audio');
|
|
||||||
export const $np = el('nowPlaying');
|
|
||||||
export const $npTitle = el('npTitle');
|
|
||||||
export const $npArtist = el('npArtist');
|
|
||||||
export const $npYear = el('npYear');
|
|
||||||
export const $readyChk = el('readyChk');
|
|
||||||
export const $startGame = el('startGame');
|
|
||||||
export const $revealBanner = el('revealBanner');
|
|
||||||
export const $placeArea = el('placeArea');
|
|
||||||
export const $slotSelect = el('slotSelect');
|
|
||||||
export const $placeBtn = el('placeBtn');
|
|
||||||
export const $mediaControls = el('mediaControls');
|
|
||||||
export const $playBtn = el('playBtn');
|
|
||||||
export const $pauseBtn = el('pauseBtn');
|
|
||||||
export const $nextArea = el('nextArea');
|
|
||||||
export const $nextBtn = el('nextBtn');
|
|
||||||
export const $recordDisc = el('recordDisc');
|
|
||||||
export const $progressFill = el('progressFill');
|
|
||||||
export const $volumeSlider = el('volumeSlider');
|
|
||||||
export const $bufferBadge = el('bufferBadge');
|
|
||||||
export const $copyRoomCode = el('copyRoomCode');
|
|
||||||
export const $nameLobby = el('name');
|
|
||||||
export const $saveName = el('saveName');
|
|
||||||
export const $createRoom = el('createRoom');
|
|
||||||
export const $joinRoom = el('joinRoom');
|
|
||||||
export const $roomCode = el('roomCode');
|
|
||||||
export const $leaveRoom = el('leaveRoom');
|
|
||||||
export const $earnToken = el('earnToken');
|
|
||||||
export const $dashboardList = el('dashboardList');
|
|
||||||
export const $toast = el('toast');
|
|
||||||
// Answer form elements
|
|
||||||
export const $answerForm = el('answerForm');
|
|
||||||
export const $guessTitle = el('guessTitle');
|
|
||||||
export const $guessArtist = el('guessArtist');
|
|
||||||
export const $answerResult = el('answerResult');
|
|
||||||
// Playlist elements
|
|
||||||
export const $playlistSection = el('playlistSection');
|
|
||||||
export const $playlistSelect = el('playlistSelect');
|
|
||||||
export const $currentPlaylist = el('currentPlaylist');
|
|
||||||
export const $playlistInfo = el('playlistInfo');
|
|
||||||
|
|
||||||
export function showLobby() {
|
|
||||||
$lobby.classList.remove('hidden');
|
|
||||||
$room.classList.add('hidden');
|
|
||||||
}
|
|
||||||
export function showRoom() {
|
|
||||||
$lobby.classList.add('hidden');
|
|
||||||
$room.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
import {
|
|
||||||
$answerResult,
|
|
||||||
$audio,
|
|
||||||
$guessArtist,
|
|
||||||
$guessTitle,
|
|
||||||
$npArtist,
|
|
||||||
$npTitle,
|
|
||||||
$npYear,
|
|
||||||
} from './dom.js';
|
|
||||||
import { state } from './state.js';
|
|
||||||
import { cacheLastRoomId, cacheSessionId, sendMsg } from './ws.js';
|
|
||||||
import { renderRoom } from './render.js';
|
|
||||||
import { applySync } from './audio.js';
|
|
||||||
|
|
||||||
function updatePlayerIdFromRoom(r) {
|
|
||||||
try {
|
|
||||||
if (r?.players?.length === 1) {
|
|
||||||
const only = r.players[0];
|
|
||||||
if (only && only.id && only.id !== state.playerId) {
|
|
||||||
state.playerId = only.id;
|
|
||||||
try {
|
|
||||||
localStorage.setItem('playerId', only.id);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortName(id) {
|
|
||||||
if (!id) return '-';
|
|
||||||
const p = state.room?.players.find((x) => x.id === id);
|
|
||||||
return p ? p.name : id.slice(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleConnected(msg) {
|
|
||||||
state.playerId = msg.playerId;
|
|
||||||
try {
|
|
||||||
if (msg.playerId) localStorage.setItem('playerId', msg.playerId);
|
|
||||||
} catch {}
|
|
||||||
if (msg.sessionId) {
|
|
||||||
const existing = localStorage.getItem('sessionId');
|
|
||||||
if (!existing) cacheSessionId(msg.sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// lazy import to avoid cycle
|
|
||||||
import('./session.js').then(({ reusePlayerName, reconnectLastRoom }) => {
|
|
||||||
reusePlayerName();
|
|
||||||
reconnectLastRoom();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (state.room) {
|
|
||||||
try {
|
|
||||||
updatePlayerIdFromRoom(state.room);
|
|
||||||
renderRoom(state.room);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleRoomUpdate(msg) {
|
|
||||||
if (msg?.room?.id) cacheLastRoomId(msg.room.id);
|
|
||||||
const r = msg.room;
|
|
||||||
updatePlayerIdFromRoom(r);
|
|
||||||
renderRoom(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handlePlayTrack(msg) {
|
|
||||||
const t = msg.track;
|
|
||||||
state.lastTrack = t;
|
|
||||||
state.revealed = false;
|
|
||||||
$npTitle.textContent = '???';
|
|
||||||
$npArtist.textContent = '';
|
|
||||||
$npYear.textContent = '';
|
|
||||||
if ($guessTitle) $guessTitle.value = '';
|
|
||||||
if ($guessArtist) $guessArtist.value = '';
|
|
||||||
if ($answerResult) {
|
|
||||||
$answerResult.textContent = '';
|
|
||||||
$answerResult.className = 'mt-1 text-sm';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$audio.preload = 'auto';
|
|
||||||
} catch {}
|
|
||||||
$audio.src = t.url;
|
|
||||||
const pf = document.getElementById('progressFill');
|
|
||||||
if (pf) pf.style.width = '0%';
|
|
||||||
const rd = document.getElementById('recordDisc');
|
|
||||||
if (rd) {
|
|
||||||
rd.classList.remove('spin-record');
|
|
||||||
rd.src = '/hitstar.png';
|
|
||||||
}
|
|
||||||
const { startAt, serverNow } = msg;
|
|
||||||
const now = Date.now();
|
|
||||||
const offsetMs = startAt - serverNow;
|
|
||||||
const localStart = now + offsetMs;
|
|
||||||
const delay = Math.max(0, localStart - now);
|
|
||||||
setTimeout(() => {
|
|
||||||
$audio.currentTime = 0;
|
|
||||||
$audio.play().catch(() => {});
|
|
||||||
const disc = document.getElementById('recordDisc');
|
|
||||||
if (disc) disc.classList.add('spin-record');
|
|
||||||
}, delay);
|
|
||||||
if (state.room) renderRoom(state.room);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleSync(msg) {
|
|
||||||
applySync(msg.startAt, msg.serverNow);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleControl(msg) {
|
|
||||||
const { action, startAt, serverNow } = msg;
|
|
||||||
if (action === 'pause') {
|
|
||||||
$audio.pause();
|
|
||||||
const disc = document.getElementById('recordDisc');
|
|
||||||
if (disc) disc.classList.remove('spin-record');
|
|
||||||
$audio.playbackRate = 1.0;
|
|
||||||
} else if (action === 'play') {
|
|
||||||
if (startAt && serverNow) {
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = (now - startAt) / 1000;
|
|
||||||
$audio.currentTime = Math.max(0, elapsed);
|
|
||||||
}
|
|
||||||
$audio.play().catch(() => {});
|
|
||||||
const disc = document.getElementById('recordDisc');
|
|
||||||
if (disc) disc.classList.add('spin-record');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleReveal(msg) {
|
|
||||||
const { result, track } = msg;
|
|
||||||
$npTitle.textContent = track.title || track.id || 'Track';
|
|
||||||
$npArtist.textContent = track.artist ? ` – ${track.artist}` : '';
|
|
||||||
$npYear.textContent = track.year ? ` (${track.year})` : '';
|
|
||||||
state.revealed = true;
|
|
||||||
const $rb = document.getElementById('revealBanner');
|
|
||||||
if ($rb) {
|
|
||||||
if (result.correct) {
|
|
||||||
$rb.textContent = 'Richtig!';
|
|
||||||
$rb.className =
|
|
||||||
'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
|
|
||||||
} else {
|
|
||||||
$rb.textContent = 'Falsch!';
|
|
||||||
$rb.className =
|
|
||||||
'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const $placeArea = document.getElementById('placeArea');
|
|
||||||
if ($placeArea) $placeArea.classList.add('hidden');
|
|
||||||
const rd = document.getElementById('recordDisc');
|
|
||||||
if (rd && track?.file) {
|
|
||||||
// Use track.file instead of track.id to include playlist folder prefix
|
|
||||||
const coverUrl = `/cover/${encodeURIComponent(track.file)}`;
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
rd.src = coverUrl;
|
|
||||||
};
|
|
||||||
img.onerror = () => {
|
|
||||||
/* keep default logo */
|
|
||||||
};
|
|
||||||
img.src = `${coverUrl}?t=${Date.now()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleGameEnded(msg) {
|
|
||||||
alert(`Gewinner: ${shortName(msg.winner)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function onMessage(ev) {
|
|
||||||
const msg = JSON.parse(ev.data);
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'resume_result': {
|
|
||||||
if (msg.ok) {
|
|
||||||
if (msg.playerId) {
|
|
||||||
state.playerId = msg.playerId;
|
|
||||||
try {
|
|
||||||
localStorage.setItem('playerId', msg.playerId);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
const code = msg.roomId || state.room?.id || localStorage.getItem('lastRoomId');
|
|
||||||
if (code) sendMsg({ type: 'join_room', code });
|
|
||||||
if (state.room) {
|
|
||||||
try {
|
|
||||||
renderRoom(state.room);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const code = state.room?.id || localStorage.getItem('lastRoomId');
|
|
||||||
if (code) sendMsg({ type: 'join_room', code });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'connected':
|
|
||||||
return handleConnected(msg);
|
|
||||||
case 'room_update':
|
|
||||||
return handleRoomUpdate(msg);
|
|
||||||
case 'play_track':
|
|
||||||
return handlePlayTrack(msg);
|
|
||||||
case 'sync':
|
|
||||||
return handleSync(msg);
|
|
||||||
case 'control':
|
|
||||||
return handleControl(msg);
|
|
||||||
case 'reveal':
|
|
||||||
return handleReveal(msg);
|
|
||||||
case 'game_ended':
|
|
||||||
return handleGameEnded(msg);
|
|
||||||
case 'answer_result': {
|
|
||||||
if ($answerResult) {
|
|
||||||
if (!msg.ok) {
|
|
||||||
$answerResult.textContent = '⛔ Eingabe ungültig oder gerade nicht möglich';
|
|
||||||
$answerResult.className = 'mt-1 text-sm text-rose-600';
|
|
||||||
} else {
|
|
||||||
const okBoth = !!(msg.correctTitle && msg.correctArtist);
|
|
||||||
const parts = [];
|
|
||||||
parts.push(msg.correctTitle ? 'Titel ✓' : 'Titel ✗');
|
|
||||||
parts.push(msg.correctArtist ? 'Künstler ✓' : 'Künstler ✗');
|
|
||||||
let coin = '';
|
|
||||||
if (msg.awarded) coin = ' +1 Token';
|
|
||||||
else if (msg.alreadyAwarded) coin = ' (bereits erhalten)';
|
|
||||||
$answerResult.textContent = `${parts.join(' · ')}${coin}`;
|
|
||||||
$answerResult.className = okBoth
|
|
||||||
? 'mt-1 text-sm text-emerald-600'
|
|
||||||
: 'mt-1 text-sm text-amber-600';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
257
public/js/ui.js
257
public/js/ui.js
@@ -1,257 +0,0 @@
|
|||||||
import {
|
|
||||||
$audio,
|
|
||||||
$answerResult,
|
|
||||||
$copyRoomCode,
|
|
||||||
$createRoom,
|
|
||||||
$guessArtist,
|
|
||||||
$guessTitle,
|
|
||||||
$joinRoom,
|
|
||||||
$lobby,
|
|
||||||
$nameDisplay,
|
|
||||||
$nameLobby,
|
|
||||||
$nextBtn,
|
|
||||||
$pauseBtn,
|
|
||||||
$placeBtn,
|
|
||||||
$readyChk,
|
|
||||||
$room,
|
|
||||||
$roomCode,
|
|
||||||
$roomId,
|
|
||||||
$slotSelect,
|
|
||||||
$startGame,
|
|
||||||
$leaveRoom,
|
|
||||||
$playBtn,
|
|
||||||
$volumeSlider,
|
|
||||||
$saveName,
|
|
||||||
} from './dom.js';
|
|
||||||
import { state } from './state.js';
|
|
||||||
import { initAudioUI, stopAudioPlayback } from './audio.js';
|
|
||||||
import { sendMsg } from './ws.js';
|
|
||||||
import { showToast, wire } from './utils.js';
|
|
||||||
|
|
||||||
export function wireUi() {
|
|
||||||
initAudioUI();
|
|
||||||
|
|
||||||
// --- Name autosave helpers
|
|
||||||
let nameDebounce;
|
|
||||||
const SAVE_DEBOUNCE_MS = 800;
|
|
||||||
// Button was removed; no state management needed.
|
|
||||||
|
|
||||||
function saveNameIfChanged(raw) {
|
|
||||||
const name = (raw || '').trim();
|
|
||||||
if (!name) return;
|
|
||||||
try {
|
|
||||||
const prev = localStorage.getItem('playerName') || '';
|
|
||||||
if (prev === name) return; // no-op
|
|
||||||
localStorage.setItem('playerName', name);
|
|
||||||
if ($nameDisplay) $nameDisplay.textContent = name;
|
|
||||||
sendMsg({ type: 'set_name', name });
|
|
||||||
showToast('Name gespeichert!');
|
|
||||||
} catch {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manual save button
|
|
||||||
if ($saveName) {
|
|
||||||
wire($saveName, 'click', () => {
|
|
||||||
if (nameDebounce) {
|
|
||||||
clearTimeout(nameDebounce);
|
|
||||||
nameDebounce = null;
|
|
||||||
}
|
|
||||||
const val = ($nameLobby?.value || '').trim();
|
|
||||||
if (!val) {
|
|
||||||
showToast('⚠️ Bitte gib einen Namen ein!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prev = localStorage.getItem('playerName') || '';
|
|
||||||
if (prev === val) {
|
|
||||||
showToast('✓ Name bereits gespeichert!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saveNameIfChanged(val);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Autosave on input with debounce
|
|
||||||
if ($nameLobby) {
|
|
||||||
wire($nameLobby, 'input', () => {
|
|
||||||
if (nameDebounce) clearTimeout(nameDebounce);
|
|
||||||
const val = ($nameLobby.value || '').trim();
|
|
||||||
if (!val) return;
|
|
||||||
nameDebounce = setTimeout(() => {
|
|
||||||
saveNameIfChanged($nameLobby.value);
|
|
||||||
nameDebounce = null;
|
|
||||||
}, SAVE_DEBOUNCE_MS);
|
|
||||||
});
|
|
||||||
// Save immediately on blur
|
|
||||||
wire($nameLobby, 'blur', () => {
|
|
||||||
if (nameDebounce) {
|
|
||||||
clearTimeout(nameDebounce);
|
|
||||||
nameDebounce = null;
|
|
||||||
}
|
|
||||||
const val = ($nameLobby.value || '').trim();
|
|
||||||
if (val) saveNameIfChanged(val);
|
|
||||||
});
|
|
||||||
// Save on Enter
|
|
||||||
wire($nameLobby, 'keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (nameDebounce) {
|
|
||||||
clearTimeout(nameDebounce);
|
|
||||||
nameDebounce = null;
|
|
||||||
}
|
|
||||||
const val = ($nameLobby.value || '').trim();
|
|
||||||
if (val) saveNameIfChanged(val);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
wire($createRoom, 'click', () => sendMsg({ type: 'create_room' }));
|
|
||||||
|
|
||||||
wire($joinRoom, 'click', () => {
|
|
||||||
const code = $roomCode.value.trim();
|
|
||||||
if (code) sendMsg({ type: 'join_room', code });
|
|
||||||
});
|
|
||||||
|
|
||||||
wire($leaveRoom, 'click', () => {
|
|
||||||
sendMsg({ type: 'leave_room' });
|
|
||||||
try {
|
|
||||||
localStorage.removeItem('playerId');
|
|
||||||
localStorage.removeItem('sessionId');
|
|
||||||
localStorage.removeItem('dashboardHintSeen');
|
|
||||||
localStorage.removeItem('lastRoomId');
|
|
||||||
} catch {}
|
|
||||||
stopAudioPlayback();
|
|
||||||
state.room = null;
|
|
||||||
if ($nameLobby) {
|
|
||||||
try {
|
|
||||||
const storedName = localStorage.getItem('playerName') || '';
|
|
||||||
$nameLobby.value = storedName;
|
|
||||||
} catch {
|
|
||||||
$nameLobby.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($nameDisplay) $nameDisplay.textContent = '';
|
|
||||||
if ($readyChk) {
|
|
||||||
try {
|
|
||||||
$readyChk.checked = false;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
$lobby.classList.remove('hidden');
|
|
||||||
$room.classList.add('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
wire($startGame, 'click', () => {
|
|
||||||
// Validate playlist selection before starting
|
|
||||||
if (!state.room?.state?.playlist) {
|
|
||||||
showToast('⚠️ Bitte wähle zuerst eine Playlist aus!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendMsg({ type: 'start_game' });
|
|
||||||
});
|
|
||||||
|
|
||||||
wire($readyChk, 'change', (e) => {
|
|
||||||
const val = !!e.target.checked;
|
|
||||||
state.pendingReady = val;
|
|
||||||
sendMsg({ type: 'set_ready', ready: val });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Playlist selection
|
|
||||||
const $playlistSelect = document.getElementById('playlistSelect');
|
|
||||||
if ($playlistSelect) {
|
|
||||||
wire($playlistSelect, 'change', (e) => {
|
|
||||||
const playlistId = e.target.value;
|
|
||||||
sendMsg({ type: 'set_playlist', playlist: playlistId });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
wire($placeBtn, 'click', () => {
|
|
||||||
const slot = parseInt($slotSelect.value, 10);
|
|
||||||
sendMsg({ type: 'place_guess', slot });
|
|
||||||
});
|
|
||||||
|
|
||||||
wire($playBtn, 'click', () => sendMsg({ type: 'player_control', action: 'play' }));
|
|
||||||
wire($pauseBtn, 'click', () => sendMsg({ type: 'player_control', action: 'pause' }));
|
|
||||||
wire($nextBtn, 'click', () => sendMsg({ type: 'next_track' }));
|
|
||||||
|
|
||||||
if ($volumeSlider && $audio) {
|
|
||||||
try {
|
|
||||||
$volumeSlider.value = String($audio.volume ?? 1);
|
|
||||||
} catch {}
|
|
||||||
$volumeSlider.addEventListener('input', () => {
|
|
||||||
$audio.volume = parseFloat($volumeSlider.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($copyRoomCode) {
|
|
||||||
$copyRoomCode.style.display = 'inline-block';
|
|
||||||
wire($copyRoomCode, 'click', () => {
|
|
||||||
if (state.room?.id) {
|
|
||||||
navigator.clipboard.writeText(state.room.id).then(() => {
|
|
||||||
$copyRoomCode.textContent = '✔️';
|
|
||||||
showToast('Code kopiert!');
|
|
||||||
setTimeout(() => {
|
|
||||||
$copyRoomCode.textContent = '📋';
|
|
||||||
}, 1200);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($roomId) {
|
|
||||||
wire($roomId, 'click', () => {
|
|
||||||
if (state.room?.id) {
|
|
||||||
navigator.clipboard.writeText(state.room.id).then(() => {
|
|
||||||
$roomId.title = 'Kopiert!';
|
|
||||||
showToast('Code kopiert!');
|
|
||||||
setTimeout(() => {
|
|
||||||
$roomId.title = 'Klicken zum Kopieren';
|
|
||||||
}, 1200);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$roomId.style.cursor = 'pointer';
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.getElementById('answerForm');
|
|
||||||
if (form) {
|
|
||||||
form.addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const title = ($guessTitle?.value || '').trim();
|
|
||||||
const artist = ($guessArtist?.value || '').trim();
|
|
||||||
if (!title || !artist) {
|
|
||||||
if ($answerResult) {
|
|
||||||
$answerResult.textContent = 'Bitte Titel und Künstler eingeben';
|
|
||||||
$answerResult.className = 'mt-1 text-sm text-amber-600';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendMsg({ type: 'submit_answer', guess: { title, artist } });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard one-time hint
|
|
||||||
const dashboard = document.getElementById('dashboard');
|
|
||||||
const dashboardHint = document.getElementById('dashboardHint');
|
|
||||||
if (dashboard && dashboardHint) {
|
|
||||||
try {
|
|
||||||
const seen = localStorage.getItem('dashboardHintSeen');
|
|
||||||
if (!seen) {
|
|
||||||
dashboardHint.classList.remove('hidden');
|
|
||||||
const hide = () => {
|
|
||||||
dashboardHint.classList.add('hidden');
|
|
||||||
try {
|
|
||||||
localStorage.setItem('dashboardHintSeen', '1');
|
|
||||||
} catch {}
|
|
||||||
dashboard.removeEventListener('toggle', hide);
|
|
||||||
dashboard.removeEventListener('click', hide);
|
|
||||||
};
|
|
||||||
dashboard.addEventListener('toggle', hide);
|
|
||||||
dashboard.addEventListener('click', hide, { once: true });
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!localStorage.getItem('dashboardHintSeen')) hide();
|
|
||||||
}, 6000);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
src/server-deno/.gitignore
vendored
Normal file
22
src/server-deno/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# .gitignore for Deno server
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Deno cache
|
||||||
|
.deno/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
321
src/server-deno/CHECKLIST.md
Normal file
321
src/server-deno/CHECKLIST.md
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# Backend Comparison Checklist
|
||||||
|
|
||||||
|
Use this checklist to verify the new backend has feature parity with the old one.
|
||||||
|
|
||||||
|
## ✅ Core Functionality
|
||||||
|
|
||||||
|
### Game Flow
|
||||||
|
- [ ] Create room
|
||||||
|
- [ ] Join room
|
||||||
|
- [ ] Leave room
|
||||||
|
- [ ] Set player name
|
||||||
|
- [ ] Ready up in lobby
|
||||||
|
- [ ] Start game
|
||||||
|
- [ ] Play tracks
|
||||||
|
- [ ] Guess title
|
||||||
|
- [ ] Guess artist
|
||||||
|
- [ ] Guess year
|
||||||
|
- [ ] Place cards in timeline
|
||||||
|
- [ ] Win condition (reach goal)
|
||||||
|
- [ ] Game end
|
||||||
|
- [ ] Multiple rounds
|
||||||
|
|
||||||
|
### Player Management
|
||||||
|
- [ ] Generate player ID
|
||||||
|
- [ ] Generate session ID
|
||||||
|
- [ ] Session resumption
|
||||||
|
- [ ] Player disconnect handling
|
||||||
|
- [ ] Player reconnect handling
|
||||||
|
- [ ] Spectator mode
|
||||||
|
- [ ] Kick player (host only)
|
||||||
|
- [ ] Host transfer on leave
|
||||||
|
|
||||||
|
### Room Features
|
||||||
|
- [ ] Room ID generation
|
||||||
|
- [ ] Room listing (if implemented)
|
||||||
|
- [ ] Room deletion when empty
|
||||||
|
- [ ] Multiple rooms simultaneously
|
||||||
|
- [ ] Turn order management
|
||||||
|
- [ ] Ready status tracking
|
||||||
|
|
||||||
|
### Playlist System
|
||||||
|
- [ ] Default playlist
|
||||||
|
- [ ] Custom playlists (subdirectories)
|
||||||
|
- [ ] Playlist selection
|
||||||
|
- [ ] List available playlists
|
||||||
|
- [ ] Track loading
|
||||||
|
- [ ] Track metadata (title, artist, year)
|
||||||
|
- [ ] Shuffle deck
|
||||||
|
- [ ] Draw next track
|
||||||
|
|
||||||
|
### Audio Streaming
|
||||||
|
- [ ] Token generation
|
||||||
|
- [ ] Token expiration (10 min)
|
||||||
|
- [ ] Stream audio by token
|
||||||
|
- [ ] HTTP HEAD support
|
||||||
|
- [ ] HTTP Range support (seeking)
|
||||||
|
- [ ] .opus preference
|
||||||
|
- [ ] Fallback to other formats
|
||||||
|
- [ ] Cover art extraction
|
||||||
|
- [ ] Cover art caching
|
||||||
|
|
||||||
|
### Answer Checking
|
||||||
|
- [ ] Fuzzy title matching
|
||||||
|
- [ ] Fuzzy artist matching
|
||||||
|
- [ ] Year matching (±1 year)
|
||||||
|
- [ ] Diacritics normalization
|
||||||
|
- [ ] Case insensitive
|
||||||
|
- [ ] Remove remaster info
|
||||||
|
- [ ] Remove parentheticals
|
||||||
|
- [ ] Artist featuring/and/& handling
|
||||||
|
- [ ] Score calculation
|
||||||
|
- [ ] Partial credit
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- [ ] Timeline tracking per player
|
||||||
|
- [ ] Token/coin system
|
||||||
|
- [ ] Current guesser tracking
|
||||||
|
- [ ] Turn order
|
||||||
|
- [ ] Game phase (guess/reveal)
|
||||||
|
- [ ] Pause/resume
|
||||||
|
- [ ] Track position tracking
|
||||||
|
- [ ] Last result tracking
|
||||||
|
- [ ] Awarded coins this round
|
||||||
|
|
||||||
|
### Real-Time Sync
|
||||||
|
- [ ] WebSocket connection
|
||||||
|
- [ ] Time synchronization
|
||||||
|
- [ ] Sync timer (1 second)
|
||||||
|
- [ ] Track start time broadcast
|
||||||
|
- [ ] Room state broadcast
|
||||||
|
- [ ] Guess result broadcast
|
||||||
|
- [ ] Game end broadcast
|
||||||
|
|
||||||
|
## ✅ HTTP API Endpoints
|
||||||
|
|
||||||
|
### Playlists
|
||||||
|
- [ ] GET `/api/playlists` - List all playlists
|
||||||
|
- Returns: `{ ok, playlists: [{ id, name, trackCount }] }`
|
||||||
|
|
||||||
|
### Tracks
|
||||||
|
- [ ] GET `/api/tracks?playlist=<id>` - Get playlist tracks
|
||||||
|
- Returns: `{ ok, tracks: [...], playlist }`
|
||||||
|
|
||||||
|
### Years
|
||||||
|
- [ ] GET `/api/reload-years?playlist=<id>` - Reload years index
|
||||||
|
- Returns: `{ ok, count, playlist }`
|
||||||
|
|
||||||
|
### Audio
|
||||||
|
- [ ] HEAD `/audio/t/:token` - Check audio availability
|
||||||
|
- [ ] GET `/audio/t/:token` - Stream audio with token
|
||||||
|
- Supports Range header
|
||||||
|
- Returns appropriate status (200/206/404/416)
|
||||||
|
|
||||||
|
### Cover Art
|
||||||
|
- [ ] GET `/cover/:name` - Get cover art
|
||||||
|
- Returns image with correct MIME type
|
||||||
|
- Handles missing covers gracefully
|
||||||
|
- Tries alternative file extensions
|
||||||
|
|
||||||
|
### Static Files
|
||||||
|
- [ ] Serve public directory
|
||||||
|
- [ ] SPA routing support (fallback to index.html)
|
||||||
|
|
||||||
|
## ✅ WebSocket Events
|
||||||
|
|
||||||
|
### Client → Server
|
||||||
|
- [ ] `resume` - Resume session
|
||||||
|
- [ ] `create_room` - Create new room
|
||||||
|
- [ ] `join_room` - Join existing room
|
||||||
|
- [ ] `leave_room` - Leave current room
|
||||||
|
- [ ] `set_name` - Change player name
|
||||||
|
- [ ] `ready` - Toggle ready status
|
||||||
|
- [ ] `select_playlist` - Choose playlist (host only)
|
||||||
|
- [ ] `start_game` - Begin game (host only)
|
||||||
|
- [ ] `guess` - Submit guess
|
||||||
|
- [ ] `pause` - Pause game
|
||||||
|
- [ ] `resume_play` - Resume game
|
||||||
|
- [ ] `skip_track` - Skip current track
|
||||||
|
- [ ] `set_spectator` - Toggle spectator mode
|
||||||
|
- [ ] `kick_player` - Kick player (host only)
|
||||||
|
|
||||||
|
### Server → Client
|
||||||
|
- [ ] `connected` - Connection established
|
||||||
|
- [ ] `resume_result` - Resume attempt result
|
||||||
|
- [ ] `room_created` - Room created successfully
|
||||||
|
- [ ] `room_joined` - Joined room successfully
|
||||||
|
- [ ] `room_update` - Room state changed
|
||||||
|
- [ ] `play_track` - New track to play
|
||||||
|
- [ ] `guess_result` - Guess outcome
|
||||||
|
- [ ] `game_ended` - Game finished
|
||||||
|
- [ ] `sync` - Time synchronization
|
||||||
|
- [ ] `error` - Error occurred
|
||||||
|
|
||||||
|
## ✅ Data Structures
|
||||||
|
|
||||||
|
### Track
|
||||||
|
```typescript
|
||||||
|
{ id, file, title, artist, year, url? }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Player
|
||||||
|
```typescript
|
||||||
|
{ id, sessionId, name, connected, roomId, spectator? }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Room
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id, name, hostId,
|
||||||
|
players: [{ id, name, connected, ready, spectator }],
|
||||||
|
state: GameState
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GameState
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
status, phase, turnOrder, currentGuesser,
|
||||||
|
currentTrack, timeline, tokens, ready, spectators,
|
||||||
|
lastResult, trackStartAt, paused, pausedPosSec,
|
||||||
|
goal, playlist, awardedThisRound
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Security Features
|
||||||
|
|
||||||
|
- [ ] Path traversal protection
|
||||||
|
- [ ] Token-based audio URLs
|
||||||
|
- [ ] Short-lived tokens (expiration)
|
||||||
|
- [ ] Input validation
|
||||||
|
- [ ] CORS configuration
|
||||||
|
- [ ] No filename exposure
|
||||||
|
- [ ] Deno permissions
|
||||||
|
|
||||||
|
## ✅ Performance Features
|
||||||
|
|
||||||
|
- [ ] LRU cache for tokens
|
||||||
|
- [ ] LRU cache for cover art
|
||||||
|
- [ ] Batch metadata processing
|
||||||
|
- [ ] Streaming with range support
|
||||||
|
- [ ] File deduplication (.opus preference)
|
||||||
|
- [ ] Memory-efficient streaming
|
||||||
|
|
||||||
|
## ✅ Configuration
|
||||||
|
|
||||||
|
- [ ] PORT environment variable
|
||||||
|
- [ ] HOST environment variable
|
||||||
|
- [ ] DATA_DIR configuration
|
||||||
|
- [ ] PUBLIC_DIR configuration
|
||||||
|
- [ ] TOKEN_TTL_MS configuration
|
||||||
|
- [ ] LOG_LEVEL configuration
|
||||||
|
- [ ] CORS_ORIGIN configuration
|
||||||
|
- [ ] AUDIO_DEBUG_NAMES flag
|
||||||
|
|
||||||
|
## ✅ Error Handling
|
||||||
|
|
||||||
|
- [ ] Invalid room ID
|
||||||
|
- [ ] Player not found
|
||||||
|
- [ ] Session not found
|
||||||
|
- [ ] File not found
|
||||||
|
- [ ] Invalid path
|
||||||
|
- [ ] Parse errors
|
||||||
|
- [ ] Network errors
|
||||||
|
- [ ] Validation errors
|
||||||
|
- [ ] Graceful degradation
|
||||||
|
|
||||||
|
## ✅ Logging
|
||||||
|
|
||||||
|
- [ ] Connection events
|
||||||
|
- [ ] Disconnection events
|
||||||
|
- [ ] Room creation
|
||||||
|
- [ ] Room deletion
|
||||||
|
- [ ] Game start
|
||||||
|
- [ ] Game end
|
||||||
|
- [ ] Player actions
|
||||||
|
- [ ] Errors
|
||||||
|
- [ ] HTTP requests
|
||||||
|
- [ ] Configurable log level
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
- [ ] Start server successfully
|
||||||
|
- [ ] Create room
|
||||||
|
- [ ] Join room from another tab
|
||||||
|
- [ ] Set player names
|
||||||
|
- [ ] Select playlist
|
||||||
|
- [ ] Ready up all players
|
||||||
|
- [ ] Start game
|
||||||
|
- [ ] Play through full game
|
||||||
|
- [ ] Guess correctly
|
||||||
|
- [ ] Guess incorrectly
|
||||||
|
- [ ] Win game
|
||||||
|
- [ ] Pause/resume
|
||||||
|
- [ ] Skip track
|
||||||
|
- [ ] Disconnect and reconnect
|
||||||
|
- [ ] Multiple rooms simultaneously
|
||||||
|
|
||||||
|
### API Testing
|
||||||
|
```bash
|
||||||
|
# Playlists
|
||||||
|
curl http://localhost:5173/api/playlists
|
||||||
|
|
||||||
|
# Tracks
|
||||||
|
curl http://localhost:5173/api/tracks?playlist=default
|
||||||
|
|
||||||
|
# Years
|
||||||
|
curl http://localhost:5173/api/reload-years?playlist=default
|
||||||
|
|
||||||
|
# Audio (need token)
|
||||||
|
curl -I http://localhost:5173/audio/t/TOKEN_HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Load Testing
|
||||||
|
- [ ] Multiple concurrent connections
|
||||||
|
- [ ] Multiple rooms active
|
||||||
|
- [ ] High frequency guesses
|
||||||
|
- [ ] Large playlists
|
||||||
|
- [ ] Memory usage stable
|
||||||
|
- [ ] CPU usage reasonable
|
||||||
|
|
||||||
|
## Migration Steps
|
||||||
|
|
||||||
|
1. **Preparation**
|
||||||
|
- [ ] Read QUICK_START.md
|
||||||
|
- [ ] Read MIGRATION_GUIDE.md
|
||||||
|
- [ ] Review PROJECT_SUMMARY.md
|
||||||
|
|
||||||
|
2. **Setup**
|
||||||
|
- [ ] Install Deno 2
|
||||||
|
- [ ] Copy .env.example to .env
|
||||||
|
- [ ] Configure environment variables
|
||||||
|
- [ ] Verify data directory
|
||||||
|
|
||||||
|
3. **Testing**
|
||||||
|
- [ ] Run `deno task dev`
|
||||||
|
- [ ] Test all features manually
|
||||||
|
- [ ] Compare with old backend
|
||||||
|
- [ ] Run test suite
|
||||||
|
- [ ] Check performance
|
||||||
|
|
||||||
|
4. **Deployment**
|
||||||
|
- [ ] Stop old backend
|
||||||
|
- [ ] Start new backend
|
||||||
|
- [ ] Monitor logs
|
||||||
|
- [ ] Verify all features
|
||||||
|
- [ ] Have rollback plan ready
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All checkboxes should be ✅ before considering migration complete
|
||||||
|
- Test thoroughly in development before production deployment
|
||||||
|
- Keep old backend as backup during transition
|
||||||
|
- Monitor performance and logs closely
|
||||||
|
- Report any issues or discrepancies
|
||||||
|
|
||||||
|
## Status Legend
|
||||||
|
- [ ] Not tested
|
||||||
|
- [✓] Tested and working
|
||||||
|
- [✗] Tested and failing
|
||||||
|
- [~] Partially working
|
||||||
317
src/server-deno/application/AnswerCheckService.ts
Normal file
317
src/server-deno/application/AnswerCheckService.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* Answer checking service for fuzzy matching of title, artist, and year guesses
|
||||||
|
* Based on the original answerCheck.js logic
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScoreResult {
|
||||||
|
score: number;
|
||||||
|
match: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer checking service
|
||||||
|
*/
|
||||||
|
export class AnswerCheckService {
|
||||||
|
/**
|
||||||
|
* Strip diacritics from string
|
||||||
|
*/
|
||||||
|
private stripDiacritics(str: string): string {
|
||||||
|
return str
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize common terms
|
||||||
|
*/
|
||||||
|
private normalizeCommon(str: string): string {
|
||||||
|
let normalized = this.stripDiacritics(str)
|
||||||
|
.toLowerCase()
|
||||||
|
// Normalize common contractions before removing punctuation
|
||||||
|
.replace(/\bcan't\b/g, 'cant')
|
||||||
|
.replace(/\bwon't\b/g, 'wont')
|
||||||
|
.replace(/\bdon't\b/g, 'dont')
|
||||||
|
.replace(/\bdidn't\b/g, 'didnt')
|
||||||
|
.replace(/\bisn't\b/g, 'isnt')
|
||||||
|
.replace(/\baren't\b/g, 'arent')
|
||||||
|
.replace(/\bwasn't\b/g, 'wasnt')
|
||||||
|
.replace(/\bweren't\b/g, 'werent')
|
||||||
|
.replace(/\bhasn't\b/g, 'hasnt')
|
||||||
|
.replace(/\bhaven't\b/g, 'havent')
|
||||||
|
.replace(/\bhadn't\b/g, 'hadnt')
|
||||||
|
.replace(/\bshouldn't\b/g, 'shouldnt')
|
||||||
|
.replace(/\bwouldn't\b/g, 'wouldnt')
|
||||||
|
.replace(/\bcouldn't\b/g, 'couldnt')
|
||||||
|
.replace(/\bmustn't\b/g, 'mustnt')
|
||||||
|
.replace(/\bi'm\b/g, 'im')
|
||||||
|
.replace(/\byou're\b/g, 'youre')
|
||||||
|
.replace(/\bhe's\b/g, 'hes')
|
||||||
|
.replace(/\bshe's\b/g, 'shes')
|
||||||
|
.replace(/\bit's\b/g, 'its')
|
||||||
|
.replace(/\bwe're\b/g, 'were')
|
||||||
|
.replace(/\bthey're\b/g, 'theyre')
|
||||||
|
.replace(/\bi've\b/g, 'ive')
|
||||||
|
.replace(/\byou've\b/g, 'youve')
|
||||||
|
.replace(/\bwe've\b/g, 'weve')
|
||||||
|
.replace(/\bthey've\b/g, 'theyve')
|
||||||
|
.replace(/\bi'll\b/g, 'ill')
|
||||||
|
.replace(/\byou'll\b/g, 'youll')
|
||||||
|
.replace(/\bhe'll\b/g, 'hell')
|
||||||
|
.replace(/\bshe'll\b/g, 'shell')
|
||||||
|
.replace(/\bwe'll\b/g, 'well')
|
||||||
|
.replace(/\bthey'll\b/g, 'theyll')
|
||||||
|
.replace(/\bthat's\b/g, 'thats')
|
||||||
|
.replace(/\bwho's\b/g, 'whos')
|
||||||
|
.replace(/\bwhat's\b/g, 'whats')
|
||||||
|
.replace(/\bwhere's\b/g, 'wheres')
|
||||||
|
.replace(/\bwhen's\b/g, 'whens')
|
||||||
|
.replace(/\bwhy's\b/g, 'whys')
|
||||||
|
.replace(/\bhow's\b/g, 'hows')
|
||||||
|
.replace(/\s*(?:&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/g, ' ')
|
||||||
|
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean title-specific noise (remasters, edits, etc.)
|
||||||
|
*/
|
||||||
|
private cleanTitleNoise(raw: string): string {
|
||||||
|
let s = raw;
|
||||||
|
|
||||||
|
// Remove common parenthetical annotations
|
||||||
|
s = s.replace(/\(([^)]*remaster[^)]*)\)/gi, '');
|
||||||
|
s = s.replace(/\(([^)]*radio edit[^)]*)\)/gi, '');
|
||||||
|
s = s.replace(/\(([^)]*edit[^)]*)\)/gi, '');
|
||||||
|
s = s.replace(/\(([^)]*version[^)]*)\)/gi, '');
|
||||||
|
s = s.replace(/\(([^)]*live[^)]*)\)/gi, '');
|
||||||
|
s = s.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
|
||||||
|
|
||||||
|
// Remove standalone noise words
|
||||||
|
s = s.replace(/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi, '');
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip optional segments (parentheses, quotes, brackets)
|
||||||
|
*/
|
||||||
|
private stripOptionalSegments(raw: string): string {
|
||||||
|
let s = raw;
|
||||||
|
s = s.replace(/"[^"]*"/g, ' '); // Remove quoted segments
|
||||||
|
s = s.replace(/\([^)]*\)/g, ' '); // Remove parenthetical
|
||||||
|
s = s.replace(/\[[^\]]*\]/g, ' '); // Remove brackets
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize title for comparison
|
||||||
|
*/
|
||||||
|
private normalizeTitle(str: string): string {
|
||||||
|
return this.normalizeCommon(this.cleanTitleNoise(str));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize title with optional segments removed
|
||||||
|
*/
|
||||||
|
private normalizeTitleBaseOptional(str: string): string {
|
||||||
|
return this.normalizeCommon(this.stripOptionalSegments(this.cleanTitleNoise(str)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize artist for comparison
|
||||||
|
*/
|
||||||
|
private normalizeArtist(str: string): string {
|
||||||
|
return this.normalizeCommon(str)
|
||||||
|
.replace(/\bthe\b/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize string
|
||||||
|
*/
|
||||||
|
private tokenize(str: string): string[] {
|
||||||
|
return str ? str.split(' ').filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create token set
|
||||||
|
*/
|
||||||
|
private tokenSet(str: string): Set<string> {
|
||||||
|
return new Set(this.tokenize(str));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Jaccard similarity
|
||||||
|
*/
|
||||||
|
private jaccard(a: string, b: string): number {
|
||||||
|
const setA = this.tokenSet(a);
|
||||||
|
const setB = this.tokenSet(b);
|
||||||
|
|
||||||
|
if (setA.size === 0 && setB.size === 0) return 1;
|
||||||
|
|
||||||
|
let intersection = 0;
|
||||||
|
for (const token of setA) {
|
||||||
|
if (setB.has(token)) intersection++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const union = setA.size + setB.size - intersection;
|
||||||
|
return union ? intersection / union : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Levenshtein distance
|
||||||
|
*/
|
||||||
|
private levenshtein(a: string, b: string): number {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
|
||||||
|
if (!m) return n;
|
||||||
|
if (!n) return m;
|
||||||
|
|
||||||
|
const dp = new Array(n + 1).fill(0);
|
||||||
|
|
||||||
|
for (let j = 0; j <= n; j++) {
|
||||||
|
dp[j] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
let prev = dp[0];
|
||||||
|
dp[0] = i;
|
||||||
|
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
const temp = dp[j];
|
||||||
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
|
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
|
||||||
|
prev = temp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dp[n];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate similarity ratio based on Levenshtein distance
|
||||||
|
*/
|
||||||
|
private simRatio(a: string, b: string): number {
|
||||||
|
if (!a && !b) return 1;
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
|
||||||
|
const dist = this.levenshtein(a, b);
|
||||||
|
const maxLen = Math.max(a.length, b.length);
|
||||||
|
return maxLen ? 1 - dist / maxLen : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split artists string
|
||||||
|
*/
|
||||||
|
splitArtists(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(/[,&+]|(?:\s+(?:feat\.?|featuring|with|vs\.?|and|x)\s+)/i)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score title guess
|
||||||
|
*/
|
||||||
|
scoreTitle(guess: string, correct: string): ScoreResult {
|
||||||
|
if (!guess || !correct) {
|
||||||
|
return { score: 0, match: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = this.normalizeTitle(guess);
|
||||||
|
const c = this.normalizeTitle(correct);
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (g === c) {
|
||||||
|
return { score: 1.0, match: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try without optional segments
|
||||||
|
const gOpt = this.normalizeTitleBaseOptional(guess);
|
||||||
|
const cOpt = this.normalizeTitleBaseOptional(correct);
|
||||||
|
|
||||||
|
if (gOpt === cOpt && gOpt.length > 0) {
|
||||||
|
return { score: 0.98, match: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy matching
|
||||||
|
const jac = this.jaccard(g, c);
|
||||||
|
const sim = this.simRatio(g, c);
|
||||||
|
const score = 0.6 * jac + 0.4 * sim;
|
||||||
|
|
||||||
|
// Accept if score >= 0.6 (softened threshold)
|
||||||
|
return { score, match: score >= 0.6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score artist guess
|
||||||
|
*/
|
||||||
|
scoreArtist(guess: string, correct: string): ScoreResult {
|
||||||
|
if (!guess || !correct) {
|
||||||
|
return { score: 0, match: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const guessParts = this.splitArtists(guess);
|
||||||
|
const correctParts = this.splitArtists(correct);
|
||||||
|
|
||||||
|
const gNorm = guessParts.map((p) => this.normalizeArtist(p));
|
||||||
|
const cNorm = correctParts.map((p) => this.normalizeArtist(p));
|
||||||
|
|
||||||
|
// Check if any guess part matches any correct part
|
||||||
|
let bestScore = 0;
|
||||||
|
|
||||||
|
for (const gPart of gNorm) {
|
||||||
|
for (const cPart of cNorm) {
|
||||||
|
if (gPart === cPart) {
|
||||||
|
return { score: 1.0, match: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const jac = this.jaccard(gPart, cPart);
|
||||||
|
const sim = this.simRatio(gPart, cPart);
|
||||||
|
const score = 0.6 * jac + 0.4 * sim;
|
||||||
|
bestScore = Math.max(bestScore, score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept if score >= 0.6 (softened threshold)
|
||||||
|
return { score: bestScore, match: bestScore >= 0.6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score year guess
|
||||||
|
*/
|
||||||
|
scoreYear(guess: number | string, correct: number | null): ScoreResult {
|
||||||
|
if (correct === null) {
|
||||||
|
return { score: 0, match: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const guessNum = typeof guess === 'string' ? parseInt(guess, 10) : guess;
|
||||||
|
|
||||||
|
if (isNaN(guessNum)) {
|
||||||
|
return { score: 0, match: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guessNum === correct) {
|
||||||
|
return { score: 1.0, match: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = Math.abs(guessNum - correct);
|
||||||
|
|
||||||
|
// Accept within 1 year
|
||||||
|
if (diff <= 1) {
|
||||||
|
return { score: 0.9, match: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within 2 years - partial credit but no match
|
||||||
|
if (diff <= 2) {
|
||||||
|
return { score: 0.7, match: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score: 0, match: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
383
src/server-deno/application/GameService.ts
Normal file
383
src/server-deno/application/GameService.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import type { GuessResult, ID, Track } from '../domain/types.ts';
|
||||||
|
import { GameStatus } from '../domain/types.ts';
|
||||||
|
import type { RoomModel } from '../domain/models/mod.ts';
|
||||||
|
import { AudioStreamingService } from '../infrastructure/mod.ts';
|
||||||
|
import { TrackService } from './TrackService.ts';
|
||||||
|
import { AnswerCheckService } from './AnswerCheckService.ts';
|
||||||
|
import { logger } from '../shared/logger.ts';
|
||||||
|
import { ValidationError } from '../shared/errors.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game service for managing game logic and flow
|
||||||
|
*/
|
||||||
|
export class GameService {
|
||||||
|
constructor(
|
||||||
|
private readonly trackService: TrackService,
|
||||||
|
private readonly audioStreaming: AudioStreamingService,
|
||||||
|
private readonly answerCheck: AnswerCheckService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a game in a room
|
||||||
|
*/
|
||||||
|
async startGame(room: RoomModel): Promise<void> {
|
||||||
|
if (room.state.status === ('playing' as GameStatus)) {
|
||||||
|
throw new ValidationError('Game already in progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.state.playlist) {
|
||||||
|
throw new ValidationError('No playlist selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.state.areAllReady()) {
|
||||||
|
throw new ValidationError('Not all players are ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load shuffled deck
|
||||||
|
const tracks = await this.trackService.loadShuffledDeck(room.state.playlist);
|
||||||
|
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
throw new ValidationError('No tracks available in selected playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
room.setDeck(tracks);
|
||||||
|
|
||||||
|
// Initialize game state
|
||||||
|
const playerIds = Array.from(room.players.keys()).filter(
|
||||||
|
(id) => !room.state.spectators[id]
|
||||||
|
);
|
||||||
|
room.state.startGame(playerIds);
|
||||||
|
|
||||||
|
logger.info(`Game started in room ${room.id} with ${playerIds.length} players`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw next track and prepare for guessing
|
||||||
|
*/
|
||||||
|
async drawNextTrack(room: RoomModel): Promise<Track | null> {
|
||||||
|
const track = room.drawTrack();
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
// No more tracks - end game
|
||||||
|
room.state.endGame();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create streaming token
|
||||||
|
try {
|
||||||
|
const token = await this.audioStreaming.createAudioToken(track.file);
|
||||||
|
track.url = `/audio/t/${token}`;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create audio token for ${track.file}: ${error}`);
|
||||||
|
// Fallback to name-based URL
|
||||||
|
track.url = `/audio/${encodeURIComponent(track.file)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update game state
|
||||||
|
room.state.currentTrack = track;
|
||||||
|
room.state.resetRound();
|
||||||
|
room.state.trackStartAt = Date.now() + 800; // Small delay for sync
|
||||||
|
|
||||||
|
logger.info(`Track drawn in room ${room.id}: ${track.title} - ${track.artist}`);
|
||||||
|
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a guess
|
||||||
|
*/
|
||||||
|
processGuess(
|
||||||
|
room: RoomModel,
|
||||||
|
playerId: ID,
|
||||||
|
guess: string,
|
||||||
|
type: 'title' | 'artist' | 'year',
|
||||||
|
): GuessResult {
|
||||||
|
const player = room.getPlayer(playerId);
|
||||||
|
const track = room.state.currentTrack;
|
||||||
|
|
||||||
|
if (!player || !track) {
|
||||||
|
throw new ValidationError('Invalid guess context');
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: GuessResult;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'title': {
|
||||||
|
const scoreResult = this.answerCheck.scoreTitle(guess, track.title);
|
||||||
|
result = {
|
||||||
|
playerId,
|
||||||
|
playerName: player.name,
|
||||||
|
guess,
|
||||||
|
correct: scoreResult.match,
|
||||||
|
type: 'title',
|
||||||
|
score: scoreResult.score,
|
||||||
|
answer: track.title,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'artist': {
|
||||||
|
const scoreResult = this.answerCheck.scoreArtist(guess, track.artist);
|
||||||
|
result = {
|
||||||
|
playerId,
|
||||||
|
playerName: player.name,
|
||||||
|
guess,
|
||||||
|
correct: scoreResult.match,
|
||||||
|
type: 'artist',
|
||||||
|
score: scoreResult.score,
|
||||||
|
answer: track.artist,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'year': {
|
||||||
|
const scoreResult = this.answerCheck.scoreYear(guess, track.year);
|
||||||
|
result = {
|
||||||
|
playerId,
|
||||||
|
playerName: player.name,
|
||||||
|
guess,
|
||||||
|
correct: scoreResult.match,
|
||||||
|
type: 'year',
|
||||||
|
score: scoreResult.score,
|
||||||
|
answer: track.year ? String(track.year) : 'Unknown',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new ValidationError('Invalid guess type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: This method is legacy and no longer awards tokens.
|
||||||
|
// Token awarding is now handled by:
|
||||||
|
// - checkTitleArtistGuess() for title+artist tokens
|
||||||
|
// - placeInTimeline() for placement tokens
|
||||||
|
|
||||||
|
room.state.lastResult = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place a card in player's timeline
|
||||||
|
*/
|
||||||
|
placeCard(room: RoomModel, playerId: ID, year: number, position: number): boolean {
|
||||||
|
const player = room.getPlayer(playerId);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
throw new ValidationError('Player not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
room.state.addToTimeline(playerId, year, position);
|
||||||
|
|
||||||
|
// Check for winner
|
||||||
|
if (room.state.hasPlayerWon(playerId)) {
|
||||||
|
room.state.endGame();
|
||||||
|
logger.info(`Player ${player.name} won in room ${room.id}!`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip to next player's turn
|
||||||
|
*/
|
||||||
|
nextTurn(room: RoomModel): ID | null {
|
||||||
|
return room.state.nextTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause game
|
||||||
|
*/
|
||||||
|
pauseGame(room: RoomModel): void {
|
||||||
|
if (!room.state.paused && room.state.trackStartAt) {
|
||||||
|
const elapsed = (Date.now() - room.state.trackStartAt) / 1000;
|
||||||
|
room.state.pausedPosSec = elapsed;
|
||||||
|
room.state.paused = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume game
|
||||||
|
*/
|
||||||
|
resumeGame(room: RoomModel): void {
|
||||||
|
if (room.state.paused) {
|
||||||
|
room.state.trackStartAt = Date.now() - (room.state.pausedPosSec * 1000);
|
||||||
|
room.state.paused = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End game
|
||||||
|
*/
|
||||||
|
endGame(room: RoomModel): void {
|
||||||
|
room.state.endGame();
|
||||||
|
logger.info(`Game ended in room ${room.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get winner
|
||||||
|
*/
|
||||||
|
getWinner(room: RoomModel): ID | null {
|
||||||
|
return room.state.getWinner();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check title and artist guess
|
||||||
|
*
|
||||||
|
* SCORING SYSTEM:
|
||||||
|
* - Tokens: Awarded ONLY for correctly guessing BOTH title AND artist
|
||||||
|
* - 1 token per round (can only get once per track)
|
||||||
|
* - Used as currency/bonus points
|
||||||
|
*
|
||||||
|
* - Score: Number of correctly placed tracks in timeline (timeline.length)
|
||||||
|
* - Increases only when placement is correct
|
||||||
|
* - This is the main win condition
|
||||||
|
*/
|
||||||
|
async checkTitleArtistGuess(
|
||||||
|
room: RoomModel,
|
||||||
|
playerId: ID,
|
||||||
|
guessTitle: string,
|
||||||
|
guessArtist: string
|
||||||
|
): Promise<{
|
||||||
|
titleCorrect: boolean;
|
||||||
|
artistCorrect: boolean;
|
||||||
|
awarded: boolean;
|
||||||
|
alreadyAwarded: boolean;
|
||||||
|
}> {
|
||||||
|
const track = room.state.currentTrack;
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('No current track');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if title+artist token already awarded this round
|
||||||
|
const alreadyAwarded = room.state.titleArtistAwardedThisRound[playerId] || false;
|
||||||
|
|
||||||
|
// Score the guesses
|
||||||
|
const titleResult = this.answerCheck.scoreTitle(guessTitle, track.title);
|
||||||
|
const artistResult = this.answerCheck.scoreArtist(guessArtist, track.artist);
|
||||||
|
|
||||||
|
const titleCorrect = titleResult.match;
|
||||||
|
const artistCorrect = artistResult.match;
|
||||||
|
const bothCorrect = titleCorrect && artistCorrect;
|
||||||
|
|
||||||
|
// Award 1 token if BOTH title and artist are correct, and not already awarded this round
|
||||||
|
let awarded = false;
|
||||||
|
if (bothCorrect && !alreadyAwarded) {
|
||||||
|
room.state.tokens[playerId] = (room.state.tokens[playerId] || 0) + 1;
|
||||||
|
room.state.titleArtistAwardedThisRound[playerId] = true;
|
||||||
|
awarded = true;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Player ${playerId} correctly guessed title AND artist. Awarded 1 token for title+artist.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
titleCorrect,
|
||||||
|
artistCorrect,
|
||||||
|
awarded,
|
||||||
|
alreadyAwarded: alreadyAwarded && bothCorrect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place track in timeline
|
||||||
|
*
|
||||||
|
* SCORING SYSTEM:
|
||||||
|
* - Correct placement: Adds track to timeline, increasing score (timeline.length)
|
||||||
|
* - Incorrect placement: Track is discarded, no score increase
|
||||||
|
* - NO tokens awarded here - tokens are only from title+artist guesses
|
||||||
|
*
|
||||||
|
* TOKEN PURCHASE:
|
||||||
|
* - If useTokens is true and player has >= 3 tokens, deduct 3 tokens and force correct placement
|
||||||
|
*/
|
||||||
|
async placeInTimeline(
|
||||||
|
room: RoomModel,
|
||||||
|
playerId: ID,
|
||||||
|
slot: number,
|
||||||
|
useTokens: boolean = false
|
||||||
|
): Promise<{ correct: boolean; tokensUsed: boolean }> {
|
||||||
|
const track = room.state.currentTrack;
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('No current track');
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = room.state.timeline[playerId] || [];
|
||||||
|
const n = timeline.length;
|
||||||
|
|
||||||
|
// Validate slot
|
||||||
|
if (slot < 0 || slot > n) {
|
||||||
|
slot = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Timeline before placement: ${JSON.stringify(timeline.map(t => ({ year: t.year, title: t.title })))}`);
|
||||||
|
logger.info(`Placing track: ${track.title} (${track.year}) at slot ${slot} of ${n}`);
|
||||||
|
|
||||||
|
// Check if player wants to use tokens to guarantee correct placement
|
||||||
|
let tokensUsed = false;
|
||||||
|
const TOKEN_COST = 3;
|
||||||
|
const playerTokens = room.state.tokens[playerId] || 0;
|
||||||
|
|
||||||
|
if (useTokens && playerTokens >= TOKEN_COST) {
|
||||||
|
// Deduct tokens and force correct placement
|
||||||
|
room.state.tokens[playerId] = playerTokens - TOKEN_COST;
|
||||||
|
tokensUsed = true;
|
||||||
|
logger.info(
|
||||||
|
`Player ${playerId} used ${TOKEN_COST} tokens to guarantee correct placement. Remaining tokens: ${room.state.tokens[playerId]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if placement is correct
|
||||||
|
let correct = false;
|
||||||
|
if (tokensUsed) {
|
||||||
|
// Tokens used - placement is always correct
|
||||||
|
correct = true;
|
||||||
|
} else if (track.year != null) {
|
||||||
|
if (n === 0) {
|
||||||
|
correct = true; // First card is always correct
|
||||||
|
} else {
|
||||||
|
const leftYear = slot > 0 ? timeline[slot - 1]?.year : null;
|
||||||
|
const rightYear = slot < n ? timeline[slot]?.year : null;
|
||||||
|
|
||||||
|
// Allow equal years (>=, <=) so cards from the same year can be placed anywhere relative to each other
|
||||||
|
const leftOk = leftYear == null || track.year >= leftYear;
|
||||||
|
const rightOk = rightYear == null || track.year <= rightYear;
|
||||||
|
|
||||||
|
correct = leftOk && rightOk;
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
logger.info(`Placement check - Track year: ${track.year}, Slot: ${slot}/${n}, Left: ${leftYear}, Right: ${rightYear}, LeftOk: ${leftOk}, RightOk: ${rightOk}, Correct: ${correct}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Track has no year: ${track.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timeline if correct (score is the timeline length)
|
||||||
|
if (correct) {
|
||||||
|
const newTimeline = [...timeline];
|
||||||
|
newTimeline.splice(slot, 0, {
|
||||||
|
trackId: track.id,
|
||||||
|
year: track.year,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
});
|
||||||
|
room.state.timeline[playerId] = newTimeline;
|
||||||
|
|
||||||
|
// Score increases automatically (it's the timeline length)
|
||||||
|
// NO token awarded here - tokens are only from title+artist guesses
|
||||||
|
logger.info(
|
||||||
|
`Player ${playerId} correctly placed track in timeline. Score is now ${newTimeline.length}.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Discard the track
|
||||||
|
room.discard.push(track);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Player ${playerId} incorrectly placed track. No score increase.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { correct, tokensUsed };
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/server-deno/application/RoomService.ts
Normal file
223
src/server-deno/application/RoomService.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import type { ID, Player, Room } from '../domain/types.ts';
|
||||||
|
import { PlayerModel, RoomModel } from '../domain/models/mod.ts';
|
||||||
|
import { generateShortId, generateUUID } from '../shared/utils.ts';
|
||||||
|
import { ConflictError, NotFoundError } from '../shared/errors.ts';
|
||||||
|
import { ROOM_ID_LENGTH } from '../shared/constants.ts';
|
||||||
|
import { logger } from '../shared/logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room service for managing game rooms and players
|
||||||
|
*/
|
||||||
|
export class RoomService {
|
||||||
|
private readonly rooms: Map<ID, RoomModel> = new Map();
|
||||||
|
private readonly players: Map<ID, PlayerModel> = new Map();
|
||||||
|
private readonly sessionToPlayer: Map<ID, ID> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new room with an existing player as host
|
||||||
|
*/
|
||||||
|
createRoomWithPlayer(player: PlayerModel, roomName?: string, goal = 10): { room: RoomModel } {
|
||||||
|
// Generate unique room ID
|
||||||
|
let roomId: string;
|
||||||
|
do {
|
||||||
|
roomId = generateShortId(ROOM_ID_LENGTH);
|
||||||
|
} while (this.rooms.has(roomId));
|
||||||
|
|
||||||
|
// Create room
|
||||||
|
const room = new RoomModel(roomId, roomName || `Room ${roomId}`, player, goal);
|
||||||
|
|
||||||
|
// Store room
|
||||||
|
this.rooms.set(roomId, room);
|
||||||
|
|
||||||
|
// Join player to room
|
||||||
|
player.joinRoom(roomId);
|
||||||
|
|
||||||
|
logger.info(`Room created: ${roomId} by player ${player.id} (${player.name})`);
|
||||||
|
|
||||||
|
return { room };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new room (legacy method that creates a new player)
|
||||||
|
*/
|
||||||
|
createRoom(hostName: string, roomName?: string, goal = 10): { room: RoomModel; player: PlayerModel } {
|
||||||
|
// Create host player
|
||||||
|
const playerId = generateUUID();
|
||||||
|
const sessionId = generateUUID();
|
||||||
|
const player = new PlayerModel(playerId, sessionId, hostName);
|
||||||
|
|
||||||
|
// Store player
|
||||||
|
this.players.set(playerId, player);
|
||||||
|
this.sessionToPlayer.set(sessionId, playerId);
|
||||||
|
|
||||||
|
// Create room with this player
|
||||||
|
const { room } = this.createRoomWithPlayer(player, roomName, goal);
|
||||||
|
|
||||||
|
return { room, player };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join an existing room with an existing player
|
||||||
|
*/
|
||||||
|
joinRoomWithPlayer(roomId: ID, player: PlayerModel): { room: RoomModel } {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
throw new NotFoundError('Room not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to room
|
||||||
|
room.addPlayer(player);
|
||||||
|
|
||||||
|
// Join player to room
|
||||||
|
player.joinRoom(roomId);
|
||||||
|
|
||||||
|
logger.info(`Player ${player.id} (${player.name}) joined room ${roomId}`);
|
||||||
|
|
||||||
|
return { room };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join an existing room (legacy method that creates a new player)
|
||||||
|
*/
|
||||||
|
joinRoom(roomId: ID, playerName: string): { room: RoomModel; player: PlayerModel } {
|
||||||
|
// Create player
|
||||||
|
const playerId = generateUUID();
|
||||||
|
const sessionId = generateUUID();
|
||||||
|
const player = new PlayerModel(playerId, sessionId, playerName);
|
||||||
|
|
||||||
|
// Store
|
||||||
|
this.players.set(playerId, player);
|
||||||
|
this.sessionToPlayer.set(sessionId, playerId);
|
||||||
|
|
||||||
|
// Join room with this player
|
||||||
|
const { room } = this.joinRoomWithPlayer(roomId, player);
|
||||||
|
|
||||||
|
return { room, player };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave room
|
||||||
|
*/
|
||||||
|
leaveRoom(playerId: ID): void {
|
||||||
|
const player = this.players.get(playerId);
|
||||||
|
|
||||||
|
if (!player || !player.roomId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = this.rooms.get(player.roomId);
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
room.removePlayer(playerId);
|
||||||
|
|
||||||
|
// If room is empty, delete it
|
||||||
|
if (room.players.size === 0) {
|
||||||
|
this.rooms.delete(room.id);
|
||||||
|
logger.info(`Room ${room.id} deleted (empty)`);
|
||||||
|
} else if (room.hostId === playerId) {
|
||||||
|
// Transfer host if current host leaves
|
||||||
|
const newHost = room.getConnectedPlayers()[0];
|
||||||
|
if (newHost) {
|
||||||
|
room.transferHost(newHost.id);
|
||||||
|
logger.info(`Host transferred to ${newHost.id} in room ${room.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
player.leaveRoom();
|
||||||
|
logger.info(`Player ${playerId} left room ${player.roomId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get room by ID
|
||||||
|
*/
|
||||||
|
getRoom(roomId: ID): RoomModel | undefined {
|
||||||
|
return this.rooms.get(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get player by ID
|
||||||
|
*/
|
||||||
|
getPlayer(playerId: ID): PlayerModel | undefined {
|
||||||
|
return this.players.get(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get player by session ID
|
||||||
|
*/
|
||||||
|
getPlayerBySession(sessionId: ID): PlayerModel | undefined {
|
||||||
|
const playerId = this.sessionToPlayer.get(sessionId);
|
||||||
|
return playerId ? this.players.get(playerId) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new player
|
||||||
|
*/
|
||||||
|
createPlayer(name?: string): { player: PlayerModel; sessionId: ID } {
|
||||||
|
const playerId = generateUUID();
|
||||||
|
const sessionId = generateUUID();
|
||||||
|
const player = new PlayerModel(playerId, sessionId, name);
|
||||||
|
|
||||||
|
this.players.set(playerId, player);
|
||||||
|
this.sessionToPlayer.set(sessionId, playerId);
|
||||||
|
|
||||||
|
return { player, sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume player session
|
||||||
|
*/
|
||||||
|
resumePlayer(sessionId: ID): PlayerModel | null {
|
||||||
|
return this.getPlayerBySession(sessionId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update player connection status
|
||||||
|
*/
|
||||||
|
setPlayerConnected(playerId: ID, connected: boolean): void {
|
||||||
|
const player = this.players.get(playerId);
|
||||||
|
if (player) {
|
||||||
|
player.setConnected(connected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set player name
|
||||||
|
*/
|
||||||
|
setPlayerName(playerId: ID, name: string): void {
|
||||||
|
const player = this.players.get(playerId);
|
||||||
|
if (player) {
|
||||||
|
player.setName(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all rooms
|
||||||
|
*/
|
||||||
|
getAllRooms(): RoomModel[] {
|
||||||
|
return Array.from(this.rooms.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all players
|
||||||
|
*/
|
||||||
|
getAllPlayers(): PlayerModel[] {
|
||||||
|
return Array.from(this.players.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up disconnected players (call periodically)
|
||||||
|
*/
|
||||||
|
cleanupDisconnected(timeout = 5 * 60 * 1000): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const player of this.players.values()) {
|
||||||
|
if (!player.connected && player.roomId) {
|
||||||
|
// Could add timestamp tracking here
|
||||||
|
// For now, just log
|
||||||
|
logger.debug(`Disconnected player: ${player.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/server-deno/application/TrackService.ts
Normal file
127
src/server-deno/application/TrackService.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Playlist, Track } from '../domain/types.ts';
|
||||||
|
import { FileSystemService, MetadataService } from '../infrastructure/mod.ts';
|
||||||
|
import { AUDIO_EXTENSIONS } from '../shared/constants.ts';
|
||||||
|
import { logger } from '../shared/logger.ts';
|
||||||
|
import { shuffle } from '../shared/utils.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track service for managing playlists and tracks
|
||||||
|
*/
|
||||||
|
export class TrackService {
|
||||||
|
constructor(
|
||||||
|
private readonly fileSystem: FileSystemService,
|
||||||
|
private readonly metadata: MetadataService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available playlists
|
||||||
|
*/
|
||||||
|
async getAvailablePlaylists(): Promise<Playlist[]> {
|
||||||
|
const playlists: Playlist[] = [];
|
||||||
|
const dataDir = this.fileSystem.getDataDir();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check root directory for default playlist
|
||||||
|
const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i');
|
||||||
|
const rootFiles = await this.fileSystem.listFiles(dataDir, audioPattern);
|
||||||
|
|
||||||
|
if (rootFiles.length > 0) {
|
||||||
|
playlists.push({
|
||||||
|
id: 'default',
|
||||||
|
name: 'Default (Root Folder)',
|
||||||
|
trackCount: rootFiles.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subdirectories
|
||||||
|
const subdirs = await this.fileSystem.listDirectories(dataDir);
|
||||||
|
|
||||||
|
for (const dir of subdirs) {
|
||||||
|
const dirPath = this.fileSystem.getPlaylistDir(dir);
|
||||||
|
const dirFiles = await this.fileSystem.listFiles(dirPath, audioPattern);
|
||||||
|
|
||||||
|
if (dirFiles.length > 0) {
|
||||||
|
playlists.push({
|
||||||
|
id: dir,
|
||||||
|
name: dir,
|
||||||
|
trackCount: dirFiles.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error reading playlists: ${error}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tracks from a playlist
|
||||||
|
*/
|
||||||
|
async loadPlaylistTracks(playlistId: string = 'default'): Promise<Track[]> {
|
||||||
|
try {
|
||||||
|
return await this.metadata.loadTracksFromPlaylist(playlistId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error loading tracks from playlist ${playlistId}: ${error}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and shuffle a deck of tracks
|
||||||
|
*/
|
||||||
|
async loadShuffledDeck(playlistId: string = 'default'): Promise<Track[]> {
|
||||||
|
const tracks = await this.loadPlaylistTracks(playlistId);
|
||||||
|
return shuffle(tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload years index for a playlist
|
||||||
|
*/
|
||||||
|
async reloadYearsIndex(playlistId: string = 'default'): Promise<{ count: number }> {
|
||||||
|
const yearsIndex = await this.metadata.loadYearsIndex(playlistId);
|
||||||
|
const count = Object.keys(yearsIndex).length;
|
||||||
|
|
||||||
|
logger.info(`Reloaded years index for playlist '${playlistId}': ${count} entries`);
|
||||||
|
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload all playlists into cache at startup
|
||||||
|
* This prevents slow loading when the first game starts
|
||||||
|
*/
|
||||||
|
async preloadAllPlaylists(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
logger.info('🎵 Starting playlist preload...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playlists = await this.getAvailablePlaylists();
|
||||||
|
|
||||||
|
if (playlists.length === 0) {
|
||||||
|
logger.warn('⚠️ No playlists found to preload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`📋 Found ${playlists.length} playlist(s) to preload`);
|
||||||
|
|
||||||
|
let totalTracks = 0;
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
const playlistStart = Date.now();
|
||||||
|
const tracks = await this.loadPlaylistTracks(playlist.id);
|
||||||
|
const playlistDuration = Date.now() - playlistStart;
|
||||||
|
|
||||||
|
totalTracks += tracks.length;
|
||||||
|
logger.info(` ✓ Loaded playlist '${playlist.name}': ${tracks.length} tracks in ${playlistDuration}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDuration = Date.now() - startTime;
|
||||||
|
logger.info(`✅ Playlist preload complete: ${totalTracks} total tracks from ${playlists.length} playlist(s) in ${totalDuration}ms`);
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.error(`❌ Playlist preload failed after ${duration}ms: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
src/server-deno/application/mod.ts
Normal file
7
src/server-deno/application/mod.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Application layer exports
|
||||||
|
*/
|
||||||
|
export { TrackService } from './TrackService.ts';
|
||||||
|
export { RoomService } from './RoomService.ts';
|
||||||
|
export { GameService } from './GameService.ts';
|
||||||
|
export { AnswerCheckService } from './AnswerCheckService.ts';
|
||||||
2162
src/server-deno/data/hitster_default/years.json
Normal file
2162
src/server-deno/data/hitster_default/years.json
Normal file
File diff suppressed because it is too large
Load Diff
50
src/server-deno/deno.json
Normal file
50
src/server-deno/deno.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["deno.window", "deno.unstable"],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"strictPropertyInitialization": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno run --allow-net --allow-read --allow-env --allow-write --watch main.ts",
|
||||||
|
"start": "deno run --allow-net --allow-read --allow-env --allow-write main.ts",
|
||||||
|
"test": "deno test --allow-net --allow-read --allow-env --allow-write",
|
||||||
|
"test:watch": "deno test --allow-net --allow-read --allow-env --allow-write --watch",
|
||||||
|
"lint": "deno lint",
|
||||||
|
"fmt": "deno fmt",
|
||||||
|
"check": "deno check **/*.ts"
|
||||||
|
},
|
||||||
|
"fmt": {
|
||||||
|
"useTabs": false,
|
||||||
|
"lineWidth": 100,
|
||||||
|
"indentWidth": 2,
|
||||||
|
"semiColons": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"proseWrap": "preserve"
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"rules": {
|
||||||
|
"tags": ["recommended"],
|
||||||
|
"exclude": ["no-explicit-any"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@oak/oak": "jsr:@oak/oak@^17.1.3",
|
||||||
|
"@std/path": "jsr:@std/path@^1.0.8",
|
||||||
|
"@std/fs": "jsr:@std/fs@^1.0.8",
|
||||||
|
"@std/log": "jsr:@std/log@^0.224.9",
|
||||||
|
"@std/crypto": "jsr:@std/crypto@^1.0.3",
|
||||||
|
"@std/encoding": "jsr:@std/encoding@^1.0.5",
|
||||||
|
"@std/uuid": "jsr:@std/uuid@^1.0.4",
|
||||||
|
"socket.io": "https://deno.land/x/socket_io@0.2.0/mod.ts",
|
||||||
|
"lru-cache": "npm:lru-cache@^11.0.0",
|
||||||
|
"music-metadata": "npm:music-metadata@^7.14.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
396
src/server-deno/deno.lock
generated
Normal file
396
src/server-deno/deno.lock
generated
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"specifiers": {
|
||||||
|
"jsr:@oak/commons@1": "1.0.1",
|
||||||
|
"jsr:@oak/oak@^17.1.3": "17.1.6",
|
||||||
|
"jsr:@std/assert@1": "1.0.15",
|
||||||
|
"jsr:@std/bytes@1": "1.0.6",
|
||||||
|
"jsr:@std/bytes@^1.0.6": "1.0.6",
|
||||||
|
"jsr:@std/crypto@1": "1.0.5",
|
||||||
|
"jsr:@std/crypto@^1.0.5": "1.0.5",
|
||||||
|
"jsr:@std/encoding@1": "1.0.10",
|
||||||
|
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||||
|
"jsr:@std/encoding@^1.0.5": "1.0.10",
|
||||||
|
"jsr:@std/fmt@^1.0.5": "1.0.8",
|
||||||
|
"jsr:@std/fs@^1.0.11": "1.0.19",
|
||||||
|
"jsr:@std/fs@^1.0.8": "1.0.19",
|
||||||
|
"jsr:@std/http@1": "1.0.21",
|
||||||
|
"jsr:@std/internal@^1.0.10": "1.0.12",
|
||||||
|
"jsr:@std/internal@^1.0.9": "1.0.12",
|
||||||
|
"jsr:@std/io@~0.225.2": "0.225.2",
|
||||||
|
"jsr:@std/log@~0.224.9": "0.224.14",
|
||||||
|
"jsr:@std/media-types@1": "1.1.0",
|
||||||
|
"jsr:@std/path@1": "1.1.2",
|
||||||
|
"jsr:@std/path@^1.0.8": "1.1.2",
|
||||||
|
"jsr:@std/path@^1.1.1": "1.1.2",
|
||||||
|
"jsr:@std/uuid@^1.0.4": "1.0.9",
|
||||||
|
"npm:lru-cache@11": "11.2.2",
|
||||||
|
"npm:music-metadata@^7.14.0": "7.14.0",
|
||||||
|
"npm:path-to-regexp@^6.3.0": "6.3.0"
|
||||||
|
},
|
||||||
|
"jsr": {
|
||||||
|
"@oak/commons@1.0.1": {
|
||||||
|
"integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert",
|
||||||
|
"jsr:@std/bytes@1",
|
||||||
|
"jsr:@std/crypto@1",
|
||||||
|
"jsr:@std/encoding@1",
|
||||||
|
"jsr:@std/http",
|
||||||
|
"jsr:@std/media-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@oak/oak@17.1.6": {
|
||||||
|
"integrity": "c7eef2eec733fba8e72b679bba3b8cf2fceccf5ef489a8b8fb43571908c0335d",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@oak/commons",
|
||||||
|
"jsr:@std/assert",
|
||||||
|
"jsr:@std/bytes@1",
|
||||||
|
"jsr:@std/http",
|
||||||
|
"jsr:@std/media-types",
|
||||||
|
"jsr:@std/path@1",
|
||||||
|
"npm:path-to-regexp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/assert@1.0.15": {
|
||||||
|
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b"
|
||||||
|
},
|
||||||
|
"@std/bytes@1.0.6": {
|
||||||
|
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
|
||||||
|
},
|
||||||
|
"@std/crypto@1.0.5": {
|
||||||
|
"integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40"
|
||||||
|
},
|
||||||
|
"@std/encoding@1.0.10": {
|
||||||
|
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||||
|
},
|
||||||
|
"@std/fmt@1.0.8": {
|
||||||
|
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
|
||||||
|
},
|
||||||
|
"@std/fs@1.0.19": {
|
||||||
|
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/internal@^1.0.9",
|
||||||
|
"jsr:@std/path@^1.1.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/http@1.0.21": {
|
||||||
|
"integrity": "abb5c747651ee6e3ea6139858fd9b1810d2c97f53a5e6722f3b6d27a6d263edc",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/encoding@^1.0.10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/internal@1.0.12": {
|
||||||
|
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||||
|
},
|
||||||
|
"@std/io@0.225.2": {
|
||||||
|
"integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7"
|
||||||
|
},
|
||||||
|
"@std/log@0.224.14": {
|
||||||
|
"integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/fmt",
|
||||||
|
"jsr:@std/fs@^1.0.11",
|
||||||
|
"jsr:@std/io"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/media-types@1.1.0": {
|
||||||
|
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
|
||||||
|
},
|
||||||
|
"@std/path@1.1.2": {
|
||||||
|
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/internal@^1.0.10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/uuid@1.0.9": {
|
||||||
|
"integrity": "44b627bf2d372fe1bd099e2ad41b2be41a777fc94e62a3151006895a037f1642",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/bytes@^1.0.6",
|
||||||
|
"jsr:@std/crypto@^1.0.5"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"npm": {
|
||||||
|
"@tokenizer/token@0.3.0": {
|
||||||
|
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
|
||||||
|
},
|
||||||
|
"abort-controller@3.0.0": {
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"dependencies": [
|
||||||
|
"event-target-shim"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"base64-js@1.5.1": {
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
||||||
|
},
|
||||||
|
"buffer@6.0.3": {
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"dependencies": [
|
||||||
|
"base64-js",
|
||||||
|
"ieee754"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content-type@1.0.5": {
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="
|
||||||
|
},
|
||||||
|
"debug@4.4.3": {
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dependencies": [
|
||||||
|
"ms"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"event-target-shim@5.0.1": {
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
|
||||||
|
},
|
||||||
|
"events@3.3.0": {
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
|
||||||
|
},
|
||||||
|
"file-type@16.5.4": {
|
||||||
|
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
|
||||||
|
"dependencies": [
|
||||||
|
"readable-web-to-node-stream",
|
||||||
|
"strtok3",
|
||||||
|
"token-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ieee754@1.2.1": {
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
|
||||||
|
},
|
||||||
|
"lru-cache@11.2.2": {
|
||||||
|
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="
|
||||||
|
},
|
||||||
|
"media-typer@1.1.0": {
|
||||||
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="
|
||||||
|
},
|
||||||
|
"ms@2.1.3": {
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
|
},
|
||||||
|
"music-metadata@7.14.0": {
|
||||||
|
"integrity": "sha512-xrm3w7SV0Wk+OythZcSbaI8mcr/KHd0knJieu8bVpaPfMv/Agz5EooCAPz3OR5hbYMiUG6dgAPKZKnMzV+3amA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@tokenizer/token",
|
||||||
|
"content-type",
|
||||||
|
"debug",
|
||||||
|
"file-type",
|
||||||
|
"media-typer",
|
||||||
|
"strtok3",
|
||||||
|
"token-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"path-to-regexp@6.3.0": {
|
||||||
|
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="
|
||||||
|
},
|
||||||
|
"peek-readable@4.1.0": {
|
||||||
|
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="
|
||||||
|
},
|
||||||
|
"process@0.11.10": {
|
||||||
|
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="
|
||||||
|
},
|
||||||
|
"readable-stream@4.7.0": {
|
||||||
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
|
"dependencies": [
|
||||||
|
"abort-controller",
|
||||||
|
"buffer",
|
||||||
|
"events",
|
||||||
|
"process",
|
||||||
|
"string_decoder"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"readable-web-to-node-stream@3.0.4": {
|
||||||
|
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
|
||||||
|
"dependencies": [
|
||||||
|
"readable-stream"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"safe-buffer@5.2.1": {
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
|
||||||
|
},
|
||||||
|
"string_decoder@1.3.0": {
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"dependencies": [
|
||||||
|
"safe-buffer"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"strtok3@6.3.0": {
|
||||||
|
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@tokenizer/token",
|
||||||
|
"peek-readable"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"token-types@4.2.1": {
|
||||||
|
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@tokenizer/token",
|
||||||
|
"ieee754"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remote": {
|
||||||
|
"https://deno.land/std@0.150.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
|
||||||
|
"https://deno.land/std@0.150.0/async/abortable.ts": "87aa7230be8360c24ad437212311c9e8d4328854baec27b4c7abb26e85515c06",
|
||||||
|
"https://deno.land/std@0.150.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0",
|
||||||
|
"https://deno.land/std@0.150.0/async/debounce.ts": "564273ef242bcfcda19a439132f940db8694173abffc159ea34f07d18fc42620",
|
||||||
|
"https://deno.land/std@0.150.0/async/deferred.ts": "bc18e28108252c9f67dfca2bbc4587c3cbf3aeb6e155f8c864ca8ecff992b98a",
|
||||||
|
"https://deno.land/std@0.150.0/async/delay.ts": "cbbdf1c87d1aed8edc7bae13592fb3e27e3106e0748f089c263390d4f49e5f6c",
|
||||||
|
"https://deno.land/std@0.150.0/async/mod.ts": "9852cd8ed897ab2d41a8fbee611d574e97898327db5c19d9d58e41126473f02c",
|
||||||
|
"https://deno.land/std@0.150.0/async/mux_async_iterator.ts": "5b4aca6781ad0f2e19ccdf1d1a1c092ccd3e00d52050d9c27c772658c8367256",
|
||||||
|
"https://deno.land/std@0.150.0/async/pool.ts": "ef9eb97b388543acbf0ac32647121e4dbe629236899586c4d4311a8770fbb239",
|
||||||
|
"https://deno.land/std@0.150.0/async/tee.ts": "bcfae0017ebb718cf4eef9e2420e8675d91cb1bcc0ed9b668681af6e6caad846",
|
||||||
|
"https://deno.land/std@0.150.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
|
||||||
|
"https://deno.land/std@0.150.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
|
||||||
|
"https://deno.land/std@0.150.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf",
|
||||||
|
"https://deno.land/std@0.150.0/fmt/colors.ts": "6f9340b7fb8cc25a993a99e5efc56fe81bb5af284ff412129dd06df06f53c0b4",
|
||||||
|
"https://deno.land/std@0.150.0/fs/exists.ts": "cb734d872f8554ea40b8bff77ad33d4143c1187eac621a55bf37781a43c56f6d",
|
||||||
|
"https://deno.land/std@0.150.0/http/server.ts": "0b0a9f3abfcfecead944b31ee9098a0c11a59b0495bf873ee200eb80e7441483",
|
||||||
|
"https://deno.land/std@0.150.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b",
|
||||||
|
"https://deno.land/std@0.150.0/log/handlers.ts": "b88c24df61eaeee8581dbef3622f21aebfd061cd2fda49affc1711c0e54d57da",
|
||||||
|
"https://deno.land/std@0.150.0/log/levels.ts": "82c965b90f763b5313e7595d4ba78d5095a13646d18430ebaf547526131604d1",
|
||||||
|
"https://deno.land/std@0.150.0/log/logger.ts": "4d25581bc02dfbe3ad7e8bb480e1f221793a85be5e056185a0cea134f7a7fdf4",
|
||||||
|
"https://deno.land/std@0.150.0/log/mod.ts": "65d2702785714b8d41061426b5c279f11b3dcbc716f3eb5384372a430af63961",
|
||||||
|
"https://deno.land/std@0.150.0/testing/_diff.ts": "029a00560b0d534bc0046f1bce4bd36b3b41ada3f2a3178c85686eb2ff5f1413",
|
||||||
|
"https://deno.land/std@0.150.0/testing/_format.ts": "0d8dc79eab15b67cdc532826213bbe05bccfd276ca473a50a3fc7bbfb7260642",
|
||||||
|
"https://deno.land/std@0.150.0/testing/_test_suite.ts": "ad453767aeb8c300878a6b7920e20370f4ce92a7b6c8e8a5d1ac2b7c14a09acb",
|
||||||
|
"https://deno.land/std@0.150.0/testing/asserts.ts": "0ee58a557ac764e762c62bb21f00e7d897e3919e71be38b2d574fb441d721005",
|
||||||
|
"https://deno.land/std@0.150.0/testing/bdd.ts": "182bb823e09bd75b76063ecf50722870101b7cfadf97a09fa29127279dc21128",
|
||||||
|
"https://deno.land/std@0.158.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
|
||||||
|
"https://deno.land/std@0.158.0/async/deferred.ts": "c01de44b9192359cebd3fe93273fcebf9e95110bf3360023917da9a2d1489fae",
|
||||||
|
"https://deno.land/std@0.158.0/async/delay.ts": "0419dfc993752849692d1f9647edf13407c7facc3509b099381be99ffbc9d699",
|
||||||
|
"https://deno.land/std@0.158.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
|
||||||
|
"https://deno.land/std@0.158.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
|
||||||
|
"https://deno.land/std@0.158.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf",
|
||||||
|
"https://deno.land/std@0.158.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289",
|
||||||
|
"https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb",
|
||||||
|
"https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917",
|
||||||
|
"https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47",
|
||||||
|
"https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68",
|
||||||
|
"https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3",
|
||||||
|
"https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73",
|
||||||
|
"https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19",
|
||||||
|
"https://deno.land/std@0.224.0/async/delay.ts": "f90dd685b97c2f142b8069082993e437b1602b8e2561134827eeb7c12b95c499",
|
||||||
|
"https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece",
|
||||||
|
"https://deno.land/std@0.224.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376",
|
||||||
|
"https://deno.land/std@0.224.0/encoding/base64.ts": "dd59695391584c8ffc5a296ba82bcdba6dd8a84d41a6a539fbee8e5075286eaf",
|
||||||
|
"https://deno.land/std@0.224.0/fmt/bytes.ts": "7b294a4b9cf0297efa55acb55d50610f3e116a0ac772d1df0ae00f0b833ccd4a",
|
||||||
|
"https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5",
|
||||||
|
"https://deno.land/std@0.224.0/http/etag.ts": "9ca56531be682f202e4239971931060b688ee5c362688e239eeaca39db9e72cb",
|
||||||
|
"https://deno.land/std@0.224.0/http/file_server.ts": "2a5392195b8e7713288f274d071711b705bb5b3220294d76cce495d456c61a93",
|
||||||
|
"https://deno.land/std@0.224.0/http/server.ts": "f9313804bf6467a1704f45f76cb6cd0a3396a3b31c316035e6a4c2035d1ea514",
|
||||||
|
"https://deno.land/std@0.224.0/http/status.ts": "ed61b4882af2514a81aefd3245e8df4c47b9a8e54929a903577643d2d1ebf514",
|
||||||
|
"https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6",
|
||||||
|
"https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2",
|
||||||
|
"https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e",
|
||||||
|
"https://deno.land/std@0.224.0/media_types/_db.ts": "19563a2491cd81b53b9c1c6ffd1a9145c355042d4a854c52f6e1424f73ff3923",
|
||||||
|
"https://deno.land/std@0.224.0/media_types/_util.ts": "e0b8da0c7d8ad2015cf27ac16ddf0809ac984b2f3ec79f7fa4206659d4f10deb",
|
||||||
|
"https://deno.land/std@0.224.0/media_types/content_type.ts": "ed3f2e1f243b418ad3f441edc95fd92efbadb0f9bde36219c7564c67f9639513",
|
||||||
|
"https://deno.land/std@0.224.0/media_types/format_media_type.ts": "ffef4718afa2489530cb94021bb865a466eb02037609f7e82899c017959d288a",
|
||||||
|
"https://deno.land/std@0.224.0/media_types/get_charset.ts": "277ebfceb205bd34e616fe6764ef03fb277b77f040706272bea8680806ae3f11",
|
||||||
|
"https://deno.land/std@0.224.0/media_types/parse_media_type.ts": "487f000a38c230ccbac25420a50f600862e06796d0eee19d19631b9e84ee9654",
|
||||||
|
"https://deno.land/std@0.224.0/media_types/type_by_extension.ts": "bf4e3f5d6b58b624d5daa01cbb8b1e86d9939940a77e7c26e796a075b60ec73b",
|
||||||
|
"https://deno.land/std@0.224.0/media_types/vendor/mime-db.v1.52.0.ts": "0218d2c7d900e8cd6fa4a866e0c387712af4af9a1bae55d6b2546c73d273a1e6",
|
||||||
|
"https://deno.land/std@0.224.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8",
|
||||||
|
"https://deno.land/std@0.224.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c",
|
||||||
|
"https://deno.land/std@0.224.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8",
|
||||||
|
"https://deno.land/std@0.224.0/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3",
|
||||||
|
"https://deno.land/std@0.224.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607",
|
||||||
|
"https://deno.land/std@0.224.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15",
|
||||||
|
"https://deno.land/std@0.224.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36",
|
||||||
|
"https://deno.land/std@0.224.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441",
|
||||||
|
"https://deno.land/std@0.224.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a",
|
||||||
|
"https://deno.land/std@0.224.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d",
|
||||||
|
"https://deno.land/std@0.224.0/path/posix/extname.ts": "e398c1d9d1908d3756a7ed94199fcd169e79466dd88feffd2f47ce0abf9d61d2",
|
||||||
|
"https://deno.land/std@0.224.0/path/posix/join.ts": "7fc2cb3716aa1b863e990baf30b101d768db479e70b7313b4866a088db016f63",
|
||||||
|
"https://deno.land/std@0.224.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91",
|
||||||
|
"https://deno.land/std@0.224.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c",
|
||||||
|
"https://deno.land/std@0.224.0/path/posix/resolve.ts": "08b699cfeee10cb6857ccab38fa4b2ec703b0ea33e8e69964f29d02a2d5257cf",
|
||||||
|
"https://deno.land/std@0.224.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add",
|
||||||
|
"https://deno.land/std@0.224.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d",
|
||||||
|
"https://deno.land/std@0.224.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808",
|
||||||
|
"https://deno.land/std@0.224.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef",
|
||||||
|
"https://deno.land/std@0.224.0/path/windows/join.ts": "8d03530ab89195185103b7da9dfc6327af13eabdcd44c7c63e42e27808f50ecf",
|
||||||
|
"https://deno.land/std@0.224.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780",
|
||||||
|
"https://deno.land/std@0.224.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7",
|
||||||
|
"https://deno.land/std@0.224.0/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972",
|
||||||
|
"https://deno.land/std@0.224.0/streams/byte_slice_stream.ts": "5bbdcadb118390affa9b3d0a0f73ef8e83754f59bb89df349add669dd9369713",
|
||||||
|
"https://deno.land/std@0.224.0/version.ts": "f6a28c9704d82d1c095988777e30e6172eb674a6570974a0d27a653be769bbbe",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/deps.ts": "136b3fc7c55f2a06a367965da3395f1bf7de66d36a0d91c5f795084fa8c67ab3",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/mod.ts": "61277a4145c378b602e8146ed4302117f130cf3b018c0a07bcb917e1b8c9fcf4",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io-parser/base64-arraybuffer.ts": "57ccea6679609df5416159fcc8a47936ad28ad6fe32235ef78d9223a3a823407",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io-parser/mod.ts": "27d35094e2159ba49f6e74f11ed83b6208a6adb5a2d5ab3cbbdcdc9dc0e36ae7",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/cors.ts": "e39b530dc3526ef85f288766ce592fa5cce2ec38b3fa19922041a7885b79b67c",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/server.ts": "1321852222ccf6b656787881fe0112c2a62930beaf1a56b6f5b327511323176f",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/socket.ts": "b4ae4e2fad305c6178785a1a2ae220e38dfb31dc0ae43759c3d3a4f96ca48c9b",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/transport.ts": "8d09ae6bde2f71942cfbae96265aa693387e054776cf2ef5a3b4f8aafa9a427f",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/transports/polling.ts": "3d3cf369eb430360b57eaf18c74fb7783a1641ed8613c460cdfa8f663ca66be4",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/transports/websocket.ts": "fd818e91e10c55b587a221669f90cc79df42574f781e50ef73bf3539fd9bcfee",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/util.ts": "9f396a141422c8a2e2ef4cbb31c8b7ec96665d8f1ca397888eaaa9ad28ca8c65",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/mod.ts": "3f7d85ebd3bee6e17838f4867927d808f35090a71e088fd4dd802e3255d44c4a",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/event-emitter/mod.ts": "dcb2cb9c0b409060cf15a6306a8dbebea844aa3c58f782ed1d4bc3ccef7c2835",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/msgpack/lib/decode.ts": "5906fa37474130b09fe308adb53c95e40d2484a015891be3249fb2f626c462bb",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/msgpack/lib/encode.ts": "15dab78be56d539c03748c9d57086f7fd580eb8fbe2f8209c28750948c7d962e",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/msgpack/mod.ts": "c7f4a9859af3e0b23794b400546d93475b19ba5110a02245112a0a994a31d309",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io-parser/mod.ts": "44479cf563b0ad80efedd1059cd40114bc5db199b45e623c2764e80f4a264f8c",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io-redis-adapter/mod.ts": "45d6b7f077f94fec385152bda7fda5ac3153c2ca3548cf4859891af673fa97cc",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/adapter.ts": "8f606f3fe57712221a73a6b01aa8bf1be39c4530ec8ebb8d2905d5313d4da9c4",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/broadcast-operator.ts": "d842eb933acc996a05ac701f6d83ffee49ee9c905c9adbdee70832776045bf63",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/client.ts": "b78e965dc3ab35d2fb9ccb859f4e1ce43d7c830aae9448d4958fa8ef9627eb4d",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/namespace.ts": "920f16545ec4220831b0aa2164e256915c7f4dec318d1527efeae1596b831fe3",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/parent-namespace.ts": "2d7f8a70498d161856aec522ae2f98727d58c5a9c252ad51a6ab5830b4fa9e2e",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/server.ts": "bd450bea5573bb6144a5eded1c03dda93cb3ed8c8c671a6de0261f97307d6c71",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/socket.ts": "7b88e37eabd31ce21039321325f51c955d9123133506acf3af692bf9337f081b",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/mod.ts": "dfd465bdcf23161af0c4d79fb8fc8912418c46a20d15e8b314cec6d9fb508196",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/test_deps.ts": "1f9dfa07a1e806ccddc9fa5f7255338d9dff67c40d7e83795f4f0f7bd710bde9",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/backoff.ts": "33e4a6e245f8743fbae0ce583993a671a3ac2ecee433a3e7f0bd77b5dd541d84",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/connection.ts": "c31d2e0cb360bc641e7286f1d53cf58790fbcda025c06887f84a821f39d0fdff",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/errors.ts": "bc8f7091cb9f36cdd31229660e0139350b02c26851e3ac69d592c066745feb27",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/executor.ts": "03e5f43df4e0c9c62b0e1be778811d45b6a1966ddf406e21ed5a227af70b7183",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/mod.ts": "20908f005f5c102525ce6aa9261648c95c5f61c6cf782b2cbb2fce88b1220f69",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/pipeline.ts": "80cc26a881149264d51dd019f1044c4ec9012399eca9f516057dc81c9b439370",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/protocol/_util.ts": "0525f7f444a96b92cd36423abdfe221f8d8de4a018dc5cb6750a428a5fc897c2",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/protocol/command.ts": "b1efd3b62fe5d1230e6d96b5c65ba7de1592a1eda2cc927161e5997a15f404ac",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/protocol/mod.ts": "f2601df31d8adc71785b5d19f6a7e43dfce94adbb6735c4dafc1fb129169d11a",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/protocol/reply.ts": "beac2061b03190bada179aef1a5d92b47a5104d9835e8c7468a55c24812ae9e4",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/pubsub.ts": "324b87dae0700e4cb350780ce3ae5bc02780f79f3de35e01366b894668b016c6",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/redis.ts": "a5c2cf8c72e7c92c9c8c6911f98227062649f6cba966938428c5414200f3aa54",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/stream.ts": "f116d73cfe04590ff9fa8a3c08be8ff85219d902ef2f6929b8c1e88d92a07810",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/vendor/https/deno.land/std/async/deferred.ts": "7391210927917113e04247ef013d800d54831f550e9a0b439244675c56058c55",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/vendor/https/deno.land/std/async/delay.ts": "c7e2604f7cb5ef00d595de8dd600604902d5be03a183b515b3d6d4bbb48e1700",
|
||||||
|
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/vendor/https/deno.land/std/io/buffer.ts": "8c5f84b7ecf71bc3e12aa299a9fae9e72e495db05281fcdd62006ecd3c5ed3f3"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@oak/oak@^17.1.3",
|
||||||
|
"jsr:@std/crypto@^1.0.3",
|
||||||
|
"jsr:@std/encoding@^1.0.5",
|
||||||
|
"jsr:@std/fs@^1.0.8",
|
||||||
|
"jsr:@std/log@~0.224.9",
|
||||||
|
"jsr:@std/path@^1.0.8",
|
||||||
|
"jsr:@std/uuid@^1.0.4",
|
||||||
|
"npm:lru-cache@11",
|
||||||
|
"npm:music-metadata@^7.14.0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/server-deno/domain/models/GameState.ts
Normal file
179
src/server-deno/domain/models/GameState.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import type { GamePhase, GameState, GameStatus, GuessResult, ID, TimelinePosition } from '../types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game state domain model
|
||||||
|
* Encapsulates all game state and provides methods to manipulate it
|
||||||
|
*/
|
||||||
|
export class GameStateModel implements GameState {
|
||||||
|
status: GameStatus;
|
||||||
|
phase: GamePhase;
|
||||||
|
turnOrder: ID[];
|
||||||
|
currentGuesser: ID | null;
|
||||||
|
currentTrack: any;
|
||||||
|
timeline: Record<ID, TimelinePosition[]>;
|
||||||
|
tokens: Record<ID, number>;
|
||||||
|
ready: Record<ID, boolean>;
|
||||||
|
spectators: Record<ID, boolean>;
|
||||||
|
lastResult: GuessResult | null;
|
||||||
|
trackStartAt: number | null;
|
||||||
|
paused: boolean;
|
||||||
|
pausedPosSec: number;
|
||||||
|
goal: number;
|
||||||
|
playlist: string | null;
|
||||||
|
titleArtistAwardedThisRound: Record<ID, boolean>;
|
||||||
|
|
||||||
|
constructor(goal = 10) {
|
||||||
|
this.status = 'lobby' as GameStatus;
|
||||||
|
this.phase = 'guess' as GamePhase;
|
||||||
|
this.turnOrder = [];
|
||||||
|
this.currentGuesser = null;
|
||||||
|
this.currentTrack = null;
|
||||||
|
this.timeline = {};
|
||||||
|
this.tokens = {};
|
||||||
|
this.ready = {};
|
||||||
|
this.spectators = {};
|
||||||
|
this.lastResult = null;
|
||||||
|
this.trackStartAt = null;
|
||||||
|
this.paused = false;
|
||||||
|
this.pausedPosSec = 0;
|
||||||
|
this.goal = goal;
|
||||||
|
this.playlist = null;
|
||||||
|
this.titleArtistAwardedThisRound = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize player in game state
|
||||||
|
*/
|
||||||
|
addPlayer(playerId: ID, isHost = false): void {
|
||||||
|
this.ready[playerId] = isHost; // Host is ready by default
|
||||||
|
this.timeline[playerId] = [];
|
||||||
|
this.tokens[playerId] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove player from game state
|
||||||
|
*/
|
||||||
|
removePlayer(playerId: ID): void {
|
||||||
|
delete this.ready[playerId];
|
||||||
|
delete this.timeline[playerId];
|
||||||
|
delete this.tokens[playerId];
|
||||||
|
delete this.spectators[playerId];
|
||||||
|
this.turnOrder = this.turnOrder.filter((id) => id !== playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set player ready status
|
||||||
|
*/
|
||||||
|
setReady(playerId: ID, ready: boolean): void {
|
||||||
|
this.ready[playerId] = ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all players are ready
|
||||||
|
*/
|
||||||
|
areAllReady(): boolean {
|
||||||
|
const playerIds = Object.keys(this.ready).filter((id) => !this.spectators[id]);
|
||||||
|
return playerIds.length > 0 && playerIds.every((id) => this.ready[id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the game
|
||||||
|
*/
|
||||||
|
startGame(playerIds: ID[]): void {
|
||||||
|
this.status = 'playing' as GameStatus;
|
||||||
|
this.turnOrder = this.shuffleArray([...playerIds.filter((id) => !this.spectators[id])]);
|
||||||
|
this.currentGuesser = this.turnOrder[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the game
|
||||||
|
*/
|
||||||
|
endGame(): void {
|
||||||
|
this.status = 'ended' as GameStatus;
|
||||||
|
this.currentTrack = null;
|
||||||
|
this.currentGuesser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move to next player's turn
|
||||||
|
*/
|
||||||
|
nextTurn(): ID | null {
|
||||||
|
if (!this.currentGuesser || this.turnOrder.length === 0) {
|
||||||
|
return this.turnOrder[0] || null;
|
||||||
|
}
|
||||||
|
const currentIndex = this.turnOrder.indexOf(this.currentGuesser);
|
||||||
|
const nextIndex = (currentIndex + 1) % this.turnOrder.length;
|
||||||
|
this.currentGuesser = this.turnOrder[nextIndex];
|
||||||
|
return this.currentGuesser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award tokens to a player
|
||||||
|
*/
|
||||||
|
awardTokens(playerId: ID, amount: number): void {
|
||||||
|
this.tokens[playerId] = (this.tokens[playerId] || 0) + amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add card to player's timeline
|
||||||
|
*/
|
||||||
|
addToTimeline(playerId: ID, year: number, position: number): void {
|
||||||
|
if (!this.timeline[playerId]) {
|
||||||
|
this.timeline[playerId] = [];
|
||||||
|
}
|
||||||
|
this.timeline[playerId].push({ year, position });
|
||||||
|
this.timeline[playerId].sort((a, b) => a.position - b.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player has won
|
||||||
|
*/
|
||||||
|
hasPlayerWon(playerId: ID): boolean {
|
||||||
|
return (this.timeline[playerId]?.length || 0) >= this.goal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get winner (if any)
|
||||||
|
*/
|
||||||
|
getWinner(): ID | null {
|
||||||
|
for (const playerId of Object.keys(this.timeline)) {
|
||||||
|
if (this.hasPlayerWon(playerId)) {
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set spectator status
|
||||||
|
*/
|
||||||
|
setSpectator(playerId: ID, spectator: boolean): void {
|
||||||
|
this.spectators[playerId] = spectator;
|
||||||
|
if (spectator) {
|
||||||
|
// Remove from turn order if spectating
|
||||||
|
this.turnOrder = this.turnOrder.filter((id) => id !== playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset round state (after track is complete)
|
||||||
|
* Note: currentTrack and trackStartAt are set separately by the caller
|
||||||
|
*/
|
||||||
|
resetRound(): void {
|
||||||
|
this.phase = 'guess' as GamePhase;
|
||||||
|
this.lastResult = null;
|
||||||
|
this.titleArtistAwardedThisRound = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility: Shuffle array
|
||||||
|
*/
|
||||||
|
private shuffleArray<T>(array: T[]): T[] {
|
||||||
|
const shuffled = [...array];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/server-deno/domain/models/Player.ts
Normal file
79
src/server-deno/domain/models/Player.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ID, Player } from '../types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player domain model
|
||||||
|
* Represents a player in the game with their connection state
|
||||||
|
*/
|
||||||
|
export class PlayerModel implements Player {
|
||||||
|
id: ID;
|
||||||
|
sessionId: ID;
|
||||||
|
name: string;
|
||||||
|
connected: boolean;
|
||||||
|
roomId: ID | null;
|
||||||
|
spectator?: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: ID,
|
||||||
|
sessionId: ID,
|
||||||
|
name?: string,
|
||||||
|
connected = true,
|
||||||
|
roomId: ID | null = null,
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.name = name || `Player-${id.slice(0, 4)}`;
|
||||||
|
this.connected = connected;
|
||||||
|
this.roomId = roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update player's connection status
|
||||||
|
*/
|
||||||
|
setConnected(connected: boolean): void {
|
||||||
|
this.connected = connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update player's name
|
||||||
|
*/
|
||||||
|
setName(name: string): void {
|
||||||
|
if (name && name.trim().length > 0) {
|
||||||
|
this.name = name.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join a room
|
||||||
|
*/
|
||||||
|
joinRoom(roomId: ID): void {
|
||||||
|
this.roomId = roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave current room
|
||||||
|
*/
|
||||||
|
leaveRoom(): void {
|
||||||
|
this.roomId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle spectator mode
|
||||||
|
*/
|
||||||
|
setSpectator(spectator: boolean): void {
|
||||||
|
this.spectator = spectator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a safe representation for serialization
|
||||||
|
*/
|
||||||
|
toJSON(): Player {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
name: this.name,
|
||||||
|
connected: this.connected,
|
||||||
|
roomId: this.roomId,
|
||||||
|
spectator: this.spectator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/server-deno/domain/models/Room.ts
Normal file
143
src/server-deno/domain/models/Room.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import type { ID, Player, Room, Track } from '../types.ts';
|
||||||
|
import { GameStateModel } from './GameState.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room domain model
|
||||||
|
* Represents a game room with players and game state
|
||||||
|
*/
|
||||||
|
export class RoomModel implements Room {
|
||||||
|
id: ID;
|
||||||
|
name: string;
|
||||||
|
hostId: ID;
|
||||||
|
players: Map<ID, Player>;
|
||||||
|
deck: Track[];
|
||||||
|
discard: Track[];
|
||||||
|
state: GameStateModel;
|
||||||
|
|
||||||
|
constructor(id: ID, name: string, host: Player, goal = 10) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name || `Room ${id}`;
|
||||||
|
this.hostId = host.id;
|
||||||
|
this.players = new Map([[host.id, host]]);
|
||||||
|
this.deck = [];
|
||||||
|
this.discard = [];
|
||||||
|
this.state = new GameStateModel(goal);
|
||||||
|
|
||||||
|
// Initialize host in game state (not ready by default)
|
||||||
|
this.state.addPlayer(host.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a player to the room
|
||||||
|
*/
|
||||||
|
addPlayer(player: Player): boolean {
|
||||||
|
if (this.players.has(player.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.players.set(player.id, player);
|
||||||
|
this.state.addPlayer(player.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a player from the room
|
||||||
|
*/
|
||||||
|
removePlayer(playerId: ID): boolean {
|
||||||
|
const removed = this.players.delete(playerId);
|
||||||
|
if (removed) {
|
||||||
|
this.state.removePlayer(playerId);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get player by ID
|
||||||
|
*/
|
||||||
|
getPlayer(playerId: ID): Player | undefined {
|
||||||
|
return this.players.get(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player is in room
|
||||||
|
*/
|
||||||
|
hasPlayer(playerId: ID): boolean {
|
||||||
|
return this.players.has(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player is host
|
||||||
|
*/
|
||||||
|
isHost(playerId: ID): boolean {
|
||||||
|
return this.hostId === playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer host to another player
|
||||||
|
*/
|
||||||
|
transferHost(newHostId: ID): boolean {
|
||||||
|
if (!this.players.has(newHostId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.hostId = newHostId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all connected players
|
||||||
|
*/
|
||||||
|
getConnectedPlayers(): Player[] {
|
||||||
|
return Array.from(this.players.values()).filter((p) => p.connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all players (including disconnected)
|
||||||
|
*/
|
||||||
|
getAllPlayers(): Player[] {
|
||||||
|
return Array.from(this.players.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set deck of tracks
|
||||||
|
*/
|
||||||
|
setDeck(tracks: Track[]): void {
|
||||||
|
this.deck = [...tracks];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw next track from deck
|
||||||
|
*/
|
||||||
|
drawTrack(): Track | null {
|
||||||
|
const track = this.deck.shift();
|
||||||
|
if (track) {
|
||||||
|
this.discard.push(track);
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if deck is empty
|
||||||
|
*/
|
||||||
|
isDeckEmpty(): boolean {
|
||||||
|
return this.deck.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get room summary for serialization
|
||||||
|
*/
|
||||||
|
toSummary() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
hostId: this.hostId,
|
||||||
|
players: this.getAllPlayers().map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
connected: p.connected,
|
||||||
|
ready: this.state.ready[p.id] || false,
|
||||||
|
spectator: this.state.spectators[p.id] || false,
|
||||||
|
})),
|
||||||
|
state: this.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/server-deno/domain/models/mod.ts
Normal file
6
src/server-deno/domain/models/mod.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Domain model exports
|
||||||
|
*/
|
||||||
|
export { PlayerModel } from './Player.ts';
|
||||||
|
export { GameStateModel } from './GameState.ts';
|
||||||
|
export { RoomModel } from './Room.ts';
|
||||||
150
src/server-deno/domain/types.ts
Normal file
150
src/server-deno/domain/types.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Core domain types for the Hitstar game
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique identifier type
|
||||||
|
*/
|
||||||
|
export type ID = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game status enum
|
||||||
|
*/
|
||||||
|
export enum GameStatus {
|
||||||
|
LOBBY = 'lobby',
|
||||||
|
PLAYING = 'playing',
|
||||||
|
ENDED = 'ended',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game phase during active play
|
||||||
|
*/
|
||||||
|
export enum GamePhase {
|
||||||
|
GUESS = 'guess',
|
||||||
|
REVEAL = 'reveal',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track metadata
|
||||||
|
*/
|
||||||
|
export interface Track {
|
||||||
|
id: string;
|
||||||
|
file: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
year: number | null;
|
||||||
|
url?: string; // Token-based streaming URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player in a room
|
||||||
|
*/
|
||||||
|
export interface Player {
|
||||||
|
id: ID;
|
||||||
|
sessionId: ID;
|
||||||
|
name: string;
|
||||||
|
connected: boolean;
|
||||||
|
roomId: ID | null;
|
||||||
|
spectator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player timeline position (for card placement)
|
||||||
|
*/
|
||||||
|
export interface TimelinePosition {
|
||||||
|
trackId: string;
|
||||||
|
year: number | null;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game state
|
||||||
|
*/
|
||||||
|
export interface GameState {
|
||||||
|
status: GameStatus;
|
||||||
|
phase: GamePhase;
|
||||||
|
turnOrder: ID[]; // Player IDs in turn order
|
||||||
|
currentGuesser: ID | null;
|
||||||
|
currentTrack: Track | null;
|
||||||
|
timeline: Record<ID, TimelinePosition[]>; // Player ID -> their timeline cards
|
||||||
|
tokens: Record<ID, number>; // Player ID -> token/coin count
|
||||||
|
ready: Record<ID, boolean>; // Player ID -> ready status in lobby
|
||||||
|
spectators: Record<ID, boolean>; // Player ID -> spectator flag
|
||||||
|
lastResult: GuessResult | null;
|
||||||
|
trackStartAt: number | null; // Timestamp when track started playing
|
||||||
|
paused: boolean;
|
||||||
|
pausedPosSec: number;
|
||||||
|
goal: number; // Win condition (e.g., 10 cards)
|
||||||
|
playlist: string | null; // Selected playlist ID
|
||||||
|
titleArtistAwardedThisRound: Record<ID, boolean>; // Track if title+artist token awarded this round
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room containing players and game state
|
||||||
|
*/
|
||||||
|
export interface Room {
|
||||||
|
id: ID;
|
||||||
|
name: string;
|
||||||
|
hostId: ID;
|
||||||
|
players: Map<ID, Player>;
|
||||||
|
deck: Track[]; // Unplayed tracks
|
||||||
|
discard: Track[]; // Played tracks
|
||||||
|
state: GameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a guess attempt
|
||||||
|
*/
|
||||||
|
export interface GuessResult {
|
||||||
|
playerId: ID;
|
||||||
|
playerName?: string;
|
||||||
|
guess?: string | null;
|
||||||
|
correct: boolean;
|
||||||
|
type: 'title' | 'artist' | 'year' | 'placement';
|
||||||
|
score?: number; // Similarity score for partial matches
|
||||||
|
answer?: string; // The correct answer
|
||||||
|
tokensUsed?: boolean; // Whether tokens were used for guaranteed placement
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist metadata
|
||||||
|
*/
|
||||||
|
export interface Playlist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
trackCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Years index structure (loaded from years.json)
|
||||||
|
*/
|
||||||
|
export interface YearsIndex {
|
||||||
|
byFile: Record<string, YearMetadata>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for a single track in years.json
|
||||||
|
*/
|
||||||
|
export interface YearMetadata {
|
||||||
|
year: number | null;
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio token metadata
|
||||||
|
*/
|
||||||
|
export interface AudioToken {
|
||||||
|
path: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover art data
|
||||||
|
*/
|
||||||
|
export interface CoverArt {
|
||||||
|
mime: string;
|
||||||
|
buf: Uint8Array;
|
||||||
|
}
|
||||||
218
src/server-deno/infrastructure/AudioStreamingService.ts
Normal file
218
src/server-deno/infrastructure/AudioStreamingService.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { join } from '@std/path';
|
||||||
|
import type { Context } from '@oak/oak';
|
||||||
|
import { FileSystemService } from './FileSystemService.ts';
|
||||||
|
import { TokenStoreService } from './TokenStoreService.ts';
|
||||||
|
import { getMimeType } from './MimeTypeService.ts';
|
||||||
|
import { PREFERRED_EXTENSION } from '../shared/constants.ts';
|
||||||
|
import { NotFoundError, ValidationError } from '../shared/errors.ts';
|
||||||
|
import { logger } from '../shared/logger.ts';
|
||||||
|
import type { AudioToken } from '../domain/types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio streaming service for serving audio files
|
||||||
|
*/
|
||||||
|
export class AudioStreamingService {
|
||||||
|
constructor(
|
||||||
|
private readonly fileSystem: FileSystemService,
|
||||||
|
private readonly tokenStore: TokenStoreService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a streaming token for an audio file
|
||||||
|
*/
|
||||||
|
async createAudioToken(fileName: string, ttlMs?: number): Promise<string> {
|
||||||
|
const resolved = this.fileSystem.resolveSafePath(fileName);
|
||||||
|
|
||||||
|
if (!await this.fileSystem.fileExists(resolved)) {
|
||||||
|
throw new NotFoundError('Audio file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer .opus sibling for streaming (better compression)
|
||||||
|
let finalPath = resolved;
|
||||||
|
const ext = resolved.slice(resolved.lastIndexOf('.')).toLowerCase();
|
||||||
|
|
||||||
|
if (ext !== PREFERRED_EXTENSION) {
|
||||||
|
const opusCandidate = resolved.slice(0, -ext.length) + PREFERRED_EXTENSION;
|
||||||
|
if (await this.fileSystem.fileExists(opusCandidate)) {
|
||||||
|
finalPath = opusCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
const stat = await this.fileSystem.statFile(finalPath);
|
||||||
|
const mime = getMimeType(finalPath, 'audio/mpeg');
|
||||||
|
|
||||||
|
const tokenData: AudioToken = {
|
||||||
|
path: finalPath,
|
||||||
|
mime,
|
||||||
|
size: stat.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.tokenStore.putToken(tokenData, ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle HEAD request for audio streaming
|
||||||
|
*/
|
||||||
|
async handleHeadRequest(ctx: Context, token: string): Promise<void> {
|
||||||
|
const tokenData = this.tokenStore.getToken(token);
|
||||||
|
|
||||||
|
if (!tokenData) {
|
||||||
|
throw new NotFoundError('Token not found or expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setCommonHeaders(ctx);
|
||||||
|
ctx.response.headers.set('Content-Type', tokenData.mime);
|
||||||
|
ctx.response.headers.set('Content-Length', String(tokenData.size));
|
||||||
|
ctx.response.status = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET request for audio streaming with range support
|
||||||
|
*/
|
||||||
|
async handleStreamRequest(ctx: Context, token: string): Promise<void> {
|
||||||
|
const tokenData = this.tokenStore.getToken(token);
|
||||||
|
|
||||||
|
if (!tokenData) {
|
||||||
|
throw new NotFoundError('Token not found or expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path: filePath, mime, size } = tokenData;
|
||||||
|
const rangeHeader = ctx.request.headers.get('range');
|
||||||
|
|
||||||
|
this.setCommonHeaders(ctx);
|
||||||
|
|
||||||
|
if (rangeHeader) {
|
||||||
|
await this.streamRange(ctx, filePath, size, mime, rangeHeader);
|
||||||
|
} else {
|
||||||
|
await this.streamFull(ctx, filePath, size, mime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream full file
|
||||||
|
*/
|
||||||
|
private async streamFull(
|
||||||
|
ctx: Context,
|
||||||
|
filePath: string,
|
||||||
|
size: number,
|
||||||
|
mime: string,
|
||||||
|
): Promise<void> {
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.headers.set('Content-Type', mime);
|
||||||
|
ctx.response.headers.set('Content-Length', String(size));
|
||||||
|
|
||||||
|
const file = await Deno.open(filePath, { read: true });
|
||||||
|
ctx.response.body = file.readable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream file range (for seeking/partial content)
|
||||||
|
*/
|
||||||
|
private async streamRange(
|
||||||
|
ctx: Context,
|
||||||
|
filePath: string,
|
||||||
|
fileSize: number,
|
||||||
|
mime: string,
|
||||||
|
rangeHeader: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const match = /bytes=(\d+)-(\d+)?/.exec(rangeHeader);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new ValidationError('Invalid range header');
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = parseInt(match[1], 10);
|
||||||
|
let end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
|
||||||
|
|
||||||
|
// Validate and clamp range
|
||||||
|
if (isNaN(start)) start = 0;
|
||||||
|
if (isNaN(end)) end = fileSize - 1;
|
||||||
|
|
||||||
|
start = Math.min(Math.max(0, start), Math.max(0, fileSize - 1));
|
||||||
|
end = Math.min(Math.max(start, end), Math.max(0, fileSize - 1));
|
||||||
|
|
||||||
|
if (start > end || start >= fileSize) {
|
||||||
|
ctx.response.status = 416; // Range Not Satisfiable
|
||||||
|
ctx.response.headers.set('Content-Range', `bytes */${fileSize}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkSize = end - start + 1;
|
||||||
|
|
||||||
|
ctx.response.status = 206; // Partial Content
|
||||||
|
ctx.response.headers.set('Content-Type', mime);
|
||||||
|
ctx.response.headers.set('Content-Length', String(chunkSize));
|
||||||
|
ctx.response.headers.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||||
|
|
||||||
|
// Open file and seek to start position
|
||||||
|
const file = await Deno.open(filePath, { read: true });
|
||||||
|
|
||||||
|
// Create a readable stream for the range
|
||||||
|
const reader = file.readable.getReader();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
try {
|
||||||
|
let bytesRead = 0;
|
||||||
|
let totalSkipped = 0;
|
||||||
|
|
||||||
|
while (totalSkipped < start) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const remaining = start - totalSkipped;
|
||||||
|
if (value.length <= remaining) {
|
||||||
|
totalSkipped += value.length;
|
||||||
|
} else {
|
||||||
|
// Partial skip - send remainder
|
||||||
|
const chunk = value.slice(remaining);
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
totalSkipped = start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the requested range
|
||||||
|
while (bytesRead < chunkSize) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const remaining = chunkSize - bytesRead;
|
||||||
|
if (value.length <= remaining) {
|
||||||
|
controller.enqueue(value);
|
||||||
|
bytesRead += value.length;
|
||||||
|
} else {
|
||||||
|
controller.enqueue(value.slice(0, remaining));
|
||||||
|
bytesRead += remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.close();
|
||||||
|
} catch (error) {
|
||||||
|
controller.error(error);
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.response.body = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set common caching headers
|
||||||
|
*/
|
||||||
|
private setCommonHeaders(ctx: Context): void {
|
||||||
|
// Allow caching of audio content to prevent redundant range requests
|
||||||
|
// The token system already provides security
|
||||||
|
ctx.response.headers.set('Cache-Control', 'public, max-age=3600');
|
||||||
|
ctx.response.headers.set('Accept-Ranges', 'bytes');
|
||||||
|
|
||||||
|
// iOS Safari specific headers to improve streaming
|
||||||
|
ctx.response.headers.set('Access-Control-Allow-Origin', '*');
|
||||||
|
ctx.response.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||||
|
ctx.response.headers.set('Access-Control-Allow-Headers', 'Range');
|
||||||
|
ctx.response.headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges');
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/server-deno/infrastructure/CoverArtService.ts
Normal file
70
src/server-deno/infrastructure/CoverArtService.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import { parseFile } from 'music-metadata';
|
||||||
|
import type { CoverArt } from '../domain/types.ts';
|
||||||
|
import { COVER_CACHE_MAX_ITEMS, COVER_CACHE_MAX_BYTES } from '../shared/constants.ts';
|
||||||
|
import { logger } from '../shared/logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover art caching service
|
||||||
|
*/
|
||||||
|
export class CoverArtService {
|
||||||
|
private readonly coverCache: LRUCache<string, CoverArt>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.coverCache = new LRUCache<string, CoverArt>({
|
||||||
|
max: COVER_CACHE_MAX_ITEMS,
|
||||||
|
maxSize: COVER_CACHE_MAX_BYTES,
|
||||||
|
sizeCalculation: (value) => value.buf.length,
|
||||||
|
ttl: 5 * 60 * 1000, // 5 minutes
|
||||||
|
ttlAutopurge: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cover art from audio file
|
||||||
|
*/
|
||||||
|
async getCoverArt(filePath: string): Promise<CoverArt | null> {
|
||||||
|
// Check cache first
|
||||||
|
const cached = this.coverCache.get(filePath);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse metadata from audio file
|
||||||
|
const metadata = await parseFile(filePath, { duration: false });
|
||||||
|
const picture = metadata.common?.picture?.[0];
|
||||||
|
|
||||||
|
if (!picture?.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverArt: CoverArt = {
|
||||||
|
mime: picture.format || 'image/jpeg',
|
||||||
|
buf: new Uint8Array(picture.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
this.coverCache.set(filePath, coverArt);
|
||||||
|
|
||||||
|
return coverArt;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to extract cover art from ${filePath}: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache
|
||||||
|
*/
|
||||||
|
clearCache(): void {
|
||||||
|
this.coverCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache size
|
||||||
|
*/
|
||||||
|
getCacheSize(): number {
|
||||||
|
return this.coverCache.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/server-deno/infrastructure/FileSystemService.ts
Normal file
186
src/server-deno/infrastructure/FileSystemService.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { join, resolve, normalize } from '@std/path';
|
||||||
|
import { exists } from '@std/fs';
|
||||||
|
import type { AppConfig } from '../shared/config.ts';
|
||||||
|
import { NotFoundError, ValidationError } from '../shared/errors.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File system service for managing file paths and operations
|
||||||
|
*/
|
||||||
|
export class FileSystemService {
|
||||||
|
private readonly dataDir: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: AppConfig) {
|
||||||
|
this.dataDir = resolve(config.dataDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a safe path within the data directory (prevent path traversal)
|
||||||
|
*/
|
||||||
|
resolveSafePath(name: string): string {
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
throw new ValidationError('Invalid path name');
|
||||||
|
}
|
||||||
|
|
||||||
|
const joined = join(this.dataDir, name);
|
||||||
|
const resolved = resolve(joined);
|
||||||
|
|
||||||
|
// Normalize both paths for consistent comparison
|
||||||
|
const normalizedDataDir = normalize(this.dataDir);
|
||||||
|
const normalizedResolved = normalize(resolved);
|
||||||
|
|
||||||
|
// Ensure resolved path is within data directory
|
||||||
|
// Check if paths are equal or if resolved starts with dataDir
|
||||||
|
if (normalizedResolved === normalizedDataDir) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the resolved path is a subdirectory of dataDir
|
||||||
|
// Add separator to prevent partial directory name matches (e.g., /data vs /data2)
|
||||||
|
const dataDirWithSep = normalizedDataDir + (normalizedDataDir.endsWith('\\') || normalizedDataDir.endsWith('/') ? '' : '\\');
|
||||||
|
const dataDirWithSepAlt = normalizedDataDir + (normalizedDataDir.endsWith('\\') || normalizedDataDir.endsWith('/') ? '' : '/');
|
||||||
|
|
||||||
|
if (normalizedResolved.startsWith(dataDirWithSep) || normalizedResolved.startsWith(dataDirWithSepAlt)) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ValidationError('Path traversal detected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file exists
|
||||||
|
*/
|
||||||
|
async fileExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await exists(path);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file stat information
|
||||||
|
*/
|
||||||
|
async statFile(path: string): Promise<Deno.FileInfo> {
|
||||||
|
try {
|
||||||
|
return await Deno.stat(path);
|
||||||
|
} catch (error) {
|
||||||
|
throw new NotFoundError(`File not found: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file as text
|
||||||
|
*/
|
||||||
|
async readTextFile(path: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await Deno.readTextFile(path);
|
||||||
|
} catch (error) {
|
||||||
|
throw new NotFoundError(`Cannot read file: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file as JSON
|
||||||
|
*/
|
||||||
|
async readJsonFile<T>(path: string, fallback?: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
const text = await this.readTextFile(path);
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
if (fallback !== undefined) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
throw new NotFoundError(`Cannot read JSON file: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write text to file
|
||||||
|
*/
|
||||||
|
async writeTextFile(path: string, content: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await Deno.writeTextFile(path, content);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Cannot write file: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write JSON to file
|
||||||
|
*/
|
||||||
|
async writeJsonFile(path: string, data: unknown): Promise<void> {
|
||||||
|
const content = JSON.stringify(data, null, 2);
|
||||||
|
await this.writeTextFile(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files in directory
|
||||||
|
*/
|
||||||
|
async listFiles(dirPath: string, pattern?: RegExp): Promise<string[]> {
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const entry of Deno.readDir(dirPath)) {
|
||||||
|
if (entry.isFile) {
|
||||||
|
if (!pattern || pattern.test(entry.name)) {
|
||||||
|
files.push(entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new NotFoundError(`Cannot list directory: ${dirPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List subdirectories
|
||||||
|
*/
|
||||||
|
async listDirectories(dirPath: string): Promise<string[]> {
|
||||||
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const entry of Deno.readDir(dirPath)) {
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
dirs.push(entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new NotFoundError(`Cannot list directory: ${dirPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playlist directory path
|
||||||
|
*/
|
||||||
|
getPlaylistDir(playlistId: string = 'default'): string {
|
||||||
|
return playlistId === 'default'
|
||||||
|
? this.dataDir
|
||||||
|
: join(this.dataDir, playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get years.json path for playlist
|
||||||
|
*/
|
||||||
|
getYearsPath(playlistId: string = 'default'): string {
|
||||||
|
const dir = this.getPlaylistDir(playlistId);
|
||||||
|
return join(dir, 'years.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data directory
|
||||||
|
*/
|
||||||
|
getDataDir(): string {
|
||||||
|
return this.dataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public directory
|
||||||
|
*/
|
||||||
|
getPublicDir(): string {
|
||||||
|
return resolve(this.config.publicDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/server-deno/infrastructure/MetadataService.ts
Normal file
157
src/server-deno/infrastructure/MetadataService.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { parseFile } from 'music-metadata';
|
||||||
|
import { join } from '@std/path';
|
||||||
|
import type { Track, YearMetadata, YearsIndex } from '../domain/types.ts';
|
||||||
|
import { FileSystemService } from './FileSystemService.ts';
|
||||||
|
import { AUDIO_EXTENSIONS, PREFERRED_EXTENSION, BATCH_SIZE } from '../shared/constants.ts';
|
||||||
|
import { logger } from '../shared/logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata service for parsing audio file metadata
|
||||||
|
*/
|
||||||
|
export class MetadataService {
|
||||||
|
constructor(private readonly fileSystem: FileSystemService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load years index from years.json
|
||||||
|
*/
|
||||||
|
async loadYearsIndex(playlistId: string = 'default'): Promise<Record<string, YearMetadata>> {
|
||||||
|
const yearsPath = this.fileSystem.getYearsPath(playlistId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const yearsData = await this.fileSystem.readJsonFile<YearsIndex>(yearsPath);
|
||||||
|
|
||||||
|
if (yearsData && yearsData.byFile && typeof yearsData.byFile === 'object') {
|
||||||
|
return yearsData.byFile;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`No years.json found for playlist '${playlistId}' at ${yearsPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse audio file metadata
|
||||||
|
*/
|
||||||
|
async parseAudioMetadata(filePath: string): Promise<{
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const metadata = await parseFile(filePath, { duration: false });
|
||||||
|
return {
|
||||||
|
title: metadata.common.title,
|
||||||
|
artist: metadata.common.artist,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to parse metadata from ${filePath}: ${error}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tracks from a playlist directory
|
||||||
|
*/
|
||||||
|
async loadTracksFromPlaylist(playlistId: string = 'default'): Promise<Track[]> {
|
||||||
|
const yearsIndex = await this.loadYearsIndex(playlistId);
|
||||||
|
const playlistDir = this.fileSystem.getPlaylistDir(playlistId);
|
||||||
|
|
||||||
|
// Check if directory exists
|
||||||
|
if (!await this.fileSystem.fileExists(playlistDir)) {
|
||||||
|
logger.error(`Playlist directory not found: ${playlistDir}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get audio files
|
||||||
|
const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i');
|
||||||
|
const files = await this.fileSystem.listFiles(playlistDir, audioPattern);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
logger.warn(`No audio files found in playlist: ${playlistId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate files (prefer .opus)
|
||||||
|
const uniqueFiles = this.deduplicateFiles(files);
|
||||||
|
|
||||||
|
// Process in batches to avoid "too many open files" error
|
||||||
|
const tracks: Track[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueFiles.length; i += BATCH_SIZE) {
|
||||||
|
const batch = uniqueFiles.slice(i, i + BATCH_SIZE);
|
||||||
|
const batchTracks = await Promise.all(
|
||||||
|
batch.map((fileName) => this.createTrackFromFile(fileName, playlistId, playlistDir, yearsIndex))
|
||||||
|
);
|
||||||
|
tracks.push(...batchTracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create track object from file
|
||||||
|
*/
|
||||||
|
private async createTrackFromFile(
|
||||||
|
fileName: string,
|
||||||
|
playlistId: string,
|
||||||
|
playlistDir: string,
|
||||||
|
yearsIndex: Record<string, YearMetadata>
|
||||||
|
): Promise<Track> {
|
||||||
|
const filePath = join(playlistDir, fileName);
|
||||||
|
|
||||||
|
// For file references, include playlist subdirectory if not default
|
||||||
|
const relativeFile = playlistId === 'default' ? fileName : join(playlistId, fileName);
|
||||||
|
|
||||||
|
// Get metadata from years.json first (priority)
|
||||||
|
const jsonMeta = yearsIndex[fileName];
|
||||||
|
let year = jsonMeta?.year ?? null;
|
||||||
|
let title = jsonMeta?.title ?? this.getFileNameWithoutExt(fileName);
|
||||||
|
let artist = jsonMeta?.artist ?? '';
|
||||||
|
|
||||||
|
// Parse audio file metadata if JSON doesn't have title or artist
|
||||||
|
if (!jsonMeta || !jsonMeta.title || !jsonMeta.artist) {
|
||||||
|
const audioMeta = await this.parseAudioMetadata(filePath);
|
||||||
|
title = jsonMeta?.title || audioMeta.title || title;
|
||||||
|
artist = jsonMeta?.artist || audioMeta.artist || artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: relativeFile,
|
||||||
|
file: relativeFile,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
year,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate files, preferring .opus versions
|
||||||
|
*/
|
||||||
|
private deduplicateFiles(files: string[]): string[] {
|
||||||
|
const fileMap = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const baseName = this.getFileNameWithoutExt(file);
|
||||||
|
const ext = file.slice(file.lastIndexOf('.')).toLowerCase();
|
||||||
|
|
||||||
|
const existing = fileMap.get(baseName);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
fileMap.set(baseName, file);
|
||||||
|
} else if (ext === PREFERRED_EXTENSION) {
|
||||||
|
// Prefer .opus version
|
||||||
|
fileMap.set(baseName, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(fileMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filename without extension
|
||||||
|
*/
|
||||||
|
private getFileNameWithoutExt(fileName: string): string {
|
||||||
|
const lastDot = fileName.lastIndexOf('.');
|
||||||
|
return lastDot > 0 ? fileName.slice(0, lastDot) : fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/server-deno/infrastructure/MimeTypeService.ts
Normal file
40
src/server-deno/infrastructure/MimeTypeService.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* MIME type utility for determining content types
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.m4a': 'audio/mp4',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.opus': 'audio/ogg', // Opus in Ogg container
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type from file extension
|
||||||
|
*/
|
||||||
|
export function getMimeType(filePath: string, fallback = 'application/octet-stream'): string {
|
||||||
|
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
|
||||||
|
return MIME_TYPES[ext] || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is an audio file
|
||||||
|
*/
|
||||||
|
export function isAudioFile(filePath: string): boolean {
|
||||||
|
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
|
||||||
|
return ['.mp3', '.wav', '.m4a', '.ogg', '.opus'].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is an image file
|
||||||
|
*/
|
||||||
|
export function isImageFile(filePath: string): boolean {
|
||||||
|
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
|
||||||
|
return ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
||||||
|
}
|
||||||
72
src/server-deno/infrastructure/TokenStoreService.ts
Normal file
72
src/server-deno/infrastructure/TokenStoreService.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { encodeHex } from '@std/encoding/hex';
|
||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import type { AudioToken } from '../domain/types.ts';
|
||||||
|
import { TOKEN_CACHE_MAX_ITEMS, TOKEN_TTL_MS } from '../shared/constants.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token store service for managing short-lived audio streaming tokens
|
||||||
|
*/
|
||||||
|
export class TokenStoreService {
|
||||||
|
private readonly tokenCache: LRUCache<string, AudioToken>;
|
||||||
|
|
||||||
|
constructor(ttlMs: number = TOKEN_TTL_MS) {
|
||||||
|
this.tokenCache = new LRUCache<string, AudioToken>({
|
||||||
|
max: TOKEN_CACHE_MAX_ITEMS,
|
||||||
|
ttl: ttlMs,
|
||||||
|
ttlAutopurge: true,
|
||||||
|
allowStale: false,
|
||||||
|
updateAgeOnGet: false,
|
||||||
|
updateAgeOnHas: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random token
|
||||||
|
*/
|
||||||
|
private generateToken(): string {
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return encodeHex(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and store a new token
|
||||||
|
*/
|
||||||
|
putToken(value: AudioToken, ttlMs?: number): string {
|
||||||
|
const token = this.generateToken();
|
||||||
|
|
||||||
|
const options = ttlMs ? { ttl: Math.max(1000, ttlMs) } : undefined;
|
||||||
|
this.tokenCache.set(token, value, options);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve token data
|
||||||
|
*/
|
||||||
|
getToken(token: string): AudioToken | undefined {
|
||||||
|
if (!token) return undefined;
|
||||||
|
return this.tokenCache.get(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a token
|
||||||
|
*/
|
||||||
|
deleteToken(token: string): boolean {
|
||||||
|
return this.tokenCache.delete(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all tokens
|
||||||
|
*/
|
||||||
|
clearAll(): void {
|
||||||
|
this.tokenCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache size
|
||||||
|
*/
|
||||||
|
getSize(): number {
|
||||||
|
return this.tokenCache.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/server-deno/infrastructure/mod.ts
Normal file
9
src/server-deno/infrastructure/mod.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Infrastructure layer exports
|
||||||
|
*/
|
||||||
|
export { FileSystemService } from './FileSystemService.ts';
|
||||||
|
export { TokenStoreService } from './TokenStoreService.ts';
|
||||||
|
export { CoverArtService } from './CoverArtService.ts';
|
||||||
|
export { MetadataService } from './MetadataService.ts';
|
||||||
|
export { AudioStreamingService } from './AudioStreamingService.ts';
|
||||||
|
export { getMimeType, isAudioFile, isImageFile } from './MimeTypeService.ts';
|
||||||
210
src/server-deno/main.ts
Normal file
210
src/server-deno/main.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env -S deno run --allow-net --allow-read --allow-env --allow-write
|
||||||
|
|
||||||
|
import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
|
||||||
|
import { serveDir } from 'https://deno.land/std@0.224.0/http/file_server.ts';
|
||||||
|
import { loadConfig } from './shared/config.ts';
|
||||||
|
import { initLogger, logger } from './shared/logger.ts';
|
||||||
|
|
||||||
|
// Infrastructure
|
||||||
|
import {
|
||||||
|
AudioStreamingService,
|
||||||
|
CoverArtService,
|
||||||
|
FileSystemService,
|
||||||
|
MetadataService,
|
||||||
|
TokenStoreService,
|
||||||
|
} from './infrastructure/mod.ts';
|
||||||
|
|
||||||
|
// Application
|
||||||
|
import {
|
||||||
|
AnswerCheckService,
|
||||||
|
GameService,
|
||||||
|
RoomService,
|
||||||
|
TrackService,
|
||||||
|
} from './application/mod.ts';
|
||||||
|
|
||||||
|
// Presentation
|
||||||
|
import { WebSocketServer } from './presentation/WebSocketServer.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application entry point
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
// Load configuration
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
initLogger(config.logLevel);
|
||||||
|
|
||||||
|
logger.info('Starting Hitstar Server (Deno 2 + TypeScript)');
|
||||||
|
logger.info(`Port: ${config.port}`);
|
||||||
|
logger.info(`Data directory: ${config.dataDir}`);
|
||||||
|
logger.info(`Public directory: ${config.publicDir}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize infrastructure services
|
||||||
|
const fileSystem = new FileSystemService(config);
|
||||||
|
const tokenStore = new TokenStoreService(config.tokenTtlMs);
|
||||||
|
const coverArt = new CoverArtService();
|
||||||
|
const metadata = new MetadataService(fileSystem);
|
||||||
|
const audioStreaming = new AudioStreamingService(fileSystem, tokenStore);
|
||||||
|
|
||||||
|
logger.info('Infrastructure services initialized');
|
||||||
|
|
||||||
|
// Initialize application services
|
||||||
|
const answerCheck = new AnswerCheckService();
|
||||||
|
const trackService = new TrackService(fileSystem, metadata);
|
||||||
|
const roomService = new RoomService();
|
||||||
|
const gameService = new GameService(trackService, audioStreaming, answerCheck);
|
||||||
|
|
||||||
|
logger.info('Application services initialized');
|
||||||
|
|
||||||
|
// Preload playlists in the background (non-blocking)
|
||||||
|
// This ensures fast game starts by caching track metadata
|
||||||
|
trackService.preloadAllPlaylists();
|
||||||
|
|
||||||
|
// Initialize WebSocket server
|
||||||
|
const wsServer = new WebSocketServer(roomService, gameService);
|
||||||
|
wsServer.initialize(config.corsOrigin);
|
||||||
|
|
||||||
|
// Create combined handler
|
||||||
|
const handler = async (request: Request, info: Deno.ServeHandlerInfo): Promise<Response> => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Socket.IO requests
|
||||||
|
if (url.pathname.startsWith('/socket.io/')) {
|
||||||
|
// Convert ServeHandlerInfo to ConnInfo format for Socket.IO compatibility
|
||||||
|
// Socket.IO 0.2.0 expects old ConnInfo format with localAddr and remoteAddr
|
||||||
|
const connInfo = {
|
||||||
|
localAddr: {
|
||||||
|
transport: 'tcp' as const,
|
||||||
|
hostname: config.host,
|
||||||
|
port: config.port
|
||||||
|
},
|
||||||
|
remoteAddr: info.remoteAddr,
|
||||||
|
};
|
||||||
|
return await wsServer.getHandler()(request, connInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API endpoints
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
if (url.pathname === '/api/playlists') {
|
||||||
|
const playlists = await trackService.getAvailablePlaylists();
|
||||||
|
return new Response(JSON.stringify({ ok: true, playlists }), {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/tracks') {
|
||||||
|
const playlistId = url.searchParams.get('playlist') || 'default';
|
||||||
|
const tracks = await trackService.loadPlaylistTracks(playlistId);
|
||||||
|
return new Response(JSON.stringify({ ok: true, tracks, playlist: playlistId }), {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/reload-years') {
|
||||||
|
const playlistId = url.searchParams.get('playlist') || 'default';
|
||||||
|
const result = await trackService.reloadYearsIndex(playlistId);
|
||||||
|
return new Response(JSON.stringify({ ok: true, count: result.count, playlist: playlistId }), {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio streaming
|
||||||
|
if (url.pathname.startsWith('/audio/t/')) {
|
||||||
|
const token = url.pathname.split('/audio/t/')[1];
|
||||||
|
try {
|
||||||
|
// Create a minimal context-like object
|
||||||
|
const ctx: any = {
|
||||||
|
request: { headers: new Headers(request.headers), url },
|
||||||
|
response: { headers: new Headers(), status: 200 },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.method === 'HEAD') {
|
||||||
|
await audioStreaming.handleHeadRequest(ctx, token);
|
||||||
|
return new Response(null, {
|
||||||
|
status: ctx.response.status,
|
||||||
|
headers: ctx.response.headers,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await audioStreaming.handleStreamRequest(ctx, token);
|
||||||
|
return new Response(ctx.response.body, {
|
||||||
|
status: ctx.response.status,
|
||||||
|
headers: ctx.response.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover art
|
||||||
|
if (url.pathname.startsWith('/cover/')) {
|
||||||
|
const encodedFileName = url.pathname.split('/cover/')[1];
|
||||||
|
const fileName = decodeURIComponent(encodedFileName);
|
||||||
|
try {
|
||||||
|
const cover = await coverArt.getCoverArt(fileSystem.resolveSafePath(fileName));
|
||||||
|
if (cover) {
|
||||||
|
return new Response(cover.buf, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': cover.mime,
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
try {
|
||||||
|
return await serveDir(request, {
|
||||||
|
fsRoot: config.publicDir,
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fallback to index.html for SPA routing
|
||||||
|
try {
|
||||||
|
return await serveDir(new Request(new URL('/index.html', url)), {
|
||||||
|
fsRoot: config.publicDir,
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
logger.info(`Server starting on http://${config.host}:${config.port}`);
|
||||||
|
|
||||||
|
await serve(handler, {
|
||||||
|
hostname: config.host,
|
||||||
|
port: config.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to start server: ${error}`);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
Deno.addSignalListener('SIGINT', () => {
|
||||||
|
logger.info('Received SIGINT, shutting down gracefully...');
|
||||||
|
Deno.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SIGTERM is not supported on Windows, only add listener on Unix-like systems
|
||||||
|
if (Deno.build.os !== 'windows') {
|
||||||
|
Deno.addSignalListener('SIGTERM', () => {
|
||||||
|
logger.info('Received SIGTERM, shutting down gracefully...');
|
||||||
|
Deno.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the application
|
||||||
|
if (import.meta.main) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
122
src/server-deno/presentation/HttpServer.ts
Normal file
122
src/server-deno/presentation/HttpServer.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Application, Router, send } from '@oak/oak';
|
||||||
|
import type { AppConfig } from '../shared/config.ts';
|
||||||
|
import { logger } from '../shared/logger.ts';
|
||||||
|
import { createTrackRoutes } from './routes/trackRoutes.ts';
|
||||||
|
import { createAudioRoutes } from './routes/audioRoutes.ts';
|
||||||
|
import type { TrackService } from '../application/mod.ts';
|
||||||
|
import type { AudioStreamingService, CoverArtService } from '../infrastructure/mod.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP server using Oak
|
||||||
|
*/
|
||||||
|
export class HttpServer {
|
||||||
|
private readonly app: Application;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: AppConfig,
|
||||||
|
private readonly trackService: TrackService,
|
||||||
|
private readonly audioStreaming: AudioStreamingService,
|
||||||
|
private readonly coverArt: CoverArtService,
|
||||||
|
) {
|
||||||
|
this.app = new Application();
|
||||||
|
this.setupMiddleware();
|
||||||
|
this.setupRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup middleware
|
||||||
|
*/
|
||||||
|
private setupMiddleware(): void {
|
||||||
|
// Error handling
|
||||||
|
this.app.use(async (ctx, next) => {
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Request error: ${error}`);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: 'Internal server error' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
this.app.use(async (ctx, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await next();
|
||||||
|
const ms = Date.now() - start;
|
||||||
|
logger.info(`${ctx.request.method} ${ctx.request.url.pathname} - ${ctx.response.status} - ${ms}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
this.app.use(async (ctx, next) => {
|
||||||
|
ctx.response.headers.set('Access-Control-Allow-Origin', this.config.corsOrigin);
|
||||||
|
ctx.response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
ctx.response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Range');
|
||||||
|
ctx.response.headers.set('Access-Control-Expose-Headers', 'Content-Range, Content-Length, Accept-Ranges');
|
||||||
|
|
||||||
|
if (ctx.request.method === 'OPTIONS') {
|
||||||
|
ctx.response.status = 204;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup routes
|
||||||
|
*/
|
||||||
|
private setupRoutes(): void {
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
const trackRoutes = createTrackRoutes(this.trackService);
|
||||||
|
const audioRoutes = createAudioRoutes(this.audioStreaming, this.coverArt, this.config);
|
||||||
|
|
||||||
|
router.use(trackRoutes.routes(), trackRoutes.allowedMethods());
|
||||||
|
router.use(audioRoutes.routes(), audioRoutes.allowedMethods());
|
||||||
|
|
||||||
|
this.app.use(router.routes());
|
||||||
|
this.app.use(router.allowedMethods());
|
||||||
|
|
||||||
|
// Static file serving (public directory)
|
||||||
|
this.app.use(async (ctx) => {
|
||||||
|
try {
|
||||||
|
await send(ctx, ctx.request.url.pathname, {
|
||||||
|
root: this.config.publicDir,
|
||||||
|
index: 'index.html',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If file not found, serve index.html for SPA routing
|
||||||
|
try {
|
||||||
|
await send(ctx, '/index.html', {
|
||||||
|
root: this.config.publicDir,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = 'Not found';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the HTTP server
|
||||||
|
*/
|
||||||
|
async listen(): Promise<void> {
|
||||||
|
const { host, port } = this.config;
|
||||||
|
|
||||||
|
logger.info(`Starting HTTP server on http://${host}:${port}`);
|
||||||
|
|
||||||
|
await this.app.listen({
|
||||||
|
hostname: host,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying Oak application
|
||||||
|
*/
|
||||||
|
getApp(): Application {
|
||||||
|
return this.app;
|
||||||
|
}
|
||||||
|
}
|
||||||
798
src/server-deno/presentation/WebSocketServer.ts
Normal file
798
src/server-deno/presentation/WebSocketServer.ts
Normal file
@@ -0,0 +1,798 @@
|
|||||||
|
import { Server as SocketIOServer } from 'https://deno.land/x/socket_io@0.2.0/mod.ts';
|
||||||
|
import type { Socket } from 'https://deno.land/x/socket_io@0.2.0/mod.ts';
|
||||||
|
import type { RoomModel, PlayerModel } from '../domain/models/mod.ts';
|
||||||
|
import { GameService, RoomService } from '../application/mod.ts';
|
||||||
|
import { WS_EVENTS, HELLO_TIMER_MS, SYNC_INTERVAL_MS } from '../shared/constants.ts';
|
||||||
|
import { GamePhase, GameStatus } from '../domain/types.ts';
|
||||||
|
import { logger } from '../shared/logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket game server using Socket.IO
|
||||||
|
*/
|
||||||
|
export class WebSocketServer {
|
||||||
|
private io!: SocketIOServer;
|
||||||
|
private syncTimers: Map<string, number> = new Map();
|
||||||
|
private playerSockets: Map<string, Socket> = new Map(); // Map player ID to socket
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly roomService: RoomService,
|
||||||
|
private readonly gameService: GameService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Socket.IO server
|
||||||
|
*/
|
||||||
|
initialize(corsOrigin: string = '*'): void {
|
||||||
|
this.io = new SocketIOServer({
|
||||||
|
cors: {
|
||||||
|
origin: corsOrigin,
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.io.on('connection', (socket: Socket) => {
|
||||||
|
this.handleConnection(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('WebSocket server initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Socket.IO handler for Deno serve
|
||||||
|
*/
|
||||||
|
getHandler(): (request: Request, connInfo: { localAddr: Deno.Addr; remoteAddr: Deno.Addr }) => Response | Promise<Response> {
|
||||||
|
if (!this.io) {
|
||||||
|
throw new Error('WebSocket server not initialized');
|
||||||
|
}
|
||||||
|
return this.io.handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle new connection
|
||||||
|
*/
|
||||||
|
private handleConnection(socket: Socket): void {
|
||||||
|
logger.info(`Client connected: ${socket.id}`);
|
||||||
|
|
||||||
|
const { player, sessionId } = this.roomService.createPlayer();
|
||||||
|
let helloSent = false;
|
||||||
|
|
||||||
|
// Track the socket for this player
|
||||||
|
this.playerSockets.set(player.id, socket);
|
||||||
|
|
||||||
|
const sendHello = () => {
|
||||||
|
if (helloSent) return;
|
||||||
|
helloSent = true;
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.CONNECTED,
|
||||||
|
playerId: player.id,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delay hello to allow resume attempts
|
||||||
|
const helloTimer = setTimeout(sendHello, HELLO_TIMER_MS);
|
||||||
|
|
||||||
|
// Setup message handlers
|
||||||
|
socket.on('message', (msg: any) => {
|
||||||
|
this.handleMessage(socket, msg, player, () => clearTimeout(helloTimer));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
this.handleDisconnect(player);
|
||||||
|
this.playerSockets.delete(player.id); // Remove socket tracking
|
||||||
|
clearTimeout(helloTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error: Error) => {
|
||||||
|
logger.error(`Socket error for ${socket.id}: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming message
|
||||||
|
*/
|
||||||
|
private async handleMessage(
|
||||||
|
socket: Socket,
|
||||||
|
msg: any,
|
||||||
|
player: PlayerModel,
|
||||||
|
clearHelloTimer: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!msg || typeof msg !== 'object' || !msg.type) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (msg.type) {
|
||||||
|
case WS_EVENTS.RESUME:
|
||||||
|
this.handleResume(socket, msg, clearHelloTimer, player.id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.CREATE_ROOM:
|
||||||
|
await this.handleCreateRoom(socket, msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.JOIN_ROOM:
|
||||||
|
this.handleJoinRoom(socket, msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.LEAVE_ROOM:
|
||||||
|
this.handleLeaveRoom(player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.SET_NAME:
|
||||||
|
this.handleSetName(msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.READY:
|
||||||
|
this.handleReady(msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.SELECT_PLAYLIST:
|
||||||
|
this.handleSelectPlaylist(msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.SET_GOAL:
|
||||||
|
this.handleSetGoal(msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.START_GAME:
|
||||||
|
await this.handleStartGame(player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.GUESS:
|
||||||
|
this.handleGuess(msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'submit_answer':
|
||||||
|
await this.handleSubmitAnswer(socket, msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'place_guess':
|
||||||
|
await this.handlePlaceGuess(socket, msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.PAUSE:
|
||||||
|
this.handlePause(player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.RESUME_PLAY:
|
||||||
|
this.handleResumePlay(player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.SKIP_TRACK:
|
||||||
|
await this.handleSkipTrack(player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.SET_SPECTATOR:
|
||||||
|
this.handleSetSpectator(msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.KICK_PLAYER:
|
||||||
|
this.handleKickPlayer(msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rematch':
|
||||||
|
this.handleRematch(player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reaction':
|
||||||
|
this.handleReaction(msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.debug(`Unknown message type: ${msg.type}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error handling message ${msg.type}: ${error}`);
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.ERROR,
|
||||||
|
error: 'Failed to process request',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume existing session
|
||||||
|
*/
|
||||||
|
private handleResume(socket: Socket, msg: any, clearHelloTimer: () => void, newPlayerId: string): void {
|
||||||
|
clearHelloTimer();
|
||||||
|
|
||||||
|
const sessionId = msg.sessionId;
|
||||||
|
if (!sessionId) {
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.RESUME_RESULT,
|
||||||
|
ok: false,
|
||||||
|
reason: 'no_session',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPlayer = this.roomService.resumePlayer(sessionId);
|
||||||
|
if (!existingPlayer) {
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.RESUME_RESULT,
|
||||||
|
ok: false,
|
||||||
|
reason: 'session_not_found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingPlayer.setConnected(true);
|
||||||
|
|
||||||
|
// Remove the temporary player that was created in handleConnection
|
||||||
|
this.playerSockets.delete(newPlayerId);
|
||||||
|
|
||||||
|
// Update socket mapping to point to the resumed player
|
||||||
|
this.playerSockets.set(existingPlayer.id, socket);
|
||||||
|
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.RESUME_RESULT,
|
||||||
|
ok: true,
|
||||||
|
playerId: existingPlayer.id,
|
||||||
|
sessionId: existingPlayer.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send room update if in a room
|
||||||
|
if (existingPlayer.roomId) {
|
||||||
|
const room = this.roomService.getRoom(existingPlayer.roomId);
|
||||||
|
if (room) {
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new room
|
||||||
|
*/
|
||||||
|
private async handleCreateRoom(socket: Socket, msg: any, player: PlayerModel): Promise<void> {
|
||||||
|
const { room } = this.roomService.createRoomWithPlayer(
|
||||||
|
player,
|
||||||
|
msg.name,
|
||||||
|
msg.goal || 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.emit('message', {
|
||||||
|
type: 'room_created',
|
||||||
|
room: room.toSummary(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join existing room
|
||||||
|
*/
|
||||||
|
private handleJoinRoom(socket: Socket, msg: any, player: PlayerModel): void {
|
||||||
|
const roomId = msg.roomId;
|
||||||
|
if (!roomId) {
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.ERROR,
|
||||||
|
error: 'Room ID required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { room } = this.roomService.joinRoomWithPlayer(roomId, player);
|
||||||
|
|
||||||
|
socket.emit('message', {
|
||||||
|
type: 'room_joined',
|
||||||
|
room: room.toSummary(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
} catch (error) {
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.ERROR,
|
||||||
|
error: `Failed to join room: ${error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave room
|
||||||
|
*/
|
||||||
|
private handleLeaveRoom(player: PlayerModel): void {
|
||||||
|
if (player.roomId) {
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
this.roomService.leaveRoom(player.id);
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set player name
|
||||||
|
*/
|
||||||
|
private handleSetName(msg: any, player: PlayerModel): void {
|
||||||
|
if (msg.name) {
|
||||||
|
this.roomService.setPlayerName(player.id, msg.name);
|
||||||
|
|
||||||
|
if (player.roomId) {
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (room) {
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set ready status
|
||||||
|
*/
|
||||||
|
private handleReady(msg: any, player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
room.state.setReady(player.id, !!msg.ready);
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select playlist
|
||||||
|
*/
|
||||||
|
private handleSelectPlaylist(msg: any, player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room || room.hostId !== player.id) return;
|
||||||
|
|
||||||
|
room.state.playlist = msg.playlist || 'default';
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set goal (win condition)
|
||||||
|
*/
|
||||||
|
private handleSetGoal(msg: any, player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room || room.hostId !== player.id) return;
|
||||||
|
|
||||||
|
// Only allow changing goal in lobby
|
||||||
|
if (room.state.status !== 'lobby') return;
|
||||||
|
|
||||||
|
const goal = parseInt(msg.goal, 10);
|
||||||
|
if (isNaN(goal) || goal < 1 || goal > 50) return;
|
||||||
|
|
||||||
|
room.state.goal = goal;
|
||||||
|
logger.info(`Room ${room.id}: Goal set to ${goal} by host ${player.id}`);
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start game
|
||||||
|
*/
|
||||||
|
private async handleStartGame(player: PlayerModel): Promise<void> {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room || room.hostId !== player.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.gameService.startGame(room);
|
||||||
|
await this.drawNextTrack(room);
|
||||||
|
this.startSyncTimer(room);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to start game: ${error}`);
|
||||||
|
this.broadcast(room, WS_EVENTS.ERROR, { error: `Failed to start game: ${error}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle guess
|
||||||
|
*/
|
||||||
|
private handleGuess(msg: any, player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = this.gameService.processGuess(
|
||||||
|
room,
|
||||||
|
player.id,
|
||||||
|
msg.guess,
|
||||||
|
msg.guessType || 'title',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.broadcast(room, WS_EVENTS.GUESS_RESULT, { result });
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Guess error: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle submit answer (title + artist guess)
|
||||||
|
*/
|
||||||
|
private async handleSubmitAnswer(socket: Socket, msg: any, player: PlayerModel): Promise<void> {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room || room.state.status !== 'playing' || room.state.phase !== 'guess') {
|
||||||
|
socket.emit('message', {
|
||||||
|
type: 'answer_result',
|
||||||
|
ok: false,
|
||||||
|
error: 'not_accepting',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guess = msg.guess || {};
|
||||||
|
const title = String(guess.title || '').trim();
|
||||||
|
const artist = String(guess.artist || '').trim();
|
||||||
|
|
||||||
|
if (!title || !artist) {
|
||||||
|
socket.emit('message', {
|
||||||
|
type: 'answer_result',
|
||||||
|
ok: false,
|
||||||
|
error: 'invalid',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.gameService.checkTitleArtistGuess(room, player.id, title, artist);
|
||||||
|
|
||||||
|
socket.emit('message', {
|
||||||
|
type: 'answer_result',
|
||||||
|
ok: true,
|
||||||
|
correctTitle: result.titleCorrect,
|
||||||
|
correctArtist: result.artistCorrect,
|
||||||
|
awarded: result.awarded,
|
||||||
|
alreadyAwarded: result.alreadyAwarded,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Submit answer error: ${error}`);
|
||||||
|
socket.emit('message', {
|
||||||
|
type: 'answer_result',
|
||||||
|
ok: false,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle place guess (timeline slot placement)
|
||||||
|
*/
|
||||||
|
private async handlePlaceGuess(socket: Socket, msg: any, player: PlayerModel): Promise<void> {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room || room.state.status !== 'playing' || room.state.phase !== 'guess') {
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.ERROR,
|
||||||
|
error: 'not_accepting',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.state.currentGuesser !== player.id) {
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.ERROR,
|
||||||
|
error: 'not_your_turn',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = typeof msg.slot === 'number' ? msg.slot : null;
|
||||||
|
if (slot === null) {
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.ERROR,
|
||||||
|
error: 'invalid_slot',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if player wants to use tokens for guaranteed placement
|
||||||
|
const useTokens = !!msg.useTokens;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.gameService.placeInTimeline(room, player.id, slot, useTokens);
|
||||||
|
|
||||||
|
// Store the result
|
||||||
|
room.state.lastResult = {
|
||||||
|
playerId: player.id,
|
||||||
|
correct: result.correct,
|
||||||
|
guess: null,
|
||||||
|
type: 'placement',
|
||||||
|
tokensUsed: result.tokensUsed,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move to reveal phase - DON'T auto-skip, wait for user to click next
|
||||||
|
room.state.phase = GamePhase.REVEAL;
|
||||||
|
|
||||||
|
// Broadcast reveal with track info and result
|
||||||
|
this.broadcast(room, 'reveal', {
|
||||||
|
result: room.state.lastResult,
|
||||||
|
track: room.state.currentTrack,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
|
||||||
|
// Check if player won
|
||||||
|
const timeline = room.state.timeline[player.id] || [];
|
||||||
|
if (result.correct && timeline.length >= room.state.goal) {
|
||||||
|
room.state.status = GameStatus.ENDED;
|
||||||
|
const winnerPlayer = room.players.get(player.id);
|
||||||
|
this.broadcast(room, WS_EVENTS.GAME_ENDED, {
|
||||||
|
winner: player.id,
|
||||||
|
winnerName: winnerPlayer?.name || player.id.slice(0, 4),
|
||||||
|
score: timeline.length,
|
||||||
|
timeline: timeline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Place guess error: ${error}`);
|
||||||
|
socket.emit('message', {
|
||||||
|
type: WS_EVENTS.ERROR,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause game
|
||||||
|
*/
|
||||||
|
private handlePause(player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room || room.state.status !== 'playing' || room.state.phase !== 'guess') return;
|
||||||
|
if (room.state.currentGuesser !== player.id) return;
|
||||||
|
if (!room.state.currentTrack) return;
|
||||||
|
|
||||||
|
if (!room.state.paused) {
|
||||||
|
this.gameService.pauseGame(room);
|
||||||
|
this.stopSyncTimer(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast control event to all players
|
||||||
|
this.broadcast(room, 'control', { action: 'pause' });
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume play
|
||||||
|
*/
|
||||||
|
private handleResumePlay(player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room || room.state.status !== 'playing' || room.state.phase !== 'guess') return;
|
||||||
|
if (room.state.currentGuesser !== player.id) return;
|
||||||
|
if (!room.state.currentTrack) return;
|
||||||
|
|
||||||
|
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;
|
||||||
|
this.startSyncTimer(room);
|
||||||
|
|
||||||
|
// Broadcast control event with timing info
|
||||||
|
this.broadcast(room, 'control', {
|
||||||
|
action: 'play',
|
||||||
|
startAt: room.state.trackStartAt,
|
||||||
|
serverNow: now,
|
||||||
|
});
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip track
|
||||||
|
*/
|
||||||
|
private async handleSkipTrack(player: PlayerModel): Promise<void> {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
await this.drawNextTrack(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set spectator mode
|
||||||
|
*/
|
||||||
|
private handleSetSpectator(msg: any, player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
room.state.setSpectator(player.id, !!msg.spectator);
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick player
|
||||||
|
*/
|
||||||
|
private handleKickPlayer(msg: any, player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room || room.hostId !== player.id) return;
|
||||||
|
|
||||||
|
const targetId = msg.playerId;
|
||||||
|
if (targetId && targetId !== player.id) {
|
||||||
|
this.roomService.leaveRoom(targetId);
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle disconnect
|
||||||
|
*/
|
||||||
|
private handleDisconnect(player: PlayerModel): void {
|
||||||
|
logger.info(`Player disconnected: ${player.id}`);
|
||||||
|
this.roomService.setPlayerConnected(player.id, false);
|
||||||
|
|
||||||
|
if (player.roomId) {
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (room) {
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle rematch request - reset game to lobby with same settings
|
||||||
|
*/
|
||||||
|
private handleRematch(player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
// Only host can initiate rematch
|
||||||
|
if (room.hostId !== player.id) {
|
||||||
|
logger.debug(`Non-host ${player.id} tried to initiate rematch`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any sync timers
|
||||||
|
this.stopSyncTimer(room);
|
||||||
|
|
||||||
|
// Reset game state to lobby
|
||||||
|
room.state.status = GameStatus.LOBBY;
|
||||||
|
room.state.phase = GamePhase.GUESS;
|
||||||
|
room.state.currentGuesser = null;
|
||||||
|
room.state.currentTrack = null;
|
||||||
|
room.state.lastResult = null;
|
||||||
|
room.state.trackStartAt = null;
|
||||||
|
room.state.paused = false;
|
||||||
|
room.state.pausedPosSec = 0;
|
||||||
|
room.state.titleArtistAwardedThisRound = {};
|
||||||
|
|
||||||
|
// Clear all player timelines, tokens, and ready states
|
||||||
|
for (const playerId of room.players.keys()) {
|
||||||
|
room.state.timeline[playerId] = [];
|
||||||
|
room.state.tokens[playerId] = 0;
|
||||||
|
room.state.ready[playerId] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep playlist and goal settings intact
|
||||||
|
// room.state.playlist stays the same
|
||||||
|
// room.state.goal stays the same
|
||||||
|
|
||||||
|
// Reset deck (will be refilled when game starts again)
|
||||||
|
room.deck = [];
|
||||||
|
room.discard = [];
|
||||||
|
|
||||||
|
logger.info(`Room ${room.id}: Rematch initiated by host ${player.id}`);
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle reaction - broadcast emoji to all players in room
|
||||||
|
*/
|
||||||
|
private handleReaction(msg: any, player: PlayerModel): void {
|
||||||
|
if (!player.roomId) return;
|
||||||
|
|
||||||
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const emoji = msg.emoji;
|
||||||
|
if (!emoji || typeof emoji !== 'string') return;
|
||||||
|
|
||||||
|
// Broadcast reaction to all players
|
||||||
|
this.broadcast(room, 'reaction', {
|
||||||
|
emoji,
|
||||||
|
playerId: player.id,
|
||||||
|
playerName: player.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw next track
|
||||||
|
*/
|
||||||
|
private async drawNextTrack(room: RoomModel): Promise<void> {
|
||||||
|
const track = await this.gameService.drawNextTrack(room);
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
const winnerId = this.gameService.getWinner(room);
|
||||||
|
const winnerPlayer = winnerId ? room.players.get(winnerId) : null;
|
||||||
|
const winnerTimeline = winnerId ? (room.state.timeline[winnerId] || []) : [];
|
||||||
|
this.broadcast(room, WS_EVENTS.GAME_ENDED, {
|
||||||
|
winner: winnerId,
|
||||||
|
winnerName: winnerPlayer?.name || (winnerId ? winnerId.slice(0, 4) : 'Unknown'),
|
||||||
|
score: winnerTimeline.length,
|
||||||
|
timeline: winnerTimeline,
|
||||||
|
});
|
||||||
|
this.stopSyncTimer(room);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate to next player
|
||||||
|
this.gameService.nextTurn(room);
|
||||||
|
|
||||||
|
this.broadcast(room, WS_EVENTS.PLAY_TRACK, {
|
||||||
|
track,
|
||||||
|
startAt: room.state.trackStartAt,
|
||||||
|
serverNow: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.broadcastRoomUpdate(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start sync timer for time synchronization
|
||||||
|
*/
|
||||||
|
private startSyncTimer(room: RoomModel): void {
|
||||||
|
this.stopSyncTimer(room);
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (room.state.status !== 'playing' || !room.state.trackStartAt || room.state.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcast(room, WS_EVENTS.SYNC, {
|
||||||
|
startAt: room.state.trackStartAt,
|
||||||
|
serverNow: Date.now(),
|
||||||
|
});
|
||||||
|
}, SYNC_INTERVAL_MS);
|
||||||
|
|
||||||
|
this.syncTimers.set(room.id, timer as unknown as number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop sync timer
|
||||||
|
*/
|
||||||
|
private stopSyncTimer(room: RoomModel): void {
|
||||||
|
const timer = this.syncTimers.get(room.id);
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
this.syncTimers.delete(room.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast to all players in room
|
||||||
|
*/
|
||||||
|
private broadcast(room: RoomModel, type: string, payload: any): void {
|
||||||
|
for (const player of room.players.values()) {
|
||||||
|
const socket = this.playerSockets.get(player.id);
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('message', { type, ...payload });
|
||||||
|
logger.debug(`Broadcasting ${type} to player ${player.id}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`No socket found for player ${player.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast room update
|
||||||
|
*/
|
||||||
|
private broadcastRoomUpdate(room: RoomModel): void {
|
||||||
|
this.broadcast(room, WS_EVENTS.ROOM_UPDATE, { room: room.toSummary() });
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/server-deno/presentation/routes/audioRoutes.ts
Normal file
107
src/server-deno/presentation/routes/audioRoutes.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Router } from '@oak/oak';
|
||||||
|
import type { Context } from '@oak/oak';
|
||||||
|
import { AudioStreamingService, CoverArtService } from '../../infrastructure/mod.ts';
|
||||||
|
import type { AppConfig } from '../../shared/config.ts';
|
||||||
|
import { NotFoundError } from '../../shared/errors.ts';
|
||||||
|
import { logger } from '../../shared/logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio streaming routes
|
||||||
|
*/
|
||||||
|
export function createAudioRoutes(
|
||||||
|
audioStreaming: AudioStreamingService,
|
||||||
|
coverArt: CoverArtService,
|
||||||
|
config: AppConfig,
|
||||||
|
): Router {
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HEAD /audio/t/:token
|
||||||
|
* Check audio file availability by token
|
||||||
|
*/
|
||||||
|
router.head('/audio/t/:token', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const token = ctx.params.token;
|
||||||
|
if (!token) {
|
||||||
|
throw new NotFoundError('Token required');
|
||||||
|
}
|
||||||
|
await audioStreaming.handleHeadRequest(ctx, token);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`HEAD audio error: ${error}`);
|
||||||
|
ctx.response.status = error instanceof NotFoundError ? 404 : 500;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /audio/t/:token
|
||||||
|
* Stream audio file by token (with range support)
|
||||||
|
*/
|
||||||
|
router.get('/audio/t/:token', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const token = ctx.params.token;
|
||||||
|
if (!token) {
|
||||||
|
throw new NotFoundError('Token required');
|
||||||
|
}
|
||||||
|
await audioStreaming.handleStreamRequest(ctx, token);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`GET audio error: ${error}`);
|
||||||
|
ctx.response.body = 'Not found';
|
||||||
|
ctx.response.status = error instanceof NotFoundError ? 404 : 500;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /cover/:name(*)
|
||||||
|
* Get cover art from audio file
|
||||||
|
*/
|
||||||
|
router.get('/cover/:name(.*)', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const fileName = ctx.params.name;
|
||||||
|
if (!fileName) {
|
||||||
|
throw new NotFoundError('Filename required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get cover art
|
||||||
|
const cover = await coverArt.getCoverArt(fileName);
|
||||||
|
|
||||||
|
if (!cover) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = 'No cover art found';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.headers.set('Content-Type', cover.mime);
|
||||||
|
ctx.response.headers.set('Content-Length', String(cover.buf.length));
|
||||||
|
ctx.response.headers.set('Cache-Control', 'public, max-age=3600');
|
||||||
|
ctx.response.body = cover.buf;
|
||||||
|
ctx.response.status = 200;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`GET cover error: ${error}`);
|
||||||
|
ctx.response.status = error instanceof NotFoundError ? 404 : 500;
|
||||||
|
ctx.response.body = 'Error fetching cover art';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only enable name-based endpoint if debug mode is on
|
||||||
|
if (config.audioDebugNames) {
|
||||||
|
logger.warn('Audio debug mode enabled - name-based endpoint active (security risk)');
|
||||||
|
|
||||||
|
router.get('/audio/:name', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const fileName = ctx.params.name;
|
||||||
|
if (!fileName) {
|
||||||
|
throw new NotFoundError('Filename required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await audioStreaming.createAudioToken(fileName);
|
||||||
|
await audioStreaming.handleStreamRequest(ctx, token);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`GET audio by name error: ${error}`);
|
||||||
|
ctx.response.status = error instanceof NotFoundError ? 404 : 500;
|
||||||
|
ctx.response.body = 'Not found';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
69
src/server-deno/presentation/routes/trackRoutes.ts
Normal file
69
src/server-deno/presentation/routes/trackRoutes.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Router } from '@oak/oak';
|
||||||
|
import type { Context } from '@oak/oak';
|
||||||
|
import { TrackService } from '../../application/mod.ts';
|
||||||
|
import { logger } from '../../shared/logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track/Playlist routes
|
||||||
|
*/
|
||||||
|
export function createTrackRoutes(trackService: TrackService): Router {
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/playlists
|
||||||
|
* Get list of available playlists
|
||||||
|
*/
|
||||||
|
router.get('/api/playlists', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const playlists = await trackService.getAvailablePlaylists();
|
||||||
|
ctx.response.body = { ok: true, playlists };
|
||||||
|
ctx.response.status = 200;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error fetching playlists: ${error}`);
|
||||||
|
ctx.response.body = { ok: false, error: 'Failed to fetch playlists' };
|
||||||
|
ctx.response.status = 500;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tracks?playlist=<id>
|
||||||
|
* Get tracks from a specific playlist
|
||||||
|
*/
|
||||||
|
router.get('/api/tracks', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const playlistId = ctx.request.url.searchParams.get('playlist') || 'default';
|
||||||
|
const tracks = await trackService.loadPlaylistTracks(playlistId);
|
||||||
|
|
||||||
|
ctx.response.body = { ok: true, tracks, playlist: playlistId };
|
||||||
|
ctx.response.status = 200;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error fetching tracks: ${error}`);
|
||||||
|
ctx.response.body = { ok: false, error: 'Failed to fetch tracks' };
|
||||||
|
ctx.response.status = 500;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/reload-years?playlist=<id>
|
||||||
|
* Reload years index for a playlist
|
||||||
|
*/
|
||||||
|
router.get('/api/reload-years', async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const playlistId = ctx.request.url.searchParams.get('playlist') || 'default';
|
||||||
|
const result = await trackService.reloadYearsIndex(playlistId);
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
ok: true,
|
||||||
|
count: result.count,
|
||||||
|
playlist: playlistId
|
||||||
|
};
|
||||||
|
ctx.response.status = 200;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error reloading years: ${error}`);
|
||||||
|
ctx.response.body = { ok: false, error: 'Failed to reload years' };
|
||||||
|
ctx.response.status = 500;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
@@ -6,7 +6,8 @@
|
|||||||
<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>
|
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/howler/2.2.4/howler.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@keyframes record-spin {
|
@keyframes record-spin {
|
||||||
from {
|
from {
|
||||||
@@ -31,6 +32,80 @@
|
|||||||
#dashboard[open] .dashboard-chevron {
|
#dashboard[open] .dashboard-chevron {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Winner popup animations */
|
||||||
|
@keyframes confetti-fall {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateY(100vh) rotate(720deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popup-entrance {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.5) translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce-slow {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateX(-50%) translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-popup {
|
||||||
|
animation: popup-entrance 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-bounce-slow {
|
||||||
|
animation: bounce-slow 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live reactions animation */
|
||||||
|
@keyframes reaction-rise {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-50%) translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%) translateY(-200px) scale(1.2);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn:hover {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -48,6 +123,10 @@
|
|||||||
Code kopiert!
|
Code kopiert!
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Reaction Container (for floating emojis) -->
|
||||||
|
<div id="reactionContainer"
|
||||||
|
class="fixed bottom-32 sm:bottom-20 left-0 right-0 h-48 sm:h-64 pointer-events-none z-40 overflow-hidden"></div>
|
||||||
|
|
||||||
<!-- Lobby Card -->
|
<!-- Lobby Card -->
|
||||||
<div id="lobby"
|
<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">
|
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">
|
||||||
@@ -129,6 +208,9 @@
|
|||||||
<!-- Playlist Selection (only shown in lobby for host) -->
|
<!-- Playlist Selection (only shown in lobby for host) -->
|
||||||
<div id="playlistSection"
|
<div id="playlistSection"
|
||||||
class="hidden rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/60 p-4">
|
class="hidden rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/60 p-4">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<!-- Playlist Selection -->
|
||||||
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
🎵 Playlist auswählen
|
🎵 Playlist auswählen
|
||||||
</label>
|
</label>
|
||||||
@@ -136,8 +218,23 @@
|
|||||||
class="w-full h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500">
|
class="w-full h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500">
|
||||||
<option value="default">Lade Playlists...</option>
|
<option value="default">Lade Playlists...</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
</div>
|
||||||
Als Host kannst du die Playlist für dieses Spiel wählen.
|
<!-- Goal/Score Selection -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
🏆 Siegpunkte
|
||||||
|
</label>
|
||||||
|
<select id="goalSelect"
|
||||||
|
class="w-full h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500">
|
||||||
|
<option value="5">5 Karten</option>
|
||||||
|
<option value="10" selected>10 Karten</option>
|
||||||
|
<option value="15">15 Karten</option>
|
||||||
|
<option value="20">20 Karten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Als Host kannst du die Playlist und Siegpunkte für dieses Spiel wählen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,6 +255,11 @@
|
|||||||
<span class="font-medium text-slate-500 dark:text-slate-400">Playlist:</span>
|
<span class="font-medium text-slate-500 dark:text-slate-400">Playlist:</span>
|
||||||
<span id="currentPlaylist" class="font-semibold text-slate-900 dark:text-slate-100">default</span>
|
<span id="currentPlaylist" class="font-semibold text-slate-900 dark:text-slate-100">default</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="goalInfoSection" class="text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
|
||||||
|
<span class="text-base">🏆</span>
|
||||||
|
<span class="font-medium text-slate-500 dark:text-slate-400">Ziel:</span>
|
||||||
|
<span id="goalInfo" class="font-semibold text-slate-900 dark:text-slate-100">10 Karten</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3 justify-start sm:justify-end">
|
<div class="flex flex-wrap items-center gap-3 justify-start sm:justify-end">
|
||||||
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">
|
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">
|
||||||
@@ -186,7 +288,7 @@
|
|||||||
<div id="revealBanner" class="hidden"></div>
|
<div id="revealBanner" class="hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<audio id="audio" preload="none" class="hidden"></audio>
|
<!-- Audio element removed - using Howler.js now -->
|
||||||
<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">
|
||||||
@@ -227,10 +329,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Volume (available to all players) -->
|
<!-- Volume (available to all players) -->
|
||||||
<div class="mt-3">
|
<div class="mt-3 flex flex-col sm:flex-row gap-4">
|
||||||
<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
|
🎵 Musik
|
||||||
<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-32 accent-indigo-600" />
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
🔊 SFX
|
||||||
|
<input id="sfxVolumeSlider" type="range" min="0" max="1" step="0.01" value="0.7"
|
||||||
|
class="w-32 accent-emerald-600" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,17 +377,26 @@
|
|||||||
<h3 class="text-lg font-semibold">Position</h3>
|
<h3 class="text-lg font-semibold">Position</h3>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Wähle die Position und klicke Platzieren.</p>
|
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Wähle die Position und klicke Platzieren.</p>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<div id="placeArea" class="hidden flex items-center gap-2">
|
<div id="placeArea" class="hidden w-full">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2 w-full">
|
||||||
<select id="slotSelect"
|
<select id="slotSelect"
|
||||||
class="h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3"></select>
|
class="h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 w-full sm:flex-1 sm:min-w-0"></select>
|
||||||
|
<div class="flex gap-2 w-full sm:w-auto">
|
||||||
<button id="placeBtn"
|
<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">
|
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 flex-1 sm:flex-none">
|
||||||
Platzieren
|
Platzieren
|
||||||
</button>
|
</button>
|
||||||
|
<button id="placeWithTokensBtn"
|
||||||
|
class="h-10 px-3 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-medium flex items-center justify-center gap-1 whitespace-nowrap flex-1 sm:flex-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Nutze 3 Tokens um die Karte automatisch richtig zu platzieren">
|
||||||
|
🪙 3 Tokens
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="nextArea" class="hidden">
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="nextArea" class="hidden w-full sm:w-auto">
|
||||||
<button id="nextBtn"
|
<button id="nextBtn"
|
||||||
class="h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium">
|
class="h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium w-full sm:w-auto">
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,6 +411,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Reaction Bar -->
|
||||||
|
<div id="reactionBar" class="hidden mt-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
|
||||||
|
<span
|
||||||
|
class="text-xs sm:text-sm text-slate-500 dark:text-slate-400 mr-1 sm:mr-2 w-full sm:w-auto text-center mb-1 sm:mb-0">Reaktionen:</span>
|
||||||
|
<button
|
||||||
|
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
|
||||||
|
data-emoji="😂" title="Lustig">😂</button>
|
||||||
|
<button
|
||||||
|
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
|
||||||
|
data-emoji="😱" title="Schock">😱</button>
|
||||||
|
<button
|
||||||
|
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
|
||||||
|
data-emoji="🔥" title="Feuer">🔥</button>
|
||||||
|
<button
|
||||||
|
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
|
||||||
|
data-emoji="👏" title="Applaus">👏</button>
|
||||||
|
<button
|
||||||
|
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
|
||||||
|
data-emoji="🎉" title="Party">🎉</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
293
src/server-deno/public/js/audio.js
Normal file
293
src/server-deno/public/js/audio.js
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { $bufferBadge, $progressFill, $recordDisc } from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
// Howler.js audio instance
|
||||||
|
let currentSound = null;
|
||||||
|
let progressInterval = null;
|
||||||
|
let isInitialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the current Howler sound instance
|
||||||
|
*/
|
||||||
|
export function getSound() {
|
||||||
|
return currentSound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and prepare a new track
|
||||||
|
* @param {string} url - The URL to load
|
||||||
|
* @param {string} [fileName] - Optional filename to extract format from
|
||||||
|
*/
|
||||||
|
export function loadTrack(url, fileName) {
|
||||||
|
// Unload previous sound
|
||||||
|
if (currentSound) {
|
||||||
|
currentSound.unload();
|
||||||
|
currentSound = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear progress interval
|
||||||
|
if (progressInterval) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progressInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine format from filename if provided
|
||||||
|
let format = null;
|
||||||
|
if (fileName) {
|
||||||
|
const ext = fileName.slice(fileName.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
// Map common extensions to Howler format names
|
||||||
|
const formatMap = {
|
||||||
|
mp3: "mp3",
|
||||||
|
opus: "opus",
|
||||||
|
ogg: "ogg",
|
||||||
|
wav: "wav",
|
||||||
|
m4a: "m4a",
|
||||||
|
aac: "aac",
|
||||||
|
};
|
||||||
|
format = formatMap[ext] || ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect iOS/Safari for optimized settings
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
// iOS ignores programmatic volume, so always use 1.0 for iOS
|
||||||
|
const initialVolume = isIOS
|
||||||
|
? 1.0
|
||||||
|
: parseFloat(localStorage.getItem("volume") || "1");
|
||||||
|
|
||||||
|
// Create new Howler sound
|
||||||
|
const howlConfig = {
|
||||||
|
src: [url],
|
||||||
|
// Always use HTML5 Audio for streaming, but optimize for iOS
|
||||||
|
html5: true,
|
||||||
|
preload: true,
|
||||||
|
autoplay: false, // Explicitly prevent autoplay
|
||||||
|
volume: initialVolume,
|
||||||
|
// Enable pool for better resource management on iOS
|
||||||
|
pool: isIOS ? 1 : 5,
|
||||||
|
onload: function () {
|
||||||
|
showBuffer(false);
|
||||||
|
},
|
||||||
|
onloaderror: function (id, error) {
|
||||||
|
console.error("Error loading audio:", error);
|
||||||
|
showBuffer(false);
|
||||||
|
},
|
||||||
|
onplayerror: function (id, error) {
|
||||||
|
console.error("Error playing audio:", error);
|
||||||
|
// iOS Safari often requires user interaction to unlock audio
|
||||||
|
currentSound.once("unlock", function () {
|
||||||
|
currentSound.play();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onplay: function () {
|
||||||
|
showBuffer(false);
|
||||||
|
if ($recordDisc) $recordDisc.classList.add("spin-record");
|
||||||
|
startProgressTracking();
|
||||||
|
},
|
||||||
|
onpause: function () {
|
||||||
|
if ($recordDisc) $recordDisc.classList.remove("spin-record");
|
||||||
|
stopProgressTracking();
|
||||||
|
},
|
||||||
|
onend: function () {
|
||||||
|
if ($recordDisc) $recordDisc.classList.remove("spin-record");
|
||||||
|
stopProgressTracking();
|
||||||
|
},
|
||||||
|
onstop: function () {
|
||||||
|
if ($recordDisc) $recordDisc.classList.remove("spin-record");
|
||||||
|
stopProgressTracking();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add format if detected
|
||||||
|
if (format) {
|
||||||
|
howlConfig.format = [format];
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSound = new Howl(howlConfig);
|
||||||
|
|
||||||
|
return currentSound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start tracking playback progress
|
||||||
|
*/
|
||||||
|
function startProgressTracking() {
|
||||||
|
if (progressInterval) return;
|
||||||
|
|
||||||
|
progressInterval = setInterval(() => {
|
||||||
|
if (!currentSound || !currentSound.playing()) {
|
||||||
|
stopProgressTracking();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = currentSound.duration() || 0;
|
||||||
|
const seek = currentSound.seek() || 0;
|
||||||
|
|
||||||
|
if (duration > 0 && $progressFill) {
|
||||||
|
const pct = Math.min(100, Math.max(0, (seek / duration) * 100));
|
||||||
|
$progressFill.style.width = pct + "%";
|
||||||
|
}
|
||||||
|
}, 100); // Update every 100ms for smooth progress
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop tracking playback progress
|
||||||
|
*/
|
||||||
|
function stopProgressTracking() {
|
||||||
|
if (progressInterval) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progressInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show/hide buffering indicator
|
||||||
|
*/
|
||||||
|
function showBuffer(visible) {
|
||||||
|
state.isBuffering = visible;
|
||||||
|
if ($bufferBadge) $bufferBadge.classList.toggle("hidden", !visible);
|
||||||
|
if ($recordDisc && !visible && currentSound?.playing()) {
|
||||||
|
$recordDisc.classList.add("spin-record");
|
||||||
|
} else if ($recordDisc && visible) {
|
||||||
|
$recordDisc.classList.remove("spin-record");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize audio UI and Howler settings
|
||||||
|
*/
|
||||||
|
export function initAudioUI() {
|
||||||
|
if (isInitialized) return;
|
||||||
|
isInitialized = true;
|
||||||
|
|
||||||
|
// Detect iOS/Safari
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||||
|
|
||||||
|
// Set up volume control if it exists
|
||||||
|
const $volumeSlider = document.getElementById("volumeSlider");
|
||||||
|
if ($volumeSlider) {
|
||||||
|
const savedVolume = localStorage.getItem("volume") || "1";
|
||||||
|
$volumeSlider.value = savedVolume;
|
||||||
|
|
||||||
|
// iOS doesn't support programmatic volume control
|
||||||
|
if (isIOS) {
|
||||||
|
// Disable the volume slider on iOS and show a message
|
||||||
|
$volumeSlider.disabled = true;
|
||||||
|
$volumeSlider.style.opacity = "0.5";
|
||||||
|
$volumeSlider.style.cursor = "not-allowed";
|
||||||
|
|
||||||
|
// Add a tooltip/label explaining iOS limitation
|
||||||
|
const volumeLabel = $volumeSlider.parentElement;
|
||||||
|
if (volumeLabel) {
|
||||||
|
// Change layout to vertical (flex-col) instead of horizontal
|
||||||
|
volumeLabel.classList.remove("inline-flex", "items-center", "gap-2");
|
||||||
|
volumeLabel.classList.add("flex", "flex-col", "items-start", "gap-1");
|
||||||
|
|
||||||
|
const iosNote = document.createElement("span");
|
||||||
|
iosNote.className = "text-xs text-slate-500 dark:text-slate-400";
|
||||||
|
iosNote.textContent = "Nutze die Lautstärketasten deines Geräts";
|
||||||
|
volumeLabel.appendChild(iosNote);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-iOS: Normal volume control
|
||||||
|
$volumeSlider.addEventListener("input", () => {
|
||||||
|
const volume = parseFloat($volumeSlider.value);
|
||||||
|
if (currentSound) {
|
||||||
|
currentSound.volume(volume);
|
||||||
|
}
|
||||||
|
Howler.volume(volume); // Set global volume
|
||||||
|
localStorage.setItem("volume", String(volume));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial volume for non-iOS devices
|
||||||
|
const initialVolume = parseFloat(savedVolume);
|
||||||
|
Howler.volume(initialVolume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock audio on first user interaction (mobile browsers)
|
||||||
|
const unlockAudio = () => {
|
||||||
|
Howler.ctx?.resume();
|
||||||
|
document.removeEventListener("touchstart", unlockAudio);
|
||||||
|
document.removeEventListener("touchend", unlockAudio);
|
||||||
|
document.removeEventListener("click", unlockAudio);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("touchstart", unlockAudio);
|
||||||
|
document.addEventListener("touchend", unlockAudio);
|
||||||
|
document.addEventListener("click", unlockAudio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply time synchronization with server
|
||||||
|
*/
|
||||||
|
export function applySync(startAt, serverNow) {
|
||||||
|
if (!startAt || !serverNow || !currentSound) return;
|
||||||
|
if (state.room?.state?.paused) return;
|
||||||
|
if (state.isBuffering) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = (now - startAt) / 1000;
|
||||||
|
const currentSeek = currentSound.seek() || 0;
|
||||||
|
const drift = currentSeek - elapsed;
|
||||||
|
const abs = Math.abs(drift);
|
||||||
|
|
||||||
|
// Detect iOS/Safari - be more conservative with sync
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
|
const needsGentleSync = isIOS || isSafari;
|
||||||
|
|
||||||
|
// iOS Safari: Higher threshold before doing hard seek (less aggressive)
|
||||||
|
const hardSeekThreshold = needsGentleSync ? 2.0 : 1.0;
|
||||||
|
// iOS Safari: Higher threshold before adjusting playback rate
|
||||||
|
const rateAdjustThreshold = needsGentleSync ? 0.3 : 0.12;
|
||||||
|
|
||||||
|
if (abs > hardSeekThreshold) {
|
||||||
|
// Large drift - hard seek
|
||||||
|
currentSound.seek(Math.max(0, elapsed));
|
||||||
|
if (!currentSound.playing()) {
|
||||||
|
currentSound.play();
|
||||||
|
}
|
||||||
|
currentSound.rate(1.0);
|
||||||
|
} else if (abs > rateAdjustThreshold && !needsGentleSync) {
|
||||||
|
// Small drift - adjust playback rate (skip this on iOS/Safari to avoid stuttering)
|
||||||
|
const maxNudge = 0.03;
|
||||||
|
const sign = drift > 0 ? -1 : 1;
|
||||||
|
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
|
||||||
|
currentSound.rate(Math.max(0.8, Math.min(1.2, rate)));
|
||||||
|
} else {
|
||||||
|
// Very small or no drift - reset to normal rate
|
||||||
|
if (Math.abs(currentSound.rate() - 1) > 0.001) {
|
||||||
|
currentSound.rate(1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop audio playback and reset state
|
||||||
|
*/
|
||||||
|
export function stopAudioPlayback() {
|
||||||
|
if (currentSound) {
|
||||||
|
currentSound.stop();
|
||||||
|
currentSound.unload();
|
||||||
|
currentSound = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressInterval) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progressInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
60
src/server-deno/public/js/dom.js
Normal file
60
src/server-deno/public/js/dom.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
export const $lobby = el("lobby");
|
||||||
|
export const $room = el("room");
|
||||||
|
export const $roomId = el("roomId");
|
||||||
|
export const $nameDisplay = el("nameDisplay");
|
||||||
|
export const $status = el("status");
|
||||||
|
export const $guesser = el("guesser");
|
||||||
|
export const $timeline = el("timeline");
|
||||||
|
export const $np = el("nowPlaying");
|
||||||
|
export const $npTitle = el("npTitle");
|
||||||
|
export const $npArtist = el("npArtist");
|
||||||
|
export const $npYear = el("npYear");
|
||||||
|
export const $readyChk = el("readyChk");
|
||||||
|
export const $startGame = el("startGame");
|
||||||
|
export const $revealBanner = el("revealBanner");
|
||||||
|
export const $placeArea = el("placeArea");
|
||||||
|
export const $slotSelect = el("slotSelect");
|
||||||
|
export const $placeBtn = el("placeBtn");
|
||||||
|
export const $placeWithTokensBtn = el("placeWithTokensBtn");
|
||||||
|
export const $mediaControls = el("mediaControls");
|
||||||
|
export const $playBtn = el("playBtn");
|
||||||
|
export const $pauseBtn = el("pauseBtn");
|
||||||
|
export const $nextArea = el("nextArea");
|
||||||
|
export const $nextBtn = el("nextBtn");
|
||||||
|
export const $recordDisc = el("recordDisc");
|
||||||
|
export const $progressFill = el("progressFill");
|
||||||
|
export const $volumeSlider = el("volumeSlider");
|
||||||
|
export const $bufferBadge = el("bufferBadge");
|
||||||
|
export const $copyRoomCode = el("copyRoomCode");
|
||||||
|
export const $nameLobby = el("name");
|
||||||
|
export const $saveName = el("saveName");
|
||||||
|
export const $createRoom = el("createRoom");
|
||||||
|
export const $joinRoom = el("joinRoom");
|
||||||
|
export const $roomCode = el("roomCode");
|
||||||
|
export const $leaveRoom = el("leaveRoom");
|
||||||
|
export const $earnToken = el("earnToken");
|
||||||
|
export const $dashboardList = el("dashboardList");
|
||||||
|
export const $toast = el("toast");
|
||||||
|
// Answer form elements
|
||||||
|
export const $answerForm = el("answerForm");
|
||||||
|
export const $guessTitle = el("guessTitle");
|
||||||
|
export const $guessArtist = el("guessArtist");
|
||||||
|
export const $answerResult = el("answerResult");
|
||||||
|
// Playlist elements
|
||||||
|
export const $playlistSection = el("playlistSection");
|
||||||
|
export const $playlistSelect = el("playlistSelect");
|
||||||
|
export const $currentPlaylist = el("currentPlaylist");
|
||||||
|
export const $playlistInfo = el("playlistInfo");
|
||||||
|
// SFX controls
|
||||||
|
export const $sfxVolumeSlider = el("sfxVolumeSlider");
|
||||||
|
|
||||||
|
export function showLobby() {
|
||||||
|
$lobby.classList.remove("hidden");
|
||||||
|
$room.classList.add("hidden");
|
||||||
|
}
|
||||||
|
export function showRoom() {
|
||||||
|
$lobby.classList.add("hidden");
|
||||||
|
$room.classList.remove("hidden");
|
||||||
|
}
|
||||||
382
src/server-deno/public/js/handlers.js
Normal file
382
src/server-deno/public/js/handlers.js
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import {
|
||||||
|
$answerResult,
|
||||||
|
$guessArtist,
|
||||||
|
$guessTitle,
|
||||||
|
$npArtist,
|
||||||
|
$npTitle,
|
||||||
|
$npYear,
|
||||||
|
} from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
import { cacheLastRoomId, cacheSessionId, clearSessionId, sendMsg } from "./ws.js";
|
||||||
|
import { renderRoom } from "./render.js";
|
||||||
|
import { applySync, loadTrack, getSound, stopAudioPlayback } from "./audio.js";
|
||||||
|
import { playCorrect, playWrong, playWinner, playTurnStart, playCoinEarned } from "./sfx.js";
|
||||||
|
import { showReaction, celebrateCorrect } from "./reactions.js";
|
||||||
|
|
||||||
|
function updatePlayerIdFromRoom(r) {
|
||||||
|
try {
|
||||||
|
if (r?.players?.length === 1) {
|
||||||
|
const only = r.players[0];
|
||||||
|
if (only && only.id && only.id !== state.playerId) {
|
||||||
|
state.playerId = only.id;
|
||||||
|
try {
|
||||||
|
localStorage.setItem("playerId", only.id);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortName(id) {
|
||||||
|
if (!id) return "-";
|
||||||
|
const p = state.room?.players.find((x) => x.id === id);
|
||||||
|
return p ? p.name : id.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleConnected(msg) {
|
||||||
|
state.playerId = msg.playerId;
|
||||||
|
try {
|
||||||
|
if (msg.playerId) localStorage.setItem("playerId", msg.playerId);
|
||||||
|
} catch { }
|
||||||
|
if (msg.sessionId) {
|
||||||
|
const existing = localStorage.getItem("sessionId");
|
||||||
|
if (!existing) cacheSessionId(msg.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// lazy import to avoid cycle
|
||||||
|
import("./session.js").then(({ reusePlayerName, reconnectLastRoom }) => {
|
||||||
|
reusePlayerName();
|
||||||
|
reconnectLastRoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.room) {
|
||||||
|
try {
|
||||||
|
updatePlayerIdFromRoom(state.room);
|
||||||
|
renderRoom(state.room);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleRoomUpdate(msg) {
|
||||||
|
if (msg?.room?.id) cacheLastRoomId(msg.room.id);
|
||||||
|
const r = msg.room;
|
||||||
|
updatePlayerIdFromRoom(r);
|
||||||
|
renderRoom(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePlayTrack(msg) {
|
||||||
|
const t = msg.track;
|
||||||
|
state.lastTrack = t;
|
||||||
|
state.revealed = false;
|
||||||
|
$npTitle.textContent = "???";
|
||||||
|
$npArtist.textContent = "";
|
||||||
|
$npYear.textContent = "";
|
||||||
|
if ($guessTitle) $guessTitle.value = "";
|
||||||
|
if ($guessArtist) $guessArtist.value = "";
|
||||||
|
if ($answerResult) {
|
||||||
|
$answerResult.textContent = "";
|
||||||
|
$answerResult.className = "mt-1 text-sm";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load track with Howler, passing the filename for format detection
|
||||||
|
const sound = loadTrack(t.url, t.file);
|
||||||
|
|
||||||
|
const pf = document.getElementById("progressFill");
|
||||||
|
if (pf) pf.style.width = "0%";
|
||||||
|
const rd = document.getElementById("recordDisc");
|
||||||
|
if (rd) {
|
||||||
|
rd.classList.remove("spin-record");
|
||||||
|
rd.src = "/hitstar.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startAt, serverNow } = msg;
|
||||||
|
const now = Date.now();
|
||||||
|
const offsetMs = startAt - serverNow;
|
||||||
|
const localStart = now + offsetMs;
|
||||||
|
const delay = Math.max(0, localStart - now);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (sound && sound === getSound() && !sound.playing()) {
|
||||||
|
sound.play();
|
||||||
|
const disc = document.getElementById("recordDisc");
|
||||||
|
if (disc) disc.classList.add("spin-record");
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
// Play turn start sound if it's the current player's turn
|
||||||
|
if (state.room?.state?.currentGuesser === state.playerId) {
|
||||||
|
playTurnStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.room) renderRoom(state.room);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSync(msg) {
|
||||||
|
applySync(msg.startAt, msg.serverNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleControl(msg) {
|
||||||
|
const { action, startAt, serverNow } = msg;
|
||||||
|
const sound = getSound();
|
||||||
|
|
||||||
|
if (!sound) return;
|
||||||
|
|
||||||
|
if (action === "pause") {
|
||||||
|
sound.pause();
|
||||||
|
const disc = document.getElementById("recordDisc");
|
||||||
|
if (disc) disc.classList.remove("spin-record");
|
||||||
|
sound.rate(1.0);
|
||||||
|
} else if (action === "play") {
|
||||||
|
if (startAt && serverNow) {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = (now - startAt) / 1000;
|
||||||
|
sound.seek(Math.max(0, elapsed));
|
||||||
|
}
|
||||||
|
sound.play();
|
||||||
|
const disc = document.getElementById("recordDisc");
|
||||||
|
if (disc) disc.classList.add("spin-record");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleReveal(msg) {
|
||||||
|
const { result, track } = msg;
|
||||||
|
$npTitle.textContent = track.title || track.id || "Track";
|
||||||
|
$npArtist.textContent = track.artist ? ` – ${track.artist}` : "";
|
||||||
|
$npYear.textContent = track.year ? ` (${track.year})` : "";
|
||||||
|
state.revealed = true;
|
||||||
|
const $rb = document.getElementById("revealBanner");
|
||||||
|
if ($rb) {
|
||||||
|
if (result.correct) {
|
||||||
|
$rb.textContent = "Richtig!";
|
||||||
|
$rb.className =
|
||||||
|
"inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium";
|
||||||
|
playCorrect();
|
||||||
|
celebrateCorrect(); // Auto 🎉 animation
|
||||||
|
} else {
|
||||||
|
$rb.textContent = "Falsch!";
|
||||||
|
$rb.className =
|
||||||
|
"inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium";
|
||||||
|
playWrong();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Note: placeArea visibility is now controlled by renderRoom() based on game phase
|
||||||
|
const rd = document.getElementById("recordDisc");
|
||||||
|
if (rd && track?.file) {
|
||||||
|
// Use track.file instead of track.id to include playlist folder prefix
|
||||||
|
const coverUrl = `/cover/${encodeURIComponent(track.file)}`;
|
||||||
|
const coverUrlWithTimestamp = `${coverUrl}?t=${Date.now()}`;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
rd.src = coverUrlWithTimestamp;
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
/* keep default logo */
|
||||||
|
};
|
||||||
|
img.src = coverUrlWithTimestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleGameEnded(msg) {
|
||||||
|
// Play winner fanfare
|
||||||
|
playWinner();
|
||||||
|
// Create and show the winner popup with confetti
|
||||||
|
showWinnerPopup(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWinnerPopup(msg) {
|
||||||
|
const winnerName = msg.winnerName || shortName(msg.winner);
|
||||||
|
const score = msg.score ?? 0;
|
||||||
|
const timeline = msg.timeline || [];
|
||||||
|
|
||||||
|
// Create the popup overlay
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'winnerOverlay';
|
||||||
|
overlay.className = 'fixed inset-0 z-50 flex items-center justify-center p-4';
|
||||||
|
overlay.style.cssText = 'background: rgba(0,0,0,0.75); backdrop-filter: blur(4px);';
|
||||||
|
|
||||||
|
// Create timeline cards HTML
|
||||||
|
const timelineHtml = timeline.length > 0
|
||||||
|
? timeline.map(t => `
|
||||||
|
<div class="flex items-start gap-3 bg-white/10 rounded-lg px-3 py-2 w-full">
|
||||||
|
<div class="flex-shrink-0 font-bold tabular-nums bg-indigo-600 text-white rounded-md px-2 py-1 min-w-[48px] text-center text-sm">${t.year ?? '?'}</div>
|
||||||
|
<div class="flex-1 min-w-0 leading-tight text-left overflow-hidden">
|
||||||
|
<div class="font-semibold text-white text-sm break-words" style="word-break: break-word; hyphens: auto;">${escapeHtmlSimple(t.title || 'Unknown')}</div>
|
||||||
|
<div class="text-xs text-white/70 break-words" style="word-break: break-word;">${escapeHtmlSimple(t.artist || '')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
: '<p class="text-white/60 text-sm">Keine Karten</p>';
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div id="confettiContainer" class="fixed inset-0 pointer-events-none overflow-hidden"></div>
|
||||||
|
<div class="relative bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-600 rounded-2xl shadow-2xl max-w-md w-full p-6 text-center animate-popup">
|
||||||
|
<div class="absolute -top-6 left-1/2 -translate-x-1/2 text-6xl animate-bounce-slow">🏆</div>
|
||||||
|
<h2 class="text-3xl font-bold text-white mt-4 mb-2">Gewinner!</h2>
|
||||||
|
<p class="text-4xl font-extrabold text-yellow-300 mb-2">${escapeHtmlSimple(winnerName)}</p>
|
||||||
|
<p class="text-lg text-white/90 mb-4">Score: <span class="font-bold text-2xl">${score}</span> Karten</p>
|
||||||
|
<div class="bg-black/20 rounded-xl p-3 max-h-60 overflow-y-auto">
|
||||||
|
<h3 class="text-sm font-semibold text-white/80 mb-2">Zeitleiste</h3>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
${timelineHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<button id="rematchBtn" class="px-6 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-600 transition-colors shadow-lg flex items-center justify-center gap-2">
|
||||||
|
🔄 Rematch
|
||||||
|
</button>
|
||||||
|
<button id="closeWinnerBtn" class="px-6 py-3 bg-white text-indigo-600 font-bold rounded-xl hover:bg-indigo-100 transition-colors shadow-lg">
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Start confetti animation
|
||||||
|
createConfetti();
|
||||||
|
|
||||||
|
// Rematch button handler
|
||||||
|
document.getElementById('rematchBtn').addEventListener('click', () => {
|
||||||
|
stopAudioPlayback(); // Stop the music
|
||||||
|
sendMsg({ type: 'rematch' });
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close button handler
|
||||||
|
document.getElementById('closeWinnerBtn').addEventListener('click', () => {
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on overlay click (outside popup)
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) {
|
||||||
|
overlay.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfetti() {
|
||||||
|
const container = document.getElementById('confettiContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'];
|
||||||
|
const confettiCount = 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < confettiCount; i++) {
|
||||||
|
const confetti = document.createElement('div');
|
||||||
|
confetti.className = 'confetti-piece';
|
||||||
|
confetti.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
width: ${Math.random() * 10 + 5}px;
|
||||||
|
height: ${Math.random() * 10 + 5}px;
|
||||||
|
background: ${colors[Math.floor(Math.random() * colors.length)]};
|
||||||
|
left: ${Math.random() * 100}%;
|
||||||
|
top: -20px;
|
||||||
|
border-radius: ${Math.random() > 0.5 ? '50%' : '2px'};
|
||||||
|
animation: confetti-fall ${Math.random() * 3 + 2}s linear forwards;
|
||||||
|
animation-delay: ${Math.random() * 0.5}s;
|
||||||
|
transform: rotate(${Math.random() * 360}deg);
|
||||||
|
`;
|
||||||
|
container.appendChild(confetti);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up confetti after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlSimple(s) {
|
||||||
|
return String(s).replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMessage(ev) {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
switch (msg.type) {
|
||||||
|
case "resume_result": {
|
||||||
|
if (msg.ok) {
|
||||||
|
if (msg.playerId) {
|
||||||
|
state.playerId = msg.playerId;
|
||||||
|
try {
|
||||||
|
localStorage.setItem("playerId", msg.playerId);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
const code =
|
||||||
|
msg.roomId || state.room?.id || localStorage.getItem("lastRoomId");
|
||||||
|
if (code) sendMsg({ type: "join_room", roomId: code });
|
||||||
|
if (state.room) {
|
||||||
|
try {
|
||||||
|
renderRoom(state.room);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
// Restore player name after successful resume
|
||||||
|
import("./session.js").then(({ reusePlayerName }) => {
|
||||||
|
reusePlayerName();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Resume failed - session expired or server restarted
|
||||||
|
// Clear stale session ID so next connect gets a fresh one
|
||||||
|
clearSessionId();
|
||||||
|
// Still try to join the last room
|
||||||
|
const code = state.room?.id || localStorage.getItem("lastRoomId");
|
||||||
|
if (code) sendMsg({ type: "join_room", roomId: code });
|
||||||
|
// Restore player name even on failed resume (new player, same name)
|
||||||
|
import("./session.js").then(({ reusePlayerName }) => {
|
||||||
|
reusePlayerName();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "connected":
|
||||||
|
return handleConnected(msg);
|
||||||
|
case "room_update":
|
||||||
|
return handleRoomUpdate(msg);
|
||||||
|
case "play_track":
|
||||||
|
return handlePlayTrack(msg);
|
||||||
|
case "sync":
|
||||||
|
return handleSync(msg);
|
||||||
|
case "control":
|
||||||
|
return handleControl(msg);
|
||||||
|
case "reveal":
|
||||||
|
return handleReveal(msg);
|
||||||
|
case "game_ended":
|
||||||
|
return handleGameEnded(msg);
|
||||||
|
case "answer_result": {
|
||||||
|
if ($answerResult) {
|
||||||
|
if (!msg.ok) {
|
||||||
|
$answerResult.textContent =
|
||||||
|
"⛔ Eingabe ungültig oder gerade nicht möglich";
|
||||||
|
$answerResult.className = "mt-1 text-sm text-rose-600";
|
||||||
|
} else {
|
||||||
|
const okBoth = !!(msg.correctTitle && msg.correctArtist);
|
||||||
|
const parts = [];
|
||||||
|
parts.push(msg.correctTitle ? "Titel ✓" : "Titel ✗");
|
||||||
|
parts.push(msg.correctArtist ? "Künstler ✓" : "Künstler ✗");
|
||||||
|
let coin = "";
|
||||||
|
if (msg.awarded) {
|
||||||
|
coin = " +1 Token";
|
||||||
|
playCoinEarned();
|
||||||
|
} else if (msg.alreadyAwarded) {
|
||||||
|
coin = " (bereits erhalten)";
|
||||||
|
}
|
||||||
|
$answerResult.textContent = `${parts.join(" · ")}${coin}`;
|
||||||
|
$answerResult.className = okBoth
|
||||||
|
? "mt-1 text-sm text-emerald-600"
|
||||||
|
: "mt-1 text-sm text-amber-600";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
case "reaction": {
|
||||||
|
// Show reaction from another player
|
||||||
|
showReaction(msg.emoji, msg.playerName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/server-deno/public/js/reactions.js
Normal file
93
src/server-deno/public/js/reactions.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Live Reactions Module for Hitstar Party Mode
|
||||||
|
* Real-time emoji reactions visible to all players
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sendMsg } from './ws.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
let lastReactionTime = 0;
|
||||||
|
const REACTION_COOLDOWN_MS = 500;
|
||||||
|
|
||||||
|
// Available reactions
|
||||||
|
export const REACTIONS = ['😂', '😱', '🔥', '👏', '🎉'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a reaction to all players
|
||||||
|
* @param {string} emoji - The emoji to send
|
||||||
|
*/
|
||||||
|
export function sendReaction(emoji) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastReactionTime < REACTION_COOLDOWN_MS) {
|
||||||
|
return; // Rate limited
|
||||||
|
}
|
||||||
|
lastReactionTime = now;
|
||||||
|
sendMsg({ type: 'reaction', emoji });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a floating reaction animation
|
||||||
|
* @param {string} emoji - The emoji to display
|
||||||
|
* @param {string} playerName - Name of the player who reacted
|
||||||
|
*/
|
||||||
|
export function showReaction(emoji, playerName) {
|
||||||
|
const container = document.getElementById('reactionContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Create reaction element
|
||||||
|
const reaction = document.createElement('div');
|
||||||
|
reaction.className = 'reaction-float';
|
||||||
|
|
||||||
|
// Random horizontal position (20-80% of container width)
|
||||||
|
const xPos = 20 + Math.random() * 60;
|
||||||
|
|
||||||
|
reaction.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: ${xPos}%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 2.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: reaction-rise 2s ease-out forwards;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Show player name if not self
|
||||||
|
const isMe = state.room?.players?.some(p => p.id === state.playerId && p.name === playerName);
|
||||||
|
const nameLabel = playerName && !isMe ? `<span style="font-size: 0.7rem; color: white; background: rgba(0,0,0,0.5); padding: 2px 6px; border-radius: 4px; margin-top: 4px; white-space: nowrap;">${escapeHtml(playerName)}</span>` : '';
|
||||||
|
|
||||||
|
reaction.innerHTML = `
|
||||||
|
<span style="text-shadow: 0 2px 8px rgba(0,0,0,0.3);">${emoji}</span>
|
||||||
|
${nameLabel}
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(reaction);
|
||||||
|
|
||||||
|
// Remove after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
reaction.remove();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger celebration reactions (auto 🎉)
|
||||||
|
*/
|
||||||
|
export function celebrateCorrect() {
|
||||||
|
// Show multiple party emojis
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
showReaction('🎉', '');
|
||||||
|
}, i * 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
$nextArea,
|
$nextArea,
|
||||||
$np,
|
$np,
|
||||||
$placeArea,
|
$placeArea,
|
||||||
|
$placeWithTokensBtn,
|
||||||
$readyChk,
|
$readyChk,
|
||||||
$revealBanner,
|
$revealBanner,
|
||||||
$room,
|
$room,
|
||||||
@@ -84,11 +85,11 @@ export function renderRoom(room) {
|
|||||||
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-start gap-3 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 w-full" 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="flex-shrink-0 font-bold tabular-nums text-white rounded-md px-2 py-1 min-w-[48px] text-center" style="${badgeStyle}">${year}</div>
|
||||||
<div class="leading-tight">
|
<div class="flex-1 min-w-0 leading-tight overflow-hidden">
|
||||||
<div class="font-semibold">${title}</div>
|
<div class="font-semibold break-words" style="word-break: break-word; hyphens: auto;">${title}</div>
|
||||||
<div class="text-sm text-slate-600 dark:text-slate-300">${artist}</div>
|
<div class="text-sm text-slate-600 dark:text-slate-300 break-words" style="word-break: break-word;">${artist}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -146,8 +147,10 @@ export function renderRoom(room) {
|
|||||||
|
|
||||||
// Mark that we've populated the dropdown
|
// Mark that we've populated the dropdown
|
||||||
state.playlistsPopulated = true;
|
state.playlistsPopulated = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select first playlist if host and in lobby (only on first population)
|
// Auto-select first playlist if host and in lobby but no playlist is set on server
|
||||||
|
// This handles both initial population AND returning to lobby after a game
|
||||||
if (
|
if (
|
||||||
!room.state.playlist &&
|
!room.state.playlist &&
|
||||||
isHost &&
|
isHost &&
|
||||||
@@ -155,11 +158,18 @@ export function renderRoom(room) {
|
|||||||
state.playlists.length > 0
|
state.playlists.length > 0
|
||||||
) {
|
) {
|
||||||
// Use setTimeout to ensure the change event is properly triggered after render
|
// Use setTimeout to ensure the change event is properly triggered after render
|
||||||
|
// Use a flag to prevent multiple auto-selects during the same render cycle
|
||||||
|
if (!state._autoSelectPending) {
|
||||||
|
state._autoSelectPending = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
state._autoSelectPending = false;
|
||||||
|
// Double-check we're still in the right state
|
||||||
|
if (state.room?.state?.status === 'lobby' && !state.room?.state?.playlist) {
|
||||||
const firstPlaylistId = state.playlists[0].id;
|
const firstPlaylistId = state.playlists[0].id;
|
||||||
$playlistSelect.value = firstPlaylistId;
|
$playlistSelect.value = firstPlaylistId;
|
||||||
// Trigger the change event to send to server
|
// Trigger the change event to send to server
|
||||||
$playlistSelect.dispatchEvent(new Event('change'));
|
$playlistSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,9 +179,23 @@ export function renderRoom(room) {
|
|||||||
$playlistSelect.value = room.state.playlist;
|
$playlistSelect.value = room.state.playlist;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync goal selector with server state
|
||||||
|
const $goalSelect = document.getElementById('goalSelect');
|
||||||
|
if ($goalSelect && room.state.goal) {
|
||||||
|
$goalSelect.value = String(room.state.goal);
|
||||||
|
}
|
||||||
|
|
||||||
if ($currentPlaylist) {
|
if ($currentPlaylist) {
|
||||||
$currentPlaylist.textContent = room.state.playlist || 'default';
|
$currentPlaylist.textContent = room.state.playlist || 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update goal info display
|
||||||
|
const $goalInfo = document.getElementById('goalInfo');
|
||||||
|
if ($goalInfo) {
|
||||||
|
$goalInfo.textContent = `${room.state.goal || 10} Karten`;
|
||||||
|
}
|
||||||
|
|
||||||
if ($playlistInfo) {
|
if ($playlistInfo) {
|
||||||
$playlistInfo.classList.toggle('hidden', room.state.status === 'lobby');
|
$playlistInfo.classList.toggle('hidden', room.state.status === 'lobby');
|
||||||
}
|
}
|
||||||
@@ -189,6 +213,8 @@ export function renderRoom(room) {
|
|||||||
if ($placeArea && $slotSelect) {
|
if ($placeArea && $slotSelect) {
|
||||||
if (canGuess) {
|
if (canGuess) {
|
||||||
const tl = room.state.timeline?.[state.playerId] || [];
|
const tl = room.state.timeline?.[state.playerId] || [];
|
||||||
|
// Preserve selected slot across re-renders
|
||||||
|
const previousValue = $slotSelect.value;
|
||||||
$slotSelect.innerHTML = '';
|
$slotSelect.innerHTML = '';
|
||||||
for (let i = 0; i <= tl.length; i++) {
|
for (let i = 0; i <= tl.length; i++) {
|
||||||
const left = i > 0 ? (tl[i - 1]?.year ?? '?') : null;
|
const left = i > 0 ? (tl[i - 1]?.year ?? '?') : null;
|
||||||
@@ -203,6 +229,21 @@ export function renderRoom(room) {
|
|||||||
opt.textContent = label;
|
opt.textContent = label;
|
||||||
$slotSelect.appendChild(opt);
|
$slotSelect.appendChild(opt);
|
||||||
}
|
}
|
||||||
|
// Restore previous selection if still valid
|
||||||
|
if (previousValue && parseInt(previousValue, 10) <= tl.length) {
|
||||||
|
$slotSelect.value = previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable/disable the "use tokens" button based on token count
|
||||||
|
if ($placeWithTokensBtn) {
|
||||||
|
const myTokens = room.state.tokens?.[state.playerId] ?? 0;
|
||||||
|
const TOKEN_COST = 3;
|
||||||
|
const canUseTokens = myTokens >= TOKEN_COST;
|
||||||
|
$placeWithTokensBtn.disabled = !canUseTokens;
|
||||||
|
$placeWithTokensBtn.title = canUseTokens
|
||||||
|
? 'Nutze 3 Tokens um die Karte automatisch richtig zu platzieren'
|
||||||
|
: `Du brauchst mindestens ${TOKEN_COST} Tokens (aktuell: ${myTokens})`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Clear options when not guessing
|
// Clear options when not guessing
|
||||||
$slotSelect.innerHTML = '';
|
$slotSelect.innerHTML = '';
|
||||||
@@ -230,6 +271,12 @@ export function renderRoom(room) {
|
|||||||
$answerResult.textContent = '';
|
$answerResult.textContent = '';
|
||||||
$answerResult.className = 'mt-1 text-sm';
|
$answerResult.className = 'mt-1 text-sm';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show reaction bar during gameplay
|
||||||
|
const $reactionBar = document.getElementById('reactionBar');
|
||||||
|
if ($reactionBar) {
|
||||||
|
$reactionBar.classList.toggle('hidden', room.state.status !== 'playing');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shortName(id) {
|
export function shortName(id) {
|
||||||
@@ -17,7 +17,9 @@ export function reusePlayerName() {
|
|||||||
|
|
||||||
export function reconnectLastRoom() {
|
export function reconnectLastRoom() {
|
||||||
const last = state.room?.id || localStorage.getItem('lastRoomId');
|
const last = state.room?.id || localStorage.getItem('lastRoomId');
|
||||||
if (last && !localStorage.getItem('sessionId')) {
|
// Always try to rejoin the last room - resume is handled separately in ws.js
|
||||||
sendMsg({ type: 'join_room', code: last });
|
if (last) {
|
||||||
|
sendMsg({ type: 'join_room', roomId: last });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
157
src/server-deno/public/js/sfx.js
Normal file
157
src/server-deno/public/js/sfx.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Sound Effects Module for Hitstar Party Mode
|
||||||
|
* Uses Howler.js for audio playback with synthesized tones
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SFX volume (0-1), separate from music
|
||||||
|
let sfxVolume = 0.7;
|
||||||
|
|
||||||
|
// Audio context for generating tones
|
||||||
|
let audioCtx = null;
|
||||||
|
|
||||||
|
function getAudioContext() {
|
||||||
|
if (!audioCtx) {
|
||||||
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
}
|
||||||
|
return audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a synthesized tone
|
||||||
|
* @param {number} frequency - Frequency in Hz
|
||||||
|
* @param {number} duration - Duration in seconds
|
||||||
|
* @param {string} type - Oscillator type: 'sine', 'square', 'sawtooth', 'triangle'
|
||||||
|
* @param {number} [volume] - Volume multiplier (0-1)
|
||||||
|
*/
|
||||||
|
function playTone(frequency, duration, type = 'sine', volume = 1) {
|
||||||
|
try {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (ctx.state === 'suspended') {
|
||||||
|
ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
oscillator.type = type;
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
|
||||||
|
|
||||||
|
// Apply volume with fade out
|
||||||
|
const effectiveVolume = sfxVolume * volume * 0.3; // Keep SFX quieter than music
|
||||||
|
gainNode.gain.setValueAtTime(effectiveVolume, ctx.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
oscillator.start(ctx.currentTime);
|
||||||
|
oscillator.stop(ctx.currentTime + duration);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('SFX playback failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a sequence of tones
|
||||||
|
* @param {Array<{freq: number, dur: number, delay: number, type?: string, vol?: number}>} notes
|
||||||
|
*/
|
||||||
|
function playSequence(notes) {
|
||||||
|
notes.forEach(note => {
|
||||||
|
setTimeout(() => {
|
||||||
|
playTone(note.freq, note.dur, note.type || 'sine', note.vol || 1);
|
||||||
|
}, note.delay * 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Sound Effect Functions =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play success sound - rising two-tone chime
|
||||||
|
*/
|
||||||
|
export function playCorrect() {
|
||||||
|
playSequence([
|
||||||
|
{ freq: 523.25, dur: 0.15, delay: 0, type: 'sine' }, // C5
|
||||||
|
{ freq: 659.25, dur: 0.25, delay: 0.1, type: 'sine' }, // E5
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play error sound - descending buzz
|
||||||
|
*/
|
||||||
|
export function playWrong() {
|
||||||
|
playSequence([
|
||||||
|
{ freq: 220, dur: 0.15, delay: 0, type: 'sawtooth', vol: 0.5 }, // A3
|
||||||
|
{ freq: 175, dur: 0.25, delay: 0.12, type: 'sawtooth', vol: 0.5 }, // F3
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play winner fanfare
|
||||||
|
*/
|
||||||
|
export function playWinner() {
|
||||||
|
playSequence([
|
||||||
|
{ freq: 523.25, dur: 0.12, delay: 0, type: 'square', vol: 0.6 }, // C5
|
||||||
|
{ freq: 659.25, dur: 0.12, delay: 0.12, type: 'square', vol: 0.6 }, // E5
|
||||||
|
{ freq: 783.99, dur: 0.12, delay: 0.24, type: 'square', vol: 0.6 }, // G5
|
||||||
|
{ freq: 1046.50, dur: 0.4, delay: 0.36, type: 'sine', vol: 0.8 }, // C6 (victory note)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play turn start notification ping
|
||||||
|
*/
|
||||||
|
export function playTurnStart() {
|
||||||
|
playSequence([
|
||||||
|
{ freq: 880, dur: 0.08, delay: 0, type: 'sine', vol: 0.4 }, // A5
|
||||||
|
{ freq: 1108.73, dur: 0.15, delay: 0.08, type: 'sine', vol: 0.6 }, // C#6
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play coin/token earned sound
|
||||||
|
*/
|
||||||
|
export function playCoinEarned() {
|
||||||
|
playSequence([
|
||||||
|
{ freq: 1318.51, dur: 0.06, delay: 0, type: 'sine', vol: 0.5 }, // E6
|
||||||
|
{ freq: 1567.98, dur: 0.06, delay: 0.05, type: 'sine', vol: 0.6 }, // G6
|
||||||
|
{ freq: 2093, dur: 0.12, delay: 0.1, type: 'sine', vol: 0.7 }, // C7
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play countdown tick sound
|
||||||
|
*/
|
||||||
|
export function playTick() {
|
||||||
|
playTone(800, 0.05, 'sine', 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set SFX volume
|
||||||
|
* @param {number} volume - Volume level (0-1)
|
||||||
|
*/
|
||||||
|
export function setSfxVolume(volume) {
|
||||||
|
sfxVolume = Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current SFX volume
|
||||||
|
* @returns {number} Current volume (0-1)
|
||||||
|
*/
|
||||||
|
export function getSfxVolume() {
|
||||||
|
return sfxVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize SFX system (call on first user interaction)
|
||||||
|
*/
|
||||||
|
export function initSfx() {
|
||||||
|
// Pre-warm audio context on user interaction
|
||||||
|
try {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (ctx.state === 'suspended') {
|
||||||
|
ctx.resume();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('SFX initialization failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
294
src/server-deno/public/js/ui.js
Normal file
294
src/server-deno/public/js/ui.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import {
|
||||||
|
$answerResult,
|
||||||
|
$copyRoomCode,
|
||||||
|
$createRoom,
|
||||||
|
$guessArtist,
|
||||||
|
$guessTitle,
|
||||||
|
$joinRoom,
|
||||||
|
$lobby,
|
||||||
|
$nameDisplay,
|
||||||
|
$nameLobby,
|
||||||
|
$nextBtn,
|
||||||
|
$pauseBtn,
|
||||||
|
$placeBtn,
|
||||||
|
$placeWithTokensBtn,
|
||||||
|
$readyChk,
|
||||||
|
$room,
|
||||||
|
$roomCode,
|
||||||
|
$roomId,
|
||||||
|
$slotSelect,
|
||||||
|
$startGame,
|
||||||
|
$leaveRoom,
|
||||||
|
$playBtn,
|
||||||
|
$volumeSlider,
|
||||||
|
$saveName,
|
||||||
|
$sfxVolumeSlider,
|
||||||
|
} from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
import { initAudioUI, stopAudioPlayback } from "./audio.js";
|
||||||
|
import { sendMsg } from "./ws.js";
|
||||||
|
import { showToast, wire } from "./utils.js";
|
||||||
|
import { setSfxVolume, initSfx } from "./sfx.js";
|
||||||
|
import { sendReaction } from "./reactions.js";
|
||||||
|
|
||||||
|
export function wireUi() {
|
||||||
|
initAudioUI();
|
||||||
|
initSfx(); // Initialize sound effects system
|
||||||
|
|
||||||
|
// Wire reaction buttons
|
||||||
|
document.querySelectorAll('.reaction-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const emoji = btn.getAttribute('data-emoji');
|
||||||
|
if (emoji) sendReaction(emoji);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Name autosave helpers
|
||||||
|
let nameDebounce;
|
||||||
|
const SAVE_DEBOUNCE_MS = 800;
|
||||||
|
// Button was removed; no state management needed.
|
||||||
|
|
||||||
|
function saveNameIfChanged(raw) {
|
||||||
|
const name = (raw || "").trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const prev = localStorage.getItem("playerName") || "";
|
||||||
|
if (prev === name) return; // no-op
|
||||||
|
localStorage.setItem("playerName", name);
|
||||||
|
if ($nameDisplay) $nameDisplay.textContent = name;
|
||||||
|
sendMsg({ type: "set_name", name });
|
||||||
|
showToast("Name gespeichert!");
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual save button
|
||||||
|
if ($saveName) {
|
||||||
|
wire($saveName, "click", () => {
|
||||||
|
if (nameDebounce) {
|
||||||
|
clearTimeout(nameDebounce);
|
||||||
|
nameDebounce = null;
|
||||||
|
}
|
||||||
|
const val = ($nameLobby?.value || "").trim();
|
||||||
|
if (!val) {
|
||||||
|
showToast("⚠️ Bitte gib einen Namen ein!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = localStorage.getItem("playerName") || "";
|
||||||
|
if (prev === val) {
|
||||||
|
showToast("✓ Name bereits gespeichert!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveNameIfChanged(val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autosave on input with debounce
|
||||||
|
if ($nameLobby) {
|
||||||
|
wire($nameLobby, "input", () => {
|
||||||
|
if (nameDebounce) clearTimeout(nameDebounce);
|
||||||
|
const val = ($nameLobby.value || "").trim();
|
||||||
|
if (!val) return;
|
||||||
|
nameDebounce = setTimeout(() => {
|
||||||
|
saveNameIfChanged($nameLobby.value);
|
||||||
|
nameDebounce = null;
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
// Save immediately on blur
|
||||||
|
wire($nameLobby, "blur", () => {
|
||||||
|
if (nameDebounce) {
|
||||||
|
clearTimeout(nameDebounce);
|
||||||
|
nameDebounce = null;
|
||||||
|
}
|
||||||
|
const val = ($nameLobby.value || "").trim();
|
||||||
|
if (val) saveNameIfChanged(val);
|
||||||
|
});
|
||||||
|
// Save on Enter
|
||||||
|
wire($nameLobby, "keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (nameDebounce) {
|
||||||
|
clearTimeout(nameDebounce);
|
||||||
|
nameDebounce = null;
|
||||||
|
}
|
||||||
|
const val = ($nameLobby.value || "").trim();
|
||||||
|
if (val) saveNameIfChanged(val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-uppercase room code input for better UX
|
||||||
|
if ($roomCode) {
|
||||||
|
wire($roomCode, "input", () => {
|
||||||
|
const pos = $roomCode.selectionStart;
|
||||||
|
$roomCode.value = $roomCode.value.toUpperCase();
|
||||||
|
$roomCode.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wire($createRoom, "click", () => sendMsg({ type: "create_room" }));
|
||||||
|
|
||||||
|
wire($joinRoom, "click", () => {
|
||||||
|
const code = $roomCode.value.trim().toUpperCase();
|
||||||
|
if (code) sendMsg({ type: "join_room", roomId: code });
|
||||||
|
});
|
||||||
|
|
||||||
|
wire($leaveRoom, "click", () => {
|
||||||
|
sendMsg({ type: "leave_room" });
|
||||||
|
try {
|
||||||
|
localStorage.removeItem("playerId");
|
||||||
|
localStorage.removeItem("sessionId");
|
||||||
|
localStorage.removeItem("dashboardHintSeen");
|
||||||
|
localStorage.removeItem("lastRoomId");
|
||||||
|
} catch { }
|
||||||
|
stopAudioPlayback();
|
||||||
|
state.room = null;
|
||||||
|
if ($nameLobby) {
|
||||||
|
try {
|
||||||
|
const storedName = localStorage.getItem("playerName") || "";
|
||||||
|
$nameLobby.value = storedName;
|
||||||
|
} catch {
|
||||||
|
$nameLobby.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($nameDisplay) $nameDisplay.textContent = "";
|
||||||
|
if ($readyChk) {
|
||||||
|
try {
|
||||||
|
$readyChk.checked = false;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
$lobby.classList.remove("hidden");
|
||||||
|
$room.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
wire($startGame, "click", () => {
|
||||||
|
// Validate playlist selection before starting
|
||||||
|
if (!state.room?.state?.playlist) {
|
||||||
|
showToast("⚠️ Bitte wähle zuerst eine Playlist aus!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendMsg({ type: "start_game" });
|
||||||
|
});
|
||||||
|
|
||||||
|
wire($readyChk, "change", (e) => {
|
||||||
|
const val = !!e.target.checked;
|
||||||
|
state.pendingReady = val;
|
||||||
|
sendMsg({ type: "ready", ready: val });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Playlist selection
|
||||||
|
const $playlistSelect = document.getElementById("playlistSelect");
|
||||||
|
if ($playlistSelect) {
|
||||||
|
wire($playlistSelect, "change", (e) => {
|
||||||
|
const playlistId = e.target.value;
|
||||||
|
sendMsg({ type: "select_playlist", playlist: playlistId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Goal/score selection
|
||||||
|
const $goalSelect = document.getElementById("goalSelect");
|
||||||
|
if ($goalSelect) {
|
||||||
|
wire($goalSelect, "change", (e) => {
|
||||||
|
const goal = parseInt(e.target.value, 10);
|
||||||
|
sendMsg({ type: "set_goal", goal });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
wire($placeBtn, "click", () => {
|
||||||
|
const slot = parseInt($slotSelect.value, 10);
|
||||||
|
sendMsg({ type: "place_guess", slot });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use tokens to place card correctly
|
||||||
|
wire($placeWithTokensBtn, "click", () => {
|
||||||
|
const slot = parseInt($slotSelect.value, 10);
|
||||||
|
sendMsg({ type: "place_guess", slot, useTokens: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
wire($playBtn, "click", () => sendMsg({ type: "resume_play" }));
|
||||||
|
wire($pauseBtn, "click", () => sendMsg({ type: "pause" }));
|
||||||
|
wire($nextBtn, "click", () => sendMsg({ type: "skip_track" }));
|
||||||
|
|
||||||
|
// Volume slider is now handled in audio.js initAudioUI()
|
||||||
|
|
||||||
|
// SFX Volume slider
|
||||||
|
if ($sfxVolumeSlider) {
|
||||||
|
wire($sfxVolumeSlider, "input", (e) => {
|
||||||
|
setSfxVolume(parseFloat(e.target.value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($copyRoomCode) {
|
||||||
|
$copyRoomCode.style.display = "inline-block";
|
||||||
|
wire($copyRoomCode, "click", () => {
|
||||||
|
if (state.room?.id) {
|
||||||
|
navigator.clipboard.writeText(state.room.id).then(() => {
|
||||||
|
$copyRoomCode.textContent = "✔️";
|
||||||
|
showToast("Code kopiert!");
|
||||||
|
setTimeout(() => {
|
||||||
|
$copyRoomCode.textContent = "📋";
|
||||||
|
}, 1200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($roomId) {
|
||||||
|
wire($roomId, "click", () => {
|
||||||
|
if (state.room?.id) {
|
||||||
|
navigator.clipboard.writeText(state.room.id).then(() => {
|
||||||
|
$roomId.title = "Kopiert!";
|
||||||
|
showToast("Code kopiert!");
|
||||||
|
setTimeout(() => {
|
||||||
|
$roomId.title = "Klicken zum Kopieren";
|
||||||
|
}, 1200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$roomId.style.cursor = "pointer";
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.getElementById("answerForm");
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const title = ($guessTitle?.value || "").trim();
|
||||||
|
const artist = ($guessArtist?.value || "").trim();
|
||||||
|
if (!title || !artist) {
|
||||||
|
if ($answerResult) {
|
||||||
|
$answerResult.textContent = "Bitte Titel und Künstler eingeben";
|
||||||
|
$answerResult.className = "mt-1 text-sm text-amber-600";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendMsg({ type: "submit_answer", guess: { title, artist } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard one-time hint
|
||||||
|
const dashboard = document.getElementById("dashboard");
|
||||||
|
const dashboardHint = document.getElementById("dashboardHint");
|
||||||
|
if (dashboard && dashboardHint) {
|
||||||
|
try {
|
||||||
|
const seen = localStorage.getItem("dashboardHintSeen");
|
||||||
|
if (!seen) {
|
||||||
|
dashboardHint.classList.remove("hidden");
|
||||||
|
const hide = () => {
|
||||||
|
dashboardHint.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
localStorage.setItem("dashboardHintSeen", "1");
|
||||||
|
} catch { }
|
||||||
|
dashboard.removeEventListener("toggle", hide);
|
||||||
|
dashboard.removeEventListener("click", hide);
|
||||||
|
};
|
||||||
|
dashboard.addEventListener("toggle", hide);
|
||||||
|
dashboard.addEventListener("click", hide, { once: true });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!localStorage.getItem("dashboardHintSeen")) hide();
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,3 +56,12 @@ export function cacheLastRoomId(id) {
|
|||||||
localStorage.setItem('lastRoomId', id);
|
localStorage.setItem('lastRoomId', id);
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear stale session data (e.g., after server restart)
|
||||||
|
export function clearSessionId() {
|
||||||
|
sessionId = null;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('sessionId');
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
38
src/server-deno/shared/config.ts
Normal file
38
src/server-deno/shared/config.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Application configuration from environment variables
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
dataDir: string;
|
||||||
|
publicDir: string;
|
||||||
|
audioDebugNames: boolean;
|
||||||
|
tokenTtlMs: number;
|
||||||
|
logLevel: string;
|
||||||
|
corsOrigin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from environment variables
|
||||||
|
*/
|
||||||
|
export function loadConfig(): AppConfig {
|
||||||
|
return {
|
||||||
|
port: parseInt(Deno.env.get('PORT') || '5173', 10),
|
||||||
|
host: Deno.env.get('HOST') || '0.0.0.0',
|
||||||
|
dataDir: Deno.env.get('DATA_DIR') || './data',
|
||||||
|
publicDir: Deno.env.get('PUBLIC_DIR') || './public',
|
||||||
|
audioDebugNames: Deno.env.get('AUDIO_DEBUG_NAMES') === '1',
|
||||||
|
tokenTtlMs: parseInt(Deno.env.get('TOKEN_TTL_MS') || '600000', 10),
|
||||||
|
logLevel: Deno.env.get('LOG_LEVEL') || 'INFO',
|
||||||
|
corsOrigin: Deno.env.get('CORS_ORIGIN') || '*',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve path relative to project root
|
||||||
|
*/
|
||||||
|
export function resolvePath(relativePath: string): string {
|
||||||
|
const projectRoot = Deno.cwd();
|
||||||
|
return new URL(relativePath, `file://${projectRoot}/`).pathname;
|
||||||
|
}
|
||||||
68
src/server-deno/shared/constants.ts
Normal file
68
src/server-deno/shared/constants.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Application constants
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_PORT = 5173;
|
||||||
|
export const DEFAULT_HOST = '0.0.0.0';
|
||||||
|
|
||||||
|
// Audio streaming
|
||||||
|
export const TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
export const COVER_CACHE_MAX_ITEMS = 256;
|
||||||
|
export const COVER_CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||||
|
export const TOKEN_CACHE_MAX_ITEMS = 2048;
|
||||||
|
|
||||||
|
// Game defaults
|
||||||
|
export const DEFAULT_GAME_GOAL = 10;
|
||||||
|
export const DEFAULT_PLAYLIST = 'default';
|
||||||
|
export const HELLO_TIMER_MS = 400;
|
||||||
|
export const SYNC_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
// File extensions
|
||||||
|
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.m4a', '.ogg', '.opus'];
|
||||||
|
export const PREFERRED_EXTENSION = '.opus';
|
||||||
|
|
||||||
|
// Metadata parsing
|
||||||
|
export const BATCH_SIZE = 50; // Process files in batches
|
||||||
|
|
||||||
|
// Room ID generation
|
||||||
|
export const ROOM_ID_LENGTH = 6;
|
||||||
|
|
||||||
|
// WebSocket events
|
||||||
|
export const WS_EVENTS = {
|
||||||
|
// Client -> Server
|
||||||
|
RESUME: 'resume',
|
||||||
|
CREATE_ROOM: 'create_room',
|
||||||
|
JOIN_ROOM: 'join_room',
|
||||||
|
LEAVE_ROOM: 'leave_room',
|
||||||
|
SET_NAME: 'set_name',
|
||||||
|
READY: 'ready',
|
||||||
|
START_GAME: 'start_game',
|
||||||
|
GUESS: 'guess',
|
||||||
|
PAUSE: 'pause',
|
||||||
|
RESUME_PLAY: 'resume_play',
|
||||||
|
SKIP_TRACK: 'skip_track',
|
||||||
|
SET_SPECTATOR: 'set_spectator',
|
||||||
|
KICK_PLAYER: 'kick_player',
|
||||||
|
SELECT_PLAYLIST: 'select_playlist',
|
||||||
|
SET_GOAL: 'set_goal',
|
||||||
|
|
||||||
|
// Server -> Client
|
||||||
|
CONNECTED: 'connected',
|
||||||
|
RESUME_RESULT: 'resume_result',
|
||||||
|
ROOM_UPDATE: 'room_update',
|
||||||
|
PLAY_TRACK: 'play_track',
|
||||||
|
GUESS_RESULT: 'guess_result',
|
||||||
|
GAME_ENDED: 'game_ended',
|
||||||
|
SYNC: 'sync',
|
||||||
|
ERROR: 'error',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// HTTP routes
|
||||||
|
export const ROUTES = {
|
||||||
|
API_PLAYLISTS: '/api/playlists',
|
||||||
|
API_TRACKS: '/api/tracks',
|
||||||
|
API_RELOAD_YEARS: '/api/reload-years',
|
||||||
|
AUDIO_TOKEN: '/audio/t/:token',
|
||||||
|
AUDIO_NAME: '/audio/:name',
|
||||||
|
COVER: '/cover/:name',
|
||||||
|
} as const;
|
||||||
49
src/server-deno/shared/errors.ts
Normal file
49
src/server-deno/shared/errors.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Custom error types for the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code: string,
|
||||||
|
public readonly statusCode: number = 500,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(message = 'Resource not found') {
|
||||||
|
super(message, 'NOT_FOUND', 404);
|
||||||
|
this.name = 'NotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message = 'Validation failed') {
|
||||||
|
super(message, 'VALIDATION_ERROR', 400);
|
||||||
|
this.name = 'ValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends AppError {
|
||||||
|
constructor(message = 'Unauthorized') {
|
||||||
|
super(message, 'UNAUTHORIZED', 401);
|
||||||
|
this.name = 'UnauthorizedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends AppError {
|
||||||
|
constructor(message = 'Forbidden') {
|
||||||
|
super(message, 'FORBIDDEN', 403);
|
||||||
|
this.name = 'ForbiddenError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
constructor(message = 'Conflict') {
|
||||||
|
super(message, 'CONFLICT', 409);
|
||||||
|
this.name = 'ConflictError';
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/server-deno/shared/logger.ts
Normal file
39
src/server-deno/shared/logger.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as log from '@std/log';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger configuration and utility
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
export function initLogger(level: string = 'INFO'): void {
|
||||||
|
const logLevel = level.toUpperCase() as keyof typeof log.LogLevels;
|
||||||
|
|
||||||
|
log.setup({
|
||||||
|
handlers: {
|
||||||
|
console: new log.ConsoleHandler(logLevel, {
|
||||||
|
formatter: (record) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
return `[${timestamp}] ${record.levelName} ${record.msg}`;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
loggers: {
|
||||||
|
default: {
|
||||||
|
level: logLevel,
|
||||||
|
handlers: ['console'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get logger instance
|
||||||
|
*/
|
||||||
|
export function getLogger(name = 'default'): log.Logger {
|
||||||
|
return log.getLogger(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger instance for easy import
|
||||||
|
*/
|
||||||
|
export const logger = getLogger();
|
||||||
69
src/server-deno/shared/utils.ts
Normal file
69
src/server-deno/shared/utils.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Generate a UUID v4
|
||||||
|
*/
|
||||||
|
export function generateUUID(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a short random ID (for room IDs)
|
||||||
|
*/
|
||||||
|
export function generateShortId(length = 6): string {
|
||||||
|
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
let result = '';
|
||||||
|
const randomValues = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(randomValues);
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[randomValues[i] % chars.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuffle an array using Fisher-Yates algorithm
|
||||||
|
*/
|
||||||
|
export function shuffle<T>(array: T[]): T[] {
|
||||||
|
const result = [...array];
|
||||||
|
for (let i = result.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[result[i], result[j]] = [result[j], result[i]];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next element in circular array
|
||||||
|
*/
|
||||||
|
export function getNextInArray<T>(array: T[], current: T): T | null {
|
||||||
|
if (array.length === 0) return null;
|
||||||
|
const index = array.indexOf(current);
|
||||||
|
if (index === -1) return array[0];
|
||||||
|
return array[(index + 1) % array.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay execution
|
||||||
|
*/
|
||||||
|
export function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe JSON parse with fallback
|
||||||
|
*/
|
||||||
|
export function safeJsonParse<T>(json: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp a number between min and max
|
||||||
|
*/
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
121
src/server-deno/tests/AnswerCheckService_test.ts
Normal file
121
src/server-deno/tests/AnswerCheckService_test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts';
|
||||||
|
import { AnswerCheckService } from '../application/AnswerCheckService.ts';
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreTitle - exact match', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreTitle('Hello World', 'Hello World');
|
||||||
|
|
||||||
|
assertEquals(result.match, true);
|
||||||
|
assertEquals(result.score, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreTitle - case insensitive', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreTitle('hello world', 'Hello World');
|
||||||
|
|
||||||
|
assertEquals(result.match, true);
|
||||||
|
assertEquals(result.score, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreTitle - with diacritics', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreTitle('café', 'cafe');
|
||||||
|
|
||||||
|
assertEquals(result.match, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreTitle - removes remaster info', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreTitle(
|
||||||
|
'Song Title',
|
||||||
|
'Song Title (2024 Remastered)'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(result.match, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreArtist - exact match', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreArtist('The Beatles', 'The Beatles');
|
||||||
|
|
||||||
|
assertEquals(result.match, true);
|
||||||
|
assertEquals(result.score, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreArtist - without "The"', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreArtist('Beatles', 'The Beatles');
|
||||||
|
|
||||||
|
assertEquals(result.match, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreArtist - featuring', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
|
||||||
|
// Should match first artist
|
||||||
|
const result1 = service.scoreArtist('Artist A', 'Artist A feat. Artist B');
|
||||||
|
assertEquals(result1.match, true);
|
||||||
|
|
||||||
|
// Should match second artist
|
||||||
|
const result2 = service.scoreArtist('Artist B', 'Artist A feat. Artist B');
|
||||||
|
assertEquals(result2.match, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreYear - exact match', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreYear(2020, 2020);
|
||||||
|
|
||||||
|
assertEquals(result.match, true);
|
||||||
|
assertEquals(result.score, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreYear - within 1 year', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
|
||||||
|
const result1 = service.scoreYear(2020, 2021);
|
||||||
|
assertEquals(result1.match, true);
|
||||||
|
assertEquals(result1.score, 0.9);
|
||||||
|
|
||||||
|
const result2 = service.scoreYear(2022, 2021);
|
||||||
|
assertEquals(result2.match, true);
|
||||||
|
assertEquals(result2.score, 0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreYear - string input', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreYear('2020', 2020);
|
||||||
|
|
||||||
|
assertEquals(result.match, true);
|
||||||
|
assertEquals(result.score, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreYear - invalid input', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreYear('invalid', 2020);
|
||||||
|
|
||||||
|
assertEquals(result.match, false);
|
||||||
|
assertEquals(result.score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - scoreYear - null correct year', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
const result = service.scoreYear(2020, null);
|
||||||
|
|
||||||
|
assertEquals(result.match, false);
|
||||||
|
assertEquals(result.score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('AnswerCheckService - splitArtists', () => {
|
||||||
|
const service = new AnswerCheckService();
|
||||||
|
|
||||||
|
const result1 = service.splitArtists('Artist A feat. Artist B');
|
||||||
|
assertEquals(result1.length, 2);
|
||||||
|
assertEquals(result1[0], 'Artist A');
|
||||||
|
assertEquals(result1[1], 'Artist B');
|
||||||
|
|
||||||
|
const result2 = service.splitArtists('Artist A & Artist B');
|
||||||
|
assertEquals(result2.length, 2);
|
||||||
|
|
||||||
|
const result3 = service.splitArtists('Artist A, Artist B, Artist C');
|
||||||
|
assertEquals(result3.length, 3);
|
||||||
|
});
|
||||||
54
src/server-deno/tests/utils_test.ts
Normal file
54
src/server-deno/tests/utils_test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts';
|
||||||
|
import { shuffle, generateShortId, getNextInArray } from '../shared/utils.ts';
|
||||||
|
|
||||||
|
Deno.test('utils - shuffle - returns different order', () => {
|
||||||
|
const original = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
const shuffled = shuffle(original);
|
||||||
|
|
||||||
|
// Length should be the same
|
||||||
|
assertEquals(shuffled.length, original.length);
|
||||||
|
|
||||||
|
// Should contain all elements
|
||||||
|
for (const item of original) {
|
||||||
|
assertEquals(shuffled.includes(item), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original should not be modified
|
||||||
|
assertEquals(original, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('utils - generateShortId - generates correct length', () => {
|
||||||
|
const id = generateShortId(6);
|
||||||
|
assertEquals(id.length, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('utils - generateShortId - generates uppercase alphanumeric', () => {
|
||||||
|
const id = generateShortId(10);
|
||||||
|
assertEquals(/^[0-9A-Z]+$/.test(id), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('utils - generateShortId - generates unique IDs', () => {
|
||||||
|
const ids = new Set();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
ids.add(generateShortId(6));
|
||||||
|
}
|
||||||
|
// Should have close to 100 unique IDs (very unlikely to get duplicates)
|
||||||
|
assertEquals(ids.size > 95, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('utils - getNextInArray - circular navigation', () => {
|
||||||
|
const arr = ['a', 'b', 'c'];
|
||||||
|
|
||||||
|
assertEquals(getNextInArray(arr, 'a'), 'b');
|
||||||
|
assertEquals(getNextInArray(arr, 'b'), 'c');
|
||||||
|
assertEquals(getNextInArray(arr, 'c'), 'a'); // Wraps around
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('utils - getNextInArray - empty array', () => {
|
||||||
|
assertEquals(getNextInArray([], 'a'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('utils - getNextInArray - element not in array', () => {
|
||||||
|
const arr = ['a', 'b', 'c'];
|
||||||
|
assertEquals(getNextInArray(arr, 'x'), 'a'); // Returns first
|
||||||
|
});
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
// Determine project root directory
|
|
||||||
const ROOT_DIR = path.resolve(__dirname, '..', '..');
|
|
||||||
|
|
||||||
export const PORT = process.env.PORT || 5173;
|
|
||||||
export const DATA_DIR = path.resolve(ROOT_DIR, 'data');
|
|
||||||
export const PUBLIC_DIR = path.resolve(ROOT_DIR, 'public');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the data directory for a specific playlist
|
|
||||||
* @param {string} [playlistId='default'] - The playlist ID
|
|
||||||
* @returns {string} The absolute path to the playlist directory
|
|
||||||
*/
|
|
||||||
export function getPlaylistDir(playlistId = 'default') {
|
|
||||||
return playlistId === 'default' ? DATA_DIR : path.join(DATA_DIR, playlistId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the years.json path for a specific playlist
|
|
||||||
* @param {string} [playlistId='default'] - The playlist ID
|
|
||||||
* @returns {string} The absolute path to the years.json file
|
|
||||||
*/
|
|
||||||
export function getYearsPath(playlistId = 'default') {
|
|
||||||
const dir = getPlaylistDir(playlistId);
|
|
||||||
return path.join(dir, 'years.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy export for backward compatibility - points to root data/years.json
|
|
||||||
export const YEARS_PATH = path.join(DATA_DIR, 'years.json');
|
|
||||||
export const PATHS = { __dirname, ROOT_DIR, DATA_DIR, PUBLIC_DIR, YEARS_PATH };
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
import { Server as SocketIOServer } from 'socket.io';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
|
|
||||||
import { createAudioToken } from './routes/audio.js';
|
|
||||||
import { loadDeck } from './game/deck.js';
|
|
||||||
import { startSyncTimer, stopSyncTimer } from './game/sync.js';
|
|
||||||
import { scoreTitle, scoreArtist, splitArtists } from './game/answerCheck.js';
|
|
||||||
|
|
||||||
function drawNextTrack(room) {
|
|
||||||
const track = room.deck.shift();
|
|
||||||
if (!track) {
|
|
||||||
room.state.status = 'ended';
|
|
||||||
room.state.winner = null;
|
|
||||||
broadcast(room, 'game_ended', { winner: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Generate an opaque, short-lived token for streaming the audio without exposing the file name
|
|
||||||
let tokenUrl = null;
|
|
||||||
try {
|
|
||||||
const token = createAudioToken(track.file);
|
|
||||||
tokenUrl = `/audio/t/${token}`;
|
|
||||||
} catch {
|
|
||||||
// Fallback to name-based URL if token generation fails (should be rare)
|
|
||||||
tokenUrl = `/audio/${encodeURIComponent(track.file)}`;
|
|
||||||
}
|
|
||||||
room.state.currentTrack = { ...track, url: tokenUrl };
|
|
||||||
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.trackStartAt = Date.now() + 800;
|
|
||||||
broadcast(room, 'play_track', {
|
|
||||||
track: room.state.currentTrack,
|
|
||||||
startAt: room.state.trackStartAt,
|
|
||||||
serverNow: Date.now(),
|
|
||||||
});
|
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
||||||
startSyncTimer(room);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupWebSocket(server) {
|
|
||||||
const io = new SocketIOServer(server, {
|
|
||||||
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.
|
|
||||||
const newId = uuidv4();
|
|
||||||
const newSessionId = uuidv4();
|
|
||||||
let player = {
|
|
||||||
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
|
|
||||||
// welcome until we see if the client sends a resume message.
|
|
||||||
let helloSent = false;
|
|
||||||
const sendHello = (p) => {
|
|
||||||
if (helloSent) return;
|
|
||||||
helloSent = true;
|
|
||||||
send('connected', { playerId: p.id, sessionId: p.sessionId });
|
|
||||||
};
|
|
||||||
// Fallback: if no resume arrives shortly, treat as a fresh connection.
|
|
||||||
const helloTimer = setTimeout(() => {
|
|
||||||
sendHello(player);
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
function isParticipant(room, pid) {
|
|
||||||
if (!room) return false;
|
|
||||||
if (room.state.turnOrder?.includes(pid)) return true;
|
|
||||||
if (room.state.timeline && Object.hasOwn(room.state.timeline, pid)) return true;
|
|
||||||
if (room.state.tokens && Object.hasOwn(room.state.tokens, pid)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('message', async (msg) => {
|
|
||||||
if (!msg || typeof msg !== 'object') return;
|
|
||||||
|
|
||||||
// Allow client to resume by session token
|
|
||||||
if (msg.type === 'resume') {
|
|
||||||
clearTimeout(helloTimer);
|
|
||||||
const reqSession = String(msg.sessionId || '');
|
|
||||||
if (!reqSession) {
|
|
||||||
send('resume_result', { ok: false, reason: 'no_session' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let found = null;
|
|
||||||
let foundRoom = null;
|
|
||||||
for (const room of rooms.values()) {
|
|
||||||
for (const p of room.players.values()) {
|
|
||||||
if (p.sessionId === reqSession) {
|
|
||||||
found = p;
|
|
||||||
foundRoom = room;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
send('resume_result', { ok: false, reason: 'not_found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Rebind socket and mark connected
|
|
||||||
try {
|
|
||||||
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 (foundRoom) {
|
|
||||||
if (isParticipant(foundRoom, found.id)) {
|
|
||||||
if (foundRoom.state.spectators) delete foundRoom.state.spectators[found.id];
|
|
||||||
found.spectator = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Send resume result and an explicit connected that preserves their original session
|
|
||||||
// BEFORE broadcasting room_update. This ensures the client sets playerId before
|
|
||||||
// rendering the first room snapshot.
|
|
||||||
send('resume_result', { ok: true, playerId: found.id, roomId: foundRoom?.id });
|
|
||||||
helloSent = true;
|
|
||||||
send('connected', { playerId: found.id, sessionId: found.sessionId });
|
|
||||||
if (foundRoom) {
|
|
||||||
// Now notify the room (and the resumed client) of the updated presence
|
|
||||||
broadcast(foundRoom, 'room_update', { room: roomSummary(foundRoom) });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatic answer check (anyone can try during guess phase)
|
|
||||||
if (msg.type === 'submit_answer') {
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
if (!room) return;
|
|
||||||
const current = room.state.currentTrack;
|
|
||||||
if (!current) {
|
|
||||||
send('answer_result', { ok: false, error: 'no_track' });
|
|
||||||
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 guessTitle = String(guess.title || '').slice(0, 200);
|
|
||||||
const guessArtist = String(guess.artist || '').slice(0, 200);
|
|
||||||
if (!guessTitle || !guessArtist) {
|
|
||||||
send('answer_result', { ok: false, error: 'invalid' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const titleScore = scoreTitle(guessTitle, current.title || current.id || '');
|
|
||||||
const artistScore = scoreArtist(guessArtist, splitArtists(current.artist || ''), 1);
|
|
||||||
const correct = !!(titleScore.pass && artistScore.pass);
|
|
||||||
let awarded = false;
|
|
||||||
let alreadyAwarded = false;
|
|
||||||
if (correct) {
|
|
||||||
room.state.awardedThisRound = room.state.awardedThisRound || {};
|
|
||||||
if (room.state.awardedThisRound[player.id]) {
|
|
||||||
alreadyAwarded = true;
|
|
||||||
} else {
|
|
||||||
const currentTokens = room.state.tokens[player.id] ?? 0;
|
|
||||||
room.state.tokens[player.id] = Math.min(5, currentTokens + 1);
|
|
||||||
room.state.awardedThisRound[player.id] = true;
|
|
||||||
awarded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
send('answer_result', {
|
|
||||||
ok: true,
|
|
||||||
correctTitle: titleScore.pass,
|
|
||||||
correctArtist: artistScore.pass,
|
|
||||||
scoreTitle: { sim: +titleScore.sim.toFixed(3), jaccard: +titleScore.jac.toFixed(3) },
|
|
||||||
scoreArtist: +artistScore.best.toFixed(3),
|
|
||||||
normalized: {
|
|
||||||
guessTitle: titleScore.g,
|
|
||||||
truthTitle: titleScore.t,
|
|
||||||
guessArtists: artistScore.guessArtists,
|
|
||||||
truthArtists: artistScore.truthArtists,
|
|
||||||
},
|
|
||||||
awarded,
|
|
||||||
alreadyAwarded,
|
|
||||||
});
|
|
||||||
if (awarded) {
|
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === 'set_name') {
|
|
||||||
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') {
|
|
||||||
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.
|
|
||||||
let existing = null;
|
|
||||||
for (const p of room.players.values()) {
|
|
||||||
if (p.sessionId && p.sessionId === player.sessionId && p.id !== player.id) {
|
|
||||||
existing = p;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 wasParticipant = isParticipant(room, player.id);
|
|
||||||
if (inProgress) {
|
|
||||||
if (wasParticipant) {
|
|
||||||
if (room.state.spectators) delete room.state.spectators[player.id];
|
|
||||||
player.spectator = false;
|
|
||||||
} 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 (!player.roomId) return;
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
if (!room) return;
|
|
||||||
room.players.delete(player.id);
|
|
||||||
player.roomId = null;
|
|
||||||
if (room.state.ready) delete room.state.ready[player.id];
|
|
||||||
if (room.state.spectators) delete room.state.spectators[player.id];
|
|
||||||
if (room.players.size === 0) rooms.delete(room.id);
|
|
||||||
else broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === 'set_ready') {
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
if (!room) return;
|
|
||||||
const value = !!msg.ready;
|
|
||||||
room.state.ready[player.id] = value;
|
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === 'set_playlist') {
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
if (!room) return;
|
|
||||||
if (room.hostId !== player.id)
|
|
||||||
return send('error', { message: 'Only host can change playlist' });
|
|
||||||
if (room.state.status !== 'lobby')
|
|
||||||
return send('error', { message: 'Can only change playlist in lobby' });
|
|
||||||
const playlistId = String(msg.playlist || 'default');
|
|
||||||
room.state.playlist = playlistId;
|
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === 'start_game') {
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
if (!room) return;
|
|
||||||
if (room.hostId !== player.id) return send('error', { message: 'Only host can start' });
|
|
||||||
if (!room.state.playlist)
|
|
||||||
return send('error', { message: 'Please select a playlist first' });
|
|
||||||
const active = [...room.players.values()].filter(
|
|
||||||
(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' });
|
|
||||||
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.timeline = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, []]));
|
|
||||||
room.state.tokens = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, 2]));
|
|
||||||
// Load deck with the selected playlist
|
|
||||||
const playlistId = room.state.playlist || 'default';
|
|
||||||
room.deck = await loadDeck(playlistId);
|
|
||||||
room.discard = [];
|
|
||||||
room.state.phase = 'guess';
|
|
||||||
room.state.lastResult = null;
|
|
||||||
drawNextTrack(room);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === 'player_control') {
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
if (!room) return;
|
|
||||||
const { action } = msg;
|
|
||||||
if (room.state.status !== 'playing') return;
|
|
||||||
if (room.state.phase !== 'guess') return;
|
|
||||||
if (room.state.currentGuesser !== player.id) return;
|
|
||||||
if (!room.state.currentTrack) return;
|
|
||||||
if (action === 'pause') {
|
|
||||||
if (!room.state.paused) {
|
|
||||||
const now = Date.now();
|
|
||||||
if (room.state.trackStartAt) {
|
|
||||||
room.state.pausedPosSec = Math.max(0, (now - room.state.trackStartAt) / 1000);
|
|
||||||
}
|
|
||||||
room.state.paused = true;
|
|
||||||
stopSyncTimer(room);
|
|
||||||
}
|
|
||||||
broadcast(room, 'control', { action: 'pause' });
|
|
||||||
}
|
|
||||||
if (action === 'play') {
|
|
||||||
const now = Date.now();
|
|
||||||
const posSec = room.state.paused
|
|
||||||
? room.state.pausedPosSec
|
|
||||||
: Math.max(0, (now - (room.state.trackStartAt || now)) / 1000);
|
|
||||||
room.state.trackStartAt = now - Math.floor(posSec * 1000);
|
|
||||||
room.state.paused = false;
|
|
||||||
startSyncTimer(room);
|
|
||||||
broadcast(room, 'control', {
|
|
||||||
action: 'play',
|
|
||||||
startAt: room.state.trackStartAt,
|
|
||||||
serverNow: now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === 'place_guess') {
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
if (!room) return;
|
|
||||||
const { position, slot: rawSlot } = msg;
|
|
||||||
if (room.state.status !== 'playing') return send('error', { message: 'Game not playing' });
|
|
||||||
if (room.state.phase !== 'guess')
|
|
||||||
return send('error', { message: 'Not accepting guesses now' });
|
|
||||||
if (room.state.currentGuesser !== player.id)
|
|
||||||
return send('error', { message: 'Not your turn' });
|
|
||||||
const current = room.state.currentTrack;
|
|
||||||
if (!current) return send('error', { message: 'No current track' });
|
|
||||||
const tl = room.state.timeline[player.id] || [];
|
|
||||||
const n = tl.length;
|
|
||||||
let slot = Number.isInteger(rawSlot) ? rawSlot : null;
|
|
||||||
if (slot == null) {
|
|
||||||
if (position === 'before') slot = 0;
|
|
||||||
else if (position === 'after') slot = n;
|
|
||||||
}
|
|
||||||
if (typeof slot !== 'number' || slot < 0 || slot > n) slot = n;
|
|
||||||
let correct = false;
|
|
||||||
if (current.year != null) {
|
|
||||||
if (n === 0) correct = slot === 0;
|
|
||||||
else {
|
|
||||||
const left = slot > 0 ? tl[slot - 1]?.year : null;
|
|
||||||
const right = slot < n ? tl[slot]?.year : null;
|
|
||||||
const leftOk = left == null || current.year >= left;
|
|
||||||
const rightOk = right == null || current.year <= right;
|
|
||||||
correct = leftOk && rightOk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (correct) {
|
|
||||||
const newTl = tl.slice();
|
|
||||||
newTl.splice(slot, 0, {
|
|
||||||
trackId: current.id,
|
|
||||||
year: current.year,
|
|
||||||
title: current.title,
|
|
||||||
artist: current.artist,
|
|
||||||
});
|
|
||||||
room.state.timeline[player.id] = newTl;
|
|
||||||
} else {
|
|
||||||
room.discard.push(current);
|
|
||||||
}
|
|
||||||
room.state.phase = 'reveal';
|
|
||||||
room.state.lastResult = { playerId: player.id, correct };
|
|
||||||
broadcast(room, 'reveal', {
|
|
||||||
result: room.state.lastResult,
|
|
||||||
track: room.state.currentTrack,
|
|
||||||
});
|
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
||||||
const tlNow = room.state.timeline[player.id] || [];
|
|
||||||
if (correct && tlNow.length >= room.state.goal) {
|
|
||||||
room.state.status = 'ended';
|
|
||||||
room.state.winner = player.id;
|
|
||||||
broadcast(room, 'game_ended', { winner: player.id });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === 'earn_token') {
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
if (!room) return;
|
|
||||||
const tokens = room.state.tokens[player.id] ?? 0;
|
|
||||||
room.state.tokens[player.id] = Math.min(5, tokens + 1);
|
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === 'next_track') {
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
if (!room) return;
|
|
||||||
if (room.state.status !== 'playing') return;
|
|
||||||
if (room.state.phase !== 'reveal') return;
|
|
||||||
const isAuthorized = player.id === room.hostId || player.id === room.state.currentGuesser;
|
|
||||||
if (!isAuthorized) return;
|
|
||||||
room.state.currentTrack = null;
|
|
||||||
room.state.trackStartAt = null;
|
|
||||||
room.state.paused = false;
|
|
||||||
room.state.pausedPosSec = 0;
|
|
||||||
stopSyncTimer(room);
|
|
||||||
room.state.currentGuesser = nextPlayer(room.state.turnOrder, room.state.currentGuesser);
|
|
||||||
room.state.phase = 'guess';
|
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
||||||
drawNextTrack(room);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
clearTimeout(helloTimer);
|
|
||||||
// Mark player disconnected but keep them in the room for resume
|
|
||||||
try {
|
|
||||||
if (player) {
|
|
||||||
player.connected = false;
|
|
||||||
if (player.roomId && rooms.has(player.roomId)) {
|
|
||||||
const room = rooms.get(player.roomId);
|
|
||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
// Fuzzy matching helpers for title/artist guessing
|
|
||||||
|
|
||||||
function stripDiacritics(s) {
|
|
||||||
return String(s)
|
|
||||||
.normalize('NFKD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCommon(s) {
|
|
||||||
return stripDiacritics(String(s))
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s*(?:&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/g, ' ')
|
|
||||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanTitleNoise(raw) {
|
|
||||||
let s = String(raw);
|
|
||||||
s = s
|
|
||||||
.replace(/\(([^)]*remaster[^)]*)\)/gi, '')
|
|
||||||
.replace(/\(([^)]*radio edit[^)]*)\)/gi, '')
|
|
||||||
.replace(/\(([^)]*edit[^)]*)\)/gi, '')
|
|
||||||
.replace(/\(([^)]*version[^)]*)\)/gi, '')
|
|
||||||
.replace(/\(([^)]*live[^)]*)\)/gi, '')
|
|
||||||
.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
|
|
||||||
s = s.replace(
|
|
||||||
/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi,
|
|
||||||
''
|
|
||||||
);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTitle(s) {
|
|
||||||
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.
|
|
||||||
function stripOptionalSegments(raw) {
|
|
||||||
let s = String(raw);
|
|
||||||
// Remove double-quoted segments first
|
|
||||||
s = s.replace(/"[^"]*"/g, ' ');
|
|
||||||
// Remove parenthetical segments (non-nested)
|
|
||||||
s = s.replace(/\([^)]*\)/g, ' ');
|
|
||||||
// Remove square-bracket segments [ ... ] (non-nested)
|
|
||||||
s = s.replace(/\[[^\]]*\]/g, ' ');
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTitleBaseOptional(s) {
|
|
||||||
// Clean general noise, then drop optional quoted/parenthetical parts, then normalize
|
|
||||||
return normalizeCommon(stripOptionalSegments(cleanTitleNoise(s)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenize(s) {
|
|
||||||
return s ? String(s).split(' ').filter(Boolean) : [];
|
|
||||||
}
|
|
||||||
function tokenSet(s) {
|
|
||||||
return new Set(tokenize(s));
|
|
||||||
}
|
|
||||||
function jaccard(a, b) {
|
|
||||||
const A = tokenSet(a),
|
|
||||||
B = tokenSet(b);
|
|
||||||
if (A.size === 0 && B.size === 0) return 1;
|
|
||||||
let inter = 0;
|
|
||||||
for (const t of A) if (B.has(t)) inter++;
|
|
||||||
const union = A.size + B.size - inter;
|
|
||||||
return union ? inter / union : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function levenshtein(a, b) {
|
|
||||||
a = String(a);
|
|
||||||
b = String(b);
|
|
||||||
const m = a.length,
|
|
||||||
n = b.length;
|
|
||||||
if (!m) return n;
|
|
||||||
if (!n) return m;
|
|
||||||
const dp = new Array(n + 1);
|
|
||||||
for (let j = 0; j <= n; j++) dp[j] = j;
|
|
||||||
for (let i = 1; i <= m; i++) {
|
|
||||||
let prev = dp[0];
|
|
||||||
dp[0] = i;
|
|
||||||
for (let j = 1; j <= n; j++) {
|
|
||||||
const temp = dp[j];
|
|
||||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
||||||
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
|
|
||||||
prev = temp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dp[n];
|
|
||||||
}
|
|
||||||
function simRatio(a, b) {
|
|
||||||
if (!a && !b) return 1;
|
|
||||||
if (!a || !b) return 0;
|
|
||||||
const d = levenshtein(a, b);
|
|
||||||
return 1 - d / Math.max(String(a).length, String(b).length);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitArtists(raw) {
|
|
||||||
const unified = String(raw)
|
|
||||||
.replace(/\s*(?:,|&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/gi, ',')
|
|
||||||
.replace(/,+/g, ',')
|
|
||||||
.replace(/(^,|,$)/g, '');
|
|
||||||
const parts = unified.split(',').map(normalizeArtist).filter(Boolean);
|
|
||||||
return Array.from(new Set(parts));
|
|
||||||
}
|
|
||||||
|
|
||||||
const TITLE_SIM_THRESHOLD = 0.86;
|
|
||||||
const TITLE_JACCARD_THRESHOLD = 0.8;
|
|
||||||
const ARTIST_SIM_THRESHOLD = 0.82;
|
|
||||||
|
|
||||||
export function scoreTitle(guessRaw, truthRaw) {
|
|
||||||
// Full normalized (keeps parentheses/quotes content after punctuation cleanup)
|
|
||||||
const gFull = normalizeTitle(guessRaw);
|
|
||||||
const tFull = normalizeTitle(truthRaw);
|
|
||||||
// Base normalized (treat anything in () or "" as optional and remove it)
|
|
||||||
const gBase = normalizeTitleBaseOptional(guessRaw);
|
|
||||||
const tBase = normalizeTitleBaseOptional(truthRaw);
|
|
||||||
|
|
||||||
const pairs = [
|
|
||||||
[gFull, tFull],
|
|
||||||
[gFull, tBase],
|
|
||||||
[gBase, tFull],
|
|
||||||
[gBase, tBase],
|
|
||||||
];
|
|
||||||
|
|
||||||
let bestSim = 0;
|
|
||||||
let bestJac = 0;
|
|
||||||
let pass = false;
|
|
||||||
let bestPair = pairs[0];
|
|
||||||
for (const [g, t] of pairs) {
|
|
||||||
const sim = simRatio(g, t);
|
|
||||||
const jac = jaccard(g, t);
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { pass, sim: bestSim, jac: bestJac, g: bestPair[0], t: bestPair[1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function scoreArtist(guessRaw, truthArtistsRaw, primaryCount) {
|
|
||||||
const truthArtists = (truthArtistsRaw || []).map((a) => normalizeArtist(a));
|
|
||||||
const truthSet = new Set(truthArtists);
|
|
||||||
const guessArtists = splitArtists(guessRaw);
|
|
||||||
const matches = new Set();
|
|
||||||
for (const ga of guessArtists) {
|
|
||||||
for (const ta of truthSet) {
|
|
||||||
const s = simRatio(ga, ta);
|
|
||||||
if (s >= ARTIST_SIM_THRESHOLD) matches.add(ta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const primary = truthArtists.slice(0, primaryCount || truthArtists.length);
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
return { pass, best, matched: Array.from(matches), guessArtists, truthArtists };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { scoreTitle, scoreArtist, splitArtists };
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { parseFile as mmParseFile } from 'music-metadata';
|
|
||||||
import { DATA_DIR } from '../config.js';
|
|
||||||
import { loadYearsIndex } from '../years.js';
|
|
||||||
import { shuffle } from './state.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of available playlists (subdirectories in data folder).
|
|
||||||
* Also includes a special "default" playlist if there are audio files in the root data directory.
|
|
||||||
* @returns {Array<{id: string, name: string, trackCount: number}>}
|
|
||||||
*/
|
|
||||||
export function getAvailablePlaylists() {
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(DATA_DIR, { withFileTypes: true });
|
|
||||||
const playlists = [];
|
|
||||||
|
|
||||||
// Check for files in root directory (legacy/default playlist)
|
|
||||||
const rootFiles = entries.filter(
|
|
||||||
(e) => e.isFile() && /\.(mp3|wav|m4a|ogg|opus)$/i.test(e.name)
|
|
||||||
);
|
|
||||||
if (rootFiles.length > 0) {
|
|
||||||
playlists.push({
|
|
||||||
id: 'default',
|
|
||||||
name: 'Default (Root Folder)',
|
|
||||||
trackCount: rootFiles.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check subdirectories for playlists
|
|
||||||
const subdirs = entries.filter((e) => e.isDirectory());
|
|
||||||
for (const dir of subdirs) {
|
|
||||||
const dirPath = path.join(DATA_DIR, dir.name);
|
|
||||||
const dirFiles = fs.readdirSync(dirPath).filter((f) => /\.(mp3|wav|m4a|ogg|opus)$/i.test(f));
|
|
||||||
if (dirFiles.length > 0) {
|
|
||||||
playlists.push({
|
|
||||||
id: dir.name,
|
|
||||||
name: dir.name,
|
|
||||||
trackCount: dirFiles.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return playlists;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading playlists:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a deck of tracks from a specific playlist.
|
|
||||||
* @param {string} [playlistId='default'] - The playlist ID to load from
|
|
||||||
* @returns {Promise<Array>} Shuffled array of track objects
|
|
||||||
*/
|
|
||||||
export async function loadDeck(playlistId = 'default') {
|
|
||||||
// Load the years index for this specific playlist
|
|
||||||
const years = loadYearsIndex(playlistId);
|
|
||||||
|
|
||||||
// Determine the directory to load from
|
|
||||||
const targetDir = playlistId === 'default' ? DATA_DIR : path.join(DATA_DIR, playlistId);
|
|
||||||
|
|
||||||
// Validate that the directory exists
|
|
||||||
if (!fs.existsSync(targetDir)) {
|
|
||||||
console.error(`Playlist directory not found: ${targetDir}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = fs.readdirSync(targetDir).filter((f) => /\.(mp3|wav|m4a|ogg|opus)$/i.test(f));
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
console.warn(`No audio files found in playlist: ${playlistId}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process files in batches to avoid "too many open files" error
|
|
||||||
const BATCH_SIZE = 50; // Process 50 files at a time
|
|
||||||
const tracks = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
||||||
const batch = files.slice(i, i + BATCH_SIZE);
|
|
||||||
const batchTracks = await Promise.all(
|
|
||||||
batch.map(async (f) => {
|
|
||||||
const fp = path.join(targetDir, f);
|
|
||||||
// For file paths, we need to include the playlist subdirectory if not default
|
|
||||||
const relativeFile = playlistId === 'default' ? f : path.join(playlistId, f);
|
|
||||||
|
|
||||||
// Get metadata from JSON first (priority), then from audio file as fallback
|
|
||||||
const jsonMeta = years[f];
|
|
||||||
let year = jsonMeta?.year ?? null;
|
|
||||||
let title = jsonMeta?.title ?? path.parse(f).name;
|
|
||||||
let artist = jsonMeta?.artist ?? '';
|
|
||||||
|
|
||||||
// Only parse audio file if JSON doesn't have title or artist metadata
|
|
||||||
// Note: year is ONLY taken from years.json, never from audio file
|
|
||||||
if (!jsonMeta || !jsonMeta.title || !jsonMeta.artist) {
|
|
||||||
try {
|
|
||||||
const meta = await mmParseFile(fp, { duration: false });
|
|
||||||
title = jsonMeta?.title || meta.common.title || title;
|
|
||||||
artist = jsonMeta?.artist || meta.common.artist || artist;
|
|
||||||
// year remains from years.json only - do not use meta.common.year
|
|
||||||
} catch (err) {
|
|
||||||
// Log error but continue processing
|
|
||||||
console.warn(`Failed to parse metadata for ${f}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { id: f, file: relativeFile, title, artist, year };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
tracks.push(...batchTracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Loaded ${tracks.length} tracks from playlist: ${playlistId}`);
|
|
||||||
return shuffle(tracks);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
export const rooms = new Map();
|
|
||||||
|
|
||||||
export function shuffle(arr) {
|
|
||||||
const a = [...arr];
|
|
||||||
for (let i = a.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[a[i], a[j]] = [a[j], a[i]];
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextPlayer(turnOrder, currentId) {
|
|
||||||
if (!turnOrder.length) return null;
|
|
||||||
if (!currentId) return turnOrder[0];
|
|
||||||
const idx = turnOrder.indexOf(currentId);
|
|
||||||
return turnOrder[(idx + 1) % turnOrder.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRoom(name, host) {
|
|
||||||
const id = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
||||||
const room = {
|
|
||||||
id,
|
|
||||||
name: name || `Room ${id}`,
|
|
||||||
hostId: host.id,
|
|
||||||
players: new Map([[host.id, host]]),
|
|
||||||
deck: [],
|
|
||||||
discard: [],
|
|
||||||
revealTimer: null,
|
|
||||||
syncTimer: null,
|
|
||||||
state: {
|
|
||||||
status: 'lobby',
|
|
||||||
turnOrder: [],
|
|
||||||
currentGuesser: null,
|
|
||||||
currentTrack: null,
|
|
||||||
timeline: {},
|
|
||||||
tokens: {},
|
|
||||||
ready: { [host.id]: false },
|
|
||||||
spectators: {},
|
|
||||||
phase: 'guess',
|
|
||||||
lastResult: null,
|
|
||||||
trackStartAt: null,
|
|
||||||
paused: false,
|
|
||||||
pausedPosSec: 0,
|
|
||||||
goal: 10,
|
|
||||||
playlist: null, // Selected playlist for this room (must be chosen by host)
|
|
||||||
},
|
|
||||||
};
|
|
||||||
rooms.set(id, room);
|
|
||||||
return room;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function broadcast(room, type, payload) {
|
|
||||||
for (const p of room.players.values()) {
|
|
||||||
try {
|
|
||||||
p.ws?.emit?.('message', { type, ...payload });
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function roomSummary(room) {
|
|
||||||
return {
|
|
||||||
id: room.id,
|
|
||||||
name: room.name,
|
|
||||||
hostId: room.hostId,
|
|
||||||
players: [...room.players.values()].map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
name: p.name,
|
|
||||||
connected: p.connected,
|
|
||||||
ready: !!room.state.ready?.[p.id],
|
|
||||||
spectator: !!room.state.spectators?.[p.id] || !!p.spectator,
|
|
||||||
})),
|
|
||||||
state: room.state,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { broadcast } from './state.js';
|
|
||||||
|
|
||||||
export function startSyncTimer(room) {
|
|
||||||
if (room.syncTimer) clearInterval(room.syncTimer);
|
|
||||||
room.syncTimer = setInterval(() => {
|
|
||||||
if (
|
|
||||||
room.state.status !== 'playing' ||
|
|
||||||
!room.state.currentTrack ||
|
|
||||||
!room.state.trackStartAt ||
|
|
||||||
room.state.paused
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
broadcast(room, 'sync', { startAt: room.state.trackStartAt, serverNow: Date.now() });
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopSyncTimer(room) {
|
|
||||||
if (room.syncTimer) {
|
|
||||||
clearInterval(room.syncTimer);
|
|
||||||
room.syncTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import http from 'http';
|
|
||||||
import fs from 'fs';
|
|
||||||
import { PORT, DATA_DIR, PUBLIC_DIR } from './config.js';
|
|
||||||
import { registerAudioRoutes } from './routes/audio.js';
|
|
||||||
import { registerTracksApi } from './tracks.js';
|
|
||||||
import { loadYearsIndex } from './years.js';
|
|
||||||
import { setupWebSocket } from './game.js';
|
|
||||||
import { getAvailablePlaylists } from './game/deck.js';
|
|
||||||
|
|
||||||
// Ensure data dir exists
|
|
||||||
if (!fs.existsSync(DATA_DIR)) {
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// Static client
|
|
||||||
app.use(express.static(PUBLIC_DIR));
|
|
||||||
|
|
||||||
// Years reload endpoint (supports optional playlist parameter)
|
|
||||||
app.get('/api/reload-years', (req, res) => {
|
|
||||||
const playlistId = req.query.playlist || 'default';
|
|
||||||
const years = loadYearsIndex(playlistId);
|
|
||||||
res.json({ ok: true, count: Object.keys(years).length, playlist: playlistId });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Playlists endpoint
|
|
||||||
app.get('/api/playlists', (req, res) => {
|
|
||||||
const playlists = getAvailablePlaylists();
|
|
||||||
res.json({ ok: true, playlists });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
registerAudioRoutes(app);
|
|
||||||
registerTracksApi(app);
|
|
||||||
|
|
||||||
const server = http.createServer(app);
|
|
||||||
setupWebSocket(server);
|
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`Hitstar server running on http://localhost:${PORT}`);
|
|
||||||
});
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import path from 'path';
|
|
||||||
import { TOKEN_TTL_MS_DEFAULT, putToken, getToken } from './audio/tokenStore.js';
|
|
||||||
import { resolveSafePath, fileExists, statFile, getMimeType } from './audio/pathUtils.js';
|
|
||||||
import { headFile, streamFile } from './audio/streaming.js';
|
|
||||||
import { getCoverForFile } from './audio/coverService.js';
|
|
||||||
|
|
||||||
export function createAudioToken(name, ttlMs = TOKEN_TTL_MS_DEFAULT) {
|
|
||||||
let resolved = resolveSafePath(name);
|
|
||||||
if (!resolved) throw new Error('Invalid path');
|
|
||||||
if (!fileExists(resolved)) throw new Error('Not found');
|
|
||||||
|
|
||||||
// Prefer .opus sibling for streaming, to save bandwidth
|
|
||||||
const ext = path.extname(resolved).toLowerCase();
|
|
||||||
if (ext !== '.opus') {
|
|
||||||
const opusCandidate = resolved.slice(0, -ext.length) + '.opus';
|
|
||||||
if (fileExists(opusCandidate)) {
|
|
||||||
resolved = opusCandidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stat = statFile(resolved);
|
|
||||||
const type = getMimeType(resolved, 'audio/mpeg');
|
|
||||||
return putToken({ path: resolved, mime: type, size: stat.size }, ttlMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerAudioRoutes(app) {
|
|
||||||
const ENABLE_NAME_ENDPOINT = process.env.AUDIO_DEBUG_NAMES === '1';
|
|
||||||
|
|
||||||
// HEAD by token
|
|
||||||
app.head('/audio/t/:token', (req, res) => {
|
|
||||||
const token = String(req.params.token || '');
|
|
||||||
const info = getToken(token);
|
|
||||||
if (!info) return res.status(404).end();
|
|
||||||
return headFile(res, { size: info.size, mime: info.mime || 'audio/mpeg' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET by token (Range support)
|
|
||||||
app.get('/audio/t/:token', (req, res) => {
|
|
||||||
const token = String(req.params.token || '');
|
|
||||||
const info = getToken(token);
|
|
||||||
if (!info) return res.status(404).send('Not found');
|
|
||||||
try {
|
|
||||||
return streamFile(req, res, { filePath: info.path, mime: info.mime || 'audio/mpeg' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Stream error (token):', error);
|
|
||||||
return res.status(500).send('Stream error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ENABLE_NAME_ENDPOINT) {
|
|
||||||
app.head('/audio/:name', (req, res) => {
|
|
||||||
const filePath = resolveSafePath(req.params.name);
|
|
||||||
if (!filePath) return res.status(400).end();
|
|
||||||
if (!fileExists(filePath)) return res.status(404).end();
|
|
||||||
const { size } = statFile(filePath);
|
|
||||||
const type = getMimeType(filePath, 'audio/mpeg');
|
|
||||||
return headFile(res, { size, mime: type });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/audio/:name', (req, res) => {
|
|
||||||
const filePath = resolveSafePath(req.params.name);
|
|
||||||
if (!filePath) return res.status(400).send('Invalid path');
|
|
||||||
if (!fileExists(filePath)) return res.status(404).send('Not found');
|
|
||||||
const type = getMimeType(filePath, 'audio/mpeg');
|
|
||||||
try {
|
|
||||||
return streamFile(req, res, { filePath, mime: type });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Stream error (name):', error);
|
|
||||||
return res.status(500).send('Stream error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Explicitly block the name-based endpoint to avoid leaking song names
|
|
||||||
app.all('/audio/:name', (req, res) => res.status(404).send('Not found'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve embedded cover art from audio files, if present
|
|
||||||
app.get('/cover/:name(*)', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const requestedName = req.params.name;
|
|
||||||
let resolved = resolveSafePath(requestedName);
|
|
||||||
if (!resolved) return res.status(400).send('Invalid path');
|
|
||||||
let exists = fileExists(resolved);
|
|
||||||
|
|
||||||
// If the requested file doesn't exist, try alternative strategies
|
|
||||||
if (!exists) {
|
|
||||||
const extensions = ['.opus', '.mp3', '.m4a', '.wav', '.ogg'];
|
|
||||||
const baseName = requestedName.replace(/\.[^.]+$/i, ''); // Remove extension
|
|
||||||
const originalExt = path.extname(requestedName).toLowerCase();
|
|
||||||
let foundFile = null;
|
|
||||||
|
|
||||||
// Strategy 1: Try alternative extensions in the same location
|
|
||||||
for (const ext of extensions) {
|
|
||||||
if (ext === originalExt) continue;
|
|
||||||
const altName = baseName + ext;
|
|
||||||
const altResolved = resolveSafePath(altName);
|
|
||||||
if (altResolved && fileExists(altResolved)) {
|
|
||||||
foundFile = altResolved;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: If not found and path doesn't contain subdirectory, search in playlist folders
|
|
||||||
if (!foundFile && !requestedName.includes('/') && !requestedName.includes('\\')) {
|
|
||||||
const { DATA_DIR } = await import('../config.js');
|
|
||||||
const fs = await import('fs');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get all subdirectories (playlists)
|
|
||||||
const entries = fs.readdirSync(DATA_DIR, { withFileTypes: true });
|
|
||||||
const subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
||||||
|
|
||||||
// Try to find the file in each playlist folder with all extensions
|
|
||||||
for (const subdir of subdirs) {
|
|
||||||
for (const ext of extensions) {
|
|
||||||
const fileName = path.basename(baseName) + ext;
|
|
||||||
const playlistPath = path.join(subdir, fileName);
|
|
||||||
const playlistResolved = resolveSafePath(playlistPath);
|
|
||||||
if (playlistResolved && fileExists(playlistResolved)) {
|
|
||||||
foundFile = playlistResolved;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (foundFile) break;
|
|
||||||
}
|
|
||||||
} catch (searchErr) {
|
|
||||||
console.debug('Error searching playlists:', searchErr.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundFile) {
|
|
||||||
return res.status(404).send('Not found');
|
|
||||||
}
|
|
||||||
resolved = foundFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cover = await getCoverForFile(resolved);
|
|
||||||
if (!cover) return res.status(404).send('No cover');
|
|
||||||
res.setHeader('Content-Type', cover.mime || 'image/jpeg');
|
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
|
||||||
return res.status(200).end(cover.buf);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading cover:', error);
|
|
||||||
return res.status(500).send('Error reading cover');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { LRUCache } from 'lru-cache';
|
|
||||||
import { parseFile as mmParseFile } from 'music-metadata';
|
|
||||||
|
|
||||||
const COVER_CACHE_MAX_ITEMS = 256;
|
|
||||||
const COVER_CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
||||||
|
|
||||||
const coverCache = new LRUCache({
|
|
||||||
max: COVER_CACHE_MAX_ITEMS,
|
|
||||||
maxSize: COVER_CACHE_MAX_BYTES,
|
|
||||||
sizeCalculation: (v) => (v?.buf?.length ? v.buf.length : 0),
|
|
||||||
ttl: 5 * 60 * 1000,
|
|
||||||
ttlAutopurge: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function getCoverForFile(resolvedPath) {
|
|
||||||
const cached = coverCache.get(resolvedPath);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const meta = await mmParseFile(resolvedPath, { duration: false });
|
|
||||||
const pic = meta.common?.picture?.[0];
|
|
||||||
if (!pic?.data?.length) return null;
|
|
||||||
const mime = pic.format || 'image/jpeg';
|
|
||||||
const buf = Buffer.from(pic.data);
|
|
||||||
const value = { mime, buf };
|
|
||||||
coverCache.set(resolvedPath, value);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import mime from 'mime';
|
|
||||||
import { DATA_DIR as RAW_DATA_DIR } from '../../config.js';
|
|
||||||
|
|
||||||
// Resolve DATA_DIR once, ensure absolute path and normalized with trailing separator for prefix checks
|
|
||||||
const DATA_DIR = path.resolve(RAW_DATA_DIR);
|
|
||||||
const DATA_DIR_WITH_SEP = DATA_DIR.endsWith(path.sep) ? DATA_DIR : DATA_DIR + path.sep;
|
|
||||||
|
|
||||||
export function resolveSafePath(name) {
|
|
||||||
if (!name || typeof name !== 'string') return null;
|
|
||||||
// Prevent path traversal and normalize input
|
|
||||||
const joined = path.join(DATA_DIR, name);
|
|
||||||
const resolved = path.resolve(joined);
|
|
||||||
if (resolved === DATA_DIR || resolved.startsWith(DATA_DIR_WITH_SEP)) return resolved;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fileExists(p) {
|
|
||||||
try {
|
|
||||||
// Ensure the path is safe before checking existence
|
|
||||||
return fs.existsSync(p);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function statFile(p) {
|
|
||||||
return fs.statSync(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMimeType(p, fallback = 'audio/mpeg') {
|
|
||||||
const t = mime.getType(p) || fallback;
|
|
||||||
// Some clients expect audio/ogg for .opus in Ogg container
|
|
||||||
if (/\.opus$/i.test(p)) return 'audio/ogg';
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
export function setCommonNoCacheHeaders(res) {
|
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
|
||||||
res.setHeader('Accept-Ranges', 'bytes');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function headFile(res, { size, mime }) {
|
|
||||||
setCommonNoCacheHeaders(res);
|
|
||||||
res.setHeader('Content-Type', mime);
|
|
||||||
res.setHeader('Content-Length', size);
|
|
||||||
res.status(200).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function streamFile(req, res, { filePath, mime }) {
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
const size = stat.size;
|
|
||||||
const range = req.headers.range;
|
|
||||||
setCommonNoCacheHeaders(res);
|
|
||||||
if (range) {
|
|
||||||
const match = /bytes=(\d+)-(\d+)?/.exec(range);
|
|
||||||
let start = match?.[1] ? parseInt(match[1], 10) : 0;
|
|
||||||
let end = match?.[2] ? parseInt(match[2], 10) : size - 1;
|
|
||||||
if (Number.isNaN(start)) start = 0;
|
|
||||||
if (Number.isNaN(end)) end = size - 1;
|
|
||||||
start = Math.min(Math.max(0, start), Math.max(0, size - 1));
|
|
||||||
end = Math.min(Math.max(start, end), Math.max(0, size - 1));
|
|
||||||
if (start > end || start >= size) {
|
|
||||||
res.setHeader('Content-Range', `bytes */${size}`);
|
|
||||||
return res.status(416).end();
|
|
||||||
}
|
|
||||||
const chunkSize = end - start + 1;
|
|
||||||
res.writeHead(206, {
|
|
||||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
|
||||||
'Content-Length': chunkSize,
|
|
||||||
'Content-Type': mime,
|
|
||||||
});
|
|
||||||
fs.createReadStream(filePath, { start, end }).pipe(res);
|
|
||||||
} else {
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Length': size,
|
|
||||||
'Content-Type': mime,
|
|
||||||
});
|
|
||||||
fs.createReadStream(filePath).pipe(res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import { LRUCache } from 'lru-cache';
|
|
||||||
|
|
||||||
const TOKEN_TTL_MS_DEFAULT = 10 * 60 * 1000; // 10 minutes
|
|
||||||
|
|
||||||
// A bounded LRU with TTL per entry. Values: { path, mime, size }
|
|
||||||
const tokenCache = new LRUCache({
|
|
||||||
max: 2048,
|
|
||||||
ttl: TOKEN_TTL_MS_DEFAULT,
|
|
||||||
ttlAutopurge: true,
|
|
||||||
allowStale: false,
|
|
||||||
updateAgeOnGet: false,
|
|
||||||
updateAgeOnHas: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function genToken() {
|
|
||||||
return crypto.randomBytes(16).toString('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function putToken(value, ttlMs = TOKEN_TTL_MS_DEFAULT) {
|
|
||||||
const token = genToken();
|
|
||||||
tokenCache.set(token, value, { ttl: Math.max(1000, ttlMs) });
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getToken(token) {
|
|
||||||
if (!token) return null;
|
|
||||||
return tokenCache.get(String(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
export { TOKEN_TTL_MS_DEFAULT };
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { parseFile as mmParseFile } from 'music-metadata';
|
|
||||||
import { getPlaylistDir } from './config.js';
|
|
||||||
import { loadYearsIndex } from './years.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List tracks from a specific playlist
|
|
||||||
* @param {string} [playlistId='default'] - The playlist ID to list tracks from
|
|
||||||
* @returns {Promise<Array>} Array of track objects
|
|
||||||
*/
|
|
||||||
export async function listTracks(playlistId = 'default') {
|
|
||||||
const years = loadYearsIndex(playlistId);
|
|
||||||
const targetDir = getPlaylistDir(playlistId);
|
|
||||||
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(targetDir)
|
|
||||||
.filter((f) => /\.(mp3|wav|m4a|ogg|opus)$/i.test(f))
|
|
||||||
.filter((f) => {
|
|
||||||
// If both base.ext and base.opus exist, list only once (prefer .opus)
|
|
||||||
const ext = path.extname(f).toLowerCase();
|
|
||||||
if (ext === '.opus') return true;
|
|
||||||
const opusTwin = f.replace(/\.[^.]+$/i, '.opus');
|
|
||||||
return !fs.existsSync(path.join(targetDir, opusTwin));
|
|
||||||
});
|
|
||||||
const tracks = await Promise.all(
|
|
||||||
files.map(async (f) => {
|
|
||||||
// Prefer .opus for playback if exists
|
|
||||||
const ext = path.extname(f).toLowerCase();
|
|
||||||
const opusName = ext === '.opus' ? f : f.replace(/\.[^.]+$/i, '.opus');
|
|
||||||
const chosen = fs.existsSync(path.join(targetDir, opusName)) ? opusName : f;
|
|
||||||
const fp = path.join(targetDir, chosen);
|
|
||||||
|
|
||||||
// Get metadata from JSON first (priority), then from audio file as fallback
|
|
||||||
const jsonMeta = years[f] || years[chosen];
|
|
||||||
let year = jsonMeta?.year ?? null;
|
|
||||||
let title = jsonMeta?.title ?? path.parse(f).name;
|
|
||||||
let artist = jsonMeta?.artist ?? '';
|
|
||||||
|
|
||||||
// Only parse audio file if JSON doesn't have title or artist metadata
|
|
||||||
// Note: year is ONLY taken from years.json, never from audio file
|
|
||||||
if (!jsonMeta || !jsonMeta.title || !jsonMeta.artist) {
|
|
||||||
try {
|
|
||||||
const meta = await mmParseFile(fp, { duration: false });
|
|
||||||
title = jsonMeta?.title || meta.common.title || title;
|
|
||||||
artist = jsonMeta?.artist || meta.common.artist || artist;
|
|
||||||
// year remains from years.json only - do not use meta.common.year
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { id: chosen, file: chosen, title, artist, year };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerTracksApi(app) {
|
|
||||||
app.get('/api/tracks', async (req, res) => {
|
|
||||||
try {
|
|
||||||
// Support optional playlist parameter (defaults to 'default')
|
|
||||||
const playlistId = req.query.playlist || 'default';
|
|
||||||
const tracks = await listTracks(playlistId);
|
|
||||||
res.json({ tracks, playlist: playlistId });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import { YEARS_PATH, getYearsPath } from './config.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load years index for a specific playlist
|
|
||||||
* @param {string} [playlistId='default'] - The playlist ID to load years for
|
|
||||||
* @returns {Object} The years index (byFile mapping)
|
|
||||||
*/
|
|
||||||
export function loadYearsIndex(playlistId = 'default') {
|
|
||||||
const yearsPath = getYearsPath(playlistId);
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(yearsPath, 'utf8');
|
|
||||||
const j = JSON.parse(raw);
|
|
||||||
if (j && j.byFile && typeof j.byFile === 'object') return j.byFile;
|
|
||||||
} catch {
|
|
||||||
console.debug(`No years.json found for playlist '${playlistId}' at ${yearsPath}`);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy function for backward compatibility - loads from root
|
|
||||||
* @deprecated Use loadYearsIndex(playlistId) instead
|
|
||||||
*/
|
|
||||||
export function loadYearsIndexLegacy() {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(YEARS_PATH, 'utf8');
|
|
||||||
const j = JSON.parse(raw);
|
|
||||||
if (j && j.byFile && typeof j.byFile === 'object') return j.byFile;
|
|
||||||
} catch {}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
2160
tmp_tracks.json
2160
tmp_tracks.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user