saving
This commit is contained in:
parent
b0afc1cf5b
commit
55e4dd3947
|
|
@ -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
|
||||||
|
|
|
||||||
142
channel.ts
142
channel.ts
|
|
@ -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;
|
||||||
|
|
||||||
|
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;
|
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;
|
||||||
|
|
||||||
|
// 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.currentIndex = 0;
|
||||||
this.startedAt = Date.now();
|
this.startedAt = Date.now();
|
||||||
this.pausedAt = 0;
|
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
148
db.ts
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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();
|
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 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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<div class="panel-header">
|
||||||
<h3>Library</h3>
|
<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>
|
||||||
|
|
|
||||||
514
public/queue.js
514
public/queue.js
|
|
@ -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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,12 +329,110 @@
|
||||||
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 (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) {
|
if (M.synced && M.currentChannelId) {
|
||||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -135,12 +440,11 @@
|
||||||
body: JSON.stringify({ index: i })
|
body: JSON.stringify({ index: i })
|
||||||
});
|
});
|
||||||
if (res.status === 403) M.flashPermissionDenied();
|
if (res.status === 403) M.flashPermissionDenied();
|
||||||
if (res.status === 400) console.warn("Jump failed: 400 - index:", i, "queue length:", M.queue.length);
|
|
||||||
} else {
|
} else {
|
||||||
M.currentIndex = i;
|
M.currentIndex = i;
|
||||||
M.currentTrackId = trackId;
|
M.currentTrackId = trackId;
|
||||||
M.serverTrackDuration = track.duration;
|
M.serverTrackDuration = track.duration;
|
||||||
M.$("#track-title").textContent = title;
|
M.setTrackTitle(title);
|
||||||
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);
|
||||||
|
|
@ -149,12 +453,66 @@
|
||||||
M.audio.play();
|
M.audio.play();
|
||||||
M.renderQueue();
|
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,19 +522,87 @@
|
||||||
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) {
|
|
||||||
|
// 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.currentTrackId = track.id;
|
||||||
M.serverTrackDuration = track.duration;
|
M.serverTrackDuration = track.duration;
|
||||||
M.$("#track-title").textContent = title;
|
M.setTrackTitle(title);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
const cachedUrl = await M.loadTrackBlob(track.id);
|
const cachedUrl = await M.loadTrackBlob(track.id);
|
||||||
M.audio.src = cachedUrl || M.getTrackUrl(track.id);
|
M.audio.src = cachedUrl || M.getTrackUrl(track.id);
|
||||||
|
|
@ -184,6 +610,57 @@
|
||||||
M.localTimestamp = 0;
|
M.localTimestamp = 0;
|
||||||
M.audio.play();
|
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);
|
container.appendChild(div);
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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); } }
|
||||||
|
|
|
||||||
|
|
@ -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"> • </span><span>${title}</span><span class="marquee-spacer"> • </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;
|
||||||
|
|
|
||||||
244
server.ts
244
server.ts
|
|
@ -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,7 +118,54 @@ async function init(): Promise<void> {
|
||||||
title: t.title || t.filename.replace(/\.[^.]+$/, ""),
|
title: t.title || t.filename.replace(/\.[^.]+$/, ""),
|
||||||
duration: t.duration,
|
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({
|
const defaultChannel = new Channel({
|
||||||
id: "main",
|
id: "main",
|
||||||
name: "Main Channel",
|
name: "Main Channel",
|
||||||
|
|
@ -91,8 +175,26 @@ async function init(): Promise<void> {
|
||||||
createdBy: null,
|
createdBy: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defaultChannel.setPersistenceCallback(persistChannel);
|
||||||
channels.set("main", defaultChannel);
|
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`);
|
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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue