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
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s
This commit is contained in:
@@ -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
|
||||
}
|
||||
83
HOWLER_INTEGRATION.md
Normal file
83
HOWLER_INTEGRATION.md
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -186,7 +187,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">
|
||||
|
||||
@@ -1,77 +1,241 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
// Large drift - hard seek
|
||||
currentSound.seek(Math.max(0, elapsed));
|
||||
if (!currentSound.playing()) {
|
||||
currentSound.play();
|
||||
}
|
||||
currentSound.rate(1.0);
|
||||
} else if (abs > 0.12) {
|
||||
// Small drift - adjust playback rate
|
||||
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,57 @@
|
||||
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 $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");
|
||||
|
||||
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,15 @@
|
||||
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, sendMsg } from "./ws.js";
|
||||
import { renderRoom } from "./render.js";
|
||||
import { applySync, loadTrack, getSound } from "./audio.js";
|
||||
|
||||
function updatePlayerIdFromRoom(r) {
|
||||
try {
|
||||
@@ -19,7 +18,7 @@ function updatePlayerIdFromRoom(r) {
|
||||
if (only && only.id && only.id !== state.playerId) {
|
||||
state.playerId = only.id;
|
||||
try {
|
||||
localStorage.setItem('playerId', only.id);
|
||||
localStorage.setItem("playerId", only.id);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +26,7 @@ function updatePlayerIdFromRoom(r) {
|
||||
}
|
||||
|
||||
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 +34,15 @@ function shortName(id) {
|
||||
export function handleConnected(msg) {
|
||||
state.playerId = msg.playerId;
|
||||
try {
|
||||
if (msg.playerId) localStorage.setItem('playerId', msg.playerId);
|
||||
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();
|
||||
});
|
||||
@@ -67,44 +66,41 @@ 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);
|
||||
|
||||
if (state.room) renderRoom(state.room);
|
||||
}
|
||||
|
||||
@@ -114,43 +110,47 @@ 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";
|
||||
} 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";
|
||||
}
|
||||
}
|
||||
// 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)}`;
|
||||
@@ -173,58 +173,60 @@ export function handleGameEnded(msg) {
|
||||
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);
|
||||
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 {}
|
||||
}
|
||||
} else {
|
||||
const code = state.room?.id || localStorage.getItem('lastRoomId');
|
||||
if (code) sendMsg({ type: 'join_room', roomId: code });
|
||||
const code = state.room?.id || localStorage.getItem("lastRoomId");
|
||||
if (code) sendMsg({ type: "join_room", roomId: code });
|
||||
}
|
||||
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";
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
$audio,
|
||||
$answerResult,
|
||||
$copyRoomCode,
|
||||
$createRoom,
|
||||
@@ -22,11 +21,11 @@ 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';
|
||||
} from "./dom.js";
|
||||
import { state } from "./state.js";
|
||||
import { initAudioUI, stopAudioPlayback } from "./audio.js";
|
||||
import { sendMsg } from "./ws.js";
|
||||
import { showToast, wire } from "./utils.js";
|
||||
|
||||
export function wireUi() {
|
||||
initAudioUI();
|
||||
@@ -37,15 +36,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 +52,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 +73,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 +83,107 @@ 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' }));
|
||||
wire($createRoom, "click", () => sendMsg({ type: "create_room" }));
|
||||
|
||||
wire($joinRoom, 'click', () => {
|
||||
wire($joinRoom, "click", () => {
|
||||
const code = $roomCode.value.trim();
|
||||
if (code) sendMsg({ type: 'join_room', roomId: code });
|
||||
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');
|
||||
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 {}
|
||||
}
|
||||
$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', () => {
|
||||
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' }));
|
||||
wire($playBtn, "click", () => sendMsg({ type: "resume_play" }));
|
||||
wire($pauseBtn, "click", () => sendMsg({ type: "pause" }));
|
||||
wire($nextBtn, "click", () => sendMsg({ type: "skip_track" }));
|
||||
|
||||
if ($volumeSlider && $audio) {
|
||||
try {
|
||||
$volumeSlider.value = String($audio.volume ?? 1);
|
||||
} catch {}
|
||||
$volumeSlider.addEventListener('input', () => {
|
||||
$audio.volume = parseFloat($volumeSlider.value);
|
||||
});
|
||||
}
|
||||
// Volume slider is now handled in audio.js initAudioUI()
|
||||
|
||||
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,57 +191,57 @@ 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');
|
||||
localStorage.setItem("dashboardHintSeen", "1");
|
||||
} catch {}
|
||||
dashboard.removeEventListener('toggle', hide);
|
||||
dashboard.removeEventListener('click', hide);
|
||||
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 {}
|
||||
|
||||
Reference in New Issue
Block a user