Compare commits
12 Commits
d17c78fa68
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
9373726347
|
|||
|
a1f1b41987
|
|||
|
8ca744cd5b
|
|||
|
c9be49d988
|
|||
|
70be1e7e39
|
|||
|
18d14b097d
|
|||
|
1dbae8b62b
|
|||
| 47b0caa52b | |||
|
445f522fa8
|
|||
|
bec0a3b72f
|
|||
|
ab87e65e18
|
|||
|
58c668de63
|
@@ -11,4 +11,11 @@ data/*
|
||||
*.wav
|
||||
*.m4a
|
||||
*.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"
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--target production \
|
||||
-f "$DOCKERFILE" \
|
||||
$TAG_ARGS \
|
||||
--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
|
||||
FROM node:22-alpine
|
||||
# Multi-stage Dockerfile for Hitstar Deno Server
|
||||
# Supports both development and production environments
|
||||
|
||||
# Base stage with common dependencies
|
||||
FROM denoland/deno:latest AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
# 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 all source files first for dependency resolution
|
||||
COPY src/server-deno/ .
|
||||
|
||||
# Copy app source (media lives outside via volume)
|
||||
COPY . .
|
||||
# Cache all dependencies based on deno.json imports
|
||||
RUN deno cache main.ts
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
# Development stage
|
||||
FROM base AS development
|
||||
|
||||
ENV DENO_ENV=development \
|
||||
PORT=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
|
||||
|
||||
```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:**
|
||||
This backend follows **Clean Architecture** principles with clear separation of concerns:
|
||||
|
||||
```
|
||||
data/
|
||||
├── (Audio-Dateien hier = "Default" Playlist)
|
||||
├── 80s-Hits/
|
||||
│ ├── Song1.opus
|
||||
│ ├── Song2.opus
|
||||
│ └── ...
|
||||
├── Rock-Classics/
|
||||
│ ├── Song1.opus
|
||||
│ └── ...
|
||||
└── Party-Mix/
|
||||
├── Song1.opus
|
||||
└── ...
|
||||
src/server-deno/
|
||||
├── domain/ # Domain models, types, and business rules (no dependencies)
|
||||
├── application/ # Use cases and application services
|
||||
├── infrastructure/ # External concerns (file system, networking, etc.)
|
||||
├── presentation/ # HTTP routes, WebSocket handlers
|
||||
├── shared/ # Shared utilities, constants, types
|
||||
└── main.ts # Application entry point and DI setup
|
||||
```
|
||||
|
||||
**So funktioniert's:**
|
||||
### Layers
|
||||
|
||||
1. **Standard-Playlist**: Audio-Dateien direkt im `data/`-Ordner werden als "Default"-Playlist erkannt
|
||||
2. **Eigene Playlists**: Erstelle Unterordner im `data/`-Verzeichnis, z.B. `data/80s-Hits/`
|
||||
3. **Playlist-Auswahl**: Als Raum-Host kannst du in der Lobby die gewünschte Playlist auswählen, bevor das Spiel startet
|
||||
4. **Unterstützte Formate**: .mp3, .wav, .m4a, .ogg, .opus
|
||||
1. **Domain Layer**: Pure business logic and types
|
||||
- No external dependencies
|
||||
- Models: Player, Room, Track, GameState
|
||||
- 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, …).
|
||||
- Legt eure Musik lokal in `data/`. Diese Dateien werden nicht ins Git-Repo eingecheckt und bleiben nur auf eurem Rechner.
|
||||
4. **Presentation Layer**: API and WebSocket
|
||||
- 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)
|
||||
- Team-Modus, Pro-/Expert-Regeln, exaktes Jahr
|
||||
- Persistenz (Räume/Spielstände), Reconnect
|
||||
- Drag & Drop-Zeitleiste, visuelle Platzierung
|
||||
### Development
|
||||
```bash
|
||||
deno task dev
|
||||
```
|
||||
|
||||
## 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 |
File diff suppressed because it is too large
Load Diff
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:
|
||||
# Production service
|
||||
hitstar:
|
||||
build: .
|
||||
image: hitstar-webapp:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
image: hitstar-deno:prod
|
||||
container_name: hitstar
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DENO_ENV=production
|
||||
- PORT=5173
|
||||
ports:
|
||||
- "5173:5173"
|
||||
- "0.0.0.0:5173:5173"
|
||||
volumes:
|
||||
- ./data:/app/data:rw
|
||||
restart: unless-stopped
|
||||
- ./data:/app/data:ro
|
||||
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" />
|
||||
<title>Hitstar Web</title>
|
||||
<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>
|
||||
@keyframes record-spin {
|
||||
from {
|
||||
@@ -31,6 +32,80 @@
|
||||
#dashboard[open] .dashboard-chevron {
|
||||
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>
|
||||
</head>
|
||||
|
||||
@@ -48,6 +123,10 @@
|
||||
Code kopiert!
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -129,15 +208,33 @@
|
||||
<!-- Playlist Selection (only shown in lobby for host) -->
|
||||
<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">
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
🎵 Playlist auswählen
|
||||
</label>
|
||||
<select id="playlistSelect"
|
||||
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>
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
Als Host kannst du die Playlist für dieses Spiel wählen.
|
||||
<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">
|
||||
🎵 Playlist auswählen
|
||||
</label>
|
||||
<select id="playlistSelect"
|
||||
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>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
@@ -158,6 +255,11 @@
|
||||
<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>
|
||||
</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 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">
|
||||
@@ -186,7 +288,7 @@
|
||||
<div id="revealBanner" class="hidden"></div>
|
||||
</div>
|
||||
<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">
|
||||
<!-- Record Disc -->
|
||||
<div class="relative" style="width: 200px; height: 200px">
|
||||
@@ -227,10 +329,15 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
Lautstärke
|
||||
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-40 accent-indigo-600" />
|
||||
🎵 Musik
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,17 +377,26 @@
|
||||
<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>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div id="placeArea" class="hidden flex items-center gap-2">
|
||||
<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>
|
||||
<button id="placeBtn"
|
||||
class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
Platzieren
|
||||
</button>
|
||||
<div id="placeArea" class="hidden w-full">
|
||||
<div class="flex flex-col sm:flex-row gap-2 w-full">
|
||||
<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 w-full sm:flex-1 sm:min-w-0"></select>
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<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 flex-1 sm:flex-none">
|
||||
Platzieren
|
||||
</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>
|
||||
<div id="nextArea" class="hidden">
|
||||
<div id="nextArea" class="hidden w-full sm:w-auto">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -295,6 +411,27 @@
|
||||
</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>
|
||||
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,
|
||||
$np,
|
||||
$placeArea,
|
||||
$placeWithTokensBtn,
|
||||
$readyChk,
|
||||
$revealBanner,
|
||||
$room,
|
||||
@@ -29,7 +30,7 @@ export function renderRoom(room) {
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('lastRoomId', room.id);
|
||||
} catch {}
|
||||
} catch { }
|
||||
$lobby.classList.add('hidden');
|
||||
$room.classList.remove('hidden');
|
||||
$roomId.textContent = room.id;
|
||||
@@ -43,7 +44,7 @@ export function renderRoom(room) {
|
||||
state.playerId = sole.id;
|
||||
try {
|
||||
localStorage.setItem('playerId', sole.id);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
const me = room.players.find((p) => p.id === state.playerId);
|
||||
@@ -84,11 +85,11 @@ export function renderRoom(room) {
|
||||
const year = t.year ?? '?';
|
||||
const badgeStyle = badgeColorForYear(year);
|
||||
return `
|
||||
<div class="flex items-center gap-2 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 bg-white text-slate-900 dark:bg-slate-800 dark:text-slate-100 shadow-sm" title="${title}${artist ? ' — ' + artist : ''} (${year})">
|
||||
<div class="font-bold tabular-nums text-white rounded-md px-2 py-0.5 min-w-[3ch] text-center" style="${badgeStyle}">${year}</div>
|
||||
<div class="leading-tight">
|
||||
<div class="font-semibold">${title}</div>
|
||||
<div class="text-sm text-slate-600 dark:text-slate-300">${artist}</div>
|
||||
<div 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="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="flex-1 min-w-0 leading-tight overflow-hidden">
|
||||
<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 break-words" style="word-break: break-word;">${artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -146,20 +147,29 @@ export function renderRoom(room) {
|
||||
|
||||
// Mark that we've populated the dropdown
|
||||
state.playlistsPopulated = true;
|
||||
}
|
||||
|
||||
// Auto-select first playlist if host and in lobby (only on first population)
|
||||
if (
|
||||
!room.state.playlist &&
|
||||
isHost &&
|
||||
room.state.status === 'lobby' &&
|
||||
state.playlists.length > 0
|
||||
) {
|
||||
// Use setTimeout to ensure the change event is properly triggered after render
|
||||
// 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 (
|
||||
!room.state.playlist &&
|
||||
isHost &&
|
||||
room.state.status === 'lobby' &&
|
||||
state.playlists.length > 0
|
||||
) {
|
||||
// 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(() => {
|
||||
const firstPlaylistId = state.playlists[0].id;
|
||||
$playlistSelect.value = firstPlaylistId;
|
||||
// Trigger the change event to send to server
|
||||
$playlistSelect.dispatchEvent(new Event('change'));
|
||||
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;
|
||||
$playlistSelect.value = firstPlaylistId;
|
||||
// Trigger the change event to send to server
|
||||
$playlistSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -169,9 +179,23 @@ export function renderRoom(room) {
|
||||
$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) {
|
||||
$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) {
|
||||
$playlistInfo.classList.toggle('hidden', room.state.status === 'lobby');
|
||||
}
|
||||
@@ -189,6 +213,8 @@ export function renderRoom(room) {
|
||||
if ($placeArea && $slotSelect) {
|
||||
if (canGuess) {
|
||||
const tl = room.state.timeline?.[state.playerId] || [];
|
||||
// Preserve selected slot across re-renders
|
||||
const previousValue = $slotSelect.value;
|
||||
$slotSelect.innerHTML = '';
|
||||
for (let i = 0; i <= tl.length; i++) {
|
||||
const left = i > 0 ? (tl[i - 1]?.year ?? '?') : null;
|
||||
@@ -203,6 +229,21 @@ export function renderRoom(room) {
|
||||
opt.textContent = label;
|
||||
$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 {
|
||||
// Clear options when not guessing
|
||||
$slotSelect.innerHTML = '';
|
||||
@@ -230,6 +271,12 @@ export function renderRoom(room) {
|
||||
$answerResult.textContent = '';
|
||||
$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) {
|
||||
@@ -17,7 +17,9 @@ export function reusePlayerName() {
|
||||
|
||||
export function reconnectLastRoom() {
|
||||
const last = state.room?.id || localStorage.getItem('lastRoomId');
|
||||
if (last && !localStorage.getItem('sessionId')) {
|
||||
sendMsg({ type: 'join_room', code: last });
|
||||
// Always try to rejoin the last room - resume is handled separately in ws.js
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function connectWS(onMessage) {
|
||||
if (sessionId) {
|
||||
try {
|
||||
socket.emit('message', { type: 'resume', sessionId });
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
// flush queued
|
||||
setTimeout(() => {
|
||||
@@ -47,12 +47,21 @@ export function cacheSessionId(id) {
|
||||
sessionId = id;
|
||||
try {
|
||||
localStorage.setItem('sessionId', id);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
export function cacheLastRoomId(id) {
|
||||
if (!id) return;
|
||||
_lastRoomId = id;
|
||||
try {
|
||||
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