This commit is contained in:
peterino2 2026-02-03 00:11:26 -08:00
parent b0afc1cf5b
commit 55e4dd3947
69 changed files with 1282 additions and 141 deletions

View File

@ -195,3 +195,8 @@ MusicRoom.clearAllCaches() // Clear IndexedDB and in-memory caches
## Config ## Config
Default port 3001 (override with `PORT` env var). Track durations read from file metadata on startup with `music-metadata`. 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

View File

@ -7,6 +7,8 @@ export interface Track {
duration: number; duration: number;
} }
export type PlaybackMode = "repeat-all" | "repeat-one" | "shuffle";
export interface ChannelConfig { export interface ChannelConfig {
id: string; id: string;
name: string; name: string;
@ -14,10 +16,17 @@ export interface ChannelConfig {
tracks: Track[]; tracks: Track[];
createdBy?: number | null; createdBy?: number | null;
isDefault?: boolean; isDefault?: boolean;
currentIndex?: number;
startedAt?: number;
paused?: boolean;
pausedAt?: number;
playbackMode?: PlaybackMode;
} }
export type WsData = { channelId: string; userId: number | null; username: string }; export type WsData = { channelId: string; userId: number | null; username: string };
export type PersistenceCallback = (channel: Channel, type: "state" | "queue") => void;
export class Channel { export class Channel {
id: string; id: string;
name: string; name: string;
@ -31,8 +40,10 @@ export class Channel {
createdBy: number | null; createdBy: number | null;
createdAt: number; createdAt: number;
isDefault: boolean; isDefault: boolean;
playbackMode: PlaybackMode = "repeat-all";
private lastQueueBroadcast: number = 0; private lastQueueBroadcast: number = 0;
private queueDirty: boolean = false; private queueDirty: boolean = false;
private onPersist: PersistenceCallback | null = null;
constructor(config: ChannelConfig) { constructor(config: ChannelConfig) {
this.id = config.id; this.id = config.id;
@ -42,6 +53,23 @@ export class Channel {
this.createdBy = config.createdBy ?? null; this.createdBy = config.createdBy ?? null;
this.createdAt = Date.now(); this.createdAt = Date.now();
this.isDefault = config.isDefault ?? false; 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 { get currentTrack(): Track | null {
@ -50,6 +78,7 @@ export class Channel {
} }
get currentTimestamp(): number { get currentTimestamp(): number {
if (this.queue.length === 0) return 0;
if (this.paused) return this.pausedAt; if (this.paused) return this.pausedAt;
return (Date.now() - this.startedAt) / 1000; return (Date.now() - this.startedAt) / 1000;
} }
@ -67,8 +96,35 @@ export class Channel {
advance() { advance() {
if (this.queue.length === 0) return; 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.startedAt = Date.now();
this.persistState();
this.broadcast();
}
setPlaybackMode(mode: PlaybackMode) {
this.playbackMode = mode;
this.persistState();
this.broadcast(); this.broadcast();
} }
@ -83,6 +139,7 @@ export class Channel {
currentIndex: this.currentIndex, currentIndex: this.currentIndex,
listenerCount: this.clients.size, listenerCount: this.clients.size,
isDefault: this.isDefault, isDefault: this.isDefault,
playbackMode: this.playbackMode,
}; };
if (includeQueue) { if (includeQueue) {
state.queue = this.queue; state.queue = this.queue;
@ -94,6 +151,7 @@ export class Channel {
if (this.paused) return; if (this.paused) return;
this.pausedAt = this.currentTimestamp; this.pausedAt = this.currentTimestamp;
this.paused = true; this.paused = true;
this.persistState();
this.broadcast(); this.broadcast();
} }
@ -101,6 +159,7 @@ export class Channel {
if (!this.paused) return; if (!this.paused) return;
this.paused = false; this.paused = false;
this.startedAt = Date.now() - this.pausedAt * 1000; this.startedAt = Date.now() - this.pausedAt * 1000;
this.persistState();
this.broadcast(); this.broadcast();
} }
@ -112,6 +171,7 @@ export class Channel {
} else { } else {
this.startedAt = Date.now(); this.startedAt = Date.now();
} }
this.persistState();
this.broadcast(); this.broadcast();
} }
@ -124,6 +184,7 @@ export class Channel {
} else { } else {
this.startedAt = Date.now() - clamped * 1000; this.startedAt = Date.now() - clamped * 1000;
} }
this.persistState();
this.broadcast(); this.broadcast();
} }
@ -132,11 +193,92 @@ export class Channel {
} }
setQueue(tracks: Track[]) { 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.queue = tracks;
this.currentIndex = 0;
this.startedAt = Date.now(); // Try to find the current track in the new queue
this.pausedAt = 0; 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.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(); this.broadcast();
} }

148
db.ts
View File

@ -293,3 +293,151 @@ export function getTrack(id: string): Track | null {
export function getAllTracks(): Track[] { export function getAllTracks(): Track[] {
return db.query("SELECT * FROM tracks ORDER BY title").all() as 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);
}

View File

@ -7,7 +7,7 @@ import { parseFile } from "music-metadata";
import { upsertTrack, type Track } from "./db"; import { upsertTrack, type Track } from "./db";
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB 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 { export interface LibraryTrack extends Track {
filename: string; filename: string;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -116,4 +116,17 @@
M.updateAuthUI(); 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");
}
};
})(); })();

View File

@ -10,7 +10,7 @@
const res = await fetch("/api/channels"); const res = await fetch("/api/channels");
const channels = await res.json(); const channels = await res.json();
if (channels.length === 0) { if (channels.length === 0) {
M.$("#track-title").textContent = "No channels available"; M.setTrackTitle("No channels available");
return; return;
} }
M.channels = channels; M.channels = channels;
@ -21,7 +21,7 @@
const targetChannel = savedChannel || channels.find(c => c.isDefault) || channels[0]; const targetChannel = savedChannel || channels.find(c => c.isDefault) || channels[0];
M.connectChannel(targetChannel.id); M.connectChannel(targetChannel.id);
} catch (e) { } catch (e) {
M.$("#track-title").textContent = "Server unavailable"; M.setTrackTitle("Server unavailable");
M.$("#status").textContent = "Local (offline)"; M.$("#status").textContent = "Local (offline)";
M.synced = false; M.synced = false;
M.updateUI(); M.updateUI();
@ -177,6 +177,21 @@
M.renderChannelList(); M.renderChannelList();
return; 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 // Handle library updates
if (data.type === "track_added") { if (data.type === "track_added") {
M.showToast(`"${data.track.title}" is now available`); M.showToast(`"${data.track.title}" is now available`);
@ -226,7 +241,7 @@
}); });
if (!data.track) { if (!data.track) {
M.$("#track-title").textContent = "No tracks"; M.setTrackTitle("No tracks");
return; return;
} }
M.$("#channel-name").textContent = data.channelName || ""; M.$("#channel-name").textContent = data.channelName || "";
@ -236,6 +251,12 @@
const wasServerPaused = M.serverPaused; const wasServerPaused = M.serverPaused;
M.serverPaused = data.paused ?? true; 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 // Update queue if provided
if (data.queue) { if (data.queue) {
M.queue = data.queue; M.queue = data.queue;
@ -251,8 +272,7 @@
const isNewTrack = trackId !== M.currentTrackId; const isNewTrack = trackId !== M.currentTrackId;
if (isNewTrack) { if (isNewTrack) {
M.currentTrackId = trackId; M.currentTrackId = trackId;
M.currentTitle = data.track.title; M.setTrackTitle(data.track.title);
M.$("#track-title").textContent = data.track.title;
M.loadingSegments.clear(); M.loadingSegments.clear();
// Debug: log cache state for this track // Debug: log cache state for this track

39
public/controls.js vendored
View File

@ -56,7 +56,7 @@
M.currentIndex = newIndex; M.currentIndex = newIndex;
M.currentTrackId = trackId; M.currentTrackId = trackId;
M.serverTrackDuration = track.duration; 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(); M.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(trackId); const cachedUrl = await M.loadTrackBlob(trackId);
M.audio.src = cachedUrl || M.getTrackUrl(trackId); M.audio.src = cachedUrl || M.getTrackUrl(trackId);
@ -96,6 +96,43 @@
M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1); M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1);
M.$("#btn-next").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 // Progress bar seek tooltip
M.$("#progress-container").onmousemove = (e) => { M.$("#progress-container").onmousemove = (e) => {
if (M.serverTrackDuration <= 0) return; if (M.serverTrackDuration <= 0) return;

View File

@ -30,6 +30,7 @@ window.MusicRoom = {
localTimestamp: 0, localTimestamp: 0,
queue: [], queue: [],
currentIndex: 0, currentIndex: 0,
playbackMode: "repeat-all",
// User state // User state
currentUser: null, currentUser: null,

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeoRose</title> <title>NeoRose</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css?v=4">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@ -40,8 +40,9 @@
<div class="user-info"> <div class="user-info">
<span class="username" id="current-username"></span> <span class="username" id="current-username"></span>
<span class="admin-badge" id="admin-badge" style="display:none;">Admin</span> <span class="admin-badge" id="admin-badge" style="display:none;">Admin</span>
<button id="btn-kick-others" title="Disconnect all other devices">kick others</button>
<button id="btn-logout">logout</button>
</div> </div>
<button id="btn-logout">Logout</button>
</div> </div>
<div id="stream-select"></div> <div id="stream-select"></div>
</div> </div>
@ -55,7 +56,10 @@
<div id="channels-list"></div> <div id="channels-list"></div>
</div> </div>
<div id="library-panel"> <div id="library-panel">
<h3>Library</h3> <div class="panel-header">
<h3>Library</h3>
<input type="text" id="library-search" placeholder="Search..." class="search-input">
</div>
<div id="library"></div> <div id="library"></div>
</div> </div>
<div id="queue-panel"> <div id="queue-panel">
@ -68,7 +72,7 @@
<div id="now-playing"> <div id="now-playing">
<div id="channel-name"></div> <div id="channel-name"></div>
<div id="track-name" class="empty"> <div id="track-name" class="empty">
<span id="track-title">Loading...</span> <span class="marquee-inner"><span id="track-title">Loading...</span></span>
</div> </div>
</div> </div>
<div id="player-controls"> <div id="player-controls">
@ -77,6 +81,7 @@
<span id="btn-prev" title="Previous track"></span> <span id="btn-prev" title="Previous track"></span>
<span id="status-icon"></span> <span id="status-icon"></span>
<span id="btn-next" title="Next track"></span> <span id="btn-next" title="Next track"></span>
<span id="btn-mode" title="Playback mode">🔁</span>
<div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div> <div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div>
<div id="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div> <div id="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div>
</div> </div>

View File

@ -4,6 +4,176 @@
(function() { (function() {
const M = window.MusicRoom; 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 // Update cache status for all tracks
M.updateCacheStatus = async function() { M.updateCacheStatus = async function() {
const cached = await TrackStorage.list(); const cached = await TrackStorage.list();
@ -104,8 +274,45 @@
const container = M.$("#queue"); const container = M.$("#queue");
if (!container) return; if (!container) return;
container.innerHTML = ""; 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) { if (M.queue.length === 0) {
container.innerHTML = '<div class="empty">Queue empty</div>'; container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
return; return;
} }
@ -122,39 +329,190 @@
const div = document.createElement("div"); const div = document.createElement("div");
const trackId = track.id || track.filename; const trackId = track.id || track.filename;
const isCached = M.cachedTracks.has(trackId); 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(/\.[^.]+$/, ""); const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`; const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
const trackNum = `<span class="track-number">${i + 1}.</span>`;
div.innerHTML = `${checkmark}<span class="cache-indicator"></span>${trackNum}<span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
div.querySelector(".track-title").onclick = async () => { // Drag and drop for reordering (if user can edit)
if (M.synced && M.currentChannelId) { if (canEdit) {
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { div.draggable = true;
method: "POST",
headers: { "Content-Type": "application/json" }, div.ondragstart = (e) => {
body: JSON.stringify({ index: i }) 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); container.appendChild(div);
}); });
}; };
// Library search state
M.librarySearchQuery = "";
// Render the library // Render the library
M.renderLibrary = function() { M.renderLibrary = function() {
const container = M.$("#library"); const container = M.$("#library");
@ -164,25 +522,144 @@
container.innerHTML = '<div class="empty">No tracks discovered</div>'; container.innerHTML = '<div class="empty">No tracks discovered</div>';
return; 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 = '<div class="empty">No matches</div>';
return;
}
filteredLibrary.forEach(({ track, i }) => {
const div = document.createElement("div"); const div = document.createElement("div");
const isCached = M.cachedTracks.has(track.id); 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(/\.[^.]+$/, ""); const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
div.querySelector(".track-title").onclick = async () => { const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
// Play directly from library (uses track ID) - only in local mode div.innerHTML = `${checkmark}<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
if (!M.synced) {
M.currentTrackId = track.id; // Drag from library to queue (if user can edit)
M.serverTrackDuration = track.duration; if (canEdit) {
M.$("#track-title").textContent = title; div.draggable = true;
M.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(track.id); div.ondragstart = (e) => {
M.audio.src = cachedUrl || M.getTrackUrl(track.id); dragSource = 'library';
M.audio.currentTime = 0; draggedIndices = [];
M.localTimestamp = 0; // If dragging a selected item, drag all selected; otherwise just this one
M.audio.play(); 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"); 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();
});
}
});
})(); })();

View File

@ -1,130 +1,160 @@
* { margin: 0; padding: 0; box-sizing: border-box; } * { 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; } 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; } #app { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0.5rem; 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; } h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; } 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 { width: 8px; height: 8px; border-radius: 50%; background: #4e8; display: none; flex-shrink: 0; }
#sync-indicator.visible { display: inline-block; } #sync-indicator.visible { display: inline-block; }
#sync-indicator.disconnected { background: #e44; } #sync-indicator.disconnected { background: #e44; }
/* Header */ /* Header */
#header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } #header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
#auth-section { display: flex; gap: 0.5rem; align-items: center; } #auth-section { display: flex; gap: 0.4rem; align-items: center; }
#auth-section .user-info { display: flex; align-items: center; gap: 0.5rem; } #auth-section .user-info { display: flex; align-items: center; gap: 0.4rem; }
#auth-section .username { color: #4e8; font-weight: 600; } #auth-section .username { color: #4e8; font-weight: 600; font-size: 0.85rem; }
#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; } #auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.65rem; }
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 0.8rem; border-radius: 4px; font-size: 0.9rem; } #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 - library and queue */
#main-content { display: flex; gap: 1rem; flex: 1; min-height: 0; margin-bottom: 1rem; } #main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; }
#channels-panel { flex: 0 0 180px; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; } #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 { 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-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-header:hover { background: #222; }
#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #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.75rem; color: #666; flex-shrink: 0; margin-left: 0.5rem; } #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: 1rem; border-left: 2px solid #333; padding-left: 0.5rem; } #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.75rem; color: #aaa; padding: 0.15rem 0; position: relative; } #channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 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::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.65rem; } #channels-list .listener-mult { color: #666; font-size: 0.55rem; }
#library-panel, #queue-panel { flex: 2; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; } #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.5rem 0; } #queue-title { margin: 0 0 0.3rem 0; }
.panel-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } .panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
.panel-header h3 { margin: 0; flex-shrink: 0; } .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 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: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0; } .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; } .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; } .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: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1.2rem; line-height: 1; padding: 0; } .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; } .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, #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; } #library .track:hover, #queue .track:hover { background: #222; }
#queue .track.active { background: #2a4a3a; color: #4e8; } #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; } .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.cached .cache-indicator { background: #4e8; }
.track.not-cached .cache-indicator { background: #ea4; } .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-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 { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.track-actions .duration { color: #666; font-size: 0.8rem; } .track-actions .duration { color: #666; font-size: 0.75rem; }
.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-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 .btn-add, .track:hover .btn-remove { opacity: 0.6; } .track:hover .track-add, .track:hover .track-remove { opacity: 0.6; }
.track-actions .btn-add:hover, .track-actions .btn-remove:hover { opacity: 1; background: #444; } .track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
.track-actions .btn-remove { color: #e44; } .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 */
#player-bar { background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; gap: 1rem; align-items: center; } #player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; }
#now-playing { min-width: 200px; } #now-playing { width: 180px; flex-shrink: 0; }
#channel-name { font-size: 0.75rem; color: #666; margin-bottom: 0.2rem; } #channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; }
#track-name { font-size: 1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #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; } #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; } #progress-row.denied { animation: flash-red 0.5s ease-out; }
@keyframes flash-red { 0% { background: #e44; } 100% { background: transparent; } } @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:hover { color: #888; }
#btn-sync.synced { color: #eb0; text-shadow: 0 0 8px #eb0, 0 0 12px #eb0; } #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-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; } #btn-prev:hover, #btn-next:hover { opacity: 1; }
#status-icon { font-size: 0.9rem; width: 1rem; text-align: center; cursor: pointer; } #btn-mode { font-size: 0.85rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
#progress-container { background: #222; border-radius: 4px; height: 6px; cursor: pointer; position: relative; flex: 1; } #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 { 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.synced { background: #4e8; }
#progress-bar.playing.local { background: #c4f; } #progress-bar.playing.local { background: #c4f; }
#progress-bar.muted { background: #555 !important; } #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%); } #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.8rem; color: #888; margin: 0; line-height: 1; white-space: nowrap; } #time { font-size: 0.75rem; color: #888; margin: 0; line-height: 1; white-space: nowrap; }
#buffer-bar { display: flex; gap: 2px; margin-bottom: 0.3rem; } #buffer-bar { display: flex; gap: 1px; margin-bottom: 0.2rem; }
#buffer-bar .segment { flex: 1; height: 3px; background: #333; border-radius: 2px; } #buffer-bar .segment { flex: 1; height: 2px; background: #333; border-radius: 1px; }
#buffer-bar .segment.available { background: #396; } #buffer-bar .segment.available { background: #396; }
#buffer-bar .segment.loading { background: #666; animation: throb 0.6s ease-in-out infinite alternate; } #buffer-bar .segment.loading { background: #666; animation: throb 0.6s ease-in-out infinite alternate; }
@keyframes throb { from { background: #444; } to { background: #888; } } @keyframes throb { from { background: #444; } to { background: #888; } }
#download-speed { font-size: 0.65rem; color: #555; text-align: right; } #download-speed { font-size: 0.6rem; color: #555; text-align: right; }
#volume-controls { display: flex; gap: 0.5rem; align-items: center; } #volume-controls { display: flex; gap: 0.4rem; align-items: center; }
#btn-mute { font-size: 1.2rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; } #btn-mute { font-size: 1rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; }
#btn-mute:hover { opacity: 1; } #btn-mute:hover { opacity: 1; }
#volume { width: 80px; accent-color: #4e8; } #volume { width: 70px; accent-color: #4e8; }
/* Common */ /* 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; } button:hover { background: #333; }
#status { margin-top: 0.5rem; font-size: 0.8rem; color: #666; text-align: center; } #status { margin-top: 0.3rem; font-size: 0.75rem; color: #666; text-align: center; }
.empty { color: #666; font-style: italic; } .empty { color: #666; font-style: italic; font-size: 0.85rem; }
/* Login panel */ /* 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.hidden { display: none; }
#login-panel h2 { font-size: 1.1rem; color: #888; margin-bottom: 0.5rem; } #login-panel h2 { font-size: 1rem; color: #888; margin-bottom: 0.3rem; }
#login-panel .tabs { display: flex; gap: 1rem; margin-bottom: 1rem; } #login-panel .tabs { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; }
#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 .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 .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 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.5rem; } #login-panel .form-group { display: flex; flex-direction: column; gap: 0.4rem; }
#login-panel .form-group.hidden { display: none; } #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; } #login-panel .submit-btn:hover { background: #5fa; }
#auth-error, #signup-error { color: #e44; font-size: 0.8rem; } #auth-error, #signup-error { color: #e44; font-size: 0.75rem; }
#guest-section { margin-top: 1rem; } #guest-section { margin-top: 0.75rem; }
#guest-section.hidden { display: none; } #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 .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; } #guest-section .guest-btn:hover { background: #444; }
#player-content { display: none; flex-direction: column; flex: 1; } #player-content { display: none; flex-direction: column; flex: 1; }
#player-content.visible { display: flex; } #player-content.visible { display: flex; }
::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background-color: #111; border-radius: 4px; } ::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; }
::-webkit-scrollbar-thumb { background-color: #333; border-radius: 4px; } ::-webkit-scrollbar-thumb { background-color: #333; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background-color: #555; } ::-webkit-scrollbar-thumb:hover { background-color: #555; }
/* Toast notifications */ /* 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-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.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 { 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; } .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-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); } } @keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px); } }

View File

@ -37,6 +37,30 @@
setTimeout(() => row.classList.remove("denied"), 500); 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 = `<span id="track-title">${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span><span>${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span>`;
} else {
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`;
}
});
};
// Get current server time (extrapolated) // Get current server time (extrapolated)
M.getServerTime = function() { M.getServerTime = function() {
if (M.serverPaused) return M.serverTimestamp; if (M.serverPaused) return M.serverTimestamp;

264
server.ts
View File

@ -1,5 +1,5 @@
import { file, serve, type ServerWebSocket } from "bun"; 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 { readdir } from "fs/promises";
import { join, resolve } from "path"; import { join, resolve } from "path";
import { import {
@ -16,6 +16,13 @@ import {
grantPermission, grantPermission,
revokePermission, revokePermission,
findUserById, findUserById,
saveChannel,
updateChannelState,
loadAllChannels,
deleteChannelFromDb,
saveChannelQueue,
loadChannelQueue,
removeTrackFromQueues,
} from "./db"; } from "./db";
import { import {
getUser, getUser,
@ -66,14 +73,44 @@ function generateChannelId(): string {
// Initialize channels - create default channel with full library // Initialize channels - create default channel with full library
const channels = new Map<string, Channel>(); const channels = new Map<string, Channel>();
async function init(): Promise<void> { // Track all WebSocket connections by user ID for kick functionality
// Scan library first const userConnections = new Map<number, Set<ServerWebSocket<WsData>>>();
await library.scan();
library.startWatching();
// Create default channel with full library // Persistence callback for channels
const allTracks = library.getAllTracks(); const persistChannel: PersistenceCallback = (channel, type) => {
const tracks: Track[] = allTracks 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) .filter(t => t.duration > 0)
.map(t => ({ .map(t => ({
id: t.id, id: t.id,
@ -81,18 +118,83 @@ async function init(): Promise<void> {
title: t.title || t.filename.replace(/\.[^.]+$/, ""), title: t.title || t.filename.replace(/\.[^.]+$/, ""),
duration: t.duration, duration: t.duration,
})); }));
}
const defaultChannel = new Channel({ async function init(): Promise<void> {
id: "main", // Scan library first
name: "Main Channel", await library.scan();
description: "All tracks from the library", library.startWatching();
tracks,
isDefault: true,
createdBy: null,
});
channels.set("main", defaultChannel); // Load channels from database
console.log(`Default channel created: ${tracks.length} tracks`); 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(); await init();
@ -134,6 +236,10 @@ library.on("added", (track) => {
library.on("removed", (track) => { library.on("removed", (track) => {
console.log(`Track removed: ${track.title}`); console.log(`Track removed: ${track.title}`);
// Remove from database queue entries
removeTrackFromQueues(track.id);
const allTracks = library.getAllTracks().map(t => ({ const allTracks = library.getAllTracks().map(t => ({
id: t.id, id: t.id,
title: t.title, title: t.title,
@ -285,7 +391,24 @@ serve({
createdBy: user.id, createdBy: user.id,
isDefault: false, isDefault: false,
}); });
channel.setPersistenceCallback(persistChannel);
channels.set(channelId, channel); 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}`); console.log(`[Channel] Created "${name.trim()}" (id=${channelId}) by user ${user.id}`);
broadcastChannelList(); broadcastChannelList();
return Response.json(channel.getListInfo(), { status: 201 }); return Response.json(channel.getListInfo(), { status: 201 });
@ -313,6 +436,7 @@ serve({
return Response.json({ error: "Access denied" }, { status: 403 }); return Response.json({ error: "Access denied" }, { status: 403 });
} }
channels.delete(channelId); channels.delete(channelId);
deleteChannelFromDb(channelId);
broadcastChannelList(); broadcastChannelList();
return Response.json({ success: true }); return Response.json({ success: true });
} }
@ -441,6 +565,32 @@ serve({
}, { headers }); }, { 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 // Admin: list users
if (path === "/api/admin/users" && req.method === "GET") { if (path === "/api/admin/users" && req.method === "GET") {
try { 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) // API: serve audio file (requires auth or guest)
// Supports both filename and track ID (sha256:...) // Supports both filename and track ID (sha256:...)
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
@ -611,6 +823,14 @@ serve({
// Broadcast updated channel list to all clients // Broadcast updated channel list to all clients
broadcastChannelList(); 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<WsData>) { close(ws: ServerWebSocket<WsData>) {
const channel = channels.get(ws.data.channelId); const channel = channels.get(ws.data.channelId);
@ -618,6 +838,14 @@ serve({
channel.removeClient(ws); channel.removeClient(ws);
broadcastChannelList(); 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<WsData>, message: string | Buffer) { message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
try { try {