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 @@
Admin
+
+
-
@@ -55,7 +56,10 @@
@@ -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 {