saving
This commit is contained in:
parent
b0afc1cf5b
commit
55e4dd3947
|
|
@ -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
|
||||
|
|
|
|||
142
channel.ts
142
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;
|
||||
|
||||
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;
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
148
db.ts
148
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
BIN
music/53-x.ogg
BIN
music/53-x.ogg
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
music/bayou.ogg
BIN
music/bayou.ogg
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
music/bounce.ogg
BIN
music/bounce.ogg
Binary file not shown.
BIN
music/bubble.ogg
BIN
music/bubble.ogg
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.
BIN
music/lane_6.ogg
BIN
music/lane_6.ogg
Binary file not shown.
BIN
music/lean.ogg
BIN
music/lean.ogg
Binary file not shown.
BIN
music/light.ogg
BIN
music/light.ogg
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
music/moon.ogg
BIN
music/moon.ogg
Binary file not shown.
BIN
music/motive.ogg
BIN
music/motive.ogg
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
music/rook.ogg
BIN
music/rook.ogg
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
music/search.ogg
BIN
music/search.ogg
Binary file not shown.
BIN
music/slime.ogg
BIN
music/slime.ogg
Binary file not shown.
Binary file not shown.
BIN
music/tesla.ogg
BIN
music/tesla.ogg
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
music/venus.ogg
BIN
music/venus.ogg
Binary file not shown.
Binary file not shown.
BIN
music/zoom.ogg
BIN
music/zoom.ogg
Binary file not shown.
BIN
musicroom.db
BIN
musicroom.db
Binary file not shown.
|
|
@ -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");
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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 || "";
|
||||
|
|
@ -236,6 +251,12 @@
|
|||
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) {
|
||||
M.queue = 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ window.MusicRoom = {
|
|||
localTimestamp: 0,
|
||||
queue: [],
|
||||
currentIndex: 0,
|
||||
playbackMode: "repeat-all",
|
||||
|
||||
// User state
|
||||
currentUser: null,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NeoRose</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="styles.css?v=4">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
|
@ -40,8 +40,9 @@
|
|||
<div class="user-info">
|
||||
<span class="username" id="current-username"></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>
|
||||
<button id="btn-logout">Logout</button>
|
||||
</div>
|
||||
<div id="stream-select"></div>
|
||||
</div>
|
||||
|
|
@ -55,7 +56,10 @@
|
|||
<div id="channels-list"></div>
|
||||
</div>
|
||||
<div id="library-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Library</h3>
|
||||
<input type="text" id="library-search" placeholder="Search..." class="search-input">
|
||||
</div>
|
||||
<div id="library"></div>
|
||||
</div>
|
||||
<div id="queue-panel">
|
||||
|
|
@ -68,7 +72,7 @@
|
|||
<div id="now-playing">
|
||||
<div id="channel-name"></div>
|
||||
<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 id="player-controls">
|
||||
|
|
@ -77,6 +81,7 @@
|
|||
<span id="btn-prev" title="Previous track">⏮</span>
|
||||
<span id="status-icon">▶</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="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div>
|
||||
</div>
|
||||
|
|
|
|||
514
public/queue.js
514
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.innerHTML = '<div class="empty">Queue empty</div>';
|
||||
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 = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -122,12 +329,110 @@
|
|||
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 = `<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 (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",
|
||||
|
|
@ -135,12 +440,11 @@
|
|||
body: JSON.stringify({ index: i })
|
||||
});
|
||||
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.setTrackTitle(title);
|
||||
M.loadingSegments.clear();
|
||||
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
|
||||
|
|
@ -149,12 +453,66 @@
|
|||
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,19 +522,87 @@
|
|||
container.innerHTML = '<div class="empty">No tracks discovered</div>';
|
||||
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 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 = `<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 () => {
|
||||
// Play directly from library (uses track ID) - only in local mode
|
||||
if (!M.synced) {
|
||||
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
|
||||
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>`;
|
||||
|
||||
// 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.$("#track-title").textContent = title;
|
||||
M.setTrackTitle(title);
|
||||
M.loadingSegments.clear();
|
||||
const cachedUrl = await M.loadTrackBlob(track.id);
|
||||
M.audio.src = cachedUrl || M.getTrackUrl(track.id);
|
||||
|
|
@ -184,6 +610,57 @@
|
|||
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);
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(div);
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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); } }
|
||||
|
|
|
|||
|
|
@ -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 = `<span id="track-title">${title}</span><span class="marquee-spacer"> • </span><span>${title}</span><span class="marquee-spacer"> • </span>`;
|
||||
} else {
|
||||
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Get current server time (extrapolated)
|
||||
M.getServerTime = function() {
|
||||
if (M.serverPaused) return M.serverTimestamp;
|
||||
|
|
|
|||
244
server.ts
244
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<string, Channel>();
|
||||
|
||||
async function init(): Promise<void> {
|
||||
// Scan library first
|
||||
await library.scan();
|
||||
library.startWatching();
|
||||
// Track all WebSocket connections by user ID for kick functionality
|
||||
const userConnections = new Map<number, Set<ServerWebSocket<WsData>>>();
|
||||
|
||||
// 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,7 +118,54 @@ async function init(): Promise<void> {
|
|||
title: t.title || t.filename.replace(/\.[^.]+$/, ""),
|
||||
duration: t.duration,
|
||||
}));
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
// 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",
|
||||
|
|
@ -91,9 +175,27 @@ async function init(): Promise<void> {
|
|||
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<WsData>) {
|
||||
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<WsData>, message: string | Buffer) {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in New Issue