diff --git a/AGENTS.md b/AGENTS.md index 8fa80ff..12704be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,3 +195,8 @@ MusicRoom.clearAllCaches() // Clear IndexedDB and in-memory caches ## Config Default port 3001 (override with `PORT` env var). Track durations read from file metadata on startup with `music-metadata`. + +## Test User + +- **Username**: test +- **Password**: testuser diff --git a/channel.ts b/channel.ts index 7dc69ae..3dc4404 100644 --- a/channel.ts +++ b/channel.ts @@ -7,6 +7,8 @@ export interface Track { duration: number; } +export type PlaybackMode = "repeat-all" | "repeat-one" | "shuffle"; + export interface ChannelConfig { id: string; name: string; @@ -14,10 +16,17 @@ export interface ChannelConfig { tracks: Track[]; createdBy?: number | null; isDefault?: boolean; + currentIndex?: number; + startedAt?: number; + paused?: boolean; + pausedAt?: number; + playbackMode?: PlaybackMode; } export type WsData = { channelId: string; userId: number | null; username: string }; +export type PersistenceCallback = (channel: Channel, type: "state" | "queue") => void; + export class Channel { id: string; name: string; @@ -31,8 +40,10 @@ export class Channel { createdBy: number | null; createdAt: number; isDefault: boolean; + playbackMode: PlaybackMode = "repeat-all"; private lastQueueBroadcast: number = 0; private queueDirty: boolean = false; + private onPersist: PersistenceCallback | null = null; constructor(config: ChannelConfig) { this.id = config.id; @@ -42,6 +53,23 @@ export class Channel { this.createdBy = config.createdBy ?? null; this.createdAt = Date.now(); this.isDefault = config.isDefault ?? false; + this.currentIndex = config.currentIndex ?? 0; + this.startedAt = config.startedAt ?? Date.now(); + this.paused = config.paused ?? false; + this.pausedAt = config.pausedAt ?? 0; + this.playbackMode = config.playbackMode ?? "repeat-all"; + } + + setPersistenceCallback(callback: PersistenceCallback) { + this.onPersist = callback; + } + + private persistState() { + this.onPersist?.(this, "state"); + } + + private persistQueue() { + this.onPersist?.(this, "queue"); } get currentTrack(): Track | null { @@ -50,6 +78,7 @@ export class Channel { } get currentTimestamp(): number { + if (this.queue.length === 0) return 0; if (this.paused) return this.pausedAt; return (Date.now() - this.startedAt) / 1000; } @@ -67,8 +96,35 @@ export class Channel { advance() { if (this.queue.length === 0) return; - this.currentIndex = (this.currentIndex + 1) % this.queue.length; + + switch (this.playbackMode) { + case "repeat-one": + // Stay on same track, just reset timestamp + break; + case "shuffle": + // Pick a random track (different from current if possible) + if (this.queue.length > 1) { + let newIndex; + do { + newIndex = Math.floor(Math.random() * this.queue.length); + } while (newIndex === this.currentIndex); + this.currentIndex = newIndex; + } + break; + case "repeat-all": + default: + this.currentIndex = (this.currentIndex + 1) % this.queue.length; + break; + } + this.startedAt = Date.now(); + this.persistState(); + this.broadcast(); + } + + setPlaybackMode(mode: PlaybackMode) { + this.playbackMode = mode; + this.persistState(); this.broadcast(); } @@ -83,6 +139,7 @@ export class Channel { currentIndex: this.currentIndex, listenerCount: this.clients.size, isDefault: this.isDefault, + playbackMode: this.playbackMode, }; if (includeQueue) { state.queue = this.queue; @@ -94,6 +151,7 @@ export class Channel { if (this.paused) return; this.pausedAt = this.currentTimestamp; this.paused = true; + this.persistState(); this.broadcast(); } @@ -101,6 +159,7 @@ export class Channel { if (!this.paused) return; this.paused = false; this.startedAt = Date.now() - this.pausedAt * 1000; + this.persistState(); this.broadcast(); } @@ -112,6 +171,7 @@ export class Channel { } else { this.startedAt = Date.now(); } + this.persistState(); this.broadcast(); } @@ -124,6 +184,7 @@ export class Channel { } else { this.startedAt = Date.now() - clamped * 1000; } + this.persistState(); this.broadcast(); } @@ -132,11 +193,92 @@ export class Channel { } setQueue(tracks: Track[]) { + // Remember current track and timestamp to preserve playback position + const currentTrackId = this.currentTrack?.id; + const currentTimestampValue = this.currentTimestamp; + const wasPaused = this.paused; + this.queue = tracks; - this.currentIndex = 0; - this.startedAt = Date.now(); - this.pausedAt = 0; + + // Try to find the current track in the new queue + if (currentTrackId) { + const newIndex = this.queue.findIndex(t => t.id === currentTrackId); + if (newIndex !== -1) { + // Found the track - preserve playback position + this.currentIndex = newIndex; + if (wasPaused) { + this.pausedAt = currentTimestampValue; + } else { + this.startedAt = Date.now() - currentTimestampValue * 1000; + } + } else { + // Track not found in new queue - reset to start + this.currentIndex = 0; + this.startedAt = Date.now(); + this.pausedAt = 0; + } + } else { + // No current track - reset to start + this.currentIndex = 0; + this.startedAt = Date.now(); + this.pausedAt = 0; + } + this.queueDirty = true; + this.persistQueue(); + this.persistState(); + this.broadcast(); + } + + addTracks(tracks: Track[]) { + if (tracks.length === 0) return; + this.queue.push(...tracks); + this.queueDirty = true; + this.persistQueue(); + this.broadcast(); + } + + removeTracksByIndex(indices: number[]) { + if (indices.length === 0) return; + + // Sort descending to remove from end first (preserve indices) + const sorted = [...indices].sort((a, b) => b - a); + const currentTrackId = this.currentTrack?.id; + + for (const idx of sorted) { + if (idx >= 0 && idx < this.queue.length) { + this.queue.splice(idx, 1); + // Adjust currentIndex if we removed a track before it + if (idx < this.currentIndex) { + this.currentIndex--; + } else if (idx === this.currentIndex) { + // Removed currently playing track - stay at same index (next track slides in) + // If we removed the last track, wrap to start + if (this.currentIndex >= this.queue.length) { + this.currentIndex = 0; + this.startedAt = Date.now(); + this.pausedAt = 0; + } + } + } + } + + // If queue is now empty, reset state + if (this.queue.length === 0) { + this.currentIndex = 0; + this.startedAt = Date.now(); + this.pausedAt = 0; + } + + // If current track changed, reset playback position + if (this.queue.length > 0 && this.currentTrack?.id !== currentTrackId) { + this.startedAt = Date.now(); + this.pausedAt = 0; + } + + this.queueDirty = true; + this.persistQueue(); + this.persistState(); this.broadcast(); } diff --git a/db.ts b/db.ts index 1380c2c..485ae91 100644 --- a/db.ts +++ b/db.ts @@ -293,3 +293,151 @@ export function getTrack(id: string): Track | null { export function getAllTracks(): Track[] { return db.query("SELECT * FROM tracks ORDER BY title").all() as Track[]; } + +// Channel tables +db.run(` + CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + created_by INTEGER, + is_default INTEGER DEFAULT 0, + current_index INTEGER DEFAULT 0, + started_at INTEGER DEFAULT (unixepoch() * 1000), + paused INTEGER DEFAULT 0, + paused_at REAL DEFAULT 0, + playback_mode TEXT DEFAULT 'repeat-all', + created_at INTEGER DEFAULT (unixepoch()), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS channel_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id TEXT NOT NULL, + track_id TEXT NOT NULL, + position INTEGER NOT NULL, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + UNIQUE(channel_id, position) + ) +`); + +// Create index for faster queue lookups +db.run(`CREATE INDEX IF NOT EXISTS idx_channel_queue_channel ON channel_queue(channel_id)`); + +// Migration: add playback_mode column to channels +try { + db.run(`ALTER TABLE channels ADD COLUMN playback_mode TEXT DEFAULT 'repeat-all'`); +} catch {} + +// Channel types +export interface ChannelRow { + id: string; + name: string; + description: string; + created_by: number | null; + is_default: number; + current_index: number; + started_at: number; + paused: number; + paused_at: number; + playback_mode: string; + created_at: number; +} + +export interface ChannelQueueRow { + id: number; + channel_id: string; + track_id: string; + position: number; +} + +// Channel CRUD functions +export function saveChannel(channel: { + id: string; + name: string; + description: string; + createdBy: number | null; + isDefault: boolean; + currentIndex: number; + startedAt: number; + paused: boolean; + pausedAt: number; + playbackMode: string; +}): void { + db.query(` + INSERT INTO channels (id, name, description, created_by, is_default, current_index, started_at, paused, paused_at, playback_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + current_index = excluded.current_index, + started_at = excluded.started_at, + paused = excluded.paused, + paused_at = excluded.paused_at, + playback_mode = excluded.playback_mode + `).run( + channel.id, + channel.name, + channel.description, + channel.createdBy, + channel.isDefault ? 1 : 0, + channel.currentIndex, + channel.startedAt, + channel.paused ? 1 : 0, + channel.pausedAt, + channel.playbackMode + ); +} + +export function updateChannelState(channelId: string, state: { + currentIndex: number; + startedAt: number; + paused: boolean; + pausedAt: number; + playbackMode: string; +}): void { + db.query(` + UPDATE channels + SET current_index = ?, started_at = ?, paused = ?, paused_at = ?, playback_mode = ? + WHERE id = ? + `).run(state.currentIndex, state.startedAt, state.paused ? 1 : 0, state.pausedAt, state.playbackMode, channelId); +} + +export function loadChannel(id: string): ChannelRow | null { + return db.query("SELECT * FROM channels WHERE id = ?").get(id) as ChannelRow | null; +} + +export function loadAllChannels(): ChannelRow[] { + return db.query("SELECT * FROM channels").all() as ChannelRow[]; +} + +export function deleteChannelFromDb(id: string): void { + db.query("DELETE FROM channels WHERE id = ?").run(id); +} + +// Queue persistence functions +export function saveChannelQueue(channelId: string, trackIds: string[]): void { + // Delete existing queue + db.query("DELETE FROM channel_queue WHERE channel_id = ?").run(channelId); + + // Insert new queue + const insert = db.query( + "INSERT INTO channel_queue (channel_id, track_id, position) VALUES (?, ?, ?)" + ); + for (let i = 0; i < trackIds.length; i++) { + insert.run(channelId, trackIds[i], i); + } +} + +export function loadChannelQueue(channelId: string): string[] { + const rows = db.query( + "SELECT track_id FROM channel_queue WHERE channel_id = ? ORDER BY position" + ).all(channelId) as { track_id: string }[]; + return rows.map(r => r.track_id); +} + +export function removeTrackFromQueues(trackId: string): void { + db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId); +} diff --git a/library.ts b/library.ts index 078e294..0cdad0a 100644 --- a/library.ts +++ b/library.ts @@ -7,7 +7,7 @@ import { parseFile } from "music-metadata"; import { upsertTrack, type Track } from "./db"; const HASH_CHUNK_SIZE = 64 * 1024; // 64KB -const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma"]); +const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]); export interface LibraryTrack extends Track { filename: string; diff --git a/music/53-x.ogg b/music/53-x.ogg deleted file mode 100644 index d8570fe..0000000 Binary files a/music/53-x.ogg and /dev/null differ diff --git a/music/air_traffic_3.ogg b/music/air_traffic_3.ogg deleted file mode 100644 index 491c974..0000000 Binary files a/music/air_traffic_3.ogg and /dev/null differ diff --git a/music/armageddon.ogg b/music/armageddon.ogg deleted file mode 100644 index 70a8875..0000000 Binary files a/music/armageddon.ogg and /dev/null differ diff --git a/music/awakening.ogg b/music/awakening.ogg deleted file mode 100644 index 282fc60..0000000 Binary files a/music/awakening.ogg and /dev/null differ diff --git a/music/back_in_time.ogg b/music/back_in_time.ogg deleted file mode 100644 index 710d212..0000000 Binary files a/music/back_in_time.ogg and /dev/null differ diff --git a/music/backwards.ogg b/music/backwards.ogg deleted file mode 100644 index 419e57e..0000000 Binary files a/music/backwards.ogg and /dev/null differ diff --git a/music/bayou.ogg b/music/bayou.ogg deleted file mode 100644 index bd64fa7..0000000 Binary files a/music/bayou.ogg and /dev/null differ diff --git a/music/bb-5022.ogg b/music/bb-5022.ogg deleted file mode 100644 index 8db4e1b..0000000 Binary files a/music/bb-5022.ogg and /dev/null differ diff --git a/music/bb-6666.ogg b/music/bb-6666.ogg deleted file mode 100644 index 29df96c..0000000 Binary files a/music/bb-6666.ogg and /dev/null differ diff --git a/music/bitbashed.ogg b/music/bitbashed.ogg deleted file mode 100644 index 7a6ca30..0000000 Binary files a/music/bitbashed.ogg and /dev/null differ diff --git a/music/block_city.ogg b/music/block_city.ogg deleted file mode 100644 index 89e1544..0000000 Binary files a/music/block_city.ogg and /dev/null differ diff --git a/music/bounce.ogg b/music/bounce.ogg deleted file mode 100644 index df33013..0000000 Binary files a/music/bounce.ogg and /dev/null differ diff --git a/music/bubble.ogg b/music/bubble.ogg deleted file mode 100644 index 6ee37c8..0000000 Binary files a/music/bubble.ogg and /dev/null differ diff --git a/music/chill_vibez.ogg b/music/chill_vibez.ogg deleted file mode 100644 index 611d001..0000000 Binary files a/music/chill_vibez.ogg and /dev/null differ diff --git a/music/chrono_courier.ogg b/music/chrono_courier.ogg deleted file mode 100644 index bebdf9e..0000000 Binary files a/music/chrono_courier.ogg and /dev/null differ diff --git a/music/cruisin.ogg b/music/cruisin.ogg deleted file mode 100644 index 7d1b9b2..0000000 Binary files a/music/cruisin.ogg and /dev/null differ diff --git a/music/deflective.ogg b/music/deflective.ogg deleted file mode 100644 index ef33702..0000000 Binary files a/music/deflective.ogg and /dev/null differ diff --git a/music/doo_wop_ghosts.ogg b/music/doo_wop_ghosts.ogg deleted file mode 100644 index 353f024..0000000 Binary files a/music/doo_wop_ghosts.ogg and /dev/null differ diff --git a/music/dubtime_5.ogg b/music/dubtime_5.ogg deleted file mode 100644 index 7475bb8..0000000 Binary files a/music/dubtime_5.ogg and /dev/null differ diff --git a/music/duck_dodgers.ogg b/music/duck_dodgers.ogg deleted file mode 100644 index 951051f..0000000 Binary files a/music/duck_dodgers.ogg and /dev/null differ diff --git a/music/electric_knights.ogg b/music/electric_knights.ogg deleted file mode 100644 index cfa8e7c..0000000 Binary files a/music/electric_knights.ogg and /dev/null differ diff --git a/music/emergency.ogg b/music/emergency.ogg deleted file mode 100644 index 1474a9f..0000000 Binary files a/music/emergency.ogg and /dev/null differ diff --git a/music/faustianreverie.ogg b/music/faustianreverie.ogg deleted file mode 100644 index 84fcfca..0000000 Binary files a/music/faustianreverie.ogg and /dev/null differ diff --git a/music/firewalker.ogg b/music/firewalker.ogg deleted file mode 100644 index c07d2de..0000000 Binary files a/music/firewalker.ogg and /dev/null differ diff --git a/music/friendly_fire.ogg b/music/friendly_fire.ogg deleted file mode 100644 index f4a50d4..0000000 Binary files a/music/friendly_fire.ogg and /dev/null differ diff --git a/music/funky_ninja.ogg b/music/funky_ninja.ogg deleted file mode 100644 index 7fdd142..0000000 Binary files a/music/funky_ninja.ogg and /dev/null differ diff --git a/music/indigo_entrance.ogg b/music/indigo_entrance.ogg deleted file mode 100644 index 2ce19e3..0000000 Binary files a/music/indigo_entrance.ogg and /dev/null differ diff --git a/music/lane_6.ogg b/music/lane_6.ogg deleted file mode 100644 index 586774a..0000000 Binary files a/music/lane_6.ogg and /dev/null differ diff --git a/music/lean.ogg b/music/lean.ogg deleted file mode 100644 index 88f5723..0000000 Binary files a/music/lean.ogg and /dev/null differ diff --git a/music/light.ogg b/music/light.ogg deleted file mode 100644 index f061c9d..0000000 Binary files a/music/light.ogg and /dev/null differ diff --git a/music/look_deeper.ogg b/music/look_deeper.ogg deleted file mode 100644 index 2483022..0000000 Binary files a/music/look_deeper.ogg and /dev/null differ diff --git a/music/m_paint.ogg b/music/m_paint.ogg deleted file mode 100644 index 38e12cb..0000000 Binary files a/music/m_paint.ogg and /dev/null differ diff --git a/music/march_of_the_undead.ogg b/music/march_of_the_undead.ogg deleted file mode 100644 index f1877b9..0000000 Binary files a/music/march_of_the_undead.ogg and /dev/null differ diff --git a/music/moon.ogg b/music/moon.ogg deleted file mode 100644 index 2055873..0000000 Binary files a/music/moon.ogg and /dev/null differ diff --git a/music/motive.ogg b/music/motive.ogg deleted file mode 100644 index 634c080..0000000 Binary files a/music/motive.ogg and /dev/null differ diff --git a/music/necromancers_laboratory.ogg b/music/necromancers_laboratory.ogg deleted file mode 100644 index dfaea63..0000000 Binary files a/music/necromancers_laboratory.ogg and /dev/null differ diff --git a/music/nnoeirpwxmc.oeirp_3.ogg b/music/nnoeirpwxmc.oeirp_3.ogg deleted file mode 100644 index 391b743..0000000 Binary files a/music/nnoeirpwxmc.oeirp_3.ogg and /dev/null differ diff --git a/music/obversions.ogg b/music/obversions.ogg deleted file mode 100644 index dc7512c..0000000 Binary files a/music/obversions.ogg and /dev/null differ diff --git a/music/order_of_the_dragon.ogg b/music/order_of_the_dragon.ogg deleted file mode 100644 index 737fb85..0000000 Binary files a/music/order_of_the_dragon.ogg and /dev/null differ diff --git a/music/reverse_the_polarity.ogg b/music/reverse_the_polarity.ogg deleted file mode 100644 index 7579e51..0000000 Binary files a/music/reverse_the_polarity.ogg and /dev/null differ diff --git a/music/rook.ogg b/music/rook.ogg deleted file mode 100644 index bc46ad7..0000000 Binary files a/music/rook.ogg and /dev/null differ diff --git a/music/sailing.ogg b/music/sailing.ogg deleted file mode 100644 index 9405120..0000000 Binary files a/music/sailing.ogg and /dev/null differ diff --git a/music/scissor_kick.ogg b/music/scissor_kick.ogg deleted file mode 100644 index 0fa3201..0000000 Binary files a/music/scissor_kick.ogg and /dev/null differ diff --git a/music/search.ogg b/music/search.ogg deleted file mode 100644 index 3ed2db6..0000000 Binary files a/music/search.ogg and /dev/null differ diff --git a/music/slime.ogg b/music/slime.ogg deleted file mode 100644 index 9951633..0000000 Binary files a/music/slime.ogg and /dev/null differ diff --git a/music/stranded.ogg b/music/stranded.ogg deleted file mode 100644 index 62990d4..0000000 Binary files a/music/stranded.ogg and /dev/null differ diff --git a/music/tesla.ogg b/music/tesla.ogg deleted file mode 100644 index 98f3fcf..0000000 Binary files a/music/tesla.ogg and /dev/null differ diff --git a/music/the_bounce.ogg b/music/the_bounce.ogg deleted file mode 100644 index ca5b452..0000000 Binary files a/music/the_bounce.ogg and /dev/null differ diff --git a/music/the_great_freeze.ogg b/music/the_great_freeze.ogg deleted file mode 100644 index f7eae80..0000000 Binary files a/music/the_great_freeze.ogg and /dev/null differ diff --git a/music/the_machine.ogg b/music/the_machine.ogg deleted file mode 100644 index 4fda4dd..0000000 Binary files a/music/the_machine.ogg and /dev/null differ diff --git a/music/thelight_2.ogg b/music/thelight_2.ogg deleted file mode 100644 index 8a2dd82..0000000 Binary files a/music/thelight_2.ogg and /dev/null differ diff --git a/music/transcendental_fire.ogg b/music/transcendental_fire.ogg deleted file mode 100644 index 5dd971c..0000000 Binary files a/music/transcendental_fire.ogg and /dev/null differ diff --git a/music/venus.ogg b/music/venus.ogg deleted file mode 100644 index 8a7691a..0000000 Binary files a/music/venus.ogg and /dev/null differ diff --git a/music/ytinrete.ogg b/music/ytinrete.ogg deleted file mode 100644 index 38c0579..0000000 Binary files a/music/ytinrete.ogg and /dev/null differ diff --git a/music/zoom.ogg b/music/zoom.ogg deleted file mode 100644 index 52733a2..0000000 Binary files a/music/zoom.ogg and /dev/null differ diff --git a/musicroom.db b/musicroom.db index c32710c..2397c17 100644 Binary files a/musicroom.db and b/musicroom.db differ diff --git a/public/auth.js b/public/auth.js index 32aa513..b60216a 100644 --- a/public/auth.js +++ b/public/auth.js @@ -116,4 +116,17 @@ M.updateAuthUI(); } }; + + // Kick other clients + M.$("#btn-kick-others").onclick = async () => { + try { + const res = await fetch("/api/auth/kick-others", { method: "POST" }); + const data = await res.json(); + if (res.ok) { + M.showToast(`Kicked ${data.kicked} other client${data.kicked !== 1 ? 's' : ''}`); + } + } catch (e) { + M.showToast("Failed to kick other clients"); + } + }; })(); diff --git a/public/channelSync.js b/public/channelSync.js index 740d3ba..ff6d904 100644 --- a/public/channelSync.js +++ b/public/channelSync.js @@ -10,7 +10,7 @@ const res = await fetch("/api/channels"); const channels = await res.json(); if (channels.length === 0) { - M.$("#track-title").textContent = "No channels available"; + M.setTrackTitle("No channels available"); return; } M.channels = channels; @@ -21,7 +21,7 @@ const targetChannel = savedChannel || channels.find(c => c.isDefault) || channels[0]; M.connectChannel(targetChannel.id); } catch (e) { - M.$("#track-title").textContent = "Server unavailable"; + M.setTrackTitle("Server unavailable"); M.$("#status").textContent = "Local (offline)"; M.synced = false; M.updateUI(); @@ -177,6 +177,21 @@ M.renderChannelList(); return; } + // Handle kick command + if (data.type === "kick") { + M.showToast("Disconnected: " + (data.reason || "Kicked by another session")); + M.wantSync = false; + M.synced = false; + M.audio.pause(); + if (M.ws) { + const oldWs = M.ws; + M.ws = null; + oldWs.onclose = null; + oldWs.close(); + } + M.updateUI(); + return; + } // Handle library updates if (data.type === "track_added") { M.showToast(`"${data.track.title}" is now available`); @@ -226,7 +241,7 @@ }); if (!data.track) { - M.$("#track-title").textContent = "No tracks"; + M.setTrackTitle("No tracks"); return; } M.$("#channel-name").textContent = data.channelName || ""; @@ -235,6 +250,12 @@ M.lastServerUpdate = Date.now(); const wasServerPaused = M.serverPaused; M.serverPaused = data.paused ?? true; + + // Update playback mode if provided + if (data.playbackMode && data.playbackMode !== M.playbackMode) { + M.playbackMode = data.playbackMode; + if (M.updateModeButton) M.updateModeButton(); + } // Update queue if provided if (data.queue) { @@ -251,8 +272,7 @@ const isNewTrack = trackId !== M.currentTrackId; if (isNewTrack) { M.currentTrackId = trackId; - M.currentTitle = data.track.title; - M.$("#track-title").textContent = data.track.title; + M.setTrackTitle(data.track.title); M.loadingSegments.clear(); // Debug: log cache state for this track diff --git a/public/controls.js b/public/controls.js index 269ac53..5631da0 100644 --- a/public/controls.js +++ b/public/controls.js @@ -56,7 +56,7 @@ M.currentIndex = newIndex; M.currentTrackId = trackId; M.serverTrackDuration = track.duration; - M.$("#track-title").textContent = track.title?.trim() || track.filename?.replace(/\.[^.]+$/, "") || "Unknown"; + M.setTrackTitle(track.title?.trim() || track.filename?.replace(/\.[^.]+$/, "") || "Unknown"); M.loadingSegments.clear(); const cachedUrl = await M.loadTrackBlob(trackId); M.audio.src = cachedUrl || M.getTrackUrl(trackId); @@ -96,6 +96,43 @@ M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1); M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1); + // Playback mode button + const modeIcons = { + "repeat-all": "🔁", + "repeat-one": "🔂", + "shuffle": "🔀" + }; + const modeOrder = ["repeat-all", "repeat-one", "shuffle"]; + + M.updateModeButton = function() { + const btn = M.$("#btn-mode"); + btn.textContent = modeIcons[M.playbackMode] || "🔁"; + btn.title = `Playback: ${M.playbackMode}`; + btn.classList.toggle("active", M.playbackMode !== "repeat-all"); + }; + + M.$("#btn-mode").onclick = async () => { + if (!M.synced || !M.currentChannelId) { + // Local mode - just cycle through modes + const currentIdx = modeOrder.indexOf(M.playbackMode); + M.playbackMode = modeOrder[(currentIdx + 1) % modeOrder.length]; + M.updateModeButton(); + return; + } + + // Synced mode - send to server + const currentIdx = modeOrder.indexOf(M.playbackMode); + const newMode = modeOrder[(currentIdx + 1) % modeOrder.length]; + const res = await fetch("/api/channels/" + M.currentChannelId + "/mode", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: newMode }) + }); + if (res.status === 403) M.flashPermissionDenied(); + }; + + M.updateModeButton(); + // Progress bar seek tooltip M.$("#progress-container").onmousemove = (e) => { if (M.serverTrackDuration <= 0) return; diff --git a/public/core.js b/public/core.js index 036ee90..18b8992 100644 --- a/public/core.js +++ b/public/core.js @@ -30,6 +30,7 @@ window.MusicRoom = { localTimestamp: 0, queue: [], currentIndex: 0, + playbackMode: "repeat-all", // User state currentUser: null, diff --git a/public/index.html b/public/index.html index d8cc7de..12380f7 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ NeoRose - +
@@ -40,8 +40,9 @@
+ +
-
@@ -55,7 +56,10 @@
-

Library

+
+

Library

+ +
@@ -68,7 +72,7 @@
- Loading... + Loading...
@@ -77,6 +81,7 @@ + 🔁
0:00/0:00
diff --git a/public/queue.js b/public/queue.js index 3cd71d7..646c0b8 100644 --- a/public/queue.js +++ b/public/queue.js @@ -4,6 +4,176 @@ (function() { const M = window.MusicRoom; + // Selection state for bulk operations + M.selectedQueueIndices = new Set(); + M.selectedLibraryIds = new Set(); + + // Last selected index for shift-select range + let lastSelectedQueueIndex = null; + let lastSelectedLibraryIndex = null; + + // Context menu state + let activeContextMenu = null; + + // Close context menu when clicking elsewhere + document.addEventListener("click", () => { + if (activeContextMenu) { + activeContextMenu.remove(); + activeContextMenu = null; + } + }); + + // Show context menu + function showContextMenu(e, items) { + e.preventDefault(); + if (activeContextMenu) activeContextMenu.remove(); + + const menu = document.createElement("div"); + menu.className = "context-menu"; + + items.forEach(item => { + const el = document.createElement("div"); + el.className = "context-menu-item" + (item.danger ? " danger" : ""); + el.textContent = item.label; + el.onclick = (ev) => { + ev.stopPropagation(); + menu.remove(); + activeContextMenu = null; + item.action(); + }; + menu.appendChild(el); + }); + + document.body.appendChild(menu); + + // Position menu, keep within viewport + let x = e.clientX; + let y = e.clientY; + const rect = menu.getBoundingClientRect(); + if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 5; + if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 5; + menu.style.left = x + "px"; + menu.style.top = y + "px"; + + activeContextMenu = menu; + } + + // Drag state for queue reordering + let draggedIndices = []; + let draggedLibraryIds = []; + let dropTargetIndex = null; + let dragSource = null; // 'queue' or 'library' + + // Insert library tracks into queue at position + async function insertTracksAtPosition(trackIds, position) { + if (!M.currentChannelId || trackIds.length === 0) return; + + // Build new queue with tracks inserted at position + const newQueue = [...M.queue]; + const newTrackIds = [...newQueue.map(t => t.id)]; + newTrackIds.splice(position, 0, ...trackIds); + + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ set: newTrackIds }) + }); + + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); + M.clearSelections(); + } + } + + // Reorder queue on server + async function reorderQueue(fromIndices, toIndex) { + if (!M.currentChannelId || fromIndices.length === 0) return; + + // Build new queue order + const newQueue = [...M.queue]; + + // Sort indices descending to remove from end first + const sortedIndices = [...fromIndices].sort((a, b) => b - a); + const movedTracks = []; + + // Remove items (in reverse order to preserve indices) + for (const idx of sortedIndices) { + movedTracks.unshift(newQueue.splice(idx, 1)[0]); + } + + // Adjust target index for removed items before it + let adjustedTarget = toIndex; + for (const idx of fromIndices) { + if (idx < toIndex) adjustedTarget--; + } + + // Insert at new position + newQueue.splice(adjustedTarget, 0, ...movedTracks); + + // Send to server + const trackIds = newQueue.map(t => t.id); + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ set: trackIds }) + }); + + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.clearSelections(); + } + } + + // Toggle selection mode (with optional shift for range select) + M.toggleQueueSelection = function(index, shiftKey = false) { + if (shiftKey && lastSelectedQueueIndex !== null) { + // Range select: select all between last and current + const start = Math.min(lastSelectedQueueIndex, index); + const end = Math.max(lastSelectedQueueIndex, index); + for (let i = start; i <= end; i++) { + M.selectedQueueIndices.add(i); + } + } else { + if (M.selectedQueueIndices.has(index)) { + M.selectedQueueIndices.delete(index); + } else { + M.selectedQueueIndices.add(index); + } + lastSelectedQueueIndex = index; + } + M.renderQueue(); + }; + + M.toggleLibrarySelection = function(index, shiftKey = false) { + if (shiftKey && lastSelectedLibraryIndex !== null) { + // Range select: select all between last and current + const start = Math.min(lastSelectedLibraryIndex, index); + const end = Math.max(lastSelectedLibraryIndex, index); + for (let i = start; i <= end; i++) { + M.selectedLibraryIds.add(M.library[i].id); + } + } else { + const trackId = M.library[index].id; + if (M.selectedLibraryIds.has(trackId)) { + M.selectedLibraryIds.delete(trackId); + } else { + M.selectedLibraryIds.add(trackId); + } + lastSelectedLibraryIndex = index; + } + M.renderLibrary(); + }; + + M.clearSelections = function() { + M.selectedQueueIndices.clear(); + M.selectedLibraryIds.clear(); + lastSelectedQueueIndex = null; + lastSelectedLibraryIndex = null; + M.renderQueue(); + M.renderLibrary(); + }; + // Update cache status for all tracks M.updateCacheStatus = async function() { const cached = await TrackStorage.list(); @@ -104,8 +274,45 @@ const container = M.$("#queue"); if (!container) return; container.innerHTML = ""; + + const canEdit = M.canControl(); + + // Setup container-level drag handlers for dropping from library + if (canEdit) { + container.ondragover = (e) => { + if (dragSource === 'library') { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + // If no tracks or hovering at bottom, show we can drop + if (M.queue.length === 0) { + container.classList.add("drop-target"); + } + } + }; + + container.ondragleave = (e) => { + // Only remove if leaving the container entirely + if (!container.contains(e.relatedTarget)) { + container.classList.remove("drop-target"); + } + }; + + container.ondrop = (e) => { + container.classList.remove("drop-target"); + // Handle drop on empty queue or at the end + if (dragSource === 'library' && draggedLibraryIds.length > 0) { + e.preventDefault(); + const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length; + insertTracksAtPosition(draggedLibraryIds, targetIndex); + draggedLibraryIds = []; + dragSource = null; + dropTargetIndex = null; + } + }; + } + if (M.queue.length === 0) { - container.innerHTML = '
Queue empty
'; + container.innerHTML = '
Queue empty - drag tracks here
'; return; } @@ -122,39 +329,190 @@ const div = document.createElement("div"); const trackId = track.id || track.filename; const isCached = M.cachedTracks.has(trackId); - div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached"); + const isSelected = M.selectedQueueIndices.has(i); + div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); + div.dataset.index = i; const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); - div.innerHTML = `${title}${M.fmt(track.duration)}`; + const checkmark = isSelected ? `` : ''; + const trackNum = `${i + 1}.`; + div.innerHTML = `${checkmark}${trackNum}${title}${M.fmt(track.duration)}`; - div.querySelector(".track-title").onclick = async () => { - if (M.synced && M.currentChannelId) { - const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ index: i }) + // Drag and drop for reordering (if user can edit) + if (canEdit) { + div.draggable = true; + + div.ondragstart = (e) => { + dragSource = 'queue'; + draggedLibraryIds = []; + // If dragging a selected item, drag all selected; otherwise just this one + if (M.selectedQueueIndices.has(i)) { + draggedIndices = [...M.selectedQueueIndices]; + } else { + draggedIndices = [i]; + } + div.classList.add("dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", "queue:" + draggedIndices.join(",")); + }; + + div.ondragend = () => { + div.classList.remove("dragging"); + draggedIndices = []; + draggedLibraryIds = []; + dragSource = null; + // Clear all drop indicators + container.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + }; + + div.ondragover = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + // Determine if dropping above or below + const rect = div.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const isAbove = e.clientY < midY; + + // Clear other indicators + container.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + + // Don't show indicator on dragged queue items (for reorder) + if (dragSource === 'queue' && draggedIndices.includes(i)) return; + + div.classList.add(isAbove ? "drop-above" : "drop-below"); + dropTargetIndex = isAbove ? i : i + 1; + }; + + div.ondragleave = () => { + div.classList.remove("drop-above", "drop-below"); + }; + + div.ondrop = (e) => { + e.preventDefault(); + div.classList.remove("drop-above", "drop-below"); + + if (dragSource === 'library' && draggedLibraryIds.length > 0 && dropTargetIndex !== null) { + // Insert library tracks at drop position + insertTracksAtPosition(draggedLibraryIds, dropTargetIndex); + } else if (dragSource === 'queue' && draggedIndices.length > 0 && dropTargetIndex !== null) { + // Reorder queue + const minDragged = Math.min(...draggedIndices); + const maxDragged = Math.max(...draggedIndices); + if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { + reorderQueue(draggedIndices, dropTargetIndex); + } + } + + draggedIndices = []; + draggedLibraryIds = []; + dragSource = null; + dropTargetIndex = null; + }; + } + + // Click toggles selection + div.onclick = (e) => { + if (e.target.closest('.track-actions')) return; + M.toggleQueueSelection(i, e.shiftKey); + }; + + // Right-click context menu + div.oncontextmenu = (e) => { + const menuItems = []; + const hasSelection = M.selectedQueueIndices.size > 0; + const selectedCount = hasSelection ? M.selectedQueueIndices.size : 1; + const indicesToRemove = hasSelection ? [...M.selectedQueueIndices] : [i]; + + // Play track option (only for single track, not bulk) + if (!hasSelection) { + menuItems.push({ + label: "▶ Play track", + action: async () => { + if (M.synced && M.currentChannelId) { + const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ index: i }) + }); + if (res.status === 403) M.flashPermissionDenied(); + } else { + M.currentIndex = i; + M.currentTrackId = trackId; + M.serverTrackDuration = track.duration; + M.setTrackTitle(title); + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(trackId); + M.audio.src = cachedUrl || M.getTrackUrl(trackId); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + M.renderQueue(); + } + } }); - if (res.status === 403) M.flashPermissionDenied(); - if (res.status === 400) console.warn("Jump failed: 400 - index:", i, "queue length:", M.queue.length); - } else { - M.currentIndex = i; - M.currentTrackId = trackId; - M.serverTrackDuration = track.duration; - M.$("#track-title").textContent = title; - M.loadingSegments.clear(); - const cachedUrl = await M.loadTrackBlob(trackId); - M.audio.src = cachedUrl || M.getTrackUrl(trackId); - M.audio.currentTime = 0; - M.localTimestamp = 0; - M.audio.play(); - M.renderQueue(); } + + // Remove track(s) option (if user can edit) + if (canEdit) { + const label = selectedCount > 1 ? `✕ Remove ${selectedCount} tracks` : "✕ Remove track"; + menuItems.push({ + label, + danger: true, + action: async () => { + if (!M.currentChannelId) return; + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ remove: indicesToRemove }) + }); + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.showToast(selectedCount > 1 ? `Removed ${selectedCount} tracks` : "Track removed"); + M.clearSelections(); + } + } + }); + } + + // Preload track(s) option + const idsToPreload = hasSelection + ? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean) + : [trackId]; + const uncachedIds = idsToPreload.filter(id => !M.cachedTracks.has(id)); + if (uncachedIds.length > 0) { + const preloadLabel = uncachedIds.length > 1 ? `⬇ Preload ${uncachedIds.length} tracks` : "⬇ Preload track"; + menuItems.push({ + label: preloadLabel, + action: () => { + M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); + uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); + } + }); + } + + // Clear selection option (if items selected) + if (hasSelection) { + menuItems.push({ + label: "Clear selection", + action: () => M.clearSelections() + }); + } + + showContextMenu(e, menuItems); }; container.appendChild(div); }); }; + // Library search state + M.librarySearchQuery = ""; + // Render the library M.renderLibrary = function() { const container = M.$("#library"); @@ -164,25 +522,144 @@ container.innerHTML = '
No tracks discovered
'; return; } - M.library.forEach((track) => { + + const canEdit = M.canControl(); + const query = M.librarySearchQuery.toLowerCase(); + + // Filter library by search query + const filteredLibrary = query + ? M.library.map((track, i) => ({ track, i })).filter(({ track }) => { + const title = track.title?.trim() || track.filename; + return title.toLowerCase().includes(query); + }) + : M.library.map((track, i) => ({ track, i })); + + if (filteredLibrary.length === 0) { + container.innerHTML = '
No matches
'; + return; + } + + filteredLibrary.forEach(({ track, i }) => { const div = document.createElement("div"); const isCached = M.cachedTracks.has(track.id); - div.className = "track" + (isCached ? " cached" : " not-cached"); + const isSelected = M.selectedLibraryIds.has(track.id); + div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); - div.innerHTML = `${title}${M.fmt(track.duration)}`; - div.querySelector(".track-title").onclick = async () => { - // Play directly from library (uses track ID) - only in local mode - if (!M.synced) { - M.currentTrackId = track.id; - M.serverTrackDuration = track.duration; - M.$("#track-title").textContent = title; - M.loadingSegments.clear(); - const cachedUrl = await M.loadTrackBlob(track.id); - M.audio.src = cachedUrl || M.getTrackUrl(track.id); - M.audio.currentTime = 0; - M.localTimestamp = 0; - M.audio.play(); + const checkmark = isSelected ? `` : ''; + div.innerHTML = `${checkmark}${title}${M.fmt(track.duration)}`; + + // Drag from library to queue (if user can edit) + if (canEdit) { + div.draggable = true; + + div.ondragstart = (e) => { + dragSource = 'library'; + draggedIndices = []; + // If dragging a selected item, drag all selected; otherwise just this one + if (M.selectedLibraryIds.has(track.id)) { + draggedLibraryIds = [...M.selectedLibraryIds]; + } else { + draggedLibraryIds = [track.id]; + } + div.classList.add("dragging"); + e.dataTransfer.effectAllowed = "copy"; + e.dataTransfer.setData("text/plain", "library:" + draggedLibraryIds.join(",")); + }; + + div.ondragend = () => { + div.classList.remove("dragging"); + draggedIndices = []; + draggedLibraryIds = []; + dragSource = null; + // Clear drop indicators in queue + const queueContainer = M.$("#queue"); + if (queueContainer) { + queueContainer.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + } + }; + } + + // Click toggles selection + div.onclick = (e) => { + if (e.target.closest('.track-actions')) return; + M.toggleLibrarySelection(i, e.shiftKey); + }; + + // Right-click context menu + div.oncontextmenu = (e) => { + const menuItems = []; + const hasSelection = M.selectedLibraryIds.size > 0; + const selectedCount = hasSelection ? M.selectedLibraryIds.size : 1; + const idsToAdd = hasSelection ? [...M.selectedLibraryIds] : [track.id]; + + // Play track option (local mode only, single track) + if (!M.synced && !hasSelection) { + menuItems.push({ + label: "▶ Play track", + action: async () => { + M.currentTrackId = track.id; + M.serverTrackDuration = track.duration; + M.setTrackTitle(title); + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(track.id); + M.audio.src = cachedUrl || M.getTrackUrl(track.id); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + } + }); + } + + // Add to queue option (if user can edit) + if (canEdit) { + const label = selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue"; + menuItems.push({ + label, + action: async () => { + if (!M.currentChannelId) { + M.showToast("No channel selected"); + return; + } + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ add: idsToAdd }) + }); + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks` : "Track added to queue"); + M.clearSelections(); + } + } + }); + } + + // Preload track(s) option + const uncachedIds = idsToAdd.filter(id => !M.cachedTracks.has(id)); + if (uncachedIds.length > 0) { + const preloadLabel = uncachedIds.length > 1 ? `⬇ Preload ${uncachedIds.length} tracks` : "⬇ Preload track"; + menuItems.push({ + label: preloadLabel, + action: () => { + M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); + uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); + } + }); + } + + // Clear selection option (if items selected) + if (hasSelection) { + menuItems.push({ + label: "Clear selection", + action: () => M.clearSelections() + }); + } + + if (menuItems.length > 0) { + showContextMenu(e, menuItems); } }; @@ -200,4 +677,15 @@ console.warn("Failed to load library"); } }; + + // Setup library search + document.addEventListener("DOMContentLoaded", () => { + const searchInput = M.$("#library-search"); + if (searchInput) { + searchInput.addEventListener("input", (e) => { + M.librarySearchQuery = e.target.value; + M.renderLibrary(); + }); + } + }); })(); diff --git a/public/styles.css b/public/styles.css index 939a310..a6a6193 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,130 +1,160 @@ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; min-height: 100vh; } -#app { width: 100%; max-width: 1200px; margin: 0 auto; padding: 1rem; display: flex; flex-direction: column; min-height: 100vh; } -h1 { font-size: 1.2rem; color: #888; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; } -h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; } +#app { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; } +h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; } +h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; } #sync-indicator { width: 8px; height: 8px; border-radius: 50%; background: #4e8; display: none; flex-shrink: 0; } #sync-indicator.visible { display: inline-block; } #sync-indicator.disconnected { background: #e44; } /* Header */ -#header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } -#auth-section { display: flex; gap: 0.5rem; align-items: center; } -#auth-section .user-info { display: flex; align-items: center; gap: 0.5rem; } -#auth-section .username { color: #4e8; font-weight: 600; } -#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; } -#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 0.8rem; border-radius: 4px; font-size: 0.9rem; } +#header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } +#auth-section { display: flex; gap: 0.4rem; align-items: center; } +#auth-section .user-info { display: flex; align-items: center; gap: 0.4rem; } +#auth-section .username { color: #4e8; font-weight: 600; font-size: 0.85rem; } +#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.65rem; } +#btn-logout { background: none; border: none; color: #e44; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; } +#btn-logout:hover { opacity: 1; text-decoration: underline; } +#btn-kick-others { background: none; border: none; color: #ea4; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; } +#btn-kick-others:hover { opacity: 1; text-decoration: underline; } +#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.85rem; } /* Main content - library and queue */ -#main-content { display: flex; gap: 1rem; flex: 1; min-height: 0; margin-bottom: 1rem; } -#channels-panel { flex: 0 0 180px; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; } +#main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; } +#channels-panel { flex: 0 0 140px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; } #channels-list { flex: 1; overflow-y: auto; } -#channels-list .channel-item { padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.9rem; display: flex; flex-direction: column; gap: 0.25rem; } +#channels-list .channel-item { padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; display: flex; flex-direction: column; gap: 0.1rem; } #channels-list .channel-item.active { background: #2a4a3a; color: #4e8; } -#channels-list .channel-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 0.25rem 0; border-radius: 4px; } +#channels-list .channel-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 0.1rem 0; border-radius: 3px; } #channels-list .channel-header:hover { background: #222; } -#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -#channels-list .listener-count { font-size: 0.75rem; color: #666; flex-shrink: 0; margin-left: 0.5rem; } -#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 1rem; border-left: 2px solid #333; padding-left: 0.5rem; } -#channels-list .listener { font-size: 0.75rem; color: #aaa; padding: 0.15rem 0; position: relative; } -#channels-list .listener::before { content: ""; position: absolute; left: -0.5rem; top: 50%; width: 0.4rem; height: 2px; background: #333; } -#channels-list .listener-mult { color: #666; font-size: 0.65rem; } -#library-panel, #queue-panel { flex: 2; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; } -#queue-title { margin: 0 0 0.5rem 0; } -.panel-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; } +#channels-list .listener-count { font-size: 0.65rem; color: #666; flex-shrink: 0; margin-left: 0.3rem; } +#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 0.5rem; border-left: 1px solid #333; padding-left: 0.3rem; } +#channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; } +#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; } +#channels-list .listener-mult { color: #666; font-size: 0.55rem; } +#library-panel, #queue-panel { flex: 2; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; } +#queue-title { margin: 0 0 0.3rem 0; } +.panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; } .panel-header h3 { margin: 0; flex-shrink: 0; } -.panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; } -.panel-header button { background: #333; color: #eee; border: 1px solid #444; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0; } +.panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } +.panel-header button { background: #333; color: #eee; border: 1px solid #444; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 0.9rem; line-height: 1; padding: 0; } .panel-header button:hover { background: #444; } -.new-channel-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; } -.btn-submit-channel { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1.2rem; line-height: 1; padding: 0; } +.new-channel-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } +.btn-submit-channel { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0; } .btn-submit-channel:hover { background: #3a5a4a; } +.search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } +.search-input::placeholder { color: #666; } #library, #queue { flex: 1; overflow-y: auto; } -#library .track, #queue .track { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; align-items: center; position: relative; } +#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; } #library .track:hover, #queue .track:hover { background: #222; } #queue .track.active { background: #2a4a3a; color: #4e8; } .cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; } .track.cached .cache-indicator { background: #4e8; } .track.not-cached .cache-indicator { background: #ea4; } +.track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; } .track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.track-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } -.track-actions .duration { color: #666; font-size: 0.8rem; } -.track-actions .btn-add, .track-actions .btn-remove { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background: #333; border-radius: 3px; font-size: 0.9rem; opacity: 0; transition: opacity 0.2s; } -.track:hover .btn-add, .track:hover .btn-remove { opacity: 0.6; } -.track-actions .btn-add:hover, .track-actions .btn-remove:hover { opacity: 1; background: #444; } -.track-actions .btn-remove { color: #e44; } +.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } +.track-actions .duration { color: #666; font-size: 0.75rem; } +.track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; } +.track:hover .track-add, .track:hover .track-remove { opacity: 0.6; } +.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; } +.track-actions .track-remove { color: #e44; } +.track-actions .track-add { color: #4e4; } + +/* Track selection */ +.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; } +.track.selected { background: #2a3a4a; } +.track.dragging { opacity: 0.5; } +.track.drop-above::before { content: ""; position: absolute; top: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; } +.track.drop-below::after { content: ""; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; } +#queue.drop-target, #queue .drop-zone { border: 2px dashed #4e8; border-radius: 4px; } +#queue .drop-zone { padding: 1.5rem; text-align: center; color: #4e8; } + +/* Context menu */ +.context-menu { position: fixed; background: #222; border: 1px solid #444; border-radius: 5px; padding: 0.2rem 0; z-index: 1000; min-width: 130px; box-shadow: 0 4px 12px rgba(0,0,0,0.6); } +.context-menu-item { padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 0.4rem; } +.context-menu-item:hover { background: #333; } +.context-menu-item.danger { color: #e44; } +.context-menu-item.danger:hover { background: #3a2a2a; } /* Player bar */ -#player-bar { background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; gap: 1rem; align-items: center; } -#now-playing { min-width: 200px; } -#channel-name { font-size: 0.75rem; color: #666; margin-bottom: 0.2rem; } -#track-name { font-size: 1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; } +#now-playing { width: 180px; flex-shrink: 0; } +#channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; } +#track-name { font-size: 0.9rem; font-weight: 600; overflow: hidden; position: relative; } +#track-name .marquee-inner { display: inline-block; white-space: nowrap; } +#track-name.scrolling .marquee-inner { animation: scroll-marquee 8s linear infinite; } +@keyframes scroll-marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } #player-controls { flex: 1; } -#progress-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.3rem; } +#progress-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.2rem; } #progress-row.denied { animation: flash-red 0.5s ease-out; } @keyframes flash-red { 0% { background: #e44; } 100% { background: transparent; } } -#btn-sync { font-size: 0.75rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; } +#btn-sync { font-size: 0.7rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; } #btn-sync:hover { color: #888; } #btn-sync.synced { color: #eb0; text-shadow: 0 0 8px #eb0, 0 0 12px #eb0; } #btn-sync.synced.connected { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; } -#btn-prev, #btn-next { font-size: 0.8rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; } +#btn-prev, #btn-next { font-size: 0.75rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; } #btn-prev:hover, #btn-next:hover { opacity: 1; } -#status-icon { font-size: 0.9rem; width: 1rem; text-align: center; cursor: pointer; } -#progress-container { background: #222; border-radius: 4px; height: 6px; cursor: pointer; position: relative; flex: 1; } +#btn-mode { font-size: 0.85rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; } +#btn-mode:hover { opacity: 1; } +#btn-mode.active { opacity: 1; color: #4e8; } +#status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; } +#progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; } #progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; } #progress-bar.playing.synced { background: #4e8; } #progress-bar.playing.local { background: #c4f; } #progress-bar.muted { background: #555 !important; } -#seek-tooltip { position: absolute; bottom: 12px; background: #333; color: #eee; padding: 2px 6px; border-radius: 3px; font-size: 0.75rem; pointer-events: none; display: none; transform: translateX(-50%); } -#time { font-size: 0.8rem; color: #888; margin: 0; line-height: 1; white-space: nowrap; } -#buffer-bar { display: flex; gap: 2px; margin-bottom: 0.3rem; } -#buffer-bar .segment { flex: 1; height: 3px; background: #333; border-radius: 2px; } +#seek-tooltip { position: absolute; bottom: 10px; background: #333; color: #eee; padding: 2px 5px; border-radius: 3px; font-size: 0.7rem; pointer-events: none; display: none; transform: translateX(-50%); } +#time { font-size: 0.75rem; color: #888; margin: 0; line-height: 1; white-space: nowrap; } +#buffer-bar { display: flex; gap: 1px; margin-bottom: 0.2rem; } +#buffer-bar .segment { flex: 1; height: 2px; background: #333; border-radius: 1px; } #buffer-bar .segment.available { background: #396; } #buffer-bar .segment.loading { background: #666; animation: throb 0.6s ease-in-out infinite alternate; } @keyframes throb { from { background: #444; } to { background: #888; } } -#download-speed { font-size: 0.65rem; color: #555; text-align: right; } -#volume-controls { display: flex; gap: 0.5rem; align-items: center; } -#btn-mute { font-size: 1.2rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; } +#download-speed { font-size: 0.6rem; color: #555; text-align: right; } +#volume-controls { display: flex; gap: 0.4rem; align-items: center; } +#btn-mute { font-size: 1rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; } #btn-mute:hover { opacity: 1; } -#volume { width: 80px; accent-color: #4e8; } +#volume { width: 70px; accent-color: #4e8; } /* Common */ -button { background: #222; color: #eee; border: 1px solid #333; padding: 0.5rem 1.2rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; } +button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; } button:hover { background: #333; } -#status { margin-top: 0.5rem; font-size: 0.8rem; color: #666; text-align: center; } -.empty { color: #666; font-style: italic; } +#status { margin-top: 0.3rem; font-size: 0.75rem; color: #666; text-align: center; } +.empty { color: #666; font-style: italic; font-size: 0.85rem; } /* Login panel */ -#login-panel { display: flex; flex-direction: column; gap: 1rem; padding: 2rem; background: #1a1a1a; border-radius: 8px; border: 1px solid #333; max-width: 400px; margin: auto; } +#login-panel { display: flex; flex-direction: column; gap: 0.75rem; padding: 1.5rem; background: #1a1a1a; border-radius: 6px; border: 1px solid #333; max-width: 360px; margin: auto; } #login-panel.hidden { display: none; } -#login-panel h2 { font-size: 1.1rem; color: #888; margin-bottom: 0.5rem; } -#login-panel .tabs { display: flex; gap: 1rem; margin-bottom: 1rem; } -#login-panel .tabs button { background: none; border: none; color: #666; font-size: 1rem; cursor: pointer; padding: 0.5rem 0; border-bottom: 2px solid transparent; } +#login-panel h2 { font-size: 1rem; color: #888; margin-bottom: 0.3rem; } +#login-panel .tabs { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; } +#login-panel .tabs button { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0.4rem 0; border-bottom: 2px solid transparent; } #login-panel .tabs button.active { color: #4e8; border-bottom-color: #4e8; } -#login-panel input { background: #222; color: #eee; border: 1px solid #333; padding: 0.6rem; border-radius: 4px; font-size: 0.95rem; } -#login-panel .form-group { display: flex; flex-direction: column; gap: 0.5rem; } +#login-panel input { background: #222; color: #eee; border: 1px solid #333; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; } +#login-panel .form-group { display: flex; flex-direction: column; gap: 0.4rem; } #login-panel .form-group.hidden { display: none; } -#login-panel .submit-btn { background: #4e8; color: #111; border: none; padding: 0.6rem; border-radius: 4px; font-size: 0.95rem; cursor: pointer; font-weight: 600; } +#login-panel .submit-btn { background: #4e8; color: #111; border: none; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; cursor: pointer; font-weight: 600; } #login-panel .submit-btn:hover { background: #5fa; } -#auth-error, #signup-error { color: #e44; font-size: 0.8rem; } -#guest-section { margin-top: 1rem; } +#auth-error, #signup-error { color: #e44; font-size: 0.75rem; } +#guest-section { margin-top: 0.75rem; } #guest-section.hidden { display: none; } -#guest-section .divider { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; color: #666; font-size: 0.85rem; } +#guest-section .divider { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; color: #666; font-size: 0.8rem; } #guest-section .divider::before, #guest-section .divider::after { content: ""; flex: 1; height: 1px; background: #333; } -#guest-section .guest-btn { width: 100%; background: #333; color: #eee; border: 1px solid #444; padding: 0.6rem; border-radius: 4px; font-size: 0.95rem; cursor: pointer; } +#guest-section .guest-btn { width: 100%; background: #333; color: #eee; border: 1px solid #444; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; cursor: pointer; } #guest-section .guest-btn:hover { background: #444; } #player-content { display: none; flex-direction: column; flex: 1; } #player-content.visible { display: flex; } -::-webkit-scrollbar { width: 8px; } -::-webkit-scrollbar-track { background-color: #111; border-radius: 4px; } -::-webkit-scrollbar-thumb { background-color: #333; border-radius: 4px; } +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; } +::-webkit-scrollbar-thumb { background-color: #333; border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background-color: #555; } /* Toast notifications */ -#toast-container { position: fixed; top: 1rem; left: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 1000; pointer-events: none; } -.toast { background: #1a3a2a; color: #4e8; padding: 0.75rem 1rem; border-radius: 6px; border: 1px solid #4e8; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 0.9rem; animation: toast-in 0.3s ease-out; max-width: 300px; } +#toast-container { position: fixed; top: 0.5rem; left: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; } +.toast { background: #1a3a2a; color: #4e8; padding: 0.5rem 0.75rem; border-radius: 5px; border: 1px solid #4e8; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 0.8rem; animation: toast-in 0.3s ease-out; max-width: 280px; } .toast.fade-out { animation: toast-out 0.3s ease-in forwards; } @keyframes toast-in { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } } @keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px); } } diff --git a/public/utils.js b/public/utils.js index cc88fc9..7cfc81a 100644 --- a/public/utils.js +++ b/public/utils.js @@ -37,6 +37,30 @@ setTimeout(() => row.classList.remove("denied"), 500); }; + // Set track title (UI and document title) + M.setTrackTitle = function(title) { + M.currentTitle = title; + const titleEl = M.$("#track-title"); + const containerEl = M.$("#track-name"); + const marqueeEl = containerEl.querySelector(".marquee-inner"); + + titleEl.textContent = title; + document.title = title ? `${title} - MusicRoom` : "MusicRoom"; + + // Check if title overflows and needs scrolling + requestAnimationFrame(() => { + const needsScroll = titleEl.scrollWidth > containerEl.clientWidth; + containerEl.classList.toggle("scrolling", needsScroll); + + // Duplicate text for seamless wrap-around scrolling + if (needsScroll) { + marqueeEl.innerHTML = `${title}   •   ${title}   •   `; + } else { + marqueeEl.innerHTML = `${title}`; + } + }); + }; + // Get current server time (extrapolated) M.getServerTime = function() { if (M.serverPaused) return M.serverTimestamp; diff --git a/server.ts b/server.ts index 702e9ff..6742d14 100644 --- a/server.ts +++ b/server.ts @@ -1,5 +1,5 @@ import { file, serve, type ServerWebSocket } from "bun"; -import { Channel, type Track, type WsData } from "./channel"; +import { Channel, type Track, type WsData, type PersistenceCallback } from "./channel"; import { readdir } from "fs/promises"; import { join, resolve } from "path"; import { @@ -16,6 +16,13 @@ import { grantPermission, revokePermission, findUserById, + saveChannel, + updateChannelState, + loadAllChannels, + deleteChannelFromDb, + saveChannelQueue, + loadChannelQueue, + removeTrackFromQueues, } from "./db"; import { getUser, @@ -66,14 +73,44 @@ function generateChannelId(): string { // Initialize channels - create default channel with full library const channels = new Map(); -async function init(): Promise { - // Scan library first - await library.scan(); - library.startWatching(); +// Track all WebSocket connections by user ID for kick functionality +const userConnections = new Map>>(); - // Create default channel with full library - const allTracks = library.getAllTracks(); - const tracks: Track[] = allTracks +// Persistence callback for channels +const persistChannel: PersistenceCallback = (channel, type) => { + if (type === "state") { + updateChannelState(channel.id, { + currentIndex: channel.currentIndex, + startedAt: channel.startedAt, + paused: channel.paused, + pausedAt: channel.pausedAt, + playbackMode: channel.playbackMode, + }); + } else if (type === "queue") { + saveChannelQueue(channel.id, channel.queue.map(t => t.id)); + } +}; + +// Helper to build Track objects from track IDs using library +function buildTracksFromIds(trackIds: string[], lib: Library): Track[] { + const tracks: Track[] = []; + for (const tid of trackIds) { + const libTrack = lib.getTrack(tid); + if (libTrack && libTrack.duration > 0) { + tracks.push({ + id: libTrack.id, + filename: libTrack.filename, + title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""), + duration: libTrack.duration, + }); + } + } + return tracks; +} + +// Helper to get all library tracks as Track objects +function getAllLibraryTracks(lib: Library): Track[] { + return lib.getAllTracks() .filter(t => t.duration > 0) .map(t => ({ id: t.id, @@ -81,18 +118,83 @@ async function init(): Promise { title: t.title || t.filename.replace(/\.[^.]+$/, ""), duration: t.duration, })); +} - const defaultChannel = new Channel({ - id: "main", - name: "Main Channel", - description: "All tracks from the library", - tracks, - isDefault: true, - createdBy: null, - }); - - channels.set("main", defaultChannel); - console.log(`Default channel created: ${tracks.length} tracks`); +async function init(): Promise { + // Scan library first + await library.scan(); + library.startWatching(); + + // Load channels from database + const savedChannels = loadAllChannels(); + let hasDefault = false; + + for (const row of savedChannels) { + // Load queue for this channel + const trackIds = loadChannelQueue(row.id); + const tracks = buildTracksFromIds(trackIds, library); + + // For default channel, if queue is empty, use full library + const isDefault = row.is_default === 1; + if (isDefault) { + hasDefault = true; + } + + const channelTracks = (isDefault && tracks.length === 0) + ? getAllLibraryTracks(library) + : tracks; + + const channel = new Channel({ + id: row.id, + name: row.name, + description: row.description, + tracks: channelTracks, + createdBy: row.created_by, + isDefault, + currentIndex: row.current_index, + startedAt: row.started_at, + paused: row.paused === 1, + pausedAt: row.paused_at, + playbackMode: (row.playback_mode as "repeat-all" | "repeat-one" | "shuffle") || "repeat-all", + }); + + channel.setPersistenceCallback(persistChannel); + channels.set(row.id, channel); + console.log(`Loaded channel "${row.name}" (id=${row.id}) with ${channelTracks.length} tracks`); + } + + // Create default channel if it doesn't exist + if (!hasDefault) { + const tracks = getAllLibraryTracks(library); + const defaultChannel = new Channel({ + id: "main", + name: "Main Channel", + description: "All tracks from the library", + tracks, + isDefault: true, + createdBy: null, + }); + + defaultChannel.setPersistenceCallback(persistChannel); + channels.set("main", defaultChannel); + + // Save to database + saveChannel({ + id: defaultChannel.id, + name: defaultChannel.name, + description: defaultChannel.description, + createdBy: defaultChannel.createdBy, + isDefault: true, + currentIndex: defaultChannel.currentIndex, + startedAt: defaultChannel.startedAt, + paused: defaultChannel.paused, + pausedAt: defaultChannel.pausedAt, + playbackMode: defaultChannel.playbackMode, + }); + saveChannelQueue(defaultChannel.id, tracks.map(t => t.id)); + + console.log(`Default channel created: ${tracks.length} tracks`); + } } await init(); @@ -134,6 +236,10 @@ library.on("added", (track) => { library.on("removed", (track) => { console.log(`Track removed: ${track.title}`); + + // Remove from database queue entries + removeTrackFromQueues(track.id); + const allTracks = library.getAllTracks().map(t => ({ id: t.id, title: t.title, @@ -285,7 +391,24 @@ serve({ createdBy: user.id, isDefault: false, }); + channel.setPersistenceCallback(persistChannel); channels.set(channelId, channel); + + // Save to database + saveChannel({ + id: channel.id, + name: channel.name, + description: channel.description, + createdBy: channel.createdBy, + isDefault: false, + currentIndex: channel.currentIndex, + startedAt: channel.startedAt, + paused: channel.paused, + pausedAt: channel.pausedAt, + playbackMode: channel.playbackMode, + }); + saveChannelQueue(channel.id, tracks.map(t => t.id)); + console.log(`[Channel] Created "${name.trim()}" (id=${channelId}) by user ${user.id}`); broadcastChannelList(); return Response.json(channel.getListInfo(), { status: 201 }); @@ -313,6 +436,7 @@ serve({ return Response.json({ error: "Access denied" }, { status: 403 }); } channels.delete(channelId); + deleteChannelFromDb(channelId); broadcastChannelList(); return Response.json({ success: true }); } @@ -441,6 +565,32 @@ serve({ }, { headers }); } + // Kick all other clients for current user + if (path === "/api/auth/kick-others" && req.method === "POST") { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const connections = userConnections.get(user.id); + if (!connections || connections.size === 0) { + return Response.json({ kicked: 0 }); + } + + // Get the current request's session to identify which connection NOT to kick + const token = req.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1]; + let kickedCount = 0; + + for (const ws of connections) { + // Send kick message to all connections (client will handle it) + ws.send(JSON.stringify({ type: "kick", reason: "Kicked by another session" })); + kickedCount++; + } + + console.log(`[Kick] User ${user.username} kicked ${kickedCount} other clients`); + return Response.json({ kicked: kickedCount }); + } + // Admin: list users if (path === "/api/admin/users" && req.method === "GET") { try { @@ -524,6 +674,68 @@ serve({ } } + // API: modify channel queue (add/remove tracks) + const queueMatch = path.match(/^\/api\/channels\/([^/]+)\/queue$/); + if (queueMatch && req.method === "PATCH") { + const channelId = queueMatch[1]; + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + const channel = channels.get(channelId); + if (!channel) return new Response("Not found", { status: 404 }); + + try { + const body = await req.json(); + const { add, remove, set } = body; + + // If 'set' is provided, replace entire queue + if (Array.isArray(set)) { + const tracks = buildTracksFromIds(set, library); + channel.setQueue(tracks); + return Response.json({ success: true, queueLength: channel.queue.length }); + } + + // Otherwise apply remove then add + if (Array.isArray(remove) && remove.length > 0) { + const indices = remove.filter((i: unknown) => typeof i === "number"); + channel.removeTracksByIndex(indices); + } + + if (Array.isArray(add) && add.length > 0) { + const tracks = buildTracksFromIds(add, library); + channel.addTracks(tracks); + } + + return Response.json({ success: true, queueLength: channel.queue.length }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + } + + // API: set channel playback mode + const modeMatch = path.match(/^\/api\/channels\/([^/]+)\/mode$/); + if (modeMatch && req.method === "POST") { + const channelId = modeMatch[1]; + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + const channel = channels.get(channelId); + if (!channel) return new Response("Not found", { status: 404 }); + try { + const body = await req.json(); + const validModes = ["repeat-all", "repeat-one", "shuffle"]; + if (typeof body.mode === "string" && validModes.includes(body.mode)) { + channel.setPlaybackMode(body.mode); + return Response.json({ success: true, playbackMode: channel.playbackMode }); + } + return new Response("Invalid mode", { status: 400 }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + } + // API: serve audio file (requires auth or guest) // Supports both filename and track ID (sha256:...) const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); @@ -611,6 +823,14 @@ serve({ // Broadcast updated channel list to all clients broadcastChannelList(); } + // Track connection by user ID + const userId = ws.data.userId; + if (userId) { + if (!userConnections.has(userId)) { + userConnections.set(userId, new Set()); + } + userConnections.get(userId)!.add(ws); + } }, close(ws: ServerWebSocket) { const channel = channels.get(ws.data.channelId); @@ -618,6 +838,14 @@ serve({ channel.removeClient(ws); broadcastChannelList(); } + // Remove from user connections tracking + const userId = ws.data.userId; + if (userId && userConnections.has(userId)) { + userConnections.get(userId)!.delete(ws); + if (userConnections.get(userId)!.size === 0) { + userConnections.delete(userId); + } + } }, message(ws: ServerWebSocket, message: string | Buffer) { try {