feat: Integrate Howler.js for audio playback and remove native audio elements
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s

This commit is contained in:
2025-10-19 22:55:49 +02:00
parent 1dbae8b62b
commit 18d14b097d
11 changed files with 506 additions and 379 deletions

View File

@@ -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',
},
};

View File

@@ -1,8 +0,0 @@
data/
public/audio/
public/cover/
**/*.mp3
node_modules/
.tmp/
dist/
coverage/

View File

@@ -1,7 +0,0 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2
}

83
HOWLER_INTEGRATION.md Normal file
View File

@@ -0,0 +1,83 @@
# Howler.js Integration
## Overview
Successfully integrated [Howler.js](https://www.npmjs.com/package/howler) v2.2.4 for audio playback, replacing the native HTML5 `<audio>` element.
## Changes Made
### 1. **index.html**
- Added Howler.js CDN script: `https://cdnjs.cloudflare.com/ajax/libs/howler/2.2.4/howler.min.js`
- Removed the `<audio id="audio">` element (no longer needed)
### 2. **audio.js** (Complete Rewrite)
Replaced HTML5 Audio API with Howler.js:
#### Key Features:
- **Sound Management**: Uses `Howl` instances for audio playback
- **Progress Tracking**: Custom interval-based progress tracking (100ms updates)
- **Volume Persistence**: Saves volume to localStorage
- **Sync Support**: Maintains time synchronization with server using playback rate adjustment
- **Mobile Support**: Includes audio unlock handlers for iOS/Android browsers
- **Event Handling**: Proper event handlers for load, play, pause, end, and errors
#### Exported Functions:
- `getSound()` - Returns current Howl instance
- `loadTrack(url)` - Creates and loads a new track
- `initAudioUI()` - Initializes Howler settings and volume controls
- `applySync(startAt, serverNow)` - Syncs playback with server timing
- `stopAudioPlayback()` - Stops and cleans up audio
### 3. **handlers.js**
Updated to use new Howler API:
- Removed `$audio` import
- Added `loadTrack` and `getSound` imports
- `handlePlayTrack()`: Uses `loadTrack()` instead of setting `audio.src`
- `handleControl()`: Uses `sound.pause()`, `sound.play()`, `sound.seek()`, `sound.rate()`
### 4. **dom.js**
- Removed `export const $audio = el('audio')` (no longer exists in DOM)
### 5. **ui.js**
- Removed `$audio` import
- Removed volume slider initialization (now handled in `audio.js`)
## Benefits of Howler.js
1. **Better Browser Compatibility**: Handles audio quirks across different browsers/devices
2. **Improved Mobile Support**: Better handling of autoplay restrictions on iOS/Android
3. **Built-in Error Handling**: Automatic retry and fallback mechanisms
4. **Sprite Support**: Ready for future audio sprite features if needed
5. **Format Flexibility**: Automatic format selection and fallback
6. **Memory Management**: Better resource cleanup and management
7. **Unified API**: Consistent API across all browsers
## Testing Recommendations
1. **Desktop Browsers**: Test on Chrome, Firefox, Safari, Edge
2. **Mobile Devices**: Test on iOS Safari and Android Chrome (autoplay restrictions)
3. **Playback Controls**: Verify play, pause, seek, volume work correctly
4. **Synchronization**: Test multi-client sync with server timing
5. **Network Conditions**: Test buffering behavior with slow connections
6. **Tab Switching**: Verify audio behavior when switching tabs
## Configuration Options
The Howler instance is configured with:
- `html5: true` - Uses HTML5 Audio for streaming (better for large files)
- `preload: true` - Preloads audio for smooth playback
- `format: [...]` - Explicitly specifies audio format (detected from filename)
- Volume persistence via localStorage
- Automatic format detection based on file extension
### Format Detection
Since the audio URLs use tokens (e.g., `/audio/t/abc123`) without file extensions, the format is extracted from the track's `file` property and explicitly passed to Howler.js using the `format` configuration option. This ensures proper codec selection for `.opus`, `.mp3`, `.ogg`, `.wav`, `.m4a`, and other formats.
## Future Enhancements
Potential improvements using Howler.js features:
- Audio sprites for sound effects
- 3D spatial audio positioning
- Multiple simultaneous tracks
- Crossfading between tracks
- Advanced audio effects
- Better offline/cache support

View File

@@ -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 }],
},
},
];

View File

@@ -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"
}
}

View File

@@ -7,6 +7,7 @@
<title>Hitstar Web</title> <title>Hitstar Web</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/howler/2.2.4/howler.min.js"></script>
<style> <style>
@keyframes record-spin { @keyframes record-spin {
from { from {
@@ -186,7 +187,7 @@
<div id="revealBanner" class="hidden"></div> <div id="revealBanner" class="hidden"></div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<audio id="audio" preload="none" class="hidden"></audio> <!-- Audio element removed - using Howler.js now -->
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<!-- Record Disc --> <!-- Record Disc -->
<div class="relative" style="width: 200px; height: 200px"> <div class="relative" style="width: 200px; height: 200px">

View File

@@ -1,77 +1,241 @@
import { $audio, $bufferBadge, $progressFill, $recordDisc } from './dom.js'; import { $bufferBadge, $progressFill, $recordDisc } from "./dom.js";
import { state } from './state.js'; import { state } from "./state.js";
export function initAudioUI() { // Howler.js audio instance
try { let currentSound = null;
if ('preservesPitch' in $audio) $audio.preservesPitch = true; let progressInterval = null;
if ('mozPreservesPitch' in $audio) $audio.mozPreservesPitch = true; let isInitialized = false;
if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true;
} catch {} /**
$audio.addEventListener('timeupdate', () => { * Get or create the current Howler sound instance
const dur = $audio.duration || 0; */
if (!dur || !$progressFill) return; export function getSound() {
const pct = Math.min(100, Math.max(0, ($audio.currentTime / dur) * 100)); return currentSound;
$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;
});
} }
/**
* 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;
}
// Create new Howler sound
const howlConfig = {
src: [url],
html5: true, // Use HTML5 Audio for streaming
preload: true,
autoplay: false, // Explicitly prevent autoplay
volume: parseFloat(localStorage.getItem("volume") || "1"),
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);
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;
// Set up volume control if it exists
const $volumeSlider = document.getElementById("volumeSlider");
if ($volumeSlider) {
const savedVolume = localStorage.getItem("volume") || "1";
$volumeSlider.value = savedVolume;
$volumeSlider.addEventListener("input", () => {
const volume = parseFloat($volumeSlider.value);
if (currentSound) {
currentSound.volume(volume);
}
Howler.volume(volume); // Set global volume
localStorage.setItem("volume", String(volume));
});
}
// 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) { export function applySync(startAt, serverNow) {
if (!startAt || !serverNow) return; if (!startAt || !serverNow || !currentSound) return;
if (state.room?.state?.paused) return; if (state.room?.state?.paused) return;
if (state.isBuffering) return; if (state.isBuffering) return;
const now = Date.now(); const now = Date.now();
const elapsed = (now - startAt) / 1000; 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); const abs = Math.abs(drift);
if (abs > 1.0) { if (abs > 1.0) {
$audio.currentTime = Math.max(0, elapsed); // Large drift - hard seek
if ($audio.paused) $audio.play().catch(() => {}); currentSound.seek(Math.max(0, elapsed));
$audio.playbackRate = 1.0; if (!currentSound.playing()) {
currentSound.play();
}
currentSound.rate(1.0);
} else if (abs > 0.12) { } else if (abs > 0.12) {
// Small drift - adjust playback rate
const maxNudge = 0.03; const maxNudge = 0.03;
const sign = drift > 0 ? -1 : 1; const sign = drift > 0 ? -1 : 1;
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5); const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate)); currentSound.rate(Math.max(0.8, Math.min(1.2, rate)));
} else { } else {
if (Math.abs($audio.playbackRate - 1) > 0.001) { // Very small or no drift - reset to normal rate
$audio.playbackRate = 1.0; if (Math.abs(currentSound.rate() - 1) > 0.001) {
currentSound.rate(1.0);
} }
} }
} }
/**
* Stop audio playback and reset state
*/
export function stopAudioPlayback() { export function stopAudioPlayback() {
if (currentSound) {
currentSound.stop();
currentSound.unload();
currentSound = null;
}
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
try { try {
$audio.pause(); if ($recordDisc) $recordDisc.classList.remove("spin-record");
} catch {} } catch {}
try { try {
$audio.currentTime = 0; if ($progressFill) $progressFill.style.width = "0%";
} catch {} } catch {}
try { try {
$audio.src = ''; if ($bufferBadge) $bufferBadge.classList.add("hidden");
} catch {}
try {
$audio.playbackRate = 1.0;
} catch {}
try {
if ($recordDisc) $recordDisc.classList.remove('spin-record');
} catch {}
try {
if ($progressFill) $progressFill.style.width = '0%';
} catch {}
try {
if ($bufferBadge) $bufferBadge.classList.add('hidden');
} catch {} } catch {}
} }

View File

@@ -1,58 +1,57 @@
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
export const $lobby = el('lobby'); export const $lobby = el("lobby");
export const $room = el('room'); export const $room = el("room");
export const $roomId = el('roomId'); export const $roomId = el("roomId");
export const $nameDisplay = el('nameDisplay'); export const $nameDisplay = el("nameDisplay");
export const $status = el('status'); export const $status = el("status");
export const $guesser = el('guesser'); export const $guesser = el("guesser");
export const $timeline = el('timeline'); export const $timeline = el("timeline");
export const $audio = el('audio'); export const $np = el("nowPlaying");
export const $np = el('nowPlaying'); export const $npTitle = el("npTitle");
export const $npTitle = el('npTitle'); export const $npArtist = el("npArtist");
export const $npArtist = el('npArtist'); export const $npYear = el("npYear");
export const $npYear = el('npYear'); export const $readyChk = el("readyChk");
export const $readyChk = el('readyChk'); export const $startGame = el("startGame");
export const $startGame = el('startGame'); export const $revealBanner = el("revealBanner");
export const $revealBanner = el('revealBanner'); export const $placeArea = el("placeArea");
export const $placeArea = el('placeArea'); export const $slotSelect = el("slotSelect");
export const $slotSelect = el('slotSelect'); export const $placeBtn = el("placeBtn");
export const $placeBtn = el('placeBtn'); export const $mediaControls = el("mediaControls");
export const $mediaControls = el('mediaControls'); export const $playBtn = el("playBtn");
export const $playBtn = el('playBtn'); export const $pauseBtn = el("pauseBtn");
export const $pauseBtn = el('pauseBtn'); export const $nextArea = el("nextArea");
export const $nextArea = el('nextArea'); export const $nextBtn = el("nextBtn");
export const $nextBtn = el('nextBtn'); export const $recordDisc = el("recordDisc");
export const $recordDisc = el('recordDisc'); export const $progressFill = el("progressFill");
export const $progressFill = el('progressFill'); export const $volumeSlider = el("volumeSlider");
export const $volumeSlider = el('volumeSlider'); export const $bufferBadge = el("bufferBadge");
export const $bufferBadge = el('bufferBadge'); export const $copyRoomCode = el("copyRoomCode");
export const $copyRoomCode = el('copyRoomCode'); export const $nameLobby = el("name");
export const $nameLobby = el('name'); export const $saveName = el("saveName");
export const $saveName = el('saveName'); export const $createRoom = el("createRoom");
export const $createRoom = el('createRoom'); export const $joinRoom = el("joinRoom");
export const $joinRoom = el('joinRoom'); export const $roomCode = el("roomCode");
export const $roomCode = el('roomCode'); export const $leaveRoom = el("leaveRoom");
export const $leaveRoom = el('leaveRoom'); export const $earnToken = el("earnToken");
export const $earnToken = el('earnToken'); export const $dashboardList = el("dashboardList");
export const $dashboardList = el('dashboardList'); export const $toast = el("toast");
export const $toast = el('toast');
// Answer form elements // Answer form elements
export const $answerForm = el('answerForm'); export const $answerForm = el("answerForm");
export const $guessTitle = el('guessTitle'); export const $guessTitle = el("guessTitle");
export const $guessArtist = el('guessArtist'); export const $guessArtist = el("guessArtist");
export const $answerResult = el('answerResult'); export const $answerResult = el("answerResult");
// Playlist elements // Playlist elements
export const $playlistSection = el('playlistSection'); export const $playlistSection = el("playlistSection");
export const $playlistSelect = el('playlistSelect'); export const $playlistSelect = el("playlistSelect");
export const $currentPlaylist = el('currentPlaylist'); export const $currentPlaylist = el("currentPlaylist");
export const $playlistInfo = el('playlistInfo'); export const $playlistInfo = el("playlistInfo");
export function showLobby() { export function showLobby() {
$lobby.classList.remove('hidden'); $lobby.classList.remove("hidden");
$room.classList.add('hidden'); $room.classList.add("hidden");
} }
export function showRoom() { export function showRoom() {
$lobby.classList.add('hidden'); $lobby.classList.add("hidden");
$room.classList.remove('hidden'); $room.classList.remove("hidden");
} }

View File

@@ -1,16 +1,15 @@
import { import {
$answerResult, $answerResult,
$audio,
$guessArtist, $guessArtist,
$guessTitle, $guessTitle,
$npArtist, $npArtist,
$npTitle, $npTitle,
$npYear, $npYear,
} from './dom.js'; } from "./dom.js";
import { state } from './state.js'; import { state } from "./state.js";
import { cacheLastRoomId, cacheSessionId, sendMsg } from './ws.js'; import { cacheLastRoomId, cacheSessionId, sendMsg } from "./ws.js";
import { renderRoom } from './render.js'; import { renderRoom } from "./render.js";
import { applySync } from './audio.js'; import { applySync, loadTrack, getSound } from "./audio.js";
function updatePlayerIdFromRoom(r) { function updatePlayerIdFromRoom(r) {
try { try {
@@ -19,7 +18,7 @@ function updatePlayerIdFromRoom(r) {
if (only && only.id && only.id !== state.playerId) { if (only && only.id && only.id !== state.playerId) {
state.playerId = only.id; state.playerId = only.id;
try { try {
localStorage.setItem('playerId', only.id); localStorage.setItem("playerId", only.id);
} catch {} } catch {}
} }
} }
@@ -27,7 +26,7 @@ function updatePlayerIdFromRoom(r) {
} }
function shortName(id) { function shortName(id) {
if (!id) return '-'; if (!id) return "-";
const p = state.room?.players.find((x) => x.id === id); const p = state.room?.players.find((x) => x.id === id);
return p ? p.name : id.slice(0, 4); return p ? p.name : id.slice(0, 4);
} }
@@ -35,15 +34,15 @@ function shortName(id) {
export function handleConnected(msg) { export function handleConnected(msg) {
state.playerId = msg.playerId; state.playerId = msg.playerId;
try { try {
if (msg.playerId) localStorage.setItem('playerId', msg.playerId); if (msg.playerId) localStorage.setItem("playerId", msg.playerId);
} catch {} } catch {}
if (msg.sessionId) { if (msg.sessionId) {
const existing = localStorage.getItem('sessionId'); const existing = localStorage.getItem("sessionId");
if (!existing) cacheSessionId(msg.sessionId); if (!existing) cacheSessionId(msg.sessionId);
} }
// lazy import to avoid cycle // lazy import to avoid cycle
import('./session.js').then(({ reusePlayerName, reconnectLastRoom }) => { import("./session.js").then(({ reusePlayerName, reconnectLastRoom }) => {
reusePlayerName(); reusePlayerName();
reconnectLastRoom(); reconnectLastRoom();
}); });
@@ -67,44 +66,41 @@ export function handlePlayTrack(msg) {
const t = msg.track; const t = msg.track;
state.lastTrack = t; state.lastTrack = t;
state.revealed = false; state.revealed = false;
$npTitle.textContent = '???'; $npTitle.textContent = "???";
$npArtist.textContent = ''; $npArtist.textContent = "";
$npYear.textContent = ''; $npYear.textContent = "";
if ($guessTitle) $guessTitle.value = ''; if ($guessTitle) $guessTitle.value = "";
if ($guessArtist) $guessArtist.value = ''; if ($guessArtist) $guessArtist.value = "";
if ($answerResult) { if ($answerResult) {
$answerResult.textContent = ''; $answerResult.textContent = "";
$answerResult.className = 'mt-1 text-sm'; $answerResult.className = "mt-1 text-sm";
} }
try {
$audio.preload = 'auto';
} catch {}
// Reset audio state before setting new source // Load track with Howler, passing the filename for format detection
try { const sound = loadTrack(t.url, t.file);
$audio.pause();
$audio.currentTime = 0;
} catch {}
$audio.src = t.url; const pf = document.getElementById("progressFill");
const pf = document.getElementById('progressFill'); if (pf) pf.style.width = "0%";
if (pf) pf.style.width = '0%'; const rd = document.getElementById("recordDisc");
const rd = document.getElementById('recordDisc');
if (rd) { if (rd) {
rd.classList.remove('spin-record'); rd.classList.remove("spin-record");
rd.src = '/hitstar.png'; rd.src = "/hitstar.png";
} }
const { startAt, serverNow } = msg; const { startAt, serverNow } = msg;
const now = Date.now(); const now = Date.now();
const offsetMs = startAt - serverNow; const offsetMs = startAt - serverNow;
const localStart = now + offsetMs; const localStart = now + offsetMs;
const delay = Math.max(0, localStart - now); const delay = Math.max(0, localStart - now);
setTimeout(() => { setTimeout(() => {
// Don't reset currentTime again - it's already 0 from above if (sound && sound === getSound() && !sound.playing()) {
$audio.play().catch(() => {}); sound.play();
const disc = document.getElementById('recordDisc'); const disc = document.getElementById("recordDisc");
if (disc) disc.classList.add('spin-record'); if (disc) disc.classList.add("spin-record");
}
}, delay); }, delay);
if (state.room) renderRoom(state.room); if (state.room) renderRoom(state.room);
} }
@@ -114,43 +110,47 @@ export function handleSync(msg) {
export function handleControl(msg) { export function handleControl(msg) {
const { action, startAt, serverNow } = msg; const { action, startAt, serverNow } = msg;
if (action === 'pause') { const sound = getSound();
$audio.pause();
const disc = document.getElementById('recordDisc'); if (!sound) return;
if (disc) disc.classList.remove('spin-record');
$audio.playbackRate = 1.0; if (action === "pause") {
} else if (action === 'play') { 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) { if (startAt && serverNow) {
const now = Date.now(); const now = Date.now();
const elapsed = (now - startAt) / 1000; const elapsed = (now - startAt) / 1000;
$audio.currentTime = Math.max(0, elapsed); sound.seek(Math.max(0, elapsed));
} }
$audio.play().catch(() => {}); sound.play();
const disc = document.getElementById('recordDisc'); const disc = document.getElementById("recordDisc");
if (disc) disc.classList.add('spin-record'); if (disc) disc.classList.add("spin-record");
} }
} }
export function handleReveal(msg) { export function handleReveal(msg) {
const { result, track } = msg; const { result, track } = msg;
$npTitle.textContent = track.title || track.id || 'Track'; $npTitle.textContent = track.title || track.id || "Track";
$npArtist.textContent = track.artist ? ` ${track.artist}` : ''; $npArtist.textContent = track.artist ? ` ${track.artist}` : "";
$npYear.textContent = track.year ? ` (${track.year})` : ''; $npYear.textContent = track.year ? ` (${track.year})` : "";
state.revealed = true; state.revealed = true;
const $rb = document.getElementById('revealBanner'); const $rb = document.getElementById("revealBanner");
if ($rb) { if ($rb) {
if (result.correct) { if (result.correct) {
$rb.textContent = 'Richtig!'; $rb.textContent = "Richtig!";
$rb.className = $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";
} else { } else {
$rb.textContent = 'Falsch!'; $rb.textContent = "Falsch!";
$rb.className = $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";
} }
} }
// Note: placeArea visibility is now controlled by renderRoom() based on game phase // 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) { if (rd && track?.file) {
// Use track.file instead of track.id to include playlist folder prefix // Use track.file instead of track.id to include playlist folder prefix
const coverUrl = `/cover/${encodeURIComponent(track.file)}`; const coverUrl = `/cover/${encodeURIComponent(track.file)}`;
@@ -173,58 +173,60 @@ export function handleGameEnded(msg) {
export function onMessage(ev) { export function onMessage(ev) {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
switch (msg.type) { switch (msg.type) {
case 'resume_result': { case "resume_result": {
if (msg.ok) { if (msg.ok) {
if (msg.playerId) { if (msg.playerId) {
state.playerId = msg.playerId; state.playerId = msg.playerId;
try { try {
localStorage.setItem('playerId', msg.playerId); localStorage.setItem("playerId", msg.playerId);
} catch {} } catch {}
} }
const code = msg.roomId || state.room?.id || localStorage.getItem('lastRoomId'); const code =
if (code) sendMsg({ type: 'join_room', roomId: code }); msg.roomId || state.room?.id || localStorage.getItem("lastRoomId");
if (code) sendMsg({ type: "join_room", roomId: code });
if (state.room) { if (state.room) {
try { try {
renderRoom(state.room); renderRoom(state.room);
} catch {} } catch {}
} }
} else { } else {
const code = state.room?.id || localStorage.getItem('lastRoomId'); const code = state.room?.id || localStorage.getItem("lastRoomId");
if (code) sendMsg({ type: 'join_room', roomId: code }); if (code) sendMsg({ type: "join_room", roomId: code });
} }
return; return;
} }
case 'connected': case "connected":
return handleConnected(msg); return handleConnected(msg);
case 'room_update': case "room_update":
return handleRoomUpdate(msg); return handleRoomUpdate(msg);
case 'play_track': case "play_track":
return handlePlayTrack(msg); return handlePlayTrack(msg);
case 'sync': case "sync":
return handleSync(msg); return handleSync(msg);
case 'control': case "control":
return handleControl(msg); return handleControl(msg);
case 'reveal': case "reveal":
return handleReveal(msg); return handleReveal(msg);
case 'game_ended': case "game_ended":
return handleGameEnded(msg); return handleGameEnded(msg);
case 'answer_result': { case "answer_result": {
if ($answerResult) { if ($answerResult) {
if (!msg.ok) { if (!msg.ok) {
$answerResult.textContent = '⛔ Eingabe ungültig oder gerade nicht möglich'; $answerResult.textContent =
$answerResult.className = 'mt-1 text-sm text-rose-600'; "⛔ Eingabe ungültig oder gerade nicht möglich";
$answerResult.className = "mt-1 text-sm text-rose-600";
} else { } else {
const okBoth = !!(msg.correctTitle && msg.correctArtist); const okBoth = !!(msg.correctTitle && msg.correctArtist);
const parts = []; const parts = [];
parts.push(msg.correctTitle ? 'Titel ✓' : 'Titel ✗'); parts.push(msg.correctTitle ? "Titel ✓" : "Titel ✗");
parts.push(msg.correctArtist ? 'Künstler ✓' : 'Künstler ✗'); parts.push(msg.correctArtist ? "Künstler ✓" : "Künstler ✗");
let coin = ''; let coin = "";
if (msg.awarded) coin = ' +1 Token'; if (msg.awarded) coin = " +1 Token";
else if (msg.alreadyAwarded) coin = ' (bereits erhalten)'; else if (msg.alreadyAwarded) coin = " (bereits erhalten)";
$answerResult.textContent = `${parts.join(' · ')}${coin}`; $answerResult.textContent = `${parts.join(" · ")}${coin}`;
$answerResult.className = okBoth $answerResult.className = okBoth
? 'mt-1 text-sm text-emerald-600' ? "mt-1 text-sm text-emerald-600"
: 'mt-1 text-sm text-amber-600'; : "mt-1 text-sm text-amber-600";
} }
} }
return; return;

View File

@@ -1,5 +1,4 @@
import { import {
$audio,
$answerResult, $answerResult,
$copyRoomCode, $copyRoomCode,
$createRoom, $createRoom,
@@ -22,11 +21,11 @@ import {
$playBtn, $playBtn,
$volumeSlider, $volumeSlider,
$saveName, $saveName,
} from './dom.js'; } from "./dom.js";
import { state } from './state.js'; import { state } from "./state.js";
import { initAudioUI, stopAudioPlayback } from './audio.js'; import { initAudioUI, stopAudioPlayback } from "./audio.js";
import { sendMsg } from './ws.js'; import { sendMsg } from "./ws.js";
import { showToast, wire } from './utils.js'; import { showToast, wire } from "./utils.js";
export function wireUi() { export function wireUi() {
initAudioUI(); initAudioUI();
@@ -37,15 +36,15 @@ export function wireUi() {
// Button was removed; no state management needed. // Button was removed; no state management needed.
function saveNameIfChanged(raw) { function saveNameIfChanged(raw) {
const name = (raw || '').trim(); const name = (raw || "").trim();
if (!name) return; if (!name) return;
try { try {
const prev = localStorage.getItem('playerName') || ''; const prev = localStorage.getItem("playerName") || "";
if (prev === name) return; // no-op if (prev === name) return; // no-op
localStorage.setItem('playerName', name); localStorage.setItem("playerName", name);
if ($nameDisplay) $nameDisplay.textContent = name; if ($nameDisplay) $nameDisplay.textContent = name;
sendMsg({ type: 'set_name', name }); sendMsg({ type: "set_name", name });
showToast('Name gespeichert!'); showToast("Name gespeichert!");
} catch { } catch {
// best-effort // best-effort
} }
@@ -53,19 +52,19 @@ export function wireUi() {
// Manual save button // Manual save button
if ($saveName) { if ($saveName) {
wire($saveName, 'click', () => { wire($saveName, "click", () => {
if (nameDebounce) { if (nameDebounce) {
clearTimeout(nameDebounce); clearTimeout(nameDebounce);
nameDebounce = null; nameDebounce = null;
} }
const val = ($nameLobby?.value || '').trim(); const val = ($nameLobby?.value || "").trim();
if (!val) { if (!val) {
showToast('⚠️ Bitte gib einen Namen ein!'); showToast("⚠️ Bitte gib einen Namen ein!");
return; return;
} }
const prev = localStorage.getItem('playerName') || ''; const prev = localStorage.getItem("playerName") || "";
if (prev === val) { if (prev === val) {
showToast('✓ Name bereits gespeichert!'); showToast("✓ Name bereits gespeichert!");
return; return;
} }
saveNameIfChanged(val); saveNameIfChanged(val);
@@ -74,9 +73,9 @@ export function wireUi() {
// Autosave on input with debounce // Autosave on input with debounce
if ($nameLobby) { if ($nameLobby) {
wire($nameLobby, 'input', () => { wire($nameLobby, "input", () => {
if (nameDebounce) clearTimeout(nameDebounce); if (nameDebounce) clearTimeout(nameDebounce);
const val = ($nameLobby.value || '').trim(); const val = ($nameLobby.value || "").trim();
if (!val) return; if (!val) return;
nameDebounce = setTimeout(() => { nameDebounce = setTimeout(() => {
saveNameIfChanged($nameLobby.value); saveNameIfChanged($nameLobby.value);
@@ -84,114 +83,107 @@ export function wireUi() {
}, SAVE_DEBOUNCE_MS); }, SAVE_DEBOUNCE_MS);
}); });
// Save immediately on blur // Save immediately on blur
wire($nameLobby, 'blur', () => { wire($nameLobby, "blur", () => {
if (nameDebounce) { if (nameDebounce) {
clearTimeout(nameDebounce); clearTimeout(nameDebounce);
nameDebounce = null; nameDebounce = null;
} }
const val = ($nameLobby.value || '').trim(); const val = ($nameLobby.value || "").trim();
if (val) saveNameIfChanged(val); if (val) saveNameIfChanged(val);
}); });
// Save on Enter // Save on Enter
wire($nameLobby, 'keydown', (e) => { wire($nameLobby, "keydown", (e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (nameDebounce) { if (nameDebounce) {
clearTimeout(nameDebounce); clearTimeout(nameDebounce);
nameDebounce = null; nameDebounce = null;
} }
const val = ($nameLobby.value || '').trim(); const val = ($nameLobby.value || "").trim();
if (val) saveNameIfChanged(val); if (val) saveNameIfChanged(val);
} }
}); });
} }
wire($createRoom, 'click', () => sendMsg({ type: 'create_room' })); wire($createRoom, "click", () => sendMsg({ type: "create_room" }));
wire($joinRoom, 'click', () => { wire($joinRoom, "click", () => {
const code = $roomCode.value.trim(); const code = $roomCode.value.trim();
if (code) sendMsg({ type: 'join_room', roomId: code }); if (code) sendMsg({ type: "join_room", roomId: code });
}); });
wire($leaveRoom, 'click', () => { wire($leaveRoom, "click", () => {
sendMsg({ type: 'leave_room' }); sendMsg({ type: "leave_room" });
try { try {
localStorage.removeItem('playerId'); localStorage.removeItem("playerId");
localStorage.removeItem('sessionId'); localStorage.removeItem("sessionId");
localStorage.removeItem('dashboardHintSeen'); localStorage.removeItem("dashboardHintSeen");
localStorage.removeItem('lastRoomId'); localStorage.removeItem("lastRoomId");
} catch {} } catch {}
stopAudioPlayback(); stopAudioPlayback();
state.room = null; state.room = null;
if ($nameLobby) { if ($nameLobby) {
try { try {
const storedName = localStorage.getItem('playerName') || ''; const storedName = localStorage.getItem("playerName") || "";
$nameLobby.value = storedName; $nameLobby.value = storedName;
} catch { } catch {
$nameLobby.value = ''; $nameLobby.value = "";
} }
} }
if ($nameDisplay) $nameDisplay.textContent = ''; if ($nameDisplay) $nameDisplay.textContent = "";
if ($readyChk) { if ($readyChk) {
try { try {
$readyChk.checked = false; $readyChk.checked = false;
} catch {} } catch {}
} }
$lobby.classList.remove('hidden'); $lobby.classList.remove("hidden");
$room.classList.add('hidden'); $room.classList.add("hidden");
}); });
wire($startGame, 'click', () => { wire($startGame, "click", () => {
// Validate playlist selection before starting // Validate playlist selection before starting
if (!state.room?.state?.playlist) { if (!state.room?.state?.playlist) {
showToast('⚠️ Bitte wähle zuerst eine Playlist aus!'); showToast("⚠️ Bitte wähle zuerst eine Playlist aus!");
return; return;
} }
sendMsg({ type: 'start_game' }); sendMsg({ type: "start_game" });
}); });
wire($readyChk, 'change', (e) => { wire($readyChk, "change", (e) => {
const val = !!e.target.checked; const val = !!e.target.checked;
state.pendingReady = val; state.pendingReady = val;
sendMsg({ type: 'ready', ready: val }); sendMsg({ type: "ready", ready: val });
}); });
// Playlist selection // Playlist selection
const $playlistSelect = document.getElementById('playlistSelect'); const $playlistSelect = document.getElementById("playlistSelect");
if ($playlistSelect) { if ($playlistSelect) {
wire($playlistSelect, 'change', (e) => { wire($playlistSelect, "change", (e) => {
const playlistId = e.target.value; const playlistId = e.target.value;
sendMsg({ type: 'select_playlist', playlist: playlistId }); sendMsg({ type: "select_playlist", playlist: playlistId });
}); });
} }
wire($placeBtn, 'click', () => { wire($placeBtn, "click", () => {
const slot = parseInt($slotSelect.value, 10); const slot = parseInt($slotSelect.value, 10);
sendMsg({ type: 'place_guess', slot }); sendMsg({ type: "place_guess", slot });
}); });
wire($playBtn, 'click', () => sendMsg({ type: 'resume_play' })); wire($playBtn, "click", () => sendMsg({ type: "resume_play" }));
wire($pauseBtn, 'click', () => sendMsg({ type: 'pause' })); wire($pauseBtn, "click", () => sendMsg({ type: "pause" }));
wire($nextBtn, 'click', () => sendMsg({ type: 'skip_track' })); wire($nextBtn, "click", () => sendMsg({ type: "skip_track" }));
if ($volumeSlider && $audio) { // Volume slider is now handled in audio.js initAudioUI()
try {
$volumeSlider.value = String($audio.volume ?? 1);
} catch {}
$volumeSlider.addEventListener('input', () => {
$audio.volume = parseFloat($volumeSlider.value);
});
}
if ($copyRoomCode) { if ($copyRoomCode) {
$copyRoomCode.style.display = 'inline-block'; $copyRoomCode.style.display = "inline-block";
wire($copyRoomCode, 'click', () => { wire($copyRoomCode, "click", () => {
if (state.room?.id) { if (state.room?.id) {
navigator.clipboard.writeText(state.room.id).then(() => { navigator.clipboard.writeText(state.room.id).then(() => {
$copyRoomCode.textContent = '✔️'; $copyRoomCode.textContent = "✔️";
showToast('Code kopiert!'); showToast("Code kopiert!");
setTimeout(() => { setTimeout(() => {
$copyRoomCode.textContent = '📋'; $copyRoomCode.textContent = "📋";
}, 1200); }, 1200);
}); });
} }
@@ -199,57 +191,57 @@ export function wireUi() {
} }
if ($roomId) { if ($roomId) {
wire($roomId, 'click', () => { wire($roomId, "click", () => {
if (state.room?.id) { if (state.room?.id) {
navigator.clipboard.writeText(state.room.id).then(() => { navigator.clipboard.writeText(state.room.id).then(() => {
$roomId.title = 'Kopiert!'; $roomId.title = "Kopiert!";
showToast('Code kopiert!'); showToast("Code kopiert!");
setTimeout(() => { setTimeout(() => {
$roomId.title = 'Klicken zum Kopieren'; $roomId.title = "Klicken zum Kopieren";
}, 1200); }, 1200);
}); });
} }
}); });
$roomId.style.cursor = 'pointer'; $roomId.style.cursor = "pointer";
} }
const form = document.getElementById('answerForm'); const form = document.getElementById("answerForm");
if (form) { if (form) {
form.addEventListener('submit', (e) => { form.addEventListener("submit", (e) => {
e.preventDefault(); e.preventDefault();
const title = ($guessTitle?.value || '').trim(); const title = ($guessTitle?.value || "").trim();
const artist = ($guessArtist?.value || '').trim(); const artist = ($guessArtist?.value || "").trim();
if (!title || !artist) { if (!title || !artist) {
if ($answerResult) { if ($answerResult) {
$answerResult.textContent = 'Bitte Titel und Künstler eingeben'; $answerResult.textContent = "Bitte Titel und Künstler eingeben";
$answerResult.className = 'mt-1 text-sm text-amber-600'; $answerResult.className = "mt-1 text-sm text-amber-600";
} }
return; return;
} }
sendMsg({ type: 'submit_answer', guess: { title, artist } }); sendMsg({ type: "submit_answer", guess: { title, artist } });
}); });
} }
// Dashboard one-time hint // Dashboard one-time hint
const dashboard = document.getElementById('dashboard'); const dashboard = document.getElementById("dashboard");
const dashboardHint = document.getElementById('dashboardHint'); const dashboardHint = document.getElementById("dashboardHint");
if (dashboard && dashboardHint) { if (dashboard && dashboardHint) {
try { try {
const seen = localStorage.getItem('dashboardHintSeen'); const seen = localStorage.getItem("dashboardHintSeen");
if (!seen) { if (!seen) {
dashboardHint.classList.remove('hidden'); dashboardHint.classList.remove("hidden");
const hide = () => { const hide = () => {
dashboardHint.classList.add('hidden'); dashboardHint.classList.add("hidden");
try { try {
localStorage.setItem('dashboardHintSeen', '1'); localStorage.setItem("dashboardHintSeen", "1");
} catch {} } catch {}
dashboard.removeEventListener('toggle', hide); dashboard.removeEventListener("toggle", hide);
dashboard.removeEventListener('click', hide); dashboard.removeEventListener("click", hide);
}; };
dashboard.addEventListener('toggle', hide); dashboard.addEventListener("toggle", hide);
dashboard.addEventListener('click', hide, { once: true }); dashboard.addEventListener("click", hide, { once: true });
setTimeout(() => { setTimeout(() => {
if (!localStorage.getItem('dashboardHintSeen')) hide(); if (!localStorage.getItem("dashboardHintSeen")) hide();
}, 6000); }, 6000);
} }
} catch {} } catch {}