feat: implement initial Deno server with WebSocket API, static file serving, and development Docker Compose.
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
This commit is contained in:
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,7 +11,7 @@ export class TrackService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly fileSystem: FileSystemService,
|
private readonly fileSystem: FileSystemService,
|
||||||
private readonly metadata: MetadataService,
|
private readonly metadata: MetadataService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of available playlists
|
* Get list of available playlists
|
||||||
@@ -24,7 +24,7 @@ export class TrackService {
|
|||||||
// Check root directory for default playlist
|
// Check root directory for default playlist
|
||||||
const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i');
|
const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i');
|
||||||
const rootFiles = await this.fileSystem.listFiles(dataDir, audioPattern);
|
const rootFiles = await this.fileSystem.listFiles(dataDir, audioPattern);
|
||||||
|
|
||||||
if (rootFiles.length > 0) {
|
if (rootFiles.length > 0) {
|
||||||
playlists.push({
|
playlists.push({
|
||||||
id: 'default',
|
id: 'default',
|
||||||
@@ -35,11 +35,11 @@ export class TrackService {
|
|||||||
|
|
||||||
// Check subdirectories
|
// Check subdirectories
|
||||||
const subdirs = await this.fileSystem.listDirectories(dataDir);
|
const subdirs = await this.fileSystem.listDirectories(dataDir);
|
||||||
|
|
||||||
for (const dir of subdirs) {
|
for (const dir of subdirs) {
|
||||||
const dirPath = this.fileSystem.getPlaylistDir(dir);
|
const dirPath = this.fileSystem.getPlaylistDir(dir);
|
||||||
const dirFiles = await this.fileSystem.listFiles(dirPath, audioPattern);
|
const dirFiles = await this.fileSystem.listFiles(dirPath, audioPattern);
|
||||||
|
|
||||||
if (dirFiles.length > 0) {
|
if (dirFiles.length > 0) {
|
||||||
playlists.push({
|
playlists.push({
|
||||||
id: dir,
|
id: dir,
|
||||||
@@ -82,9 +82,46 @@ export class TrackService {
|
|||||||
async reloadYearsIndex(playlistId: string = 'default'): Promise<{ count: number }> {
|
async reloadYearsIndex(playlistId: string = 'default'): Promise<{ count: number }> {
|
||||||
const yearsIndex = await this.metadata.loadYearsIndex(playlistId);
|
const yearsIndex = await this.metadata.loadYearsIndex(playlistId);
|
||||||
const count = Object.keys(yearsIndex).length;
|
const count = Object.keys(yearsIndex).length;
|
||||||
|
|
||||||
logger.info(`Reloaded years index for playlist '${playlistId}': ${count} entries`);
|
logger.info(`Reloaded years index for playlist '${playlistId}': ${count} entries`);
|
||||||
|
|
||||||
return { count };
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ async function main() {
|
|||||||
|
|
||||||
logger.info('Application services initialized');
|
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
|
// Initialize WebSocket server
|
||||||
const wsServer = new WebSocketServer(roomService, gameService);
|
const wsServer = new WebSocketServer(roomService, gameService);
|
||||||
wsServer.initialize(config.corsOrigin);
|
wsServer.initialize(config.corsOrigin);
|
||||||
@@ -65,22 +69,22 @@ async function main() {
|
|||||||
// Create combined handler
|
// Create combined handler
|
||||||
const handler = async (request: Request, info: Deno.ServeHandlerInfo): Promise<Response> => {
|
const handler = async (request: Request, info: Deno.ServeHandlerInfo): Promise<Response> => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// Socket.IO requests
|
// Socket.IO requests
|
||||||
if (url.pathname.startsWith('/socket.io/')) {
|
if (url.pathname.startsWith('/socket.io/')) {
|
||||||
// Convert ServeHandlerInfo to ConnInfo format for Socket.IO compatibility
|
// Convert ServeHandlerInfo to ConnInfo format for Socket.IO compatibility
|
||||||
// Socket.IO 0.2.0 expects old ConnInfo format with localAddr and remoteAddr
|
// Socket.IO 0.2.0 expects old ConnInfo format with localAddr and remoteAddr
|
||||||
const connInfo = {
|
const connInfo = {
|
||||||
localAddr: {
|
localAddr: {
|
||||||
transport: 'tcp' as const,
|
transport: 'tcp' as const,
|
||||||
hostname: config.host,
|
hostname: config.host,
|
||||||
port: config.port
|
port: config.port
|
||||||
},
|
},
|
||||||
remoteAddr: info.remoteAddr,
|
remoteAddr: info.remoteAddr,
|
||||||
};
|
};
|
||||||
return await wsServer.getHandler()(request, connInfo);
|
return await wsServer.getHandler()(request, connInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// API endpoints
|
// API endpoints
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
if (url.pathname === '/api/playlists') {
|
if (url.pathname === '/api/playlists') {
|
||||||
@@ -89,7 +93,7 @@ async function main() {
|
|||||||
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/tracks') {
|
if (url.pathname === '/api/tracks') {
|
||||||
const playlistId = url.searchParams.get('playlist') || 'default';
|
const playlistId = url.searchParams.get('playlist') || 'default';
|
||||||
const tracks = await trackService.loadPlaylistTracks(playlistId);
|
const tracks = await trackService.loadPlaylistTracks(playlistId);
|
||||||
@@ -97,7 +101,7 @@ async function main() {
|
|||||||
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/reload-years') {
|
if (url.pathname === '/api/reload-years') {
|
||||||
const playlistId = url.searchParams.get('playlist') || 'default';
|
const playlistId = url.searchParams.get('playlist') || 'default';
|
||||||
const result = await trackService.reloadYearsIndex(playlistId);
|
const result = await trackService.reloadYearsIndex(playlistId);
|
||||||
@@ -106,7 +110,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio streaming
|
// Audio streaming
|
||||||
if (url.pathname.startsWith('/audio/t/')) {
|
if (url.pathname.startsWith('/audio/t/')) {
|
||||||
const token = url.pathname.split('/audio/t/')[1];
|
const token = url.pathname.split('/audio/t/')[1];
|
||||||
@@ -116,7 +120,7 @@ async function main() {
|
|||||||
request: { headers: new Headers(request.headers), url },
|
request: { headers: new Headers(request.headers), url },
|
||||||
response: { headers: new Headers(), status: 200 },
|
response: { headers: new Headers(), status: 200 },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request.method === 'HEAD') {
|
if (request.method === 'HEAD') {
|
||||||
await audioStreaming.handleHeadRequest(ctx, token);
|
await audioStreaming.handleHeadRequest(ctx, token);
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
@@ -134,7 +138,7 @@ async function main() {
|
|||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cover art
|
// Cover art
|
||||||
if (url.pathname.startsWith('/cover/')) {
|
if (url.pathname.startsWith('/cover/')) {
|
||||||
const encodedFileName = url.pathname.split('/cover/')[1];
|
const encodedFileName = url.pathname.split('/cover/')[1];
|
||||||
@@ -149,10 +153,10 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
try {
|
try {
|
||||||
return await serveDir(request, {
|
return await serveDir(request, {
|
||||||
@@ -174,7 +178,7 @@ async function main() {
|
|||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
logger.info(`Server starting on http://${config.host}:${config.port}`);
|
logger.info(`Server starting on http://${config.host}:${config.port}`);
|
||||||
|
|
||||||
await serve(handler, {
|
await serve(handler, {
|
||||||
hostname: config.host,
|
hostname: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class WebSocketServer {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly roomService: RoomService,
|
private readonly roomService: RoomService,
|
||||||
private readonly gameService: GameService,
|
private readonly gameService: GameService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Socket.IO server
|
* Initialize Socket.IO server
|
||||||
@@ -129,6 +129,10 @@ export class WebSocketServer {
|
|||||||
this.handleSelectPlaylist(msg, player);
|
this.handleSelectPlaylist(msg, player);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case WS_EVENTS.SET_GOAL:
|
||||||
|
this.handleSetGoal(msg, player);
|
||||||
|
break;
|
||||||
|
|
||||||
case WS_EVENTS.START_GAME:
|
case WS_EVENTS.START_GAME:
|
||||||
await this.handleStartGame(player);
|
await this.handleStartGame(player);
|
||||||
break;
|
break;
|
||||||
@@ -204,13 +208,13 @@ export class WebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
existingPlayer.setConnected(true);
|
existingPlayer.setConnected(true);
|
||||||
|
|
||||||
// Remove the temporary player that was created in handleConnection
|
// Remove the temporary player that was created in handleConnection
|
||||||
this.playerSockets.delete(newPlayerId);
|
this.playerSockets.delete(newPlayerId);
|
||||||
|
|
||||||
// Update socket mapping to point to the resumed player
|
// Update socket mapping to point to the resumed player
|
||||||
this.playerSockets.set(existingPlayer.id, socket);
|
this.playerSockets.set(existingPlayer.id, socket);
|
||||||
|
|
||||||
socket.emit('message', {
|
socket.emit('message', {
|
||||||
type: WS_EVENTS.RESUME_RESULT,
|
type: WS_EVENTS.RESUME_RESULT,
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -260,7 +264,7 @@ export class WebSocketServer {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { room } = this.roomService.joinRoomWithPlayer(roomId, player);
|
const { room } = this.roomService.joinRoomWithPlayer(roomId, player);
|
||||||
|
|
||||||
socket.emit('message', {
|
socket.emit('message', {
|
||||||
type: 'room_joined',
|
type: 'room_joined',
|
||||||
room: room.toSummary(),
|
room: room.toSummary(),
|
||||||
@@ -282,7 +286,7 @@ export class WebSocketServer {
|
|||||||
if (player.roomId) {
|
if (player.roomId) {
|
||||||
const room = this.roomService.getRoom(player.roomId);
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
this.roomService.leaveRoom(player.id);
|
this.roomService.leaveRoom(player.id);
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
this.broadcastRoomUpdate(room);
|
this.broadcastRoomUpdate(room);
|
||||||
}
|
}
|
||||||
@@ -295,7 +299,7 @@ export class WebSocketServer {
|
|||||||
private handleSetName(msg: any, player: PlayerModel): void {
|
private handleSetName(msg: any, player: PlayerModel): void {
|
||||||
if (msg.name) {
|
if (msg.name) {
|
||||||
this.roomService.setPlayerName(player.id, msg.name);
|
this.roomService.setPlayerName(player.id, msg.name);
|
||||||
|
|
||||||
if (player.roomId) {
|
if (player.roomId) {
|
||||||
const room = this.roomService.getRoom(player.roomId);
|
const room = this.roomService.getRoom(player.roomId);
|
||||||
if (room) {
|
if (room) {
|
||||||
@@ -331,6 +335,26 @@ export class WebSocketServer {
|
|||||||
this.broadcastRoomUpdate(room);
|
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
|
* Start game
|
||||||
*/
|
*/
|
||||||
@@ -405,7 +429,7 @@ export class WebSocketServer {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.gameService.checkTitleArtistGuess(room, player.id, title, artist);
|
const result = await this.gameService.checkTitleArtistGuess(room, player.id, title, artist);
|
||||||
|
|
||||||
socket.emit('message', {
|
socket.emit('message', {
|
||||||
type: 'answer_result',
|
type: 'answer_result',
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -460,7 +484,7 @@ export class WebSocketServer {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.gameService.placeInTimeline(room, player.id, slot);
|
const result = await this.gameService.placeInTimeline(room, player.id, slot);
|
||||||
|
|
||||||
// Store the result
|
// Store the result
|
||||||
room.state.lastResult = {
|
room.state.lastResult = {
|
||||||
playerId: player.id,
|
playerId: player.id,
|
||||||
@@ -471,7 +495,7 @@ export class WebSocketServer {
|
|||||||
|
|
||||||
// Move to reveal phase - DON'T auto-skip, wait for user to click next
|
// Move to reveal phase - DON'T auto-skip, wait for user to click next
|
||||||
room.state.phase = GamePhase.REVEAL;
|
room.state.phase = GamePhase.REVEAL;
|
||||||
|
|
||||||
// Broadcast reveal with track info and result
|
// Broadcast reveal with track info and result
|
||||||
this.broadcast(room, 'reveal', {
|
this.broadcast(room, 'reveal', {
|
||||||
result: room.state.lastResult,
|
result: room.state.lastResult,
|
||||||
@@ -531,7 +555,7 @@ export class WebSocketServer {
|
|||||||
const posSec = room.state.paused
|
const posSec = room.state.paused
|
||||||
? room.state.pausedPosSec
|
? room.state.pausedPosSec
|
||||||
: Math.max(0, (now - (room.state.trackStartAt || now)) / 1000);
|
: Math.max(0, (now - (room.state.trackStartAt || now)) / 1000);
|
||||||
|
|
||||||
room.state.trackStartAt = now - Math.floor(posSec * 1000);
|
room.state.trackStartAt = now - Math.floor(posSec * 1000);
|
||||||
room.state.paused = false;
|
room.state.paused = false;
|
||||||
this.startSyncTimer(room);
|
this.startSyncTimer(room);
|
||||||
|
|||||||
@@ -130,15 +130,33 @@
|
|||||||
<!-- Playlist Selection (only shown in lobby for host) -->
|
<!-- Playlist Selection (only shown in lobby for host) -->
|
||||||
<div id="playlistSection"
|
<div id="playlistSection"
|
||||||
class="hidden rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/60 p-4">
|
class="hidden rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/60 p-4">
|
||||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
🎵 Playlist auswählen
|
<!-- Playlist Selection -->
|
||||||
</label>
|
<div>
|
||||||
<select id="playlistSelect"
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
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">
|
🎵 Playlist auswählen
|
||||||
<option value="default">Lade Playlists...</option>
|
</label>
|
||||||
</select>
|
<select id="playlistSelect"
|
||||||
<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
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">
|
||||||
Als Host kannst du die Playlist für dieses Spiel wählen.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,6 +177,11 @@
|
|||||||
<span class="font-medium text-slate-500 dark:text-slate-400">Playlist:</span>
|
<span class="font-medium text-slate-500 dark:text-slate-400">Playlist:</span>
|
||||||
<span id="currentPlaylist" class="font-semibold text-slate-900 dark:text-slate-100">default</span>
|
<span id="currentPlaylist" class="font-semibold text-slate-900 dark:text-slate-100">default</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="goalInfoSection" class="text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
|
||||||
|
<span class="text-base">🏆</span>
|
||||||
|
<span class="font-medium text-slate-500 dark:text-slate-400">Ziel:</span>
|
||||||
|
<span id="goalInfo" class="font-semibold text-slate-900 dark:text-slate-100">10 Karten</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3 justify-start sm:justify-end">
|
<div class="flex flex-wrap items-center gap-3 justify-start sm:justify-end">
|
||||||
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">
|
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
$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, clearSessionId, sendMsg } from "./ws.js";
|
||||||
import { renderRoom } from "./render.js";
|
import { renderRoom } from "./render.js";
|
||||||
import { applySync, loadTrack, getSound } from "./audio.js";
|
import { applySync, loadTrack, getSound } from "./audio.js";
|
||||||
|
|
||||||
@@ -19,10 +19,10 @@ function updatePlayerIdFromRoom(r) {
|
|||||||
state.playerId = only.id;
|
state.playerId = only.id;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("playerId", only.id);
|
localStorage.setItem("playerId", only.id);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortName(id) {
|
function shortName(id) {
|
||||||
@@ -35,7 +35,7 @@ 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);
|
||||||
@@ -51,7 +51,7 @@ export function handleConnected(msg) {
|
|||||||
try {
|
try {
|
||||||
updatePlayerIdFromRoom(state.room);
|
updatePlayerIdFromRoom(state.room);
|
||||||
renderRoom(state.room);
|
renderRoom(state.room);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export function onMessage(ev) {
|
|||||||
state.playerId = msg.playerId;
|
state.playerId = msg.playerId;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("playerId", msg.playerId);
|
localStorage.setItem("playerId", msg.playerId);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
const code =
|
const code =
|
||||||
msg.roomId || state.room?.id || localStorage.getItem("lastRoomId");
|
msg.roomId || state.room?.id || localStorage.getItem("lastRoomId");
|
||||||
@@ -187,11 +187,23 @@ export function onMessage(ev) {
|
|||||||
if (state.room) {
|
if (state.room) {
|
||||||
try {
|
try {
|
||||||
renderRoom(state.room);
|
renderRoom(state.room);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
|
// Restore player name after successful resume
|
||||||
|
import("./session.js").then(({ reusePlayerName }) => {
|
||||||
|
reusePlayerName();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// Resume failed - session expired or server restarted
|
||||||
|
// Clear stale session ID so next connect gets a fresh one
|
||||||
|
clearSessionId();
|
||||||
|
// Still try to join the last room
|
||||||
const code = state.room?.id || localStorage.getItem("lastRoomId");
|
const code = state.room?.id || localStorage.getItem("lastRoomId");
|
||||||
if (code) sendMsg({ type: "join_room", roomId: code });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function renderRoom(room) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('lastRoomId', room.id);
|
localStorage.setItem('lastRoomId', room.id);
|
||||||
} catch {}
|
} catch { }
|
||||||
$lobby.classList.add('hidden');
|
$lobby.classList.add('hidden');
|
||||||
$room.classList.remove('hidden');
|
$room.classList.remove('hidden');
|
||||||
$roomId.textContent = room.id;
|
$roomId.textContent = room.id;
|
||||||
@@ -43,7 +43,7 @@ export function renderRoom(room) {
|
|||||||
state.playerId = sole.id;
|
state.playerId = sole.id;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('playerId', sole.id);
|
localStorage.setItem('playerId', sole.id);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const me = room.players.find((p) => p.id === state.playerId);
|
const me = room.players.find((p) => p.id === state.playerId);
|
||||||
@@ -146,20 +146,29 @@ export function renderRoom(room) {
|
|||||||
|
|
||||||
// Mark that we've populated the dropdown
|
// Mark that we've populated the dropdown
|
||||||
state.playlistsPopulated = true;
|
state.playlistsPopulated = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select first playlist if host and in lobby (only on first population)
|
// Auto-select first playlist if host and in lobby but no playlist is set on server
|
||||||
if (
|
// This handles both initial population AND returning to lobby after a game
|
||||||
!room.state.playlist &&
|
if (
|
||||||
isHost &&
|
!room.state.playlist &&
|
||||||
room.state.status === 'lobby' &&
|
isHost &&
|
||||||
state.playlists.length > 0
|
room.state.status === 'lobby' &&
|
||||||
) {
|
state.playlists.length > 0
|
||||||
// Use setTimeout to ensure the change event is properly triggered after render
|
) {
|
||||||
|
// Use setTimeout to ensure the change event is properly triggered after render
|
||||||
|
// Use a flag to prevent multiple auto-selects during the same render cycle
|
||||||
|
if (!state._autoSelectPending) {
|
||||||
|
state._autoSelectPending = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const firstPlaylistId = state.playlists[0].id;
|
state._autoSelectPending = false;
|
||||||
$playlistSelect.value = firstPlaylistId;
|
// Double-check we're still in the right state
|
||||||
// Trigger the change event to send to server
|
if (state.room?.state?.status === 'lobby' && !state.room?.state?.playlist) {
|
||||||
$playlistSelect.dispatchEvent(new Event('change'));
|
const firstPlaylistId = state.playlists[0].id;
|
||||||
|
$playlistSelect.value = firstPlaylistId;
|
||||||
|
// Trigger the change event to send to server
|
||||||
|
$playlistSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,9 +178,23 @@ export function renderRoom(room) {
|
|||||||
$playlistSelect.value = room.state.playlist;
|
$playlistSelect.value = room.state.playlist;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync goal selector with server state
|
||||||
|
const $goalSelect = document.getElementById('goalSelect');
|
||||||
|
if ($goalSelect && room.state.goal) {
|
||||||
|
$goalSelect.value = String(room.state.goal);
|
||||||
|
}
|
||||||
|
|
||||||
if ($currentPlaylist) {
|
if ($currentPlaylist) {
|
||||||
$currentPlaylist.textContent = room.state.playlist || 'default';
|
$currentPlaylist.textContent = room.state.playlist || 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update goal info display
|
||||||
|
const $goalInfo = document.getElementById('goalInfo');
|
||||||
|
if ($goalInfo) {
|
||||||
|
$goalInfo.textContent = `${room.state.goal || 10} Karten`;
|
||||||
|
}
|
||||||
|
|
||||||
if ($playlistInfo) {
|
if ($playlistInfo) {
|
||||||
$playlistInfo.classList.toggle('hidden', room.state.status === 'lobby');
|
$playlistInfo.classList.toggle('hidden', room.state.status === 'lobby');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export function reusePlayerName() {
|
|||||||
|
|
||||||
export function reconnectLastRoom() {
|
export function reconnectLastRoom() {
|
||||||
const last = state.room?.id || localStorage.getItem('lastRoomId');
|
const last = state.room?.id || localStorage.getItem('lastRoomId');
|
||||||
if (last && !localStorage.getItem('sessionId')) {
|
// Always try to rejoin the last room - resume is handled separately in ws.js
|
||||||
|
if (last) {
|
||||||
sendMsg({ type: 'join_room', roomId: last });
|
sendMsg({ type: 'join_room', roomId: last });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function wireUi() {
|
|||||||
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) {
|
||||||
@@ -134,7 +134,7 @@ export function wireUi() {
|
|||||||
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");
|
||||||
@@ -164,6 +164,16 @@ export function wireUi() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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", () => {
|
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 });
|
||||||
@@ -234,7 +244,7 @@ export function wireUi() {
|
|||||||
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);
|
||||||
};
|
};
|
||||||
@@ -244,6 +254,6 @@ export function wireUi() {
|
|||||||
if (!localStorage.getItem("dashboardHintSeen")) hide();
|
if (!localStorage.getItem("dashboardHintSeen")) hide();
|
||||||
}, 6000);
|
}, 6000);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function connectWS(onMessage) {
|
|||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
try {
|
try {
|
||||||
socket.emit('message', { type: 'resume', sessionId });
|
socket.emit('message', { type: 'resume', sessionId });
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
// flush queued
|
// flush queued
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -47,12 +47,21 @@ export function cacheSessionId(id) {
|
|||||||
sessionId = id;
|
sessionId = id;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('sessionId', id);
|
localStorage.setItem('sessionId', id);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
export function cacheLastRoomId(id) {
|
export function cacheLastRoomId(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
_lastRoomId = id;
|
_lastRoomId = id;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('lastRoomId', id);
|
localStorage.setItem('lastRoomId', id);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear stale session data (e.g., after server restart)
|
||||||
|
export function clearSessionId() {
|
||||||
|
sessionId = null;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('sessionId');
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const WS_EVENTS = {
|
|||||||
SET_SPECTATOR: 'set_spectator',
|
SET_SPECTATOR: 'set_spectator',
|
||||||
KICK_PLAYER: 'kick_player',
|
KICK_PLAYER: 'kick_player',
|
||||||
SELECT_PLAYLIST: 'select_playlist',
|
SELECT_PLAYLIST: 'select_playlist',
|
||||||
|
SET_GOAL: 'set_goal',
|
||||||
|
|
||||||
// Server -> Client
|
// Server -> Client
|
||||||
CONNECTED: 'connected',
|
CONNECTED: 'connected',
|
||||||
|
|||||||
Reference in New Issue
Block a user