Compare commits
8 Commits
deno-rewri
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
9373726347
|
|||
|
a1f1b41987
|
|||
|
8ca744cd5b
|
|||
|
c9be49d988
|
|||
|
70be1e7e39
|
|||
|
18d14b097d
|
|||
|
1dbae8b62b
|
|||
| 47b0caa52b |
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -11,10 +11,9 @@ services:
|
||||
- DENO_ENV=production
|
||||
- PORT=5173
|
||||
ports:
|
||||
- "5173:5173"
|
||||
- "0.0.0.0:5173:5173"
|
||||
volumes:
|
||||
- ./data:/app/data:ro
|
||||
- ./src/server-deno/public:/app/public:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hitstar-network
|
||||
|
||||
@@ -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,371 +0,0 @@
|
||||
# Hitstar Backend Migration Guide
|
||||
## Node.js to Deno 2 + TypeScript
|
||||
|
||||
This document provides comprehensive information about the new Deno backend rewrite.
|
||||
|
||||
## Overview
|
||||
|
||||
The new backend has been completely rewritten using:
|
||||
- **Deno 2** - Modern JavaScript/TypeScript runtime
|
||||
- **TypeScript** - Full type safety
|
||||
- **Clean Architecture** - Clear separation of concerns
|
||||
- **SOLID Principles** - Maintainable and extensible code
|
||||
|
||||
## Architecture
|
||||
|
||||
### Layer Structure
|
||||
|
||||
```
|
||||
src/server-deno/
|
||||
├── domain/ # Core business logic (no dependencies)
|
||||
│ ├── models/ # Domain models (Player, Room, GameState)
|
||||
│ └── types.ts # TypeScript interfaces and types
|
||||
│
|
||||
├── application/ # Use cases and orchestration
|
||||
│ ├── AnswerCheckService.ts # Fuzzy matching for guesses
|
||||
│ ├── GameService.ts # Game flow and logic
|
||||
│ ├── RoomService.ts # Room and player management
|
||||
│ └── TrackService.ts # Playlist and track loading
|
||||
│
|
||||
├── infrastructure/ # External concerns
|
||||
│ ├── FileSystemService.ts # File operations
|
||||
│ ├── TokenStoreService.ts # Audio streaming tokens
|
||||
│ ├── CoverArtService.ts # Cover art extraction
|
||||
│ ├── MetadataService.ts # Audio metadata parsing
|
||||
│ ├── AudioStreamingService.ts # Audio streaming with range support
|
||||
│ └── MimeTypeService.ts # MIME type detection
|
||||
│
|
||||
├── presentation/ # API layer
|
||||
│ ├── HttpServer.ts # Oak HTTP server
|
||||
│ ├── WebSocketServer.ts # Socket.IO game server
|
||||
│ └── routes/ # HTTP route handlers
|
||||
│
|
||||
├── shared/ # Cross-cutting concerns
|
||||
│ ├── config.ts # Configuration management
|
||||
│ ├── constants.ts # Application constants
|
||||
│ ├── errors.ts # Custom error types
|
||||
│ ├── logger.ts # Logging utilities
|
||||
│ └── utils.ts # Helper functions
|
||||
│
|
||||
└── main.ts # Application entry point
|
||||
```
|
||||
|
||||
### Design Patterns Used
|
||||
|
||||
1. **Clean Architecture**
|
||||
- Domain layer has no external dependencies
|
||||
- Dependencies point inward (dependency inversion)
|
||||
- Business logic isolated from frameworks
|
||||
|
||||
2. **Dependency Injection**
|
||||
- Services injected through constructors
|
||||
- Easy to test and swap implementations
|
||||
|
||||
3. **Repository Pattern**
|
||||
- RoomService acts as repository for rooms/players
|
||||
- Abstracts storage mechanism
|
||||
|
||||
4. **Service Layer**
|
||||
- Business logic in services
|
||||
- Controllers are thin
|
||||
|
||||
5. **Strategy Pattern**
|
||||
- AnswerCheckService uses different matching strategies
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### Code Quality
|
||||
- ✅ Full TypeScript type safety
|
||||
- ✅ Explicit error handling
|
||||
- ✅ Consistent naming conventions
|
||||
- ✅ Comprehensive JSDoc comments
|
||||
- ✅ SOLID principles throughout
|
||||
|
||||
### Architecture
|
||||
- ✅ Clear separation of concerns
|
||||
- ✅ Testable code structure
|
||||
- ✅ Dependency injection
|
||||
- ✅ No circular dependencies
|
||||
- ✅ Single responsibility principle
|
||||
|
||||
### Features
|
||||
- ✅ Audio streaming with range support
|
||||
- ✅ Token-based audio URLs (security)
|
||||
- ✅ Cover art extraction and caching
|
||||
- ✅ Playlist management
|
||||
- ✅ Fuzzy matching for answers
|
||||
- ✅ Real-time game sync
|
||||
|
||||
## Migration from Old Backend
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Old (Node.js) | New (Deno) |
|
||||
|--------|--------------|------------|
|
||||
| Runtime | Node.js | Deno 2 |
|
||||
| Language | JavaScript | TypeScript |
|
||||
| Module System | ESM + package.json | Deno imports |
|
||||
| HTTP Framework | Express | Oak |
|
||||
| File Access | fs module | Deno.* APIs |
|
||||
| Permissions | None | Explicit flags |
|
||||
| Type Safety | None | Full |
|
||||
|
||||
### API Compatibility
|
||||
|
||||
The new backend maintains **100% API compatibility** with the old one:
|
||||
|
||||
#### HTTP Endpoints
|
||||
- ✅ `GET /api/playlists` - List playlists
|
||||
- ✅ `GET /api/tracks?playlist=<id>` - Get tracks
|
||||
- ✅ `GET /api/reload-years?playlist=<id>` - Reload years
|
||||
- ✅ `HEAD /audio/t/:token` - Check audio
|
||||
- ✅ `GET /audio/t/:token` - Stream audio
|
||||
- ✅ `GET /cover/:name` - Get cover art
|
||||
|
||||
#### WebSocket Events
|
||||
- ✅ All original events supported
|
||||
- ✅ Same message format
|
||||
- ✅ Compatible with existing client
|
||||
|
||||
## Running the New Backend
|
||||
|
||||
### Prerequisites
|
||||
- Deno 2.x installed ([deno.land](https://deno.land))
|
||||
- Audio files in `data/` directory
|
||||
- Optional: `years.json` metadata files
|
||||
|
||||
### Installation
|
||||
|
||||
No dependencies to install! Deno handles everything automatically.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Copy `.env.example` to `.env`:
|
||||
```bash
|
||||
cp src/server-deno/.env.example src/server-deno/.env
|
||||
```
|
||||
|
||||
2. Edit `.env` as needed:
|
||||
```env
|
||||
PORT=5173
|
||||
DATA_DIR=./data
|
||||
PUBLIC_DIR=./public
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
**Development mode** (with auto-reload):
|
||||
```bash
|
||||
cd src/server-deno
|
||||
deno task dev
|
||||
```
|
||||
|
||||
**Production mode**:
|
||||
```bash
|
||||
cd src/server-deno
|
||||
deno task start
|
||||
```
|
||||
|
||||
**Run tests**:
|
||||
```bash
|
||||
deno task test
|
||||
```
|
||||
|
||||
**Format code**:
|
||||
```bash
|
||||
deno task fmt
|
||||
```
|
||||
|
||||
**Lint code**:
|
||||
```bash
|
||||
deno task lint
|
||||
```
|
||||
|
||||
### Permissions
|
||||
|
||||
The new backend requires these Deno permissions:
|
||||
- `--allow-net` - HTTP server and network access
|
||||
- `--allow-read` - Read audio files and config
|
||||
- `--allow-env` - Read environment variables
|
||||
- `--allow-write` - Write logs (optional)
|
||||
|
||||
These are already configured in `deno.json` tasks.
|
||||
|
||||
## Development Guide
|
||||
|
||||
### Adding a New Feature
|
||||
|
||||
1. **Define domain types** in `domain/types.ts`
|
||||
2. **Create service** in appropriate layer
|
||||
3. **Add routes** in `presentation/routes/`
|
||||
4. **Wire up in** `main.ts`
|
||||
5. **Write tests** (optional but recommended)
|
||||
|
||||
### Example: Adding New API Endpoint
|
||||
|
||||
```typescript
|
||||
// 1. Add route in presentation/routes/customRoutes.ts
|
||||
export function createCustomRoutes(service: CustomService): Router {
|
||||
const router = new Router();
|
||||
|
||||
router.get('/api/custom', async (ctx: Context) => {
|
||||
const result = await service.doSomething();
|
||||
ctx.response.body = { ok: true, result };
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// 2. Wire up in HttpServer.ts
|
||||
const customRoutes = createCustomRoutes(customService);
|
||||
router.use(customRoutes.routes(), customRoutes.allowedMethods());
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Create test files with `_test.ts` suffix:
|
||||
|
||||
```typescript
|
||||
// services/AnswerCheckService_test.ts
|
||||
import { assertEquals } from '@std/assert';
|
||||
import { AnswerCheckService } from './AnswerCheckService.ts';
|
||||
|
||||
Deno.test('scoreTitle - exact match', () => {
|
||||
const service = new AnswerCheckService();
|
||||
const result = service.scoreTitle('Hello World', 'Hello World');
|
||||
assertEquals(result.match, true);
|
||||
assertEquals(result.score, 1.0);
|
||||
});
|
||||
```
|
||||
|
||||
Run with: `deno task test`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue**: Module not found errors in IDE
|
||||
- **Solution**: Run `deno cache main.ts` to download dependencies
|
||||
|
||||
**Issue**: Permission denied errors
|
||||
- **Solution**: Check Deno permissions in task commands
|
||||
|
||||
**Issue**: Audio files not streaming
|
||||
- **Solution**: Verify DATA_DIR path in `.env`
|
||||
|
||||
**Issue**: Port already in use
|
||||
- **Solution**: Change PORT in `.env` or stop old server
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable debug logging:
|
||||
```env
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
Check logs for detailed information about requests, errors, and game events.
|
||||
|
||||
## Performance
|
||||
|
||||
### Optimizations Implemented
|
||||
|
||||
1. **LRU Caching**
|
||||
- Audio tokens cached
|
||||
- Cover art cached
|
||||
- Configurable limits
|
||||
|
||||
2. **Batch Processing**
|
||||
- Metadata parsed in batches
|
||||
- Prevents "too many open files"
|
||||
|
||||
3. **Streaming**
|
||||
- Audio streamed with range support
|
||||
- Efficient memory usage
|
||||
|
||||
4. **Connection Pooling**
|
||||
- WebSocket connections reused
|
||||
- Minimal overhead
|
||||
|
||||
### Benchmarks
|
||||
|
||||
*TODO: Add performance benchmarks comparing old vs new backend*
|
||||
|
||||
## Security
|
||||
|
||||
### Improvements
|
||||
|
||||
1. **Path Traversal Protection**
|
||||
- All file paths validated
|
||||
- Restricted to data directory
|
||||
|
||||
2. **Token-Based Audio URLs**
|
||||
- Short-lived tokens (10 min default)
|
||||
- No filename exposure in URLs
|
||||
|
||||
3. **Input Validation**
|
||||
- Type checking at boundaries
|
||||
- Sanitized user input
|
||||
|
||||
4. **CORS Configuration**
|
||||
- Configurable origins
|
||||
- Secure defaults
|
||||
|
||||
## Future Improvements
|
||||
|
||||
### Planned Features
|
||||
|
||||
- [ ] Database integration (SQLite/PostgreSQL)
|
||||
- [ ] User authentication
|
||||
- [ ] OpenAPI/Swagger documentation
|
||||
- [ ] Comprehensive test suite
|
||||
- [ ] Performance monitoring
|
||||
- [ ] Rate limiting
|
||||
- [ ] WebSocket authentication
|
||||
- [ ] Player statistics tracking
|
||||
- [ ] Replay system
|
||||
|
||||
### Potential Enhancements
|
||||
|
||||
- [ ] GraphQL API option
|
||||
- [ ] Redis caching layer
|
||||
- [ ] Multi-room tournament mode
|
||||
- [ ] Achievements system
|
||||
- [ ] Leaderboards
|
||||
- [ ] Custom game modes
|
||||
- [ ] AI opponent
|
||||
|
||||
## Contributing
|
||||
|
||||
### Code Style
|
||||
|
||||
- Use TypeScript strict mode
|
||||
- Follow existing patterns
|
||||
- Add JSDoc comments
|
||||
- Keep functions small and focused
|
||||
- Prefer composition over inheritance
|
||||
|
||||
### Commit Guidelines
|
||||
|
||||
- Use conventional commits
|
||||
- Reference issues in commits
|
||||
- Keep commits atomic
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. Create feature branch
|
||||
2. Implement changes
|
||||
3. Add tests
|
||||
4. Update documentation
|
||||
5. Submit PR with description
|
||||
|
||||
## Support
|
||||
|
||||
For questions or issues:
|
||||
- Check this documentation
|
||||
- Review code comments
|
||||
- Ask in project discussions
|
||||
|
||||
## License
|
||||
|
||||
*Same as main project*
|
||||
@@ -1,297 +0,0 @@
|
||||
# Hitstar Backend Rewrite - Summary
|
||||
|
||||
## What Was Done
|
||||
|
||||
I've successfully rewritten your entire Hitstar backend from Node.js/JavaScript to Deno 2/TypeScript following modern software architecture principles and best practices.
|
||||
|
||||
## Complete File Structure Created
|
||||
|
||||
```
|
||||
src/server-deno/
|
||||
├── deno.json # Deno configuration and tasks
|
||||
├── .env.example # Environment variables template
|
||||
├── .gitignore # Git ignore rules
|
||||
├── README.md # Project overview
|
||||
├── MIGRATION_GUIDE.md # Comprehensive migration guide
|
||||
├── QUICK_START.md # 5-minute quick start guide
|
||||
├── main.ts # Application entry point
|
||||
│
|
||||
├── domain/ # Domain Layer (Business Logic)
|
||||
│ ├── types.ts # Core type definitions
|
||||
│ └── models/
|
||||
│ ├── Player.ts # Player domain model
|
||||
│ ├── GameState.ts # Game state domain model
|
||||
│ ├── Room.ts # Room domain model
|
||||
│ └── mod.ts # Model exports
|
||||
│
|
||||
├── application/ # Application Layer (Use Cases)
|
||||
│ ├── AnswerCheckService.ts # Fuzzy matching for title/artist/year
|
||||
│ ├── GameService.ts # Game flow orchestration
|
||||
│ ├── RoomService.ts # Room and player management
|
||||
│ ├── TrackService.ts # Playlist and track operations
|
||||
│ └── mod.ts # Service exports
|
||||
│
|
||||
├── infrastructure/ # Infrastructure Layer (External Concerns)
|
||||
│ ├── FileSystemService.ts # File operations with security
|
||||
│ ├── TokenStoreService.ts # Audio streaming token management
|
||||
│ ├── CoverArtService.ts # Cover art extraction and caching
|
||||
│ ├── MetadataService.ts # Audio metadata parsing
|
||||
│ ├── AudioStreamingService.ts # HTTP range streaming
|
||||
│ ├── MimeTypeService.ts # MIME type detection
|
||||
│ └── mod.ts # Infrastructure exports
|
||||
│
|
||||
├── presentation/ # Presentation Layer (API)
|
||||
│ ├── HttpServer.ts # Oak HTTP server setup
|
||||
│ ├── WebSocketServer.ts # Socket.IO game server
|
||||
│ └── routes/
|
||||
│ ├── trackRoutes.ts # Playlist/track endpoints
|
||||
│ └── audioRoutes.ts # Audio streaming endpoints
|
||||
│
|
||||
└── shared/ # Shared Utilities
|
||||
├── config.ts # Configuration loader
|
||||
├── constants.ts # Application constants
|
||||
├── errors.ts # Custom error types
|
||||
├── logger.ts # Logging utilities
|
||||
└── utils.ts # Helper functions
|
||||
```
|
||||
|
||||
**Total: 30+ TypeScript files, ~3000+ lines of well-structured code**
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
### Clean Architecture ✅
|
||||
- **Domain Layer**: Pure business logic, no dependencies
|
||||
- **Application Layer**: Use cases and orchestration
|
||||
- **Infrastructure Layer**: External systems (file system, caching)
|
||||
- **Presentation Layer**: HTTP/WebSocket APIs
|
||||
|
||||
### SOLID Principles ✅
|
||||
- **Single Responsibility**: Each class has one job
|
||||
- **Open/Closed**: Extensible without modification
|
||||
- **Liskov Substitution**: Proper inheritance
|
||||
- **Interface Segregation**: Focused interfaces
|
||||
- **Dependency Inversion**: Depend on abstractions
|
||||
|
||||
### Design Patterns ✅
|
||||
- **Dependency Injection**: Constructor-based DI
|
||||
- **Repository Pattern**: RoomService as data store
|
||||
- **Service Layer**: Business logic separation
|
||||
- **Strategy Pattern**: Flexible answer checking
|
||||
- **Factory Pattern**: Player/Room creation
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
### Core Game Logic
|
||||
- ✅ Room creation and management
|
||||
- ✅ Player session handling with resume capability
|
||||
- ✅ Turn-based gameplay
|
||||
- ✅ Fuzzy answer matching (title/artist/year)
|
||||
- ✅ Timeline card placement
|
||||
- ✅ Token/coin system
|
||||
- ✅ Win condition checking
|
||||
- ✅ Spectator mode
|
||||
- ✅ Game pause/resume
|
||||
|
||||
### Audio System
|
||||
- ✅ Secure token-based streaming
|
||||
- ✅ HTTP range request support (seeking)
|
||||
- ✅ .opus preference for bandwidth
|
||||
- ✅ Cover art extraction and caching
|
||||
- ✅ Metadata parsing (music-metadata)
|
||||
- ✅ Path traversal protection
|
||||
|
||||
### Playlist Management
|
||||
- ✅ Multiple playlist support
|
||||
- ✅ Default and custom playlists
|
||||
- ✅ Track loading with metadata
|
||||
- ✅ Years.json integration
|
||||
- ✅ Batch processing for performance
|
||||
|
||||
### Real-Time Communication
|
||||
- ✅ Socket.IO integration
|
||||
- ✅ Room state broadcasting
|
||||
- ✅ Time synchronization
|
||||
- ✅ Player reconnection
|
||||
- ✅ Session resumption
|
||||
|
||||
## API Compatibility
|
||||
|
||||
**100% backward compatible** with existing client!
|
||||
|
||||
### HTTP Endpoints
|
||||
- `GET /api/playlists`
|
||||
- `GET /api/tracks?playlist=<id>`
|
||||
- `GET /api/reload-years?playlist=<id>`
|
||||
- `HEAD /audio/t/:token`
|
||||
- `GET /audio/t/:token` (with Range support)
|
||||
- `GET /cover/:name`
|
||||
- Static file serving
|
||||
|
||||
### WebSocket Events
|
||||
All original events supported:
|
||||
- create_room, join_room, leave_room
|
||||
- set_name, ready, start_game
|
||||
- guess, pause, resume_play, skip_track
|
||||
- set_spectator, kick_player
|
||||
- And all server-to-client events
|
||||
|
||||
## Code Quality Improvements
|
||||
|
||||
### Type Safety
|
||||
- ✅ Full TypeScript strict mode
|
||||
- ✅ Explicit types everywhere
|
||||
- ✅ No `any` types (except where necessary)
|
||||
- ✅ Compile-time error checking
|
||||
|
||||
### Error Handling
|
||||
- ✅ Custom error classes
|
||||
- ✅ Proper error propagation
|
||||
- ✅ Try-catch blocks
|
||||
- ✅ Meaningful error messages
|
||||
|
||||
### Documentation
|
||||
- ✅ JSDoc comments on all public APIs
|
||||
- ✅ Inline code comments
|
||||
- ✅ README files
|
||||
- ✅ Migration guide
|
||||
- ✅ Quick start guide
|
||||
|
||||
### Testing Ready
|
||||
- ✅ Dependency injection for mocking
|
||||
- ✅ Pure functions where possible
|
||||
- ✅ Deno test structure ready
|
||||
- ✅ Easy to add unit tests
|
||||
|
||||
## Major Improvements Over Old Backend
|
||||
|
||||
| Aspect | Old Backend | New Backend |
|
||||
|--------|-------------|-------------|
|
||||
| **Language** | JavaScript | TypeScript |
|
||||
| **Runtime** | Node.js | Deno 2 |
|
||||
| **Type Safety** | None | Full |
|
||||
| **Architecture** | Mixed concerns | Clean Architecture |
|
||||
| **Testability** | Difficult | Easy (DI) |
|
||||
| **Dependencies** | npm packages | Deno imports |
|
||||
| **Setup Time** | npm install (~2 min) | Instant |
|
||||
| **Code Size** | ~1500 lines | ~3000 lines (but cleaner) |
|
||||
| **Maintainability** | Medium | High |
|
||||
| **Extensibility** | Coupled | Decoupled |
|
||||
| **Security** | Basic | Enhanced |
|
||||
| **Performance** | Good | Better (Deno runtime) |
|
||||
|
||||
## Security Enhancements
|
||||
|
||||
1. **Path Traversal Protection**: All file paths validated
|
||||
2. **Token-Based URLs**: Short-lived tokens (10 min)
|
||||
3. **Permission System**: Explicit Deno permissions
|
||||
4. **Input Validation**: Type checking at boundaries
|
||||
5. **No Filename Exposure**: Opaque tokens only
|
||||
6. **CORS Control**: Configurable origins
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
1. **LRU Caching**: Tokens and cover art
|
||||
2. **Batch Processing**: Metadata parsing
|
||||
3. **Streaming**: Efficient memory usage
|
||||
4. **Range Requests**: Partial content support
|
||||
5. **Deno Runtime**: Faster than Node.js
|
||||
|
||||
## How to Run
|
||||
|
||||
### Quick Start (5 minutes)
|
||||
```bash
|
||||
cd src/server-deno
|
||||
deno task dev
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
```bash
|
||||
deno task dev # Development with hot reload
|
||||
deno task start # Production mode
|
||||
deno task test # Run tests
|
||||
deno task lint # Lint code
|
||||
deno task fmt # Format code
|
||||
deno task check # Type check
|
||||
```
|
||||
|
||||
### Configuration
|
||||
Edit `.env`:
|
||||
```env
|
||||
PORT=5173
|
||||
HOST=0.0.0.0
|
||||
DATA_DIR=../../data
|
||||
PUBLIC_DIR=../../public
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
## What's Next? (Your Decision)
|
||||
|
||||
### Integration Options
|
||||
|
||||
1. **Side-by-side**: Run both backends during transition
|
||||
2. **Gradual migration**: Move features one by one
|
||||
3. **Complete switch**: Replace old backend entirely
|
||||
|
||||
### Testing Recommendation
|
||||
1. Run new backend on different port
|
||||
2. Test all features thoroughly
|
||||
3. Compare with old backend
|
||||
4. Fix any compatibility issues
|
||||
5. Switch production traffic
|
||||
|
||||
### Potential Enhancements (Future)
|
||||
|
||||
**Nice to Have:**
|
||||
- [ ] Add comprehensive test suite
|
||||
- [ ] Set up database (SQLite/PostgreSQL)
|
||||
- [ ] Add authentication system
|
||||
- [ ] OpenAPI/Swagger docs
|
||||
- [ ] CI/CD pipeline
|
||||
- [ ] Docker container
|
||||
- [ ] Performance monitoring
|
||||
- [ ] Rate limiting
|
||||
|
||||
**Framework Note:**
|
||||
I kept Socket.IO as requested, but note that Deno has native WebSocket support that could be used as an alternative in the future.
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Socket.IO Integration
|
||||
The WebSocket server is implemented but needs Socket.IO Deno package setup. The current Socket.IO Deno package may need additional configuration or you might want to consider using Deno's native WebSocket API.
|
||||
|
||||
### Testing
|
||||
The code structure makes it easy to add tests. Each service can be tested independently due to dependency injection.
|
||||
|
||||
### Migration Path
|
||||
The old backend (`src/server/`) is unchanged. You can run both simultaneously on different ports for testing.
|
||||
|
||||
## Questions I Can Help With
|
||||
|
||||
1. **Framework choices**: Do you want to use Socket.IO or switch to native WebSockets?
|
||||
2. **Database**: Should we add a database layer (SQLite/PostgreSQL)?
|
||||
3. **Authentication**: Do you need user authentication?
|
||||
4. **Testing**: Want me to add unit tests?
|
||||
5. **Docker**: Need a Dockerfile for deployment?
|
||||
6. **Documentation**: Need OpenAPI/Swagger docs?
|
||||
|
||||
## Files You Should Review First
|
||||
|
||||
1. `src/server-deno/QUICK_START.md` - Get running in 5 minutes
|
||||
2. `src/server-deno/README.md` - Architecture overview
|
||||
3. `src/server-deno/MIGRATION_GUIDE.md` - Complete documentation
|
||||
4. `src/server-deno/main.ts` - Entry point
|
||||
5. `src/server-deno/domain/types.ts` - Core types
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **Complete backend rewrite** - Deno 2 + TypeScript
|
||||
✅ **Clean Architecture** - Proper separation of concerns
|
||||
✅ **100% API compatible** - Works with existing client
|
||||
✅ **Type safe** - Full TypeScript strict mode
|
||||
✅ **Well documented** - README, guides, comments
|
||||
✅ **Ready to run** - `deno task dev`
|
||||
✅ **Production ready** - Security, performance, error handling
|
||||
✅ **Maintainable** - SOLID, DI, clean code
|
||||
✅ **Extensible** - Easy to add features
|
||||
|
||||
The new backend is a significant improvement in code quality, maintainability, and follows industry best practices. It's ready for you to test and deploy! 🚀
|
||||
@@ -1,155 +0,0 @@
|
||||
# Quick Start Guide - Deno Backend
|
||||
|
||||
Get the new Deno backend running in 5 minutes!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Install Deno** (if not already installed):
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
irm https://deno.land/install.ps1 | iex
|
||||
|
||||
# macOS/Linux
|
||||
curl -fsSL https://deno.land/install.sh | sh
|
||||
```
|
||||
|
||||
2. **Verify installation**:
|
||||
```bash
|
||||
deno --version
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Navigate to the new backend**:
|
||||
```bash
|
||||
cd src/server-deno
|
||||
```
|
||||
|
||||
2. **Create environment file** (optional):
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Default settings work out of the box!
|
||||
|
||||
3. **Run the server**:
|
||||
```bash
|
||||
deno task dev
|
||||
```
|
||||
|
||||
That's it! The server will start on `http://localhost:5173`
|
||||
|
||||
## What Just Happened?
|
||||
|
||||
Deno automatically:
|
||||
- ✅ Downloaded all dependencies
|
||||
- ✅ Compiled TypeScript
|
||||
- ✅ Started the HTTP server
|
||||
- ✅ Enabled hot reload
|
||||
|
||||
No `npm install` needed! 🎉
|
||||
|
||||
## Testing the Server
|
||||
|
||||
### Check Server Health
|
||||
```bash
|
||||
# Get playlists
|
||||
curl http://localhost:5173/api/playlists
|
||||
|
||||
# Get tracks
|
||||
curl http://localhost:5173/api/tracks?playlist=default
|
||||
```
|
||||
|
||||
### Access from Browser
|
||||
Open `http://localhost:5173` in your browser to use the web interface.
|
||||
|
||||
## Available Commands
|
||||
|
||||
```bash
|
||||
# Development (with auto-reload)
|
||||
deno task dev
|
||||
|
||||
# Production
|
||||
deno task start
|
||||
|
||||
# Run tests
|
||||
deno task test
|
||||
|
||||
# Format code
|
||||
deno task fmt
|
||||
|
||||
# Lint code
|
||||
deno task lint
|
||||
|
||||
# Type check
|
||||
deno task check
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/server-deno/
|
||||
├── main.ts # Start here!
|
||||
├── domain/ # Business logic
|
||||
├── application/ # Services
|
||||
├── infrastructure/ # File system, streaming
|
||||
├── presentation/ # HTTP & WebSocket
|
||||
└── shared/ # Utilities
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `.env` to customize:
|
||||
|
||||
```env
|
||||
PORT=5173 # Server port
|
||||
HOST=0.0.0.0 # Server host
|
||||
DATA_DIR=../../data # Audio files location
|
||||
PUBLIC_DIR=../../public # Static files location
|
||||
LOG_LEVEL=INFO # Logging level
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
```bash
|
||||
# Change port in .env
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
### Audio Files Not Found
|
||||
```bash
|
||||
# Update data directory in .env
|
||||
DATA_DIR=./data
|
||||
```
|
||||
|
||||
### Module Errors
|
||||
```bash
|
||||
# Clear cache and retry
|
||||
deno cache --reload main.ts
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Read the full documentation**: `MIGRATION_GUIDE.md`
|
||||
2. **Explore the code**: Start with `main.ts`
|
||||
3. **Check the architecture**: Review layer structure
|
||||
4. **Run tests**: `deno task test`
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Check the `README.md` for architecture overview
|
||||
- See `MIGRATION_GUIDE.md` for detailed documentation
|
||||
- Review code comments for implementation details
|
||||
|
||||
## Comparison with Old Backend
|
||||
|
||||
| Feature | Old (Node.js) | New (Deno) |
|
||||
|---------|--------------|------------|
|
||||
| Setup time | `npm install` (~2 min) | Instant |
|
||||
| Dependencies | node_modules/ (100+ MB) | Cached (~10 MB) |
|
||||
| Type safety | None | Full TypeScript |
|
||||
| Hot reload | nodemon | Built-in |
|
||||
| Security | Manual | Permissions-based |
|
||||
|
||||
Enjoy the new backend! 🚀
|
||||
@@ -1,75 +0,0 @@
|
||||
# Hitstar Backend - Deno 2 + TypeScript
|
||||
|
||||
Modern backend rewrite using Deno 2 and TypeScript with clean architecture principles.
|
||||
|
||||
## Architecture
|
||||
|
||||
This backend follows **Clean Architecture** principles with clear separation of concerns:
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Layers
|
||||
|
||||
1. **Domain Layer**: Pure business logic and types
|
||||
- No external dependencies
|
||||
- Models: Player, Room, Track, GameState
|
||||
- Domain services for core game logic
|
||||
|
||||
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)
|
||||
|
||||
3. **Infrastructure Layer**: External concerns
|
||||
- File system operations
|
||||
- Audio streaming
|
||||
- Token management
|
||||
- Metadata parsing
|
||||
|
||||
4. **Presentation Layer**: API and WebSocket
|
||||
- REST routes for HTTP endpoints
|
||||
- Socket.IO handlers for real-time game
|
||||
|
||||
## Running
|
||||
|
||||
### Development
|
||||
```bash
|
||||
deno task dev
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
deno task start
|
||||
```
|
||||
|
||||
### 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.
|
||||
@@ -1,229 +0,0 @@
|
||||
# Quick Reference - Deno Backend
|
||||
|
||||
## 🚀 Quick Commands
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
deno task dev
|
||||
|
||||
# Start production server
|
||||
deno task start
|
||||
|
||||
# Run tests
|
||||
deno task test
|
||||
|
||||
# Format code
|
||||
deno task fmt
|
||||
|
||||
# Lint code
|
||||
deno task lint
|
||||
|
||||
# Type check
|
||||
deno task check
|
||||
```
|
||||
|
||||
## 📁 Important Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `main.ts` | Entry point - start here |
|
||||
| `deno.json` | Configuration & tasks |
|
||||
| `.env` | Environment variables |
|
||||
| `PROJECT_SUMMARY.md` | Complete overview |
|
||||
| `QUICK_START.md` | 5-minute guide |
|
||||
| `MIGRATION_GUIDE.md` | Full documentation |
|
||||
| `CHECKLIST.md` | Testing checklist |
|
||||
|
||||
## 🏗️ Architecture Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Presentation Layer │ HTTP/WebSocket APIs
|
||||
│ (routes, controllers) │
|
||||
├─────────────────────────────┤
|
||||
│ Application Layer │ Business logic services
|
||||
│ (services, use cases) │
|
||||
├─────────────────────────────┤
|
||||
│ Domain Layer │ Core models & types
|
||||
│ (models, entities) │
|
||||
├─────────────────────────────┤
|
||||
│ Infrastructure Layer │ External integrations
|
||||
│ (file system, streaming) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
Edit `.env`:
|
||||
```env
|
||||
PORT=5173 # Server port
|
||||
DATA_DIR=../../data # Audio files
|
||||
PUBLIC_DIR=../../public # Static files
|
||||
LOG_LEVEL=INFO # DEBUG|INFO|WARN|ERROR
|
||||
```
|
||||
|
||||
## 📡 API Endpoints
|
||||
|
||||
### Playlists
|
||||
```
|
||||
GET /api/playlists
|
||||
GET /api/tracks?playlist=<id>
|
||||
GET /api/reload-years?playlist=<id>
|
||||
```
|
||||
|
||||
### Audio
|
||||
```
|
||||
HEAD /audio/t/:token
|
||||
GET /audio/t/:token (supports Range header)
|
||||
GET /cover/:name
|
||||
```
|
||||
|
||||
## 🔌 WebSocket Events
|
||||
|
||||
### Client → Server
|
||||
```
|
||||
create_room, join_room, leave_room
|
||||
set_name, ready, start_game
|
||||
guess, pause, resume_play, skip_track
|
||||
set_spectator, kick_player, select_playlist
|
||||
```
|
||||
|
||||
### Server → Client
|
||||
```
|
||||
connected, room_update, play_track
|
||||
guess_result, game_ended, sync, error
|
||||
```
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
```
|
||||
src/server-deno/
|
||||
├── domain/ # Models & types
|
||||
│ ├── models/ # Player, Room, GameState
|
||||
│ └── types.ts # TypeScript interfaces
|
||||
├── application/ # Services
|
||||
│ ├── GameService.ts
|
||||
│ ├── RoomService.ts
|
||||
│ ├── TrackService.ts
|
||||
│ └── AnswerCheckService.ts
|
||||
├── infrastructure/ # External systems
|
||||
│ ├── FileSystemService.ts
|
||||
│ ├── AudioStreamingService.ts
|
||||
│ ├── TokenStoreService.ts
|
||||
│ └── CoverArtService.ts
|
||||
├── presentation/ # API layer
|
||||
│ ├── HttpServer.ts
|
||||
│ ├── WebSocketServer.ts
|
||||
│ └── routes/
|
||||
└── shared/ # Utilities
|
||||
├── config.ts
|
||||
├── logger.ts
|
||||
└── utils.ts
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
deno task test
|
||||
|
||||
# Run specific test
|
||||
deno test tests/AnswerCheckService_test.ts
|
||||
|
||||
# Watch mode
|
||||
deno task test:watch
|
||||
|
||||
# Coverage (if configured)
|
||||
deno test --coverage=coverage
|
||||
```
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
Enable debug logs:
|
||||
```env
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
Check logs for:
|
||||
- HTTP requests
|
||||
- WebSocket connections
|
||||
- Game events
|
||||
- Errors
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
✅ Path traversal protection
|
||||
✅ Token-based audio URLs
|
||||
✅ Short-lived tokens (10 min)
|
||||
✅ Input validation
|
||||
✅ CORS configuration
|
||||
✅ Deno permissions
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
- LRU caching (tokens, cover art)
|
||||
- Batch processing (metadata)
|
||||
- Streaming (range support)
|
||||
- .opus preference
|
||||
|
||||
## 📝 Code Style
|
||||
|
||||
```typescript
|
||||
// Use dependency injection
|
||||
constructor(
|
||||
private readonly service: SomeService
|
||||
) {}
|
||||
|
||||
// Use explicit types
|
||||
function process(data: string): Result {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Use async/await
|
||||
async function load(): Promise<Data> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Port in use | Change PORT in .env |
|
||||
| Files not found | Check DATA_DIR path |
|
||||
| Permission denied | Add --allow-* flags |
|
||||
| Module errors | Run `deno cache main.ts` |
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
1. **Quick Start**: `QUICK_START.md`
|
||||
2. **Full Guide**: `MIGRATION_GUIDE.md`
|
||||
3. **Testing**: `CHECKLIST.md`
|
||||
4. **Summary**: `PROJECT_SUMMARY.md`
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Follow existing patterns
|
||||
2. Add TypeScript types
|
||||
3. Write JSDoc comments
|
||||
4. Add tests
|
||||
5. Update documentation
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
- Use `deno fmt` before committing
|
||||
- Run `deno lint` to catch issues
|
||||
- Check types with `deno check`
|
||||
- Read JSDoc comments in code
|
||||
- Follow Clean Architecture
|
||||
|
||||
## 🔗 Useful Links
|
||||
|
||||
- [Deno Manual](https://docs.deno.com)
|
||||
- [Deno Standard Library](https://deno.land/std)
|
||||
- [Oak Framework](https://deno.land/x/oak)
|
||||
- [Socket.IO Deno](https://deno.land/x/socket_io)
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Check the full documentation or code comments!
|
||||
@@ -15,7 +15,7 @@ export class GameService {
|
||||
private readonly trackService: TrackService,
|
||||
private readonly audioStreaming: AudioStreamingService,
|
||||
private readonly answerCheck: AnswerCheckService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Start a game in a room
|
||||
@@ -35,7 +35,7 @@ export class GameService {
|
||||
|
||||
// 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');
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export class GameService {
|
||||
*/
|
||||
async drawNextTrack(room: RoomModel): Promise<Track | null> {
|
||||
const track = room.drawTrack();
|
||||
|
||||
|
||||
if (!track) {
|
||||
// No more tracks - end game
|
||||
room.state.endGame();
|
||||
@@ -162,7 +162,7 @@ export class GameService {
|
||||
*/
|
||||
placeCard(room: RoomModel, playerId: ID, year: number, position: number): boolean {
|
||||
const player = room.getPlayer(playerId);
|
||||
|
||||
|
||||
if (!player) {
|
||||
throw new ValidationError('Player not found');
|
||||
}
|
||||
@@ -267,7 +267,7 @@ export class GameService {
|
||||
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.`
|
||||
);
|
||||
@@ -288,12 +288,16 @@ export class GameService {
|
||||
* - 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
|
||||
): Promise<{ correct: boolean }> {
|
||||
slot: number,
|
||||
useTokens: boolean = false
|
||||
): Promise<{ correct: boolean; tokensUsed: boolean }> {
|
||||
const track = room.state.currentTrack;
|
||||
if (!track) {
|
||||
throw new Error('No current track');
|
||||
@@ -310,21 +314,38 @@ export class GameService {
|
||||
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 (track.year != null) {
|
||||
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}`);
|
||||
}
|
||||
@@ -342,7 +363,7 @@ export class GameService {
|
||||
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(
|
||||
@@ -351,12 +372,12 @@ export class GameService {
|
||||
} else {
|
||||
// Discard the track
|
||||
room.discard.push(track);
|
||||
|
||||
|
||||
logger.info(
|
||||
`Player ${playerId} incorrectly placed track. No score increase.`
|
||||
);
|
||||
}
|
||||
|
||||
return { correct };
|
||||
return { correct, tokensUsed };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export class TrackService {
|
||||
constructor(
|
||||
private readonly fileSystem: FileSystemService,
|
||||
private readonly metadata: MetadataService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Get list of available playlists
|
||||
@@ -24,7 +24,7 @@ export class TrackService {
|
||||
// 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',
|
||||
@@ -35,11 +35,11 @@ export class TrackService {
|
||||
|
||||
// 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,
|
||||
@@ -82,9 +82,46 @@ export class TrackService {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ export interface GuessResult {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -208,5 +208,11 @@ export class AudioStreamingService {
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ async function main() {
|
||||
|
||||
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);
|
||||
@@ -65,22 +69,22 @@ async function main() {
|
||||
// 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
|
||||
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') {
|
||||
@@ -89,7 +93,7 @@ async function main() {
|
||||
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);
|
||||
@@ -97,7 +101,7 @@ async function main() {
|
||||
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);
|
||||
@@ -106,7 +110,7 @@ async function main() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Audio streaming
|
||||
if (url.pathname.startsWith('/audio/t/')) {
|
||||
const token = url.pathname.split('/audio/t/')[1];
|
||||
@@ -116,7 +120,7 @@ async function main() {
|
||||
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, {
|
||||
@@ -134,7 +138,7 @@ async function main() {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Cover art
|
||||
if (url.pathname.startsWith('/cover/')) {
|
||||
const encodedFileName = url.pathname.split('/cover/')[1];
|
||||
@@ -149,10 +153,10 @@ async function main() {
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
// Static files
|
||||
try {
|
||||
return await serveDir(request, {
|
||||
@@ -174,7 +178,7 @@ async function main() {
|
||||
|
||||
// Start server
|
||||
logger.info(`Server starting on http://${config.host}:${config.port}`);
|
||||
|
||||
|
||||
await serve(handler, {
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
|
||||
@@ -17,7 +17,7 @@ export class WebSocketServer {
|
||||
constructor(
|
||||
private readonly roomService: RoomService,
|
||||
private readonly gameService: GameService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Initialize Socket.IO server
|
||||
@@ -129,6 +129,10 @@ export class WebSocketServer {
|
||||
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;
|
||||
@@ -165,6 +169,14 @@ export class WebSocketServer {
|
||||
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}`);
|
||||
}
|
||||
@@ -204,13 +216,13 @@ export class WebSocketServer {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -260,7 +272,7 @@ export class WebSocketServer {
|
||||
|
||||
try {
|
||||
const { room } = this.roomService.joinRoomWithPlayer(roomId, player);
|
||||
|
||||
|
||||
socket.emit('message', {
|
||||
type: 'room_joined',
|
||||
room: room.toSummary(),
|
||||
@@ -282,7 +294,7 @@ export class WebSocketServer {
|
||||
if (player.roomId) {
|
||||
const room = this.roomService.getRoom(player.roomId);
|
||||
this.roomService.leaveRoom(player.id);
|
||||
|
||||
|
||||
if (room) {
|
||||
this.broadcastRoomUpdate(room);
|
||||
}
|
||||
@@ -295,7 +307,7 @@ export class WebSocketServer {
|
||||
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) {
|
||||
@@ -331,6 +343,26 @@ export class WebSocketServer {
|
||||
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
|
||||
*/
|
||||
@@ -405,7 +437,7 @@ export class WebSocketServer {
|
||||
|
||||
try {
|
||||
const result = await this.gameService.checkTitleArtistGuess(room, player.id, title, artist);
|
||||
|
||||
|
||||
socket.emit('message', {
|
||||
type: 'answer_result',
|
||||
ok: true,
|
||||
@@ -458,20 +490,24 @@ export class WebSocketServer {
|
||||
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);
|
||||
|
||||
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,
|
||||
@@ -484,7 +520,13 @@ export class WebSocketServer {
|
||||
const timeline = room.state.timeline[player.id] || [];
|
||||
if (result.correct && timeline.length >= room.state.goal) {
|
||||
room.state.status = GameStatus.ENDED;
|
||||
this.broadcast(room, WS_EVENTS.GAME_ENDED, { winner: player.id });
|
||||
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}`);
|
||||
@@ -531,7 +573,7 @@ export class WebSocketServer {
|
||||
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);
|
||||
@@ -601,6 +643,74 @@ export class WebSocketServer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -608,7 +718,15 @@ export class WebSocketServer {
|
||||
const track = await this.gameService.drawNextTrack(room);
|
||||
|
||||
if (!track) {
|
||||
this.broadcast(room, WS_EVENTS.GAME_ENDED, { winner: this.gameService.getWinner(room) });
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<title>Hitstar Web</title>
|
||||
<script src="https://cdn.tailwindcss.com"></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>
|
||||
|
||||
@@ -1,77 +1,293 @@
|
||||
import { $audio, $bufferBadge, $progressFill, $recordDisc } from './dom.js';
|
||||
import { state } from './state.js';
|
||||
import { $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;
|
||||
});
|
||||
// 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) return;
|
||||
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 drift = ($audio.currentTime || 0) - elapsed;
|
||||
const currentSeek = currentSound.seek() || 0;
|
||||
const drift = currentSeek - 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) {
|
||||
|
||||
// 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);
|
||||
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate));
|
||||
currentSound.rate(Math.max(0.8, Math.min(1.2, rate)));
|
||||
} else {
|
||||
if (Math.abs($audio.playbackRate - 1) > 0.001) {
|
||||
$audio.playbackRate = 1.0;
|
||||
// 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 {
|
||||
$audio.pause();
|
||||
if ($recordDisc) $recordDisc.classList.remove("spin-record");
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
$audio.currentTime = 0;
|
||||
if ($progressFill) $progressFill.style.width = "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');
|
||||
if ($bufferBadge) $bufferBadge.classList.add("hidden");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1,58 +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 $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');
|
||||
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');
|
||||
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 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');
|
||||
$lobby.classList.remove("hidden");
|
||||
$room.classList.add("hidden");
|
||||
}
|
||||
export function showRoom() {
|
||||
$lobby.classList.add('hidden');
|
||||
$room.classList.remove('hidden');
|
||||
$lobby.classList.add("hidden");
|
||||
$room.classList.remove("hidden");
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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';
|
||||
} 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 {
|
||||
@@ -19,15 +20,15 @@ function updatePlayerIdFromRoom(r) {
|
||||
if (only && only.id && only.id !== state.playerId) {
|
||||
state.playerId = only.id;
|
||||
try {
|
||||
localStorage.setItem('playerId', only.id);
|
||||
} catch {}
|
||||
localStorage.setItem("playerId", only.id);
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function shortName(id) {
|
||||
if (!id) return '-';
|
||||
if (!id) return "-";
|
||||
const p = state.room?.players.find((x) => x.id === id);
|
||||
return p ? p.name : id.slice(0, 4);
|
||||
}
|
||||
@@ -35,15 +36,15 @@ function shortName(id) {
|
||||
export function handleConnected(msg) {
|
||||
state.playerId = msg.playerId;
|
||||
try {
|
||||
if (msg.playerId) localStorage.setItem('playerId', msg.playerId);
|
||||
} catch {}
|
||||
if (msg.playerId) localStorage.setItem("playerId", msg.playerId);
|
||||
} catch { }
|
||||
if (msg.sessionId) {
|
||||
const existing = localStorage.getItem('sessionId');
|
||||
const existing = localStorage.getItem("sessionId");
|
||||
if (!existing) cacheSessionId(msg.sessionId);
|
||||
}
|
||||
|
||||
// lazy import to avoid cycle
|
||||
import('./session.js').then(({ reusePlayerName, reconnectLastRoom }) => {
|
||||
import("./session.js").then(({ reusePlayerName, reconnectLastRoom }) => {
|
||||
reusePlayerName();
|
||||
reconnectLastRoom();
|
||||
});
|
||||
@@ -52,7 +53,7 @@ export function handleConnected(msg) {
|
||||
try {
|
||||
updatePlayerIdFromRoom(state.room);
|
||||
renderRoom(state.room);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,44 +68,46 @@ 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 = '';
|
||||
$npTitle.textContent = "???";
|
||||
$npArtist.textContent = "";
|
||||
$npYear.textContent = "";
|
||||
if ($guessTitle) $guessTitle.value = "";
|
||||
if ($guessArtist) $guessArtist.value = "";
|
||||
if ($answerResult) {
|
||||
$answerResult.textContent = '';
|
||||
$answerResult.className = 'mt-1 text-sm';
|
||||
$answerResult.textContent = "";
|
||||
$answerResult.className = "mt-1 text-sm";
|
||||
}
|
||||
try {
|
||||
$audio.preload = 'auto';
|
||||
} catch {}
|
||||
|
||||
// Reset audio state before setting new source
|
||||
try {
|
||||
$audio.pause();
|
||||
$audio.currentTime = 0;
|
||||
} catch {}
|
||||
// Load track with Howler, passing the filename for format detection
|
||||
const sound = loadTrack(t.url, t.file);
|
||||
|
||||
$audio.src = t.url;
|
||||
const pf = document.getElementById('progressFill');
|
||||
if (pf) pf.style.width = '0%';
|
||||
const rd = document.getElementById('recordDisc');
|
||||
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';
|
||||
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(() => {
|
||||
// Don't reset currentTime again - it's already 0 from above
|
||||
$audio.play().catch(() => {});
|
||||
const disc = document.getElementById('recordDisc');
|
||||
if (disc) disc.classList.add('spin-record');
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -114,43 +117,50 @@ export function handleSync(msg) {
|
||||
|
||||
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') {
|
||||
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;
|
||||
$audio.currentTime = Math.max(0, elapsed);
|
||||
sound.seek(Math.max(0, elapsed));
|
||||
}
|
||||
$audio.play().catch(() => {});
|
||||
const disc = document.getElementById('recordDisc');
|
||||
if (disc) disc.classList.add('spin-record');
|
||||
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})` : '';
|
||||
$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');
|
||||
const $rb = document.getElementById("revealBanner");
|
||||
if ($rb) {
|
||||
if (result.correct) {
|
||||
$rb.textContent = 'Richtig!';
|
||||
$rb.textContent = "Richtig!";
|
||||
$rb.className =
|
||||
'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
|
||||
"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.textContent = "Falsch!";
|
||||
$rb.className =
|
||||
'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
|
||||
"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');
|
||||
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)}`;
|
||||
@@ -167,69 +177,206 @@ export function handleReveal(msg) {
|
||||
}
|
||||
|
||||
export function handleGameEnded(msg) {
|
||||
alert(`Gewinner: ${shortName(msg.winner)}`);
|
||||
// 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': {
|
||||
case "resume_result": {
|
||||
if (msg.ok) {
|
||||
if (msg.playerId) {
|
||||
state.playerId = msg.playerId;
|
||||
try {
|
||||
localStorage.setItem('playerId', msg.playerId);
|
||||
} catch {}
|
||||
localStorage.setItem("playerId", msg.playerId);
|
||||
} catch { }
|
||||
}
|
||||
const code = msg.roomId || state.room?.id || localStorage.getItem('lastRoomId');
|
||||
if (code) sendMsg({ type: 'join_room', roomId: code });
|
||||
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 {}
|
||||
} catch { }
|
||||
}
|
||||
// Restore player name after successful resume
|
||||
import("./session.js").then(({ reusePlayerName }) => {
|
||||
reusePlayerName();
|
||||
});
|
||||
} else {
|
||||
const code = state.room?.id || localStorage.getItem('lastRoomId');
|
||||
if (code) sendMsg({ type: 'join_room', roomId: code });
|
||||
// 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':
|
||||
case "connected":
|
||||
return handleConnected(msg);
|
||||
case 'room_update':
|
||||
case "room_update":
|
||||
return handleRoomUpdate(msg);
|
||||
case 'play_track':
|
||||
case "play_track":
|
||||
return handlePlayTrack(msg);
|
||||
case 'sync':
|
||||
case "sync":
|
||||
return handleSync(msg);
|
||||
case 'control':
|
||||
case "control":
|
||||
return handleControl(msg);
|
||||
case 'reveal':
|
||||
case "reveal":
|
||||
return handleReveal(msg);
|
||||
case 'game_ended':
|
||||
case "game_ended":
|
||||
return handleGameEnded(msg);
|
||||
case 'answer_result': {
|
||||
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';
|
||||
$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}`;
|
||||
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';
|
||||
? "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')) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
$audio,
|
||||
$answerResult,
|
||||
$copyRoomCode,
|
||||
$createRoom,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
$nextBtn,
|
||||
$pauseBtn,
|
||||
$placeBtn,
|
||||
$placeWithTokensBtn,
|
||||
$readyChk,
|
||||
$room,
|
||||
$roomCode,
|
||||
@@ -22,14 +22,26 @@ import {
|
||||
$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';
|
||||
$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;
|
||||
@@ -37,15 +49,15 @@ export function wireUi() {
|
||||
// Button was removed; no state management needed.
|
||||
|
||||
function saveNameIfChanged(raw) {
|
||||
const name = (raw || '').trim();
|
||||
const name = (raw || "").trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const prev = localStorage.getItem('playerName') || '';
|
||||
const prev = localStorage.getItem("playerName") || "";
|
||||
if (prev === name) return; // no-op
|
||||
localStorage.setItem('playerName', name);
|
||||
localStorage.setItem("playerName", name);
|
||||
if ($nameDisplay) $nameDisplay.textContent = name;
|
||||
sendMsg({ type: 'set_name', name });
|
||||
showToast('Name gespeichert!');
|
||||
sendMsg({ type: "set_name", name });
|
||||
showToast("Name gespeichert!");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
@@ -53,19 +65,19 @@ export function wireUi() {
|
||||
|
||||
// Manual save button
|
||||
if ($saveName) {
|
||||
wire($saveName, 'click', () => {
|
||||
wire($saveName, "click", () => {
|
||||
if (nameDebounce) {
|
||||
clearTimeout(nameDebounce);
|
||||
nameDebounce = null;
|
||||
}
|
||||
const val = ($nameLobby?.value || '').trim();
|
||||
const val = ($nameLobby?.value || "").trim();
|
||||
if (!val) {
|
||||
showToast('⚠️ Bitte gib einen Namen ein!');
|
||||
showToast("⚠️ Bitte gib einen Namen ein!");
|
||||
return;
|
||||
}
|
||||
const prev = localStorage.getItem('playerName') || '';
|
||||
const prev = localStorage.getItem("playerName") || "";
|
||||
if (prev === val) {
|
||||
showToast('✓ Name bereits gespeichert!');
|
||||
showToast("✓ Name bereits gespeichert!");
|
||||
return;
|
||||
}
|
||||
saveNameIfChanged(val);
|
||||
@@ -74,9 +86,9 @@ export function wireUi() {
|
||||
|
||||
// Autosave on input with debounce
|
||||
if ($nameLobby) {
|
||||
wire($nameLobby, 'input', () => {
|
||||
wire($nameLobby, "input", () => {
|
||||
if (nameDebounce) clearTimeout(nameDebounce);
|
||||
const val = ($nameLobby.value || '').trim();
|
||||
const val = ($nameLobby.value || "").trim();
|
||||
if (!val) return;
|
||||
nameDebounce = setTimeout(() => {
|
||||
saveNameIfChanged($nameLobby.value);
|
||||
@@ -84,114 +96,139 @@ export function wireUi() {
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
});
|
||||
// Save immediately on blur
|
||||
wire($nameLobby, 'blur', () => {
|
||||
wire($nameLobby, "blur", () => {
|
||||
if (nameDebounce) {
|
||||
clearTimeout(nameDebounce);
|
||||
nameDebounce = null;
|
||||
}
|
||||
const val = ($nameLobby.value || '').trim();
|
||||
const val = ($nameLobby.value || "").trim();
|
||||
if (val) saveNameIfChanged(val);
|
||||
});
|
||||
// Save on Enter
|
||||
wire($nameLobby, 'keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
wire($nameLobby, "keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (nameDebounce) {
|
||||
clearTimeout(nameDebounce);
|
||||
nameDebounce = null;
|
||||
}
|
||||
const val = ($nameLobby.value || '').trim();
|
||||
const val = ($nameLobby.value || "").trim();
|
||||
if (val) saveNameIfChanged(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wire($createRoom, 'click', () => sendMsg({ type: 'create_room' }));
|
||||
// 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($joinRoom, 'click', () => {
|
||||
const code = $roomCode.value.trim();
|
||||
if (code) sendMsg({ type: 'join_room', roomId: code });
|
||||
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' });
|
||||
wire($leaveRoom, "click", () => {
|
||||
sendMsg({ type: "leave_room" });
|
||||
try {
|
||||
localStorage.removeItem('playerId');
|
||||
localStorage.removeItem('sessionId');
|
||||
localStorage.removeItem('dashboardHintSeen');
|
||||
localStorage.removeItem('lastRoomId');
|
||||
} catch {}
|
||||
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') || '';
|
||||
const storedName = localStorage.getItem("playerName") || "";
|
||||
$nameLobby.value = storedName;
|
||||
} catch {
|
||||
$nameLobby.value = '';
|
||||
$nameLobby.value = "";
|
||||
}
|
||||
}
|
||||
if ($nameDisplay) $nameDisplay.textContent = '';
|
||||
if ($nameDisplay) $nameDisplay.textContent = "";
|
||||
if ($readyChk) {
|
||||
try {
|
||||
$readyChk.checked = false;
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
$lobby.classList.remove('hidden');
|
||||
$room.classList.add('hidden');
|
||||
$lobby.classList.remove("hidden");
|
||||
$room.classList.add("hidden");
|
||||
});
|
||||
|
||||
wire($startGame, 'click', () => {
|
||||
wire($startGame, "click", () => {
|
||||
// Validate playlist selection before starting
|
||||
if (!state.room?.state?.playlist) {
|
||||
showToast('⚠️ Bitte wähle zuerst eine Playlist aus!');
|
||||
showToast("⚠️ Bitte wähle zuerst eine Playlist aus!");
|
||||
return;
|
||||
}
|
||||
sendMsg({ type: 'start_game' });
|
||||
sendMsg({ type: "start_game" });
|
||||
});
|
||||
|
||||
wire($readyChk, 'change', (e) => {
|
||||
wire($readyChk, "change", (e) => {
|
||||
const val = !!e.target.checked;
|
||||
state.pendingReady = val;
|
||||
sendMsg({ type: 'ready', ready: val });
|
||||
sendMsg({ type: "ready", ready: val });
|
||||
});
|
||||
|
||||
// Playlist selection
|
||||
const $playlistSelect = document.getElementById('playlistSelect');
|
||||
const $playlistSelect = document.getElementById("playlistSelect");
|
||||
if ($playlistSelect) {
|
||||
wire($playlistSelect, 'change', (e) => {
|
||||
wire($playlistSelect, "change", (e) => {
|
||||
const playlistId = e.target.value;
|
||||
sendMsg({ type: 'select_playlist', playlist: playlistId });
|
||||
sendMsg({ type: "select_playlist", playlist: playlistId });
|
||||
});
|
||||
}
|
||||
|
||||
wire($placeBtn, 'click', () => {
|
||||
// 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 });
|
||||
sendMsg({ type: "place_guess", slot });
|
||||
});
|
||||
|
||||
wire($playBtn, 'click', () => sendMsg({ type: 'resume_play' }));
|
||||
wire($pauseBtn, 'click', () => sendMsg({ type: 'pause' }));
|
||||
wire($nextBtn, 'click', () => sendMsg({ type: 'skip_track' }));
|
||||
// Use tokens to place card correctly
|
||||
wire($placeWithTokensBtn, "click", () => {
|
||||
const slot = parseInt($slotSelect.value, 10);
|
||||
sendMsg({ type: "place_guess", slot, useTokens: true });
|
||||
});
|
||||
|
||||
if ($volumeSlider && $audio) {
|
||||
try {
|
||||
$volumeSlider.value = String($audio.volume ?? 1);
|
||||
} catch {}
|
||||
$volumeSlider.addEventListener('input', () => {
|
||||
$audio.volume = parseFloat($volumeSlider.value);
|
||||
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', () => {
|
||||
$copyRoomCode.style.display = "inline-block";
|
||||
wire($copyRoomCode, "click", () => {
|
||||
if (state.room?.id) {
|
||||
navigator.clipboard.writeText(state.room.id).then(() => {
|
||||
$copyRoomCode.textContent = '✔️';
|
||||
showToast('Code kopiert!');
|
||||
$copyRoomCode.textContent = "✔️";
|
||||
showToast("Code kopiert!");
|
||||
setTimeout(() => {
|
||||
$copyRoomCode.textContent = '📋';
|
||||
$copyRoomCode.textContent = "📋";
|
||||
}, 1200);
|
||||
});
|
||||
}
|
||||
@@ -199,59 +236,59 @@ export function wireUi() {
|
||||
}
|
||||
|
||||
if ($roomId) {
|
||||
wire($roomId, 'click', () => {
|
||||
wire($roomId, "click", () => {
|
||||
if (state.room?.id) {
|
||||
navigator.clipboard.writeText(state.room.id).then(() => {
|
||||
$roomId.title = 'Kopiert!';
|
||||
showToast('Code kopiert!');
|
||||
$roomId.title = "Kopiert!";
|
||||
showToast("Code kopiert!");
|
||||
setTimeout(() => {
|
||||
$roomId.title = 'Klicken zum Kopieren';
|
||||
$roomId.title = "Klicken zum Kopieren";
|
||||
}, 1200);
|
||||
});
|
||||
}
|
||||
});
|
||||
$roomId.style.cursor = 'pointer';
|
||||
$roomId.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
const form = document.getElementById('answerForm');
|
||||
const form = document.getElementById("answerForm");
|
||||
if (form) {
|
||||
form.addEventListener('submit', (e) => {
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const title = ($guessTitle?.value || '').trim();
|
||||
const artist = ($guessArtist?.value || '').trim();
|
||||
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';
|
||||
$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 } });
|
||||
sendMsg({ type: "submit_answer", guess: { title, artist } });
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboard one-time hint
|
||||
const dashboard = document.getElementById('dashboard');
|
||||
const dashboardHint = document.getElementById('dashboardHint');
|
||||
const dashboard = document.getElementById("dashboard");
|
||||
const dashboardHint = document.getElementById("dashboardHint");
|
||||
if (dashboard && dashboardHint) {
|
||||
try {
|
||||
const seen = localStorage.getItem('dashboardHintSeen');
|
||||
const seen = localStorage.getItem("dashboardHintSeen");
|
||||
if (!seen) {
|
||||
dashboardHint.classList.remove('hidden');
|
||||
dashboardHint.classList.remove("hidden");
|
||||
const hide = () => {
|
||||
dashboardHint.classList.add('hidden');
|
||||
dashboardHint.classList.add("hidden");
|
||||
try {
|
||||
localStorage.setItem('dashboardHintSeen', '1');
|
||||
} catch {}
|
||||
dashboard.removeEventListener('toggle', hide);
|
||||
dashboard.removeEventListener('click', hide);
|
||||
localStorage.setItem("dashboardHintSeen", "1");
|
||||
} catch { }
|
||||
dashboard.removeEventListener("toggle", hide);
|
||||
dashboard.removeEventListener("click", hide);
|
||||
};
|
||||
dashboard.addEventListener('toggle', hide);
|
||||
dashboard.addEventListener('click', hide, { once: true });
|
||||
dashboard.addEventListener("toggle", hide);
|
||||
dashboard.addEventListener("click", hide, { once: true });
|
||||
setTimeout(() => {
|
||||
if (!localStorage.getItem('dashboardHintSeen')) hide();
|
||||
if (!localStorage.getItem("dashboardHintSeen")) hide();
|
||||
}, 6000);
|
||||
}
|
||||
} catch {}
|
||||
} 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 { }
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export const WS_EVENTS = {
|
||||
SET_SPECTATOR: 'set_spectator',
|
||||
KICK_PLAYER: 'kick_player',
|
||||
SELECT_PLAYLIST: 'select_playlist',
|
||||
SET_GOAL: 'set_goal',
|
||||
|
||||
// Server -> Client
|
||||
CONNECTED: 'connected',
|
||||
|
||||
Reference in New Issue
Block a user