Compare commits
9 Commits
0.0.3
...
dev/androi
| Author | SHA1 | Date |
|---|---|---|
|
|
c6490b9654 | |
|
|
089fef215c | |
|
|
4fe19f5abd | |
|
|
6b6fb26a6e | |
|
|
7740643421 | |
|
|
f7d73a6ef2 | |
|
|
96dd5d1c8b | |
|
|
aef6b9590e | |
|
|
6a62242482 |
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3
|
|
||||||
|
|
@ -18,6 +18,8 @@ export interface ReplayGainConfig {
|
||||||
command: string;
|
command: string;
|
||||||
truePeak: boolean;
|
truePeak: boolean;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
maxConcurrent?: number;
|
||||||
|
maxOutputBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
|
|
@ -51,7 +53,9 @@ export const DEFAULT_CONFIG: Config = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
command: "rsgain",
|
command: "rsgain",
|
||||||
truePeak: false,
|
truePeak: false,
|
||||||
timeoutMs: 120000
|
timeoutMs: 120000,
|
||||||
|
maxConcurrent: 1,
|
||||||
|
maxOutputBytes: 262144
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
43
init.ts
43
init.ts
|
|
@ -85,6 +85,21 @@ export function getAllLibraryTracks(lib: Library): Track[] {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeLibraryTrack(track: ReturnType<Library["getAllTracks"]>[number]) {
|
||||||
|
return {
|
||||||
|
id: track.id,
|
||||||
|
filename: track.filename,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: track.duration,
|
||||||
|
replayGainDb: track.replayGainDb,
|
||||||
|
replayPeak: track.replayPeak,
|
||||||
|
createdAt: track.created_at,
|
||||||
|
available: track.available,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function init(): Promise<void> {
|
export async function init(): Promise<void> {
|
||||||
// Initialize yt-dlp if configured
|
// Initialize yt-dlp if configured
|
||||||
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
|
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
|
||||||
|
|
@ -215,7 +230,7 @@ export async function init(): Promise<void> {
|
||||||
setTimeout(() => checkPendingPlaylistAddition(track), 100);
|
setTimeout(() => checkPendingPlaylistAddition(track), 100);
|
||||||
});
|
});
|
||||||
library.on("changed", (track) => {
|
library.on("changed", (track) => {
|
||||||
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });
|
console.log(`Track metadata updated: ${track.title || track.filename}`);
|
||||||
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
|
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -352,22 +367,10 @@ export async function init(): Promise<void> {
|
||||||
// Listen for library changes and notify clients
|
// Listen for library changes and notify clients
|
||||||
library.on("added", (track) => {
|
library.on("added", (track) => {
|
||||||
console.log(`New track detected: ${track.title}`);
|
console.log(`New track detected: ${track.title}`);
|
||||||
const allTracks = library.getAllTracks().map(t => ({
|
const allTracks = library.getAllTracks().map(serializeLibraryTrack);
|
||||||
id: t.id,
|
|
||||||
title: t.title,
|
|
||||||
duration: t.duration,
|
|
||||||
replayGainDb: t.replayGainDb,
|
|
||||||
replayPeak: t.replayPeak,
|
|
||||||
}));
|
|
||||||
broadcastToAll({
|
broadcastToAll({
|
||||||
type: "track_added",
|
type: "track_added",
|
||||||
track: {
|
track: serializeLibraryTrack(track),
|
||||||
id: track.id,
|
|
||||||
title: track.title,
|
|
||||||
duration: track.duration,
|
|
||||||
replayGainDb: track.replayGainDb,
|
|
||||||
replayPeak: track.replayPeak,
|
|
||||||
},
|
|
||||||
library: allTracks
|
library: allTracks
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -375,16 +378,10 @@ export async function init(): Promise<void> {
|
||||||
library.on("removed", (track) => {
|
library.on("removed", (track) => {
|
||||||
console.log(`Track removed: ${track.title}`);
|
console.log(`Track removed: ${track.title}`);
|
||||||
removeTrackFromQueues(track.id);
|
removeTrackFromQueues(track.id);
|
||||||
const allTracks = library.getAllTracks().map(t => ({
|
const allTracks = library.getAllTracks().map(serializeLibraryTrack);
|
||||||
id: t.id,
|
|
||||||
title: t.title,
|
|
||||||
duration: t.duration,
|
|
||||||
replayGainDb: t.replayGainDb,
|
|
||||||
replayPeak: t.replayPeak,
|
|
||||||
}));
|
|
||||||
broadcastToAll({
|
broadcastToAll({
|
||||||
type: "track_removed",
|
type: "track_removed",
|
||||||
track: { id: track.id, title: track.title },
|
track: serializeLibraryTrack(track),
|
||||||
library: allTracks
|
library: allTracks
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
178
library.ts
178
library.ts
|
|
@ -1,5 +1,5 @@
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { spawn } from "child_process";
|
import { spawn, type ChildProcess } from "child_process";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import { watch, type FSWatcher } from "fs";
|
import { watch, type FSWatcher } from "fs";
|
||||||
import { readdir, stat } from "fs/promises";
|
import { readdir, stat } from "fs/promises";
|
||||||
|
|
@ -9,11 +9,13 @@ import { 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", ".mp4"]);
|
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);
|
||||||
const DEFAULT_REPLAY_GAIN_CONFIG: ReplayGainScanConfig = {
|
const DEFAULT_REPLAY_GAIN_CONFIG: Required<ReplayGainScanConfig> = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
command: "rsgain",
|
command: "rsgain",
|
||||||
truePeak: false,
|
truePeak: false,
|
||||||
timeoutMs: 120000,
|
timeoutMs: 120000,
|
||||||
|
maxConcurrent: 1,
|
||||||
|
maxOutputBytes: 256 * 1024,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ReplayGainScanConfig {
|
export interface ReplayGainScanConfig {
|
||||||
|
|
@ -21,6 +23,8 @@ export interface ReplayGainScanConfig {
|
||||||
command?: string;
|
command?: string;
|
||||||
truePeak?: boolean;
|
truePeak?: boolean;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
maxConcurrent?: number;
|
||||||
|
maxOutputBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReplayGainScanResult {
|
interface ReplayGainScanResult {
|
||||||
|
|
@ -55,6 +59,9 @@ export class Library {
|
||||||
private pendingFiles = new Map<string, ReturnType<typeof setTimeout>>(); // filepath -> debounce timer
|
private pendingFiles = new Map<string, ReturnType<typeof setTimeout>>(); // filepath -> debounce timer
|
||||||
private replayGainConfig: Required<ReplayGainScanConfig>;
|
private replayGainConfig: Required<ReplayGainScanConfig>;
|
||||||
private replayGainAvailable: boolean | null = null;
|
private replayGainAvailable: boolean | null = null;
|
||||||
|
private replayGainAvailabilityCheck: Promise<boolean> | null = null;
|
||||||
|
private replayGainActive = 0;
|
||||||
|
private replayGainQueue: Array<() => void> = [];
|
||||||
|
|
||||||
// Scan progress tracking
|
// Scan progress tracking
|
||||||
private _scanProgress = { scanning: false, processed: 0, total: 0 };
|
private _scanProgress = { scanning: false, processed: 0, total: 0 };
|
||||||
|
|
@ -75,12 +82,19 @@ export class Library {
|
||||||
enabled: replayGainConfig.enabled ?? DEFAULT_REPLAY_GAIN_CONFIG.enabled,
|
enabled: replayGainConfig.enabled ?? DEFAULT_REPLAY_GAIN_CONFIG.enabled,
|
||||||
command: replayGainConfig.command ?? DEFAULT_REPLAY_GAIN_CONFIG.command,
|
command: replayGainConfig.command ?? DEFAULT_REPLAY_GAIN_CONFIG.command,
|
||||||
truePeak: replayGainConfig.truePeak ?? DEFAULT_REPLAY_GAIN_CONFIG.truePeak,
|
truePeak: replayGainConfig.truePeak ?? DEFAULT_REPLAY_GAIN_CONFIG.truePeak,
|
||||||
timeoutMs: replayGainConfig.timeoutMs ?? DEFAULT_REPLAY_GAIN_CONFIG.timeoutMs,
|
timeoutMs: this.normalizePositiveInteger(replayGainConfig.timeoutMs, DEFAULT_REPLAY_GAIN_CONFIG.timeoutMs!),
|
||||||
|
maxConcurrent: this.normalizePositiveInteger(replayGainConfig.maxConcurrent, DEFAULT_REPLAY_GAIN_CONFIG.maxConcurrent!),
|
||||||
|
maxOutputBytes: this.normalizePositiveInteger(replayGainConfig.maxOutputBytes, DEFAULT_REPLAY_GAIN_CONFIG.maxOutputBytes!),
|
||||||
};
|
};
|
||||||
this.cacheDb.run("PRAGMA journal_mode = WAL");
|
this.cacheDb.run("PRAGMA journal_mode = WAL");
|
||||||
this.initCacheDb();
|
this.initCacheDb();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizePositiveInteger(value: number | undefined, fallback: number): number {
|
||||||
|
if (value == null || !Number.isFinite(value)) return fallback;
|
||||||
|
return Math.max(1, Math.floor(value));
|
||||||
|
}
|
||||||
|
|
||||||
private initCacheDb(): void {
|
private initCacheDb(): void {
|
||||||
this.cacheDb.run(`
|
this.cacheDb.run(`
|
||||||
CREATE TABLE IF NOT EXISTS file_cache (
|
CREATE TABLE IF NOT EXISTS file_cache (
|
||||||
|
|
@ -229,19 +243,26 @@ export class Library {
|
||||||
private async ensureReplayGainAvailable(): Promise<boolean> {
|
private async ensureReplayGainAvailable(): Promise<boolean> {
|
||||||
if (!this.replayGainConfig.enabled) return false;
|
if (!this.replayGainConfig.enabled) return false;
|
||||||
if (this.replayGainAvailable !== null) return this.replayGainAvailable;
|
if (this.replayGainAvailable !== null) return this.replayGainAvailable;
|
||||||
|
if (this.replayGainAvailabilityCheck) return this.replayGainAvailabilityCheck;
|
||||||
|
|
||||||
try {
|
this.replayGainAvailabilityCheck = this.withReplayGainSlot(async () => {
|
||||||
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
|
try {
|
||||||
const version = stdout.trim().split(/\r?\n/)[0] || "available";
|
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
|
||||||
console.log(`[Library] rsgain found: ${version}`);
|
const version = stdout.trim().split(/\r?\n/)[0] || "available";
|
||||||
this.replayGainAvailable = true;
|
console.log(`[Library] rsgain found: ${version}`);
|
||||||
} catch (e) {
|
this.replayGainAvailable = true;
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
} catch (e) {
|
||||||
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
this.replayGainAvailable = false;
|
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
|
||||||
}
|
this.replayGainAvailable = false;
|
||||||
|
} finally {
|
||||||
|
this.replayGainAvailabilityCheck = null;
|
||||||
|
}
|
||||||
|
|
||||||
return this.replayGainAvailable;
|
return this.replayGainAvailable;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.replayGainAvailabilityCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async scanReplayGain(filePath: string): Promise<ReplayGainScanResult | null> {
|
private async scanReplayGain(filePath: string): Promise<ReplayGainScanResult | null> {
|
||||||
|
|
@ -262,7 +283,9 @@ export class Library {
|
||||||
args.push(filePath);
|
args.push(filePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await this.runCommand(this.replayGainConfig.command, args, this.replayGainConfig.timeoutMs);
|
const { stdout } = await this.withReplayGainSlot(() =>
|
||||||
|
this.runCommand(this.replayGainConfig.command, args, this.replayGainConfig.timeoutMs)
|
||||||
|
);
|
||||||
return this.parseReplayGainOutput(stdout);
|
return this.parseReplayGainOutput(stdout);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
|
@ -295,44 +318,117 @@ export class Library {
|
||||||
return { replayGainDb, replayPeak };
|
return { replayGainDb, replayPeak };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withReplayGainSlot<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const run = () => {
|
||||||
|
this.replayGainActive++;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(task)
|
||||||
|
.then(resolve, reject)
|
||||||
|
.finally(() => {
|
||||||
|
this.replayGainActive--;
|
||||||
|
this.replayGainQueue.shift()?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.replayGainActive < this.replayGainConfig.maxConcurrent) {
|
||||||
|
run();
|
||||||
|
} else {
|
||||||
|
this.replayGainQueue.push(run);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private terminateProcess(proc: ChildProcess): void {
|
||||||
|
if (process.platform === "win32" && proc.pid) {
|
||||||
|
try {
|
||||||
|
const killer = spawn("taskkill", ["/pid", String(proc.pid), "/t", "/f"], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
killer.unref();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall back to direct child termination below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
|
||||||
private runCommand(command: string, args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
private runCommand(command: string, args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn(command, args, { windowsHide: true });
|
const proc = spawn(command, args, {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let finished = false;
|
let finished = false;
|
||||||
let timeout: ReturnType<typeof setTimeout>;
|
let timedOut = false;
|
||||||
|
let outputExceeded = false;
|
||||||
const finish = (callback: () => void) => {
|
const maxOutputBytes = this.replayGainConfig.maxOutputBytes;
|
||||||
if (finished) return;
|
const timeout = setTimeout(() => {
|
||||||
finished = true;
|
timedOut = true;
|
||||||
clearTimeout(timeout);
|
this.terminateProcess(proc);
|
||||||
callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
proc.kill();
|
|
||||||
finish(() => reject(new Error(`timed out after ${timeoutMs}ms`)));
|
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
const killGraceTimeout = setTimeout(() => {
|
||||||
|
if (!finished && timedOut) {
|
||||||
|
finish(() => reject(new Error(`timed out after ${timeoutMs}ms and did not exit after termination`)));
|
||||||
|
}
|
||||||
|
}, timeoutMs + 5000);
|
||||||
|
|
||||||
proc.stdout.on("data", (data) => {
|
const onStdout = (data: Buffer) => {
|
||||||
stdout += data.toString();
|
stdout = appendOutput(stdout, data);
|
||||||
});
|
};
|
||||||
proc.stderr.on("data", (data) => {
|
const onStderr = (data: Buffer) => {
|
||||||
stderr += data.toString();
|
stderr = appendOutput(stderr, data);
|
||||||
});
|
};
|
||||||
proc.on("error", (e) => {
|
const onError = (e: Error) => {
|
||||||
finish(() => reject(e));
|
finish(() => reject(e));
|
||||||
});
|
};
|
||||||
proc.on("close", (code) => {
|
const onClose = (code: number | null) => {
|
||||||
finish(() => {
|
finish(() => {
|
||||||
if (code === 0) {
|
if (timedOut) {
|
||||||
|
reject(new Error(`timed out after ${timeoutMs}ms`));
|
||||||
|
} else if (outputExceeded) {
|
||||||
|
reject(new Error(`output exceeded ${maxOutputBytes} bytes`));
|
||||||
|
} else if (code === 0) {
|
||||||
resolve({ stdout, stderr });
|
resolve({ stdout, stderr });
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
clearTimeout(killGraceTimeout);
|
||||||
|
proc.stdout.off("data", onStdout);
|
||||||
|
proc.stderr.off("data", onStderr);
|
||||||
|
proc.off("error", onError);
|
||||||
|
proc.off("close", onClose);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendOutput = (current: string, data: Buffer): string => {
|
||||||
|
if (outputExceeded) return current;
|
||||||
|
|
||||||
|
const text = data.toString();
|
||||||
|
const next = current + text;
|
||||||
|
if (next.length <= maxOutputBytes) return next;
|
||||||
|
|
||||||
|
outputExceeded = true;
|
||||||
|
this.terminateProcess(proc);
|
||||||
|
return next.slice(0, maxOutputBytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
proc.stdout.on("data", onStdout);
|
||||||
|
proc.stderr.on("data", onStderr);
|
||||||
|
proc.on("error", onError);
|
||||||
|
proc.on("close", onClose);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -633,6 +729,10 @@ export class Library {
|
||||||
this.watcher.close();
|
this.watcher.close();
|
||||||
this.watcher = null;
|
this.watcher = null;
|
||||||
}
|
}
|
||||||
|
for (const timer of this.pendingFiles.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
this.pendingFiles.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event handling
|
// Event handling
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
M.channels = channels;
|
M.channels = channels;
|
||||||
M.renderChannelList();
|
M.renderChannelList();
|
||||||
// Try saved channel first, fall back to default
|
// Try saved channel first, fall back to default
|
||||||
const savedChannelId = localStorage.getItem("blastoise_channel");
|
const savedChannelId = M.getSavedChannelId();
|
||||||
const savedChannel = savedChannelId && channels.find(c => c.id === savedChannelId);
|
const savedChannel = savedChannelId && channels.find(c => c.id === savedChannelId);
|
||||||
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);
|
||||||
|
|
@ -58,22 +58,31 @@
|
||||||
M.showToast("Cannot delete default channel");
|
M.showToast("Cannot delete default channel");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm(`Delete channel "${channel.name}"?`)) return;
|
M.showConfirmToast(`Delete channel "${channel.name}"?`, async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
|
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
M.showToast(err.error || "Failed to delete channel");
|
M.showToast(err.error || "Failed to delete channel", "error");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
if (M.getSavedChannelId() === channelId) {
|
||||||
|
M.clearRememberedChannel();
|
||||||
|
}
|
||||||
|
M.showToast(`Channel "${channel.name}" deleted`);
|
||||||
|
} catch (e) {
|
||||||
|
M.showToast("Failed to delete channel", "error");
|
||||||
}
|
}
|
||||||
M.showToast(`Channel "${channel.name}" deleted`);
|
}, { confirmText: "Delete", duration: 15000 });
|
||||||
} catch (e) {
|
|
||||||
M.showToast("Failed to delete channel");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// New channel creation with slideout input
|
// New channel creation with slideout input
|
||||||
M.createNewChannel = async function() {
|
M.createNewChannel = async function() {
|
||||||
|
if (!M.canCreateUserContent()) {
|
||||||
|
M.showToast("Sign in to create channels", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const header = M.$("#channels-panel .panel-header");
|
const header = M.$("#channels-panel .panel-header");
|
||||||
const btn = M.$("#btn-new-channel");
|
const btn = M.$("#btn-new-channel");
|
||||||
|
|
||||||
|
|
@ -241,7 +250,8 @@
|
||||||
oldWs.close();
|
oldWs.close();
|
||||||
}
|
}
|
||||||
M.currentChannelId = id;
|
M.currentChannelId = id;
|
||||||
localStorage.setItem("blastoise_channel", id);
|
M.rememberChannel(id);
|
||||||
|
setPlaybackBlocked(false);
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
|
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
|
||||||
|
|
||||||
|
|
@ -259,6 +269,7 @@
|
||||||
// Handle channel switch confirmation
|
// Handle channel switch confirmation
|
||||||
if (data.type === "switched") {
|
if (data.type === "switched") {
|
||||||
M.currentChannelId = data.channelId;
|
M.currentChannelId = data.channelId;
|
||||||
|
M.rememberChannel(data.channelId);
|
||||||
M.renderChannelList();
|
M.renderChannelList();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +279,7 @@
|
||||||
M.wantSync = false;
|
M.wantSync = false;
|
||||||
M.synced = false;
|
M.synced = false;
|
||||||
M.audio.pause();
|
M.audio.pause();
|
||||||
|
setPlaybackBlocked(false);
|
||||||
if (M.ws) {
|
if (M.ws) {
|
||||||
const oldWs = M.ws;
|
const oldWs = M.ws;
|
||||||
M.ws = null;
|
M.ws = null;
|
||||||
|
|
@ -341,13 +353,18 @@
|
||||||
M.ws.onclose = () => {
|
M.ws.onclose = () => {
|
||||||
M.synced = false;
|
M.synced = false;
|
||||||
M.ws = null;
|
M.ws = null;
|
||||||
|
setPlaybackBlocked(false);
|
||||||
M.$("#sync-indicator").classList.add("disconnected");
|
M.$("#sync-indicator").classList.add("disconnected");
|
||||||
M.updateUI();
|
M.updateUI();
|
||||||
// Auto-reconnect if user wants to be synced
|
// Auto-reconnect if user wants to be synced
|
||||||
// Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting
|
// Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting
|
||||||
if (M.wantSync) {
|
if (M.wantSync) {
|
||||||
const delay = wasConnected ? 3000 : 2000;
|
const delay = wasConnected ? 3000 : 2000;
|
||||||
setTimeout(() => M.connectChannel(id), delay);
|
setTimeout(() => {
|
||||||
|
if (M.ws) return;
|
||||||
|
const reconnectId = M.currentChannelId || M.getSavedChannelId() || id;
|
||||||
|
M.connectChannel(reconnectId);
|
||||||
|
}, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -359,6 +376,85 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let playbackUnlockListenersInstalled = false;
|
||||||
|
let playbackBlockedToastShown = false;
|
||||||
|
|
||||||
|
function removePlaybackUnlockListeners() {
|
||||||
|
if (!playbackUnlockListenersInstalled) return;
|
||||||
|
document.removeEventListener("pointerdown", retryPlaybackFromUserActivation, true);
|
||||||
|
document.removeEventListener("keydown", retryPlaybackFromUserActivation, true);
|
||||||
|
playbackUnlockListenersInstalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPlaybackUnlockListeners() {
|
||||||
|
if (playbackUnlockListenersInstalled) return;
|
||||||
|
document.addEventListener("pointerdown", retryPlaybackFromUserActivation, true);
|
||||||
|
document.addEventListener("keydown", retryPlaybackFromUserActivation, true);
|
||||||
|
playbackUnlockListenersInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlaybackBlocked(blocked) {
|
||||||
|
const changed = M.playbackBlocked !== blocked;
|
||||||
|
M.playbackBlocked = blocked;
|
||||||
|
|
||||||
|
if (blocked) {
|
||||||
|
installPlaybackUnlockListeners();
|
||||||
|
if (!playbackBlockedToastShown) {
|
||||||
|
playbackBlockedToastShown = true;
|
||||||
|
M.showToast("Click play or press any key to resume this channel", "warning", 7000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playbackBlockedToastShown = false;
|
||||||
|
removePlaybackUnlockListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) M.updateUI?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSyncedAudioSource(timestamp, forceReload = false) {
|
||||||
|
if (!M.currentTrackId) return;
|
||||||
|
|
||||||
|
if (forceReload || !M.audio.src) {
|
||||||
|
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
|
||||||
|
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTime = Number.isFinite(timestamp) ? Math.max(0, timestamp) : 0;
|
||||||
|
try {
|
||||||
|
M.audio.currentTime = nextTime;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Playback] Unable to set synced time yet", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSyncedAudio(timestamp, forceReload = false) {
|
||||||
|
if (!M.currentTrackId || M.serverPaused) return false;
|
||||||
|
await ensureSyncedAudioSource(timestamp, forceReload);
|
||||||
|
M.resumeVisualizer?.();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await M.audio.play();
|
||||||
|
setPlaybackBlocked(false);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Playback] Browser blocked or delayed autoplay", error);
|
||||||
|
setPlaybackBlocked(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryPlaybackFromUserActivation() {
|
||||||
|
if (!M.playbackBlocked) return;
|
||||||
|
M.retryBlockedPlayback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
M.retryBlockedPlayback = async function() {
|
||||||
|
if (!M.currentTrackId || !M.synced || M.serverPaused) return false;
|
||||||
|
return playSyncedAudio(M.getServerTime(), !M.audio.src);
|
||||||
|
};
|
||||||
|
|
||||||
|
M.audio.addEventListener("play", () => setPlaybackBlocked(false));
|
||||||
|
|
||||||
// Handle channel state update from server
|
// Handle channel state update from server
|
||||||
M.handleUpdate = async function(data) {
|
M.handleUpdate = async function(data) {
|
||||||
console.log("[WS] State update:", {
|
console.log("[WS] State update:", {
|
||||||
|
|
@ -388,6 +484,7 @@
|
||||||
|
|
||||||
if (!data.track) {
|
if (!data.track) {
|
||||||
M.setTrackTitle("No tracks");
|
M.setTrackTitle("No tracks");
|
||||||
|
setPlaybackBlocked(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
M.serverTimestamp = data.currentTimestamp;
|
M.serverTimestamp = data.currentTimestamp;
|
||||||
|
|
@ -426,15 +523,11 @@
|
||||||
if (!M.serverPaused) {
|
if (!M.serverPaused) {
|
||||||
// Server is playing - ensure we're playing and synced
|
// Server is playing - ensure we're playing and synced
|
||||||
if (isNewTrack || !M.audio.src) {
|
if (isNewTrack || !M.audio.src) {
|
||||||
// Try cache first
|
await playSyncedAudio(data.currentTimestamp, true);
|
||||||
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
|
} else if (M.audio.paused || M.playbackBlocked) {
|
||||||
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
|
await playSyncedAudio(data.currentTimestamp, false);
|
||||||
M.audio.currentTime = data.currentTimestamp;
|
|
||||||
M.audio.play().catch(() => {});
|
|
||||||
} else if (M.audio.paused) {
|
|
||||||
M.audio.currentTime = data.currentTimestamp;
|
|
||||||
M.audio.play().catch(() => {});
|
|
||||||
} else {
|
} else {
|
||||||
|
setPlaybackBlocked(false);
|
||||||
// Check drift
|
// Check drift
|
||||||
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
|
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
|
||||||
if (drift >= 2) {
|
if (drift >= 2) {
|
||||||
|
|
@ -444,6 +537,7 @@
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Server is paused - ensure we're paused too
|
// Server is paused - ensure we're paused too
|
||||||
|
setPlaybackBlocked(false);
|
||||||
if (!M.audio.paused) {
|
if (!M.audio.paused) {
|
||||||
M.audio.pause();
|
M.audio.pause();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@
|
||||||
(function() {
|
(function() {
|
||||||
const M = window.MusicRoom;
|
const M = window.MusicRoom;
|
||||||
|
|
||||||
|
function blockSyncedControlIfNeeded() {
|
||||||
|
if (!M.synced || M.canControl()) return false;
|
||||||
|
M.flashPermissionDenied();
|
||||||
|
M.showToast("Sign in to control playback", "warning", 2500);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Load saved volume
|
// Load saved volume
|
||||||
const savedVolume = localStorage.getItem(M.STORAGE_KEY);
|
const savedVolume = localStorage.getItem(M.STORAGE_KEY);
|
||||||
if (savedVolume !== null) {
|
if (savedVolume !== null) {
|
||||||
|
|
@ -17,8 +24,14 @@
|
||||||
// Toggle play/pause
|
// Toggle play/pause
|
||||||
function togglePlayback() {
|
function togglePlayback() {
|
||||||
if (!M.currentTrackId) return;
|
if (!M.currentTrackId) return;
|
||||||
|
M.resumeVisualizer?.();
|
||||||
|
|
||||||
if (M.synced) {
|
if (M.synced) {
|
||||||
|
if (!M.serverPaused && M.playbackBlocked) {
|
||||||
|
M.retryBlockedPlayback?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (blockSyncedControlIfNeeded()) return;
|
||||||
if (M.ws && M.ws.readyState === WebSocket.OPEN) {
|
if (M.ws && M.ws.readyState === WebSocket.OPEN) {
|
||||||
M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" }));
|
M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" }));
|
||||||
}
|
}
|
||||||
|
|
@ -40,9 +53,11 @@
|
||||||
// Jump to a specific track index
|
// Jump to a specific track index
|
||||||
async function jumpToTrack(index) {
|
async function jumpToTrack(index) {
|
||||||
if (M.queue.length === 0) return;
|
if (M.queue.length === 0) return;
|
||||||
|
M.resumeVisualizer?.();
|
||||||
const newIndex = (index + M.queue.length) % M.queue.length;
|
const newIndex = (index + M.queue.length) % M.queue.length;
|
||||||
|
|
||||||
if (M.synced && M.currentChannelId) {
|
if (M.synced && M.currentChannelId) {
|
||||||
|
if (blockSyncedControlIfNeeded()) return;
|
||||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -136,6 +151,7 @@
|
||||||
M.updateModeButton();
|
M.updateModeButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (blockSyncedControlIfNeeded()) return;
|
||||||
|
|
||||||
// Synced mode - send to server
|
// Synced mode - send to server
|
||||||
const currentIdx = modeOrder.indexOf(M.playbackMode);
|
const currentIdx = modeOrder.indexOf(M.playbackMode);
|
||||||
|
|
@ -175,6 +191,7 @@
|
||||||
const seekTime = pct * dur;
|
const seekTime = pct * dur;
|
||||||
|
|
||||||
if (M.synced && M.currentChannelId) {
|
if (M.synced && M.currentChannelId) {
|
||||||
|
if (blockSyncedControlIfNeeded()) return;
|
||||||
fetch("/api/channels/" + M.currentChannelId + "/seek", {
|
fetch("/api/channels/" + M.currentChannelId + "/seek", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ window.MusicRoom = {
|
||||||
// Volume
|
// Volume
|
||||||
preMuteVolume: 1,
|
preMuteVolume: 1,
|
||||||
STORAGE_KEY: "blastoise_volume",
|
STORAGE_KEY: "blastoise_volume",
|
||||||
|
CHANNEL_STORAGE_KEY: "blastoise_channel",
|
||||||
|
THEME_STORAGE_KEY: "blastoise_theme",
|
||||||
|
VISUALIZER_STORAGE_KEY: "blastoise_visualizer",
|
||||||
|
|
||||||
// Playback state
|
// Playback state
|
||||||
localTimestamp: 0,
|
localTimestamp: 0,
|
||||||
|
|
@ -65,3 +68,63 @@ window.MusicRoom = {
|
||||||
lastBufferPct: -1,
|
lastBufferPct: -1,
|
||||||
lastSpeedText: ""
|
lastSpeedText: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const M = window.MusicRoom;
|
||||||
|
const CHANNEL_COOKIE_MAX_AGE = 60 * 60 * 24 * 400;
|
||||||
|
|
||||||
|
M.getCookieValue = function(name) {
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
return document.cookie
|
||||||
|
.split(";")
|
||||||
|
.map(cookie => cookie.trim())
|
||||||
|
.find(cookie => cookie.startsWith(prefix))
|
||||||
|
?.slice(prefix.length) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.setCookieValue = function(name, value, maxAge = CHANNEL_COOKIE_MAX_AGE) {
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=${maxAge}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.clearCookieValue = function(name) {
|
||||||
|
document.cookie = `${name}=; Path=/; SameSite=Lax; Max-Age=0`;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.getSavedChannelId = function() {
|
||||||
|
const cookieValue = M.getCookieValue(M.CHANNEL_STORAGE_KEY);
|
||||||
|
if (cookieValue) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(cookieValue);
|
||||||
|
} catch {
|
||||||
|
M.clearRememberedChannel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedValue = localStorage.getItem(M.CHANNEL_STORAGE_KEY);
|
||||||
|
if (storedValue) {
|
||||||
|
M.rememberChannel(storedValue);
|
||||||
|
return storedValue;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.rememberChannel = function(channelId) {
|
||||||
|
if (!channelId) return;
|
||||||
|
M.setCookieValue(M.CHANNEL_STORAGE_KEY, channelId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(M.CHANNEL_STORAGE_KEY, channelId);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.clearRememberedChannel = function() {
|
||||||
|
M.clearCookieValue(M.CHANNEL_STORAGE_KEY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(M.CHANNEL_STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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>Blastoise! A very special music server</title>
|
<title>Blastoise! A very special music server</title>
|
||||||
<link rel="stylesheet" href="/styles.css?v=20">
|
<link rel="stylesheet" href="/styles.css?v=23">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
@ -47,14 +47,44 @@
|
||||||
<button id="btn-logout">logout</button>
|
<button id="btn-logout">logout</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="stream-select"></div>
|
<div id="stream-select">
|
||||||
|
<select id="theme-select" title="Theme">
|
||||||
|
<optgroup label="Themes">
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="phosphor">Phosphor Noir</option>
|
||||||
|
<option value="vaporwave">Vaporwave Pool</option>
|
||||||
|
<option value="sunrise">Sunrise Radio</option>
|
||||||
|
<option value="paper">Paper Sleeve</option>
|
||||||
|
<option value="contrast">High Contrast</option>
|
||||||
|
<option value="aquarium">Aquarium Glass</option>
|
||||||
|
<option value="amber">Terminal Amber</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Layouts">
|
||||||
|
<option value="compact">Tiny Desk</option>
|
||||||
|
<option value="cinema">Cinema Queue</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Bad Ideas">
|
||||||
|
<option value="hotdog">Hot Dog Stand</option>
|
||||||
|
<option value="spreadsheet">Bad Spreadsheet</option>
|
||||||
|
<option value="myspace">MySpace Accident</option>
|
||||||
|
<option value="sideways">Sideways Doomscroll</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<select id="visualizer-select" title="Visualizer">
|
||||||
|
<option value="off">Visualizer off</option>
|
||||||
|
<option value="bars">Bars</option>
|
||||||
|
<option value="wave">Waveform</option>
|
||||||
|
<option value="radial">Radial</option>
|
||||||
|
<option value="pulse">Pulse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile tab bar -->
|
<!-- Mobile tab bar -->
|
||||||
<div id="mobile-tabs">
|
<div id="mobile-tabs">
|
||||||
<button class="mobile-tab active" data-panel="channels-panel">Channels</button>
|
<button class="mobile-tab active" data-panel="channels-panel">Channels</button>
|
||||||
<button class="mobile-tab" data-panel="library-panel">Library</button>
|
|
||||||
<button class="mobile-tab" data-panel="queue-panel">Queue</button>
|
<button class="mobile-tab" data-panel="queue-panel">Queue</button>
|
||||||
|
<button class="mobile-tab" data-panel="library-panel">Library</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="main-content">
|
<div id="main-content">
|
||||||
|
|
@ -78,6 +108,17 @@
|
||||||
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
|
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
<div id="scan-progress" class="scan-progress hidden"></div>
|
<div id="scan-progress" class="scan-progress hidden"></div>
|
||||||
|
<div id="recent-library-section" class="library-section">
|
||||||
|
<div class="library-section-header">
|
||||||
|
<h4>Recently added</h4>
|
||||||
|
<span id="recent-library-count"></span>
|
||||||
|
</div>
|
||||||
|
<div id="recent-library"></div>
|
||||||
|
</div>
|
||||||
|
<div class="library-section-header library-all-header">
|
||||||
|
<h4>All songs</h4>
|
||||||
|
<span id="library-count"></span>
|
||||||
|
</div>
|
||||||
<div id="library"></div>
|
<div id="library"></div>
|
||||||
<div id="add-panel" class="add-panel hidden">
|
<div id="add-panel" class="add-panel hidden">
|
||||||
<button id="btn-add-close" class="add-panel-close">Close</button>
|
<button id="btn-add-close" class="add-panel-close">Close</button>
|
||||||
|
|
@ -139,6 +180,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="visualizer-shell" class="visualizer-shell hidden">
|
||||||
|
<canvas id="visualizer-canvas" aria-hidden="true"></canvas>
|
||||||
|
<button id="btn-visualizer-fullscreen" class="visualizer-fullscreen-btn" title="Fullscreen visualizer" aria-label="Fullscreen visualizer">⛶</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="player-bar">
|
<div id="player-bar">
|
||||||
<div id="now-playing">
|
<div id="now-playing">
|
||||||
<div id="channel-name"></div>
|
<div id="channel-name"></div>
|
||||||
|
|
@ -179,7 +225,9 @@
|
||||||
</div>
|
</div>
|
||||||
<script src="/trackStorage.js"></script>
|
<script src="/trackStorage.js"></script>
|
||||||
<script src="/core.js"></script>
|
<script src="/core.js"></script>
|
||||||
|
<script src="/themes.js"></script>
|
||||||
<script src="/utils.js"></script>
|
<script src="/utils.js"></script>
|
||||||
|
<script src="/visualizer.js"></script>
|
||||||
<script src="/trackComponent.js"></script>
|
<script src="/trackComponent.js"></script>
|
||||||
<script src="/trackContainer.js"></script>
|
<script src="/trackContainer.js"></script>
|
||||||
<script src="/audioCache.js"></script>
|
<script src="/audioCache.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -117,11 +117,7 @@
|
||||||
|
|
||||||
// Update UI based on server status
|
// Update UI based on server status
|
||||||
function updateFeatureVisibility() {
|
function updateFeatureVisibility() {
|
||||||
const fetchBtn = M.$("#btn-fetch-url");
|
if (M.updatePermissionUI) M.updatePermissionUI();
|
||||||
if (fetchBtn) {
|
|
||||||
const ytdlpEnabled = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available;
|
|
||||||
fetchBtn.style.display = ytdlpEnabled ? "" : "none";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the application
|
// Initialize the application
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
const myContainer = $('#my-playlists');
|
const myContainer = $('#my-playlists');
|
||||||
const sharedContainer = $('#shared-playlists');
|
const sharedContainer = $('#shared-playlists');
|
||||||
if (!myContainer || !sharedContainer) return;
|
if (!myContainer || !sharedContainer) return;
|
||||||
|
if (M.updatePermissionUI) M.updatePermissionUI();
|
||||||
|
|
||||||
// My playlists
|
// My playlists
|
||||||
if (myPlaylists.length === 0) {
|
if (myPlaylists.length === 0) {
|
||||||
|
|
@ -107,7 +108,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
header.textContent = selectedPlaylist.name;
|
header.textContent = selectedPlaylist.name;
|
||||||
actions.classList.remove('hidden');
|
actions.classList.toggle('hidden', !M.canControl());
|
||||||
|
|
||||||
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
|
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
|
||||||
|
|
||||||
|
|
@ -144,18 +145,23 @@
|
||||||
if (!playlist) return;
|
if (!playlist) return;
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
const canEditQueue = M.canControl();
|
||||||
|
|
||||||
// Add to queue options
|
// Add to queue options
|
||||||
items.push({
|
if (canEditQueue) {
|
||||||
label: '▶ Add to Queue',
|
items.push({
|
||||||
action: () => addPlaylistToQueue(playlistId)
|
label: '▶ Add to Queue',
|
||||||
});
|
action: () => addPlaylistToQueue(playlistId)
|
||||||
items.push({
|
});
|
||||||
label: '⏭ Play Next',
|
items.push({
|
||||||
action: () => addPlaylistToQueue(playlistId, true)
|
label: '⏭ Play Next',
|
||||||
});
|
action: () => addPlaylistToQueue(playlistId, true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
items.push({ separator: true });
|
if (canEditQueue && (isMine || M.canCreateUserContent())) {
|
||||||
|
items.push({ separator: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (isMine) {
|
if (isMine) {
|
||||||
// Rename
|
// Rename
|
||||||
|
|
@ -183,9 +189,9 @@
|
||||||
items.push({
|
items.push({
|
||||||
label: '🗑️ Delete',
|
label: '🗑️ Delete',
|
||||||
action: () => deletePlaylist(playlistId),
|
action: () => deletePlaylist(playlistId),
|
||||||
className: 'danger'
|
danger: true
|
||||||
});
|
});
|
||||||
} else {
|
} else if (M.canCreateUserContent()) {
|
||||||
// Copy to my playlists
|
// Copy to my playlists
|
||||||
items.push({
|
items.push({
|
||||||
label: '📋 Copy to My Playlists',
|
label: '📋 Copy to My Playlists',
|
||||||
|
|
@ -193,10 +199,20 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while (items[0]?.separator) items.shift();
|
||||||
|
while (items[items.length - 1]?.separator) items.pop();
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
M.contextMenu.show(e, items);
|
M.contextMenu.show(e, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addPlaylistToQueue(playlistId, playNext = false) {
|
async function addPlaylistToQueue(playlistId, playNext = false) {
|
||||||
|
if (!M.canControl()) {
|
||||||
|
M.flashPermissionDenied();
|
||||||
|
showToast('Sign in to control playback', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId);
|
const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId);
|
||||||
if (!playlist || playlist.trackIds.length === 0) {
|
if (!playlist || playlist.trackIds.length === 0) {
|
||||||
showToast('Playlist is empty', 'error');
|
showToast('Playlist is empty', 'error');
|
||||||
|
|
@ -273,21 +289,23 @@
|
||||||
const playlist = myPlaylists.find(p => p.id === playlistId);
|
const playlist = myPlaylists.find(p => p.id === playlistId);
|
||||||
if (!playlist) return;
|
if (!playlist) return;
|
||||||
|
|
||||||
try {
|
M.showConfirmToast(`Delete playlist "${playlist.name}"?`, async () => {
|
||||||
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
|
try {
|
||||||
if (!res.ok) throw new Error('Failed to delete playlist');
|
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
|
||||||
showToast(`Deleted playlist "${playlist.name}"`);
|
if (!res.ok) throw new Error('Failed to delete playlist');
|
||||||
|
showToast(`Deleted playlist "${playlist.name}"`);
|
||||||
|
|
||||||
if (selectedPlaylistId === playlistId) {
|
if (selectedPlaylistId === playlistId) {
|
||||||
selectedPlaylistId = null;
|
selectedPlaylistId = null;
|
||||||
selectedPlaylist = null;
|
selectedPlaylist = null;
|
||||||
renderPlaylistContents();
|
renderPlaylistContents();
|
||||||
|
}
|
||||||
|
await loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete playlist:', err);
|
||||||
|
showToast('Failed to delete playlist', 'error');
|
||||||
}
|
}
|
||||||
await loadPlaylists();
|
}, { confirmText: 'Delete', duration: 15000 });
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete playlist:', err);
|
|
||||||
showToast('Failed to delete playlist', 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRenamePlaylist(playlistId) {
|
function startRenamePlaylist(playlistId) {
|
||||||
|
|
@ -448,6 +466,7 @@
|
||||||
|
|
||||||
// Show "Add to Playlist" submenu
|
// Show "Add to Playlist" submenu
|
||||||
function showAddToPlaylistMenu(trackIds) {
|
function showAddToPlaylistMenu(trackIds) {
|
||||||
|
if (!M.canCreateUserContent()) return null;
|
||||||
if (myPlaylists.length === 0) {
|
if (myPlaylists.length === 0) {
|
||||||
showToast('Create a playlist first', 'info');
|
showToast('Create a playlist first', 'info');
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -471,6 +490,11 @@
|
||||||
const btnNew = $('#btn-new-playlist');
|
const btnNew = $('#btn-new-playlist');
|
||||||
if (btnNew) {
|
if (btnNew) {
|
||||||
btnNew.onclick = () => {
|
btnNew.onclick = () => {
|
||||||
|
if (!M.canCreateUserContent()) {
|
||||||
|
showToast('Sign in to create playlists', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Inline input for new playlist name
|
// Inline input for new playlist name
|
||||||
const container = $('#my-playlists');
|
const container = $('#my-playlists');
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
|
|
|
||||||
108
public/queue.js
108
public/queue.js
|
|
@ -12,9 +12,12 @@
|
||||||
// Container instances
|
// Container instances
|
||||||
let queueContainer = null;
|
let queueContainer = null;
|
||||||
let libraryContainer = null;
|
let libraryContainer = null;
|
||||||
|
let recentLibraryContainer = null;
|
||||||
|
|
||||||
// Library search state
|
// Library search state
|
||||||
M.librarySearchQuery = "";
|
M.librarySearchQuery = "";
|
||||||
|
const RECENT_LIBRARY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const RECENT_LIBRARY_LIMIT = 50;
|
||||||
|
|
||||||
// Download a track to user's device (uses cache if available)
|
// Download a track to user's device (uses cache if available)
|
||||||
async function downloadTrack(trackId, filename) {
|
async function downloadTrack(trackId, filename) {
|
||||||
|
|
@ -190,9 +193,70 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize containers
|
// Initialize containers
|
||||||
|
function trackMatchesLibrarySearch(track) {
|
||||||
|
const query = M.librarySearchQuery.toLowerCase();
|
||||||
|
if (!query) return true;
|
||||||
|
const title = track.title?.trim() || track.filename || "";
|
||||||
|
const artist = track.artist || "";
|
||||||
|
const album = track.album || "";
|
||||||
|
return [title, artist, album, track.filename || ""]
|
||||||
|
.some(value => value.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackCreatedMs(track) {
|
||||||
|
const value = Number(track.createdAt ?? track.created_at ?? 0);
|
||||||
|
if (!value || !Number.isFinite(value)) return 0;
|
||||||
|
return value > 1000000000000 ? value : value * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecentlyAdded(track) {
|
||||||
|
const createdMs = getTrackCreatedMs(track);
|
||||||
|
if (!createdMs) return false;
|
||||||
|
const now = Date.now();
|
||||||
|
return createdMs >= now - RECENT_LIBRARY_WINDOW_MS && createdMs <= now + 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLibraryRows() {
|
||||||
|
return M.library
|
||||||
|
.map((track, i) => ({ track, originalIndex: i }))
|
||||||
|
.filter(({ track }) => trackMatchesLibrarySearch(track));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecentLibraryRows(limit = true) {
|
||||||
|
const rows = M.library
|
||||||
|
.map((track, i) => ({ track, originalIndex: i }))
|
||||||
|
.filter(({ track }) => isRecentlyAdded(track) && trackMatchesLibrarySearch(track))
|
||||||
|
.sort((a, b) => getTrackCreatedMs(b.track) - getTrackCreatedMs(a.track));
|
||||||
|
return limit ? rows.slice(0, RECENT_LIBRARY_LIMIT) : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLibrarySectionLabels() {
|
||||||
|
const recentCount = getRecentLibraryRows(false).length;
|
||||||
|
const visibleRecentCount = Math.min(recentCount, RECENT_LIBRARY_LIMIT);
|
||||||
|
const libraryCount = getLibraryRows().length;
|
||||||
|
const recentCountEl = M.$("#recent-library-count");
|
||||||
|
const libraryCountEl = M.$("#library-count");
|
||||||
|
const hasQuery = M.librarySearchQuery.trim().length > 0;
|
||||||
|
|
||||||
|
if (recentCountEl) {
|
||||||
|
const baseText = hasQuery
|
||||||
|
? `${recentCount} match${recentCount === 1 ? "" : "es"} from 7 days`
|
||||||
|
: `${recentCount} from 7 days`;
|
||||||
|
recentCountEl.textContent = recentCount > visibleRecentCount
|
||||||
|
? `${baseText} · newest ${visibleRecentCount}`
|
||||||
|
: baseText;
|
||||||
|
}
|
||||||
|
if (libraryCountEl) {
|
||||||
|
libraryCountEl.textContent = hasQuery
|
||||||
|
? `${libraryCount} match${libraryCount === 1 ? "" : "es"}`
|
||||||
|
: `${libraryCount} total`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initContainers() {
|
function initContainers() {
|
||||||
const queueEl = M.$("#queue");
|
const queueEl = M.$("#queue");
|
||||||
const libraryEl = M.$("#library");
|
const libraryEl = M.$("#library");
|
||||||
|
const recentLibraryEl = M.$("#recent-library");
|
||||||
|
|
||||||
if (queueEl && !queueContainer) {
|
if (queueEl && !queueContainer) {
|
||||||
queueContainer = M.trackContainer.createContainer({
|
queueContainer = M.trackContainer.createContainer({
|
||||||
|
|
@ -209,18 +273,18 @@
|
||||||
type: 'library',
|
type: 'library',
|
||||||
element: libraryEl,
|
element: libraryEl,
|
||||||
getTracks: () => M.library,
|
getTracks: () => M.library,
|
||||||
getFilteredTracks: () => {
|
getFilteredTracks: getLibraryRows,
|
||||||
const query = M.librarySearchQuery.toLowerCase();
|
emptyMessage: "No library matches"
|
||||||
if (!query) {
|
});
|
||||||
return M.library.map((track, i) => ({ track, originalIndex: i }));
|
}
|
||||||
}
|
|
||||||
return M.library
|
if (recentLibraryEl && !recentLibraryContainer) {
|
||||||
.map((track, i) => ({ track, originalIndex: i }))
|
recentLibraryContainer = M.trackContainer.createContainer({
|
||||||
.filter(({ track }) => {
|
type: 'library',
|
||||||
const title = track.title?.trim() || track.filename || '';
|
element: recentLibraryEl,
|
||||||
return title.toLowerCase().includes(query);
|
getTracks: () => M.library,
|
||||||
});
|
getFilteredTracks: getRecentLibraryRows,
|
||||||
}
|
emptyMessage: "No songs added in the last 7 days"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -254,6 +318,10 @@
|
||||||
|
|
||||||
M.renderLibrary = function() {
|
M.renderLibrary = function() {
|
||||||
initContainers();
|
initContainers();
|
||||||
|
updateLibrarySectionLabels();
|
||||||
|
if (recentLibraryContainer) {
|
||||||
|
recentLibraryContainer.render();
|
||||||
|
}
|
||||||
if (libraryContainer) {
|
if (libraryContainer) {
|
||||||
libraryContainer.render();
|
libraryContainer.render();
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +350,21 @@
|
||||||
|
|
||||||
const activeTrack = container.querySelector(".track.active");
|
const activeTrack = container.querySelector(".track.active");
|
||||||
if (activeTrack) {
|
if (activeTrack) {
|
||||||
activeTrack.scrollIntoView({ behavior: "smooth", block: "center" });
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const trackRect = activeTrack.getBoundingClientRect();
|
||||||
|
const trackTop = trackRect.top - containerRect.top + container.scrollTop;
|
||||||
|
const targetTop = trackTop - (container.clientHeight - activeTrack.offsetHeight) / 2;
|
||||||
|
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
top: Math.min(Math.max(0, targetTop), maxTop),
|
||||||
|
behavior: "smooth"
|
||||||
|
});
|
||||||
|
container.scrollLeft = 0;
|
||||||
|
|
||||||
|
if (window.scrollX !== 0) {
|
||||||
|
window.scrollTo({ left: 0, top: window.scrollY, behavior: "auto" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
* { 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; }
|
html { width: 100%; max-width: 100%; overflow-x: hidden; overscroll-behavior-x: none; }
|
||||||
#app { width: 100%; max-width: 1700px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; }
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; width: 100%; max-width: 100%; min-height: 100vh; overflow-x: hidden; }
|
||||||
h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
|
#app { width: 100%; max-width: 1700px; min-width: 0; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; overflow-x: hidden; }
|
||||||
h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
h1 { font-size: 1rem; color: #aaa; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
h3 { font-size: 0.8rem; color: #999; 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; }
|
||||||
|
|
@ -26,8 +27,8 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.85rem; }
|
#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: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; max-width: 1600px; margin-left: auto; margin-right: auto; }
|
#main-content { display: flex; gap: 0.5rem; flex: 1; width: 100%; min-width: 0; min-height: 0; margin-bottom: 0.5rem; max-width: 1600px; margin-left: auto; margin-right: auto; overflow-x: hidden; }
|
||||||
#channels-panel { flex: 0 0 160px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; }
|
#channels-panel { order: 1; flex: 0 0 160px; min-width: 0; 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.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; display: flex; flex-direction: column; gap: 0.1rem; }
|
#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; }
|
||||||
|
|
@ -35,7 +36,7 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#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; font-size: 0.8rem; }
|
#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
|
||||||
#channels-list .channel-name-input { flex: 1; font-size: 0.8rem; background: #111; color: #eee; border: 1px solid #4e8; border-radius: 3px; padding: 0.1rem 0.3rem; outline: none; min-width: 0; }
|
#channels-list .channel-name-input { flex: 1; font-size: 0.8rem; background: #111; color: #eee; border: 1px solid #4e8; border-radius: 3px; padding: 0.1rem 0.3rem; outline: none; min-width: 0; }
|
||||||
#channels-list .listener-count { font-size: 0.65rem; color: #666; flex-shrink: 0; margin-left: 0.3rem; }
|
#channels-list .listener-count { font-size: 0.65rem; color: #aaa; flex-shrink: 0; margin-left: 0.3rem; }
|
||||||
#channels-list .btn-delete-channel { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; }
|
#channels-list .btn-delete-channel { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; }
|
||||||
#channels-list .channel-header:hover .btn-delete-channel { opacity: 1; }
|
#channels-list .channel-header:hover .btn-delete-channel { opacity: 1; }
|
||||||
#channels-list .btn-delete-channel:hover { color: #e44; }
|
#channels-list .btn-delete-channel:hover { color: #e44; }
|
||||||
|
|
@ -45,10 +46,12 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 0.5rem; border-left: 1px solid #333; padding-left: 0.3rem; }
|
#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 0.5rem; border-left: 1px solid #333; padding-left: 0.3rem; }
|
||||||
#channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; }
|
#channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; }
|
||||||
#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; }
|
#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; }
|
||||||
#channels-list .listener-mult { color: #666; font-size: 0.55rem; }
|
#channels-list .listener-mult { color: #999; font-size: 0.55rem; }
|
||||||
#library-panel, #queue-panel { flex: 0 0 700px; min-width: 0; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; }
|
#library-panel, #queue-panel { flex: 1 1 0; min-width: 0; max-width: 100%; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; }
|
||||||
|
#queue-panel { order: 2; }
|
||||||
|
#library-panel { order: 3; }
|
||||||
.panel-tabs { display: flex; gap: 0; margin-bottom: 0; flex-shrink: 0; }
|
.panel-tabs { display: flex; gap: 0; margin-bottom: 0; flex-shrink: 0; }
|
||||||
.panel-tab { background: #252525; border: none; color: #666; font-family: inherit; font-size: 0.8rem; font-weight: bold; padding: 0.3rem 0.6rem; cursor: pointer; border-radius: 4px 4px 0 0; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 2px; }
|
.panel-tab { background: #252525; border: none; color: #999; font-family: inherit; font-size: 0.8rem; font-weight: bold; padding: 0.3rem 0.6rem; cursor: pointer; border-radius: 4px 4px 0 0; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 2px; }
|
||||||
.panel-tab:hover { color: #aaa; background: #2a2a2a; }
|
.panel-tab:hover { color: #aaa; background: #2a2a2a; }
|
||||||
.panel-tab.active { color: #eee; background: #222; }
|
.panel-tab.active { color: #eee; background: #222; }
|
||||||
.panel-views { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; position: relative; background: #222; border-radius: 0 4px 4px 4px; }
|
.panel-views { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; position: relative; background: #222; border-radius: 0 4px 4px 4px; }
|
||||||
|
|
@ -133,7 +136,7 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
.now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.now-playing-bar:hover { background: #2a3a2a; }
|
.now-playing-bar:hover { background: #2a3a2a; }
|
||||||
.now-playing-bar.hidden { display: none; }
|
.now-playing-bar.hidden { display: none; }
|
||||||
.now-playing-bar .label { color: #666; }
|
.now-playing-bar .label { color: #aaa; }
|
||||||
.panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
|
.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.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
|
.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; }
|
||||||
|
|
@ -144,18 +147,26 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
.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 { 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; }
|
.search-input::placeholder { color: #666; }
|
||||||
#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
|
#library, #queue, #recent-library { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
|
||||||
#library .track, #queue .track, #playlist-tracks .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; min-width: 0; }
|
.library-section { flex: 0 0 auto; min-height: 0; display: flex; flex-direction: column; margin-bottom: 0.45rem; }
|
||||||
#library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; }
|
.library-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin: 0.15rem 0 0.25rem; color: #888; flex-shrink: 0; }
|
||||||
#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; }
|
.library-section-header h4 { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #aaa; margin: 0; }
|
||||||
|
.library-section-header span { color: #666; font-size: 0.68rem; white-space: nowrap; }
|
||||||
|
.library-all-header { margin-top: 0.1rem; }
|
||||||
|
#recent-library { flex: 0 1 auto; max-height: min(34vh, 220px); border-bottom: 1px solid #262626; padding-bottom: 0.35rem; }
|
||||||
|
#library .track, #queue .track, #recent-library .track, #playlist-tracks .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; min-width: 0; }
|
||||||
|
#library .track[title], #queue .track[title], #recent-library .track[title], #playlist-tracks .track[title] { cursor: pointer; }
|
||||||
|
#library .track:hover, #queue .track:hover, #recent-library .track:hover, #playlist-tracks .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-number { color: #888; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; }
|
||||||
.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||||
.track-actions .duration { color: #666; font-size: 0.75rem; }
|
.track-actions .duration { color: #999; font-size: 0.75rem; }
|
||||||
|
#queue .track.active .track-number,
|
||||||
|
#queue .track.active .duration { color: #d6e6dc; }
|
||||||
.track-actions .track-play-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
.track-actions .track-play-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
||||||
.track-actions .track-play-btn:hover { background: #48f; color: #fff; }
|
.track-actions .track-play-btn:hover { background: #48f; color: #fff; }
|
||||||
.track-actions .track-preview-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
.track-actions .track-preview-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
||||||
|
|
@ -165,6 +176,10 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
|
.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
|
||||||
.track-actions .track-remove { color: #e44; }
|
.track-actions .track-remove { color: #e44; }
|
||||||
.track-actions .track-add { color: #4e4; }
|
.track-actions .track-add { color: #4e4; }
|
||||||
|
.track-actions .track-menu-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; color: #ccc; cursor: pointer; opacity: 0; font-size: 1rem; line-height: 1; padding: 0; transition: opacity 0.2s, background 0.2s; }
|
||||||
|
.track-actions .track-menu-btn::before { content: "..."; }
|
||||||
|
.track:hover .track-menu-btn, .track-menu-btn:focus-visible { opacity: 0.75; }
|
||||||
|
.track-actions .track-menu-btn:hover { opacity: 1; background: #444; }
|
||||||
|
|
||||||
/* Track selection */
|
/* Track selection */
|
||||||
.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
|
.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
|
||||||
|
|
@ -187,14 +202,32 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
.context-menu-item.danger:hover { background: #3a2a2a; }
|
.context-menu-item.danger:hover { background: #3a2a2a; }
|
||||||
|
|
||||||
/* Player bar */
|
/* Player bar */
|
||||||
#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; }
|
.visualizer-shell { --visualizer-bg: rgba(10, 14, 12, 0.9); --visualizer-accent: #44ee88; --visualizer-secondary: #66aaff; --visualizer-muted: rgba(255, 255, 255, 0.16); position: relative; isolation: isolate; width: 100%; max-width: 1600px; height: 118px; margin: 0 auto 0.5rem auto; background: #121816; border: 1px solid #26382f; border-radius: 6px; overflow: hidden; min-width: 0; flex: 0 0 auto; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03), 0 12px 30px rgba(0,0,0,0.18); }
|
||||||
#now-playing { width: 180px; flex-shrink: 0; }
|
#visualizer-canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
.visualizer-fullscreen-btn { position: absolute; top: 0.45rem; right: 0.45rem; z-index: 2; width: 30px; height: 30px; padding: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.42); border: 1px solid rgba(255,255,255,0.24); border-radius: 4px; color: #fff; font-size: 1rem; line-height: 1; opacity: 0; transition: opacity 0.15s, background 0.15s, transform 0.15s; backdrop-filter: blur(4px); }
|
||||||
|
.visualizer-shell:hover .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:focus-within .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell.is-fullscreen .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:fullscreen .visualizer-fullscreen-btn { opacity: 0.86; }
|
||||||
|
.visualizer-fullscreen-btn:hover { background: rgba(0,0,0,0.68); transform: scale(1.04); }
|
||||||
|
.visualizer-shell.is-fullscreen,
|
||||||
|
.visualizer-shell:fullscreen,
|
||||||
|
.visualizer-shell:-webkit-full-screen { width: 100vw; height: 100vh; max-width: none; margin: 0; border: 0; border-radius: 0; background: #050505; box-shadow: none; }
|
||||||
|
.visualizer-shell.is-fullscreen { position: fixed; inset: 0; z-index: 3000; }
|
||||||
|
.visualizer-shell.is-fullscreen #visualizer-canvas,
|
||||||
|
.visualizer-shell:fullscreen #visualizer-canvas,
|
||||||
|
.visualizer-shell:-webkit-full-screen #visualizer-canvas { width: 100vw; height: 100vh; }
|
||||||
|
.visualizer-shell.is-fullscreen .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:fullscreen .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:-webkit-full-screen .visualizer-fullscreen-btn { top: 1rem; right: 1rem; width: 40px; height: 40px; font-size: 1.4rem; }
|
||||||
|
#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; min-width: 0; max-width: 100%; overflow-x: hidden; }
|
||||||
|
#now-playing { width: 180px; flex: 0 1 180px; min-width: 0; }
|
||||||
#channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; }
|
#channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; }
|
||||||
#track-name { font-size: 0.9rem; font-weight: 600; overflow: hidden; position: relative; }
|
#track-name { font-size: 0.9rem; font-weight: 600; overflow: hidden; position: relative; min-width: 0; }
|
||||||
#track-name .marquee-inner { display: inline-block; white-space: nowrap; }
|
#track-name .marquee-inner { display: inline-block; white-space: nowrap; }
|
||||||
#track-name.scrolling .marquee-inner { animation: scroll-marquee 8s linear infinite; }
|
#track-name.scrolling .marquee-inner { animation: scroll-marquee 8s linear infinite; }
|
||||||
@keyframes scroll-marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
|
@keyframes scroll-marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
|
||||||
#player-controls { flex: 1; }
|
#player-controls { flex: 1 1 auto; min-width: 0; }
|
||||||
#progress-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.2rem; }
|
#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; } }
|
||||||
|
|
@ -211,20 +244,22 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; }
|
#btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; }
|
||||||
#btn-mode:hover { opacity: 0.8; }
|
#btn-mode:hover { opacity: 0.8; }
|
||||||
#status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; }
|
#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; }
|
#status-icon.playback-blocked { color: #eb0; text-shadow: 0 0 8px rgba(238, 187, 0, 0.65); }
|
||||||
|
.control-disabled { opacity: 0.45 !important; cursor: not-allowed !important; }
|
||||||
|
#progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; min-width: 0; }
|
||||||
#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: 10px; background: #333; color: #eee; padding: 2px 5px; border-radius: 3px; font-size: 0.7rem; 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.75rem; color: #888; margin: 0; line-height: 1; white-space: nowrap; }
|
#time { font-size: 0.75rem; color: #aaa; margin: 0; line-height: 1; white-space: nowrap; }
|
||||||
#buffer-bar { display: flex; gap: 1px; margin-bottom: 0.2rem; }
|
#buffer-bar { display: flex; gap: 1px; margin-bottom: 0.2rem; }
|
||||||
#buffer-bar .segment { flex: 1; height: 2px; background: #333; border-radius: 1px; }
|
#buffer-bar .segment { 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.6rem; color: #555; text-align: right; }
|
#download-speed { font-size: 0.6rem; color: #888; text-align: right; }
|
||||||
#volume-controls { display: flex; gap: 0.4rem; align-items: center; }
|
#volume-controls { display: flex; gap: 0.4rem; align-items: center; min-width: 0; }
|
||||||
#btn-stream-only { font-size: 0.7rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; }
|
#btn-stream-only { font-size: 0.7rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; }
|
||||||
#btn-stream-only:hover { color: #888; }
|
#btn-stream-only:hover { color: #888; }
|
||||||
#btn-stream-only.active { color: #4af; text-shadow: 0 0 6px #4af; }
|
#btn-stream-only.active { color: #4af; text-shadow: 0 0 6px #4af; }
|
||||||
|
|
@ -235,8 +270,9 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
/* Common */
|
/* Common */
|
||||||
button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
|
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.3rem; font-size: 0.75rem; color: #666; text-align: center; }
|
.hidden { display: none !important; }
|
||||||
.empty { color: #666; font-style: italic; font-size: 0.85rem; }
|
#status { margin-top: 0.3rem; font-size: 0.75rem; color: #888; text-align: center; }
|
||||||
|
.empty { color: #999; font-style: italic; font-size: 0.85rem; }
|
||||||
|
|
||||||
/* Login panel */
|
/* Login panel */
|
||||||
#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 { 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; }
|
||||||
|
|
@ -258,8 +294,10 @@ button:hover { background: #333; }
|
||||||
#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 { 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; min-height: 0; }
|
||||||
#player-content.visible { display: flex; }
|
#player-content.visible { display: flex; }
|
||||||
|
#player-content.visualizer-active #main-content { flex: 0 1 60vh; max-height: 60vh; }
|
||||||
|
#player-content.visualizer-active .visualizer-shell { flex: 1 1 118px; height: auto; min-height: 118px; }
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 6px; }
|
::-webkit-scrollbar { width: 6px; }
|
||||||
::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; }
|
::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; }
|
||||||
|
|
@ -267,10 +305,16 @@ button:hover { background: #333; }
|
||||||
::-webkit-scrollbar-thumb:hover { background-color: #555; }
|
::-webkit-scrollbar-thumb:hover { background-color: #555; }
|
||||||
|
|
||||||
/* Toast notifications */
|
/* Toast notifications */
|
||||||
#toast-container { position: fixed; top: 0.5rem; left: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; max-height: 80vh; overflow-y: auto; }
|
#toast-container { position: fixed; top: 0.5rem; right: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; max-width: 320px; max-height: 50vh; overflow-y: auto; }
|
||||||
.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 { 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.toast-warning { background: #3a3a1a; color: #ea4; border-color: #ea4; }
|
.toast.toast-warning { background: #3a3a1a; color: #ea4; border-color: #ea4; }
|
||||||
.toast.toast-error { background: #3a1a1a; color: #e44; border-color: #e44; }
|
.toast.toast-error { background: #3a1a1a; color: #e44; border-color: #e44; }
|
||||||
|
.toast-confirm { pointer-events: auto; color: #eee; max-width: 320px; }
|
||||||
|
.toast-confirm-message { display: block; margin-bottom: 0.5rem; line-height: 1.35; }
|
||||||
|
.toast-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
|
||||||
|
.toast-actions button { padding: 0.3rem 0.65rem; font-size: 0.75rem; min-height: 32px; }
|
||||||
|
.toast-action-confirm { background: #4e8; border-color: #4e8; color: #111; font-weight: 600; }
|
||||||
|
.toast-action-cancel { background: #222; border-color: #555; color: #ddd; }
|
||||||
.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); } }
|
||||||
|
|
@ -330,6 +374,43 @@ button:hover { background: #333; }
|
||||||
/* Mobile tab bar - hidden on desktop */
|
/* Mobile tab bar - hidden on desktop */
|
||||||
#mobile-tabs { display: none; }
|
#mobile-tabs { display: none; }
|
||||||
|
|
||||||
|
/* Medium-width layout: keep the queue closer to the user's hand */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
#main-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#channels-panel {
|
||||||
|
flex-basis: 145px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#queue-panel {
|
||||||
|
order: 2;
|
||||||
|
flex-grow: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
#library-panel {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-bar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#now-playing {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-controls {
|
||||||
|
flex: 999 1 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#volume-controls {
|
||||||
|
flex: 0 1 180px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile responsive styles */
|
/* Mobile responsive styles */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
html, body {
|
html, body {
|
||||||
|
|
@ -352,6 +433,7 @@ button:hover { background: #333; }
|
||||||
#site-header { margin-bottom: 0.3rem; flex-shrink: 0; }
|
#site-header { margin-bottom: 0.3rem; flex-shrink: 0; }
|
||||||
#site-header h1 { font-size: 0.9rem; }
|
#site-header h1 { font-size: 0.9rem; }
|
||||||
#btn-report-bug { font-size: 0.7rem; padding: 0.2rem 0.4rem; }
|
#btn-report-bug { font-size: 0.7rem; padding: 0.2rem 0.4rem; }
|
||||||
|
#btn-logout.guest-signin { min-height: 44px; }
|
||||||
|
|
||||||
#header-row { flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.3rem; flex-shrink: 0; }
|
#header-row { flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.3rem; flex-shrink: 0; }
|
||||||
#auth-section .user-info { flex-wrap: wrap; gap: 0.3rem; }
|
#auth-section .user-info { flex-wrap: wrap; gap: 0.3rem; }
|
||||||
|
|
@ -374,7 +456,8 @@ button:hover { background: #333; }
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.5rem;
|
min-height: 44px;
|
||||||
|
padding: 0.65rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
@ -418,6 +501,9 @@ button:hover { background: #333; }
|
||||||
#library-panel .panel-tabs {
|
#library-panel .panel-tabs {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
#library-panel .panel-tab {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
#library-panel .panel-views {
|
#library-panel .panel-views {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
@ -443,6 +529,10 @@ button:hover { background: #333; }
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
#recent-library {
|
||||||
|
max-height: 28vh;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
.add-btn {
|
.add-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|
@ -483,6 +573,25 @@ button:hover { background: #333; }
|
||||||
.track-title { font-size: 0.85rem; }
|
.track-title { font-size: 0.85rem; }
|
||||||
|
|
||||||
/* Player bar - stacked layout */
|
/* Player bar - stacked layout */
|
||||||
|
.visualizer-shell {
|
||||||
|
height: 74px;
|
||||||
|
min-height: 74px;
|
||||||
|
max-height: 74px;
|
||||||
|
flex: 0 0 74px;
|
||||||
|
margin: 0 0 0.3rem 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
#player-content.visualizer-active #main-content {
|
||||||
|
flex: 1;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
#player-content.visualizer-active .visualizer-shell {
|
||||||
|
flex: 0 0 74px;
|
||||||
|
height: 74px;
|
||||||
|
min-height: 74px;
|
||||||
|
max-height: 74px;
|
||||||
|
}
|
||||||
|
|
||||||
#player-bar {
|
#player-bar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|
@ -495,6 +604,7 @@ button:hover { background: #333; }
|
||||||
}
|
}
|
||||||
#now-playing {
|
#now-playing {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
@ -504,8 +614,42 @@ button:hover { background: #333; }
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
#player-controls { width: 100%; }
|
#player-controls { width: 100%; flex: 0 0 auto; }
|
||||||
#progress-row { gap: 0.5rem; }
|
#progress-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(42px, auto) 44px 44px 44px minmax(58px, auto);
|
||||||
|
grid-template-areas:
|
||||||
|
"sync prev play next mode"
|
||||||
|
"progress progress progress progress time";
|
||||||
|
gap: 0.25rem 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#btn-sync {
|
||||||
|
grid-area: sync;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#btn-prev { grid-area: prev; }
|
||||||
|
#status-icon { grid-area: play; }
|
||||||
|
#btn-next { grid-area: next; }
|
||||||
|
#btn-mode {
|
||||||
|
grid-area: mode;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#progress-container {
|
||||||
|
grid-area: progress;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
#time {
|
||||||
|
grid-area: time;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
#status-icon {
|
#status-icon {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
|
|
@ -528,11 +672,28 @@ button:hover { background: #333; }
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
}
|
}
|
||||||
#volume-controls {
|
#volume-controls {
|
||||||
justify-content: flex-end;
|
flex: 0 0 auto;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
#btn-stream-only { display: none; }
|
#btn-stream-only { display: none; }
|
||||||
#volume-slider { width: 80px; }
|
#btn-mute {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#volume { width: min(160px, 55vw); height: 44px; }
|
||||||
|
.track-actions .track-menu-btn {
|
||||||
|
opacity: 1;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide history button on mobile */
|
/* Hide history button on mobile */
|
||||||
#btn-history { display: none; }
|
#btn-history { display: none; }
|
||||||
|
|
@ -540,12 +701,572 @@ button:hover { background: #333; }
|
||||||
/* Toast positioning */
|
/* Toast positioning */
|
||||||
#toast-container {
|
#toast-container {
|
||||||
top: auto;
|
top: auto;
|
||||||
bottom: 5rem;
|
bottom: calc(9.25rem + env(safe-area-inset-bottom));
|
||||||
left: 0.3rem;
|
left: 0.3rem;
|
||||||
right: 0.3rem;
|
right: 0.3rem;
|
||||||
|
max-width: none;
|
||||||
|
max-height: 30vh;
|
||||||
}
|
}
|
||||||
.toast { max-width: none; }
|
.toast { max-width: none; }
|
||||||
|
|
||||||
/* Login panel */
|
/* Login panel */
|
||||||
#login-panel { padding: 1rem; }
|
#login-panel { padding: 1rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Theme selector */
|
||||||
|
#stream-select { margin-left: auto; display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
#theme-select { min-width: 170px; }
|
||||||
|
#visualizer-select { min-width: 140px; }
|
||||||
|
|
||||||
|
/* Shared theme surfaces */
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #channels-panel,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #library-panel,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #queue-panel,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .visualizer-shell,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #player-bar,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #login-panel,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .panel-views,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #playlist-contents-header,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .context-menu,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #toast-history {
|
||||||
|
background: var(--panel-bg, #1a1a1a);
|
||||||
|
border-color: var(--panel-border, #333);
|
||||||
|
color: var(--text-color, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #site-header h1,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) h3,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .panel-tab.active,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #selected-playlist-name {
|
||||||
|
color: var(--heading-color, var(--text-color, #eee));
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) button,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) select,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) input,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .panel-tab,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track-actions .track-menu-btn,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track-actions .track-play-btn,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track-actions .track-preview-btn {
|
||||||
|
background: var(--control-bg, #222);
|
||||||
|
border-color: var(--control-border, #444);
|
||||||
|
color: var(--control-color, var(--text-color, #eee));
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .visualizer-fullscreen-btn {
|
||||||
|
background: rgba(0,0,0,0.48);
|
||||||
|
border-color: rgba(255,255,255,0.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track:hover,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .playlist-item:hover,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .context-menu-item:hover {
|
||||||
|
background: var(--hover-bg, #222);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #queue .track.active,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .playlist-item.selected,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .mobile-tab.active,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .now-playing-bar,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .toast {
|
||||||
|
background: var(--active-bg, #2a4a3a);
|
||||||
|
border-color: var(--accent, #4e8);
|
||||||
|
color: var(--active-color, var(--accent, #4e8));
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track.cached .cache-indicator,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #progress-bar.playing.synced,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #buffer-bar .segment.available,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #sync-indicator.visible {
|
||||||
|
background: var(--accent, #4e8);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track.not-cached .cache-indicator,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #btn-sync.synced,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .toast.toast-warning {
|
||||||
|
color: var(--warning, #ea4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="phosphor"] {
|
||||||
|
--panel-bg: #07140f;
|
||||||
|
--panel-border: #1f7a48;
|
||||||
|
--text-color: #caf7d9;
|
||||||
|
--heading-color: #73ff9c;
|
||||||
|
--control-bg: #0b1f17;
|
||||||
|
--control-border: #24965a;
|
||||||
|
--control-color: #dfffe8;
|
||||||
|
--hover-bg: #123321;
|
||||||
|
--active-bg: #123d29;
|
||||||
|
--active-color: #8cffad;
|
||||||
|
--accent: #51f080;
|
||||||
|
--warning: #f5de72;
|
||||||
|
--visualizer-bg: rgba(3, 13, 8, 0.95);
|
||||||
|
--visualizer-accent: #51f080;
|
||||||
|
--visualizer-secondary: #f5de72;
|
||||||
|
--visualizer-muted: rgba(81, 240, 128, 0.18);
|
||||||
|
background: #020806;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="phosphor"] #app { filter: drop-shadow(0 0 8px rgba(81, 240, 128, 0.12)); }
|
||||||
|
body[data-ui-theme="phosphor"] #track-name,
|
||||||
|
body[data-ui-theme="phosphor"] #btn-sync.synced.connected { text-shadow: 0 0 10px rgba(81, 240, 128, 0.55); }
|
||||||
|
|
||||||
|
body[data-ui-theme="vaporwave"] {
|
||||||
|
--panel-bg: #21123a;
|
||||||
|
--panel-border: #ff71ce;
|
||||||
|
--text-color: #f8d7ff;
|
||||||
|
--heading-color: #05ffa1;
|
||||||
|
--control-bg: #351a54;
|
||||||
|
--control-border: #b967ff;
|
||||||
|
--control-color: #ffffff;
|
||||||
|
--hover-bg: #46206d;
|
||||||
|
--active-bg: #2d567a;
|
||||||
|
--active-color: #05ffa1;
|
||||||
|
--accent: #01cdfe;
|
||||||
|
--warning: #fffb96;
|
||||||
|
--visualizer-bg: rgba(25, 9, 45, 0.92);
|
||||||
|
--visualizer-accent: #01cdfe;
|
||||||
|
--visualizer-secondary: #ff71ce;
|
||||||
|
--visualizer-muted: rgba(255, 113, 206, 0.22);
|
||||||
|
background: linear-gradient(135deg, #12091f 0%, #2f1856 46%, #0b4857 100%);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="vaporwave"] #main-content { gap: 0.8rem; }
|
||||||
|
body[data-ui-theme="vaporwave"] #channels-panel,
|
||||||
|
body[data-ui-theme="vaporwave"] #library-panel,
|
||||||
|
body[data-ui-theme="vaporwave"] #queue-panel,
|
||||||
|
body[data-ui-theme="vaporwave"] #player-bar { box-shadow: 0 0 22px rgba(1, 205, 254, 0.2), inset 0 0 0 1px rgba(255, 113, 206, 0.22); }
|
||||||
|
|
||||||
|
body[data-ui-theme="sunrise"] {
|
||||||
|
--panel-bg: #241b2d;
|
||||||
|
--panel-border: #f18f01;
|
||||||
|
--text-color: #fff0d4;
|
||||||
|
--heading-color: #ffcf56;
|
||||||
|
--control-bg: #3a2340;
|
||||||
|
--control-border: #f45d48;
|
||||||
|
--control-color: #fff7e8;
|
||||||
|
--hover-bg: #50314f;
|
||||||
|
--active-bg: #5f342e;
|
||||||
|
--active-color: #ffd166;
|
||||||
|
--accent: #ff9f1c;
|
||||||
|
--warning: #2ec4b6;
|
||||||
|
--visualizer-bg: rgba(36, 20, 43, 0.92);
|
||||||
|
--visualizer-accent: #ff9f1c;
|
||||||
|
--visualizer-secondary: #2ec4b6;
|
||||||
|
--visualizer-muted: rgba(255, 207, 86, 0.18);
|
||||||
|
background: radial-gradient(circle at 20% 0%, #7a2f5c 0, #2b1838 45%, #101018 100%);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="paper"] {
|
||||||
|
--panel-bg: #f4efe3;
|
||||||
|
--panel-border: #3b3327;
|
||||||
|
--text-color: #1d1a17;
|
||||||
|
--heading-color: #0b0b0b;
|
||||||
|
--control-bg: #fffaf0;
|
||||||
|
--control-border: #5a4a36;
|
||||||
|
--control-color: #1d1a17;
|
||||||
|
--hover-bg: #e5d8bd;
|
||||||
|
--active-bg: #d9c69f;
|
||||||
|
--active-color: #1d1a17;
|
||||||
|
--accent: #285f47;
|
||||||
|
--warning: #a75a00;
|
||||||
|
--visualizer-bg: rgba(255, 250, 240, 0.95);
|
||||||
|
--visualizer-accent: #285f47;
|
||||||
|
--visualizer-secondary: #a75a00;
|
||||||
|
--visualizer-muted: rgba(29, 26, 23, 0.16);
|
||||||
|
background: #d7c9aa;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="paper"] #channels-panel,
|
||||||
|
body[data-ui-theme="paper"] #library-panel,
|
||||||
|
body[data-ui-theme="paper"] #queue-panel,
|
||||||
|
body[data-ui-theme="paper"] #player-bar {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
box-shadow: 4px 4px 0 #3b3327;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="contrast"] {
|
||||||
|
--panel-bg: #000;
|
||||||
|
--panel-border: #fff;
|
||||||
|
--text-color: #fff;
|
||||||
|
--heading-color: #ffff00;
|
||||||
|
--control-bg: #000;
|
||||||
|
--control-border: #fff;
|
||||||
|
--control-color: #fff;
|
||||||
|
--hover-bg: #202020;
|
||||||
|
--active-bg: #ffff00;
|
||||||
|
--active-color: #000;
|
||||||
|
--accent: #00ffff;
|
||||||
|
--warning: #ff00ff;
|
||||||
|
--visualizer-bg: #000000;
|
||||||
|
--visualizer-accent: #00ffff;
|
||||||
|
--visualizer-secondary: #ffff00;
|
||||||
|
--visualizer-muted: rgba(255, 255, 255, 0.32);
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="contrast"] #channels-panel,
|
||||||
|
body[data-ui-theme="contrast"] #library-panel,
|
||||||
|
body[data-ui-theme="contrast"] #queue-panel,
|
||||||
|
body[data-ui-theme="contrast"] #player-bar { border: 2px solid #fff; border-radius: 0; }
|
||||||
|
|
||||||
|
body[data-ui-theme="aquarium"] {
|
||||||
|
--panel-bg: rgba(8, 42, 58, 0.86);
|
||||||
|
--panel-border: #52e0c4;
|
||||||
|
--text-color: #d7fff8;
|
||||||
|
--heading-color: #a3fff1;
|
||||||
|
--control-bg: #10394d;
|
||||||
|
--control-border: #59bde0;
|
||||||
|
--control-color: #ebfffb;
|
||||||
|
--hover-bg: #174d62;
|
||||||
|
--active-bg: #0d5f65;
|
||||||
|
--active-color: #adfff0;
|
||||||
|
--accent: #52e0c4;
|
||||||
|
--warning: #ffd166;
|
||||||
|
--visualizer-bg: rgba(5, 35, 49, 0.82);
|
||||||
|
--visualizer-accent: #52e0c4;
|
||||||
|
--visualizer-secondary: #59bde0;
|
||||||
|
--visualizer-muted: rgba(163, 255, 241, 0.18);
|
||||||
|
background: linear-gradient(180deg, #062c45, #03161d);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="aquarium"] #app { backdrop-filter: blur(2px); }
|
||||||
|
body[data-ui-theme="aquarium"] #channels-panel,
|
||||||
|
body[data-ui-theme="aquarium"] #library-panel,
|
||||||
|
body[data-ui-theme="aquarium"] #queue-panel,
|
||||||
|
body[data-ui-theme="aquarium"] #player-bar { border: 1px solid rgba(163, 255, 241, 0.42); }
|
||||||
|
|
||||||
|
body[data-ui-theme="amber"] {
|
||||||
|
--panel-bg: #1b1204;
|
||||||
|
--panel-border: #8c5d13;
|
||||||
|
--text-color: #ffd98a;
|
||||||
|
--heading-color: #ffb000;
|
||||||
|
--control-bg: #2a1b06;
|
||||||
|
--control-border: #b97919;
|
||||||
|
--control-color: #ffe8ad;
|
||||||
|
--hover-bg: #3a2508;
|
||||||
|
--active-bg: #4c3109;
|
||||||
|
--active-color: #ffc857;
|
||||||
|
--accent: #ffb000;
|
||||||
|
--warning: #f77f00;
|
||||||
|
--visualizer-bg: rgba(18, 12, 3, 0.94);
|
||||||
|
--visualizer-accent: #ffb000;
|
||||||
|
--visualizer-secondary: #f77f00;
|
||||||
|
--visualizer-muted: rgba(255, 176, 0, 0.2);
|
||||||
|
background: #080502;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: "Cascadia Mono", "Consolas", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="amber"] * { letter-spacing: 0; }
|
||||||
|
body[data-ui-theme="amber"] #track-name { text-transform: uppercase; }
|
||||||
|
|
||||||
|
body[data-ui-theme="compact"] {
|
||||||
|
--panel-bg: #15181b;
|
||||||
|
--panel-border: #35414a;
|
||||||
|
--text-color: #e6edf3;
|
||||||
|
--heading-color: #9fc7ff;
|
||||||
|
--control-bg: #20262c;
|
||||||
|
--control-border: #3e4d58;
|
||||||
|
--control-color: #edf4fb;
|
||||||
|
--hover-bg: #242c33;
|
||||||
|
--active-bg: #22364b;
|
||||||
|
--active-color: #9fc7ff;
|
||||||
|
--accent: #79c0ff;
|
||||||
|
--warning: #e3b341;
|
||||||
|
--visualizer-bg: rgba(13, 17, 23, 0.94);
|
||||||
|
--visualizer-accent: #79c0ff;
|
||||||
|
--visualizer-secondary: #e3b341;
|
||||||
|
--visualizer-muted: rgba(121, 192, 255, 0.18);
|
||||||
|
background: #0d1117;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="compact"] #app { max-width: 1200px; padding: 0.25rem; }
|
||||||
|
body[data-ui-theme="compact"] #main-content { gap: 0.25rem; }
|
||||||
|
body[data-ui-theme="compact"] #channels-panel { flex-basis: 130px; }
|
||||||
|
body[data-ui-theme="compact"] #library-panel,
|
||||||
|
body[data-ui-theme="compact"] #queue-panel { padding: 0.25rem; max-height: 66vh; }
|
||||||
|
body[data-ui-theme="compact"] #library .track,
|
||||||
|
body[data-ui-theme="compact"] #queue .track,
|
||||||
|
body[data-ui-theme="compact"] #recent-library .track,
|
||||||
|
body[data-ui-theme="compact"] #playlist-tracks .track { padding: 0.16rem 0.35rem; font-size: 0.78rem; }
|
||||||
|
body[data-ui-theme="compact"] #player-bar { padding: 0.35rem 0.5rem; }
|
||||||
|
|
||||||
|
body[data-ui-theme="cinema"] {
|
||||||
|
--panel-bg: #101010;
|
||||||
|
--panel-border: #41362a;
|
||||||
|
--text-color: #e8dfcc;
|
||||||
|
--heading-color: #f6c65b;
|
||||||
|
--control-bg: #1f1a15;
|
||||||
|
--control-border: #6d5735;
|
||||||
|
--control-color: #f3ead7;
|
||||||
|
--hover-bg: #292118;
|
||||||
|
--active-bg: #3d2b18;
|
||||||
|
--active-color: #f6c65b;
|
||||||
|
--accent: #f6c65b;
|
||||||
|
--warning: #db5461;
|
||||||
|
--visualizer-bg: rgba(5, 5, 5, 0.95);
|
||||||
|
--visualizer-accent: #f6c65b;
|
||||||
|
--visualizer-secondary: #db5461;
|
||||||
|
--visualizer-muted: rgba(246, 198, 91, 0.18);
|
||||||
|
background: #050505;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="cinema"] #main-content { display: grid; grid-template-columns: 150px minmax(280px, 0.8fr) minmax(420px, 1.45fr); }
|
||||||
|
body[data-ui-theme="cinema"] #queue-panel { order: 2; max-height: 70vh; }
|
||||||
|
body[data-ui-theme="cinema"] #queue .track.active { font-size: 1rem; padding: 0.65rem 0.75rem; }
|
||||||
|
body[data-ui-theme="cinema"] #player-bar { border-top: 4px solid var(--accent); }
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] {
|
||||||
|
--panel-bg: #ffff00;
|
||||||
|
--panel-border: #000000;
|
||||||
|
--text-color: #000000;
|
||||||
|
--heading-color: #ffff00;
|
||||||
|
--control-bg: #ffffff;
|
||||||
|
--control-border: #000000;
|
||||||
|
--control-color: #000000;
|
||||||
|
--hover-bg: #ff0000;
|
||||||
|
--active-bg: #ff0000;
|
||||||
|
--active-color: #ffffff;
|
||||||
|
--accent: #ff0000;
|
||||||
|
--warning: #000000;
|
||||||
|
--visualizer-bg: #ffff00;
|
||||||
|
--visualizer-accent: #ff0000;
|
||||||
|
--visualizer-secondary: #000000;
|
||||||
|
--visualizer-muted: rgba(0, 0, 0, 0.22);
|
||||||
|
background: #ff0000;
|
||||||
|
color: #000000;
|
||||||
|
font-family: "MS Sans Serif", "Arial", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #site-header h1,
|
||||||
|
body[data-ui-theme="hotdog"] h3,
|
||||||
|
body[data-ui-theme="hotdog"] .panel-tab.active {
|
||||||
|
background: #ff0000;
|
||||||
|
color: #ffff00;
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #channels-panel,
|
||||||
|
body[data-ui-theme="hotdog"] #library-panel,
|
||||||
|
body[data-ui-theme="hotdog"] #queue-panel,
|
||||||
|
body[data-ui-theme="hotdog"] .visualizer-shell,
|
||||||
|
body[data-ui-theme="hotdog"] #player-bar,
|
||||||
|
body[data-ui-theme="hotdog"] #login-panel,
|
||||||
|
body[data-ui-theme="hotdog"] .panel-views,
|
||||||
|
body[data-ui-theme="hotdog"] #toast-history,
|
||||||
|
body[data-ui-theme="hotdog"] .context-menu {
|
||||||
|
border: 3px solid #000000;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 5px 5px 0 #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] button,
|
||||||
|
body[data-ui-theme="hotdog"] select,
|
||||||
|
body[data-ui-theme="hotdog"] input,
|
||||||
|
body[data-ui-theme="hotdog"] .panel-tab,
|
||||||
|
body[data-ui-theme="hotdog"] .track-actions .track-menu-btn,
|
||||||
|
body[data-ui-theme="hotdog"] .track-actions .track-play-btn,
|
||||||
|
body[data-ui-theme="hotdog"] .track-actions .track-preview-btn {
|
||||||
|
border: 2px outset #ffffff;
|
||||||
|
border-radius: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] button:active,
|
||||||
|
body[data-ui-theme="hotdog"] .panel-tab.active {
|
||||||
|
border-style: inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] .panel-tab,
|
||||||
|
body[data-ui-theme="hotdog"] .track,
|
||||||
|
body[data-ui-theme="hotdog"] .playlist-item,
|
||||||
|
body[data-ui-theme="hotdog"] .context-menu-item,
|
||||||
|
body[data-ui-theme="hotdog"] .channel-item {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] .track:hover,
|
||||||
|
body[data-ui-theme="hotdog"] .context-menu-item:hover,
|
||||||
|
body[data-ui-theme="hotdog"] .playlist-item:hover {
|
||||||
|
color: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #queue .track.active,
|
||||||
|
body[data-ui-theme="hotdog"] .now-playing-bar,
|
||||||
|
body[data-ui-theme="hotdog"] .mobile-tab.active,
|
||||||
|
body[data-ui-theme="hotdog"] .toast {
|
||||||
|
border: 2px solid #000000;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #progress-container,
|
||||||
|
body[data-ui-theme="hotdog"] #buffer-bar .segment {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #progress-bar.playing.synced,
|
||||||
|
body[data-ui-theme="hotdog"] #buffer-bar .segment.available,
|
||||||
|
body[data-ui-theme="hotdog"] .track.cached .cache-indicator {
|
||||||
|
background: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] .visualizer-shell {
|
||||||
|
background: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-ui-theme="spreadsheet"],
|
||||||
|
html[data-ui-theme="myspace"],
|
||||||
|
html[data-ui-theme="sideways"] {
|
||||||
|
max-width: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-ui-theme="spreadsheet"] { min-width: 2100px; }
|
||||||
|
html[data-ui-theme="myspace"] { min-width: 1850px; }
|
||||||
|
html[data-ui-theme="sideways"] { min-width: 2600px; }
|
||||||
|
|
||||||
|
body[data-ui-theme="spreadsheet"],
|
||||||
|
body[data-ui-theme="myspace"],
|
||||||
|
body[data-ui-theme="sideways"] {
|
||||||
|
max-width: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="spreadsheet"] {
|
||||||
|
--panel-bg: #ffffff;
|
||||||
|
--panel-border: #8b8b8b;
|
||||||
|
--text-color: #000000;
|
||||||
|
--heading-color: #008000;
|
||||||
|
--control-bg: #efefef;
|
||||||
|
--control-border: #777777;
|
||||||
|
--control-color: #000000;
|
||||||
|
--hover-bg: #ffffcc;
|
||||||
|
--active-bg: #00ff00;
|
||||||
|
--active-color: #000000;
|
||||||
|
--accent: #0000ff;
|
||||||
|
--warning: #ff0000;
|
||||||
|
--visualizer-bg: #ffffff;
|
||||||
|
--visualizer-accent: #0000ff;
|
||||||
|
--visualizer-secondary: #ff0000;
|
||||||
|
--visualizer-muted: rgba(0, 0, 0, 0.18);
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
font-family: "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="spreadsheet"] #app { width: 2100px; max-width: none; overflow-x: visible; }
|
||||||
|
body[data-ui-theme="spreadsheet"] #main-content { width: 2050px; max-width: none; overflow-x: visible; gap: 0; }
|
||||||
|
body[data-ui-theme="spreadsheet"] .visualizer-shell { width: 2050px; max-width: none; }
|
||||||
|
body[data-ui-theme="spreadsheet"] #channels-panel,
|
||||||
|
body[data-ui-theme="spreadsheet"] #library-panel,
|
||||||
|
body[data-ui-theme="spreadsheet"] #queue-panel,
|
||||||
|
body[data-ui-theme="spreadsheet"] #player-bar {
|
||||||
|
border: 1px solid #000;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
body[data-ui-theme="spreadsheet"] .track { border-bottom: 1px solid #999; border-radius: 0; }
|
||||||
|
body[data-ui-theme="spreadsheet"] #library,
|
||||||
|
body[data-ui-theme="spreadsheet"] #recent-library,
|
||||||
|
body[data-ui-theme="spreadsheet"] #queue { overflow-x: scroll; }
|
||||||
|
|
||||||
|
body[data-ui-theme="myspace"] {
|
||||||
|
--panel-bg: #220044;
|
||||||
|
--panel-border: #00ff66;
|
||||||
|
--text-color: #ffff00;
|
||||||
|
--heading-color: #ff00ff;
|
||||||
|
--control-bg: #0000cc;
|
||||||
|
--control-border: #ffff00;
|
||||||
|
--control-color: #ffffff;
|
||||||
|
--hover-bg: #ff00ff;
|
||||||
|
--active-bg: #00ffff;
|
||||||
|
--active-color: #000000;
|
||||||
|
--accent: #00ff66;
|
||||||
|
--warning: #ff6600;
|
||||||
|
--visualizer-bg: rgba(34, 0, 68, 0.92);
|
||||||
|
--visualizer-accent: #00ff66;
|
||||||
|
--visualizer-secondary: #ff00ff;
|
||||||
|
--visualizer-muted: rgba(255, 255, 0, 0.22);
|
||||||
|
background: repeating-linear-gradient(45deg, #ff00ff 0 18px, #00ffff 18px 36px, #ffff00 36px 54px);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: Impact, "Arial Black", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="myspace"] #app { width: 1850px; max-width: none; overflow-x: visible; transform: rotate(-0.35deg); }
|
||||||
|
body[data-ui-theme="myspace"] #main-content { width: 1780px; max-width: none; overflow-x: visible; gap: 1.25rem; }
|
||||||
|
body[data-ui-theme="myspace"] .visualizer-shell { width: 1780px; max-width: none; }
|
||||||
|
body[data-ui-theme="myspace"] #channels-panel,
|
||||||
|
body[data-ui-theme="myspace"] #library-panel,
|
||||||
|
body[data-ui-theme="myspace"] #queue-panel,
|
||||||
|
body[data-ui-theme="myspace"] #player-bar {
|
||||||
|
border: 6px ridge #00ff66;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 12px 12px 0 #ff00ff, -9px -7px 0 #00ffff;
|
||||||
|
}
|
||||||
|
body[data-ui-theme="myspace"] .track:nth-child(odd) { transform: rotate(0.45deg); }
|
||||||
|
body[data-ui-theme="myspace"] .track:nth-child(even) { transform: rotate(-0.45deg); }
|
||||||
|
body[data-ui-theme="myspace"] #track-name { font-size: 1.25rem; color: #00ff66; text-shadow: 2px 2px #ff00ff; }
|
||||||
|
|
||||||
|
body[data-ui-theme="sideways"] {
|
||||||
|
--panel-bg: #241313;
|
||||||
|
--panel-border: #cc3333;
|
||||||
|
--text-color: #f8dcdc;
|
||||||
|
--heading-color: #ff6b35;
|
||||||
|
--control-bg: #351b1b;
|
||||||
|
--control-border: #8c2f39;
|
||||||
|
--control-color: #fff1f1;
|
||||||
|
--hover-bg: #482121;
|
||||||
|
--active-bg: #5a231f;
|
||||||
|
--active-color: #ffb199;
|
||||||
|
--accent: #ff6b35;
|
||||||
|
--warning: #ffd166;
|
||||||
|
--visualizer-bg: rgba(25, 11, 11, 0.94);
|
||||||
|
--visualizer-accent: #ff6b35;
|
||||||
|
--visualizer-secondary: #ffd166;
|
||||||
|
--visualizer-muted: rgba(248, 220, 220, 0.16);
|
||||||
|
background: #190b0b;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="sideways"] #app { width: 2600px; max-width: none; overflow-x: visible; }
|
||||||
|
body[data-ui-theme="sideways"] .visualizer-shell { width: 2480px; max-width: none; }
|
||||||
|
body[data-ui-theme="sideways"] #main-content {
|
||||||
|
width: 2480px;
|
||||||
|
max-width: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1060px 1060px;
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
body[data-ui-theme="sideways"] #channels-panel,
|
||||||
|
body[data-ui-theme="sideways"] #library-panel,
|
||||||
|
body[data-ui-theme="sideways"] #queue-panel { max-height: 42vh; }
|
||||||
|
body[data-ui-theme="sideways"] #player-bar { width: 2480px; max-width: none; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#stream-select { width: 100%; margin-left: 0; justify-content: stretch; }
|
||||||
|
#theme-select,
|
||||||
|
#visualizer-select { flex: 1 1 150px; min-height: 44px; min-width: 0; }
|
||||||
|
|
||||||
|
body[data-ui-theme="compact"] #app,
|
||||||
|
body[data-ui-theme="cinema"] #app {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="cinema"] #main-content {
|
||||||
|
display: flex;
|
||||||
|
grid-template-columns: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
// MusicRoom - UI theme and layout experiments
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const M = window.MusicRoom;
|
||||||
|
|
||||||
|
const THEMES = new Set([
|
||||||
|
"default",
|
||||||
|
"phosphor",
|
||||||
|
"vaporwave",
|
||||||
|
"sunrise",
|
||||||
|
"paper",
|
||||||
|
"contrast",
|
||||||
|
"aquarium",
|
||||||
|
"amber",
|
||||||
|
"compact",
|
||||||
|
"cinema",
|
||||||
|
"hotdog",
|
||||||
|
"spreadsheet",
|
||||||
|
"myspace",
|
||||||
|
"sideways"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function decodeCookieValue(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
M.clearCookieValue(M.THEME_STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTheme(theme) {
|
||||||
|
return THEMES.has(theme) ? theme : "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
M.getSavedTheme = function() {
|
||||||
|
const cookieTheme = normalizeTheme(decodeCookieValue(M.getCookieValue(M.THEME_STORAGE_KEY)));
|
||||||
|
if (cookieTheme !== "default") return cookieTheme;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedTheme = normalizeTheme(localStorage.getItem(M.THEME_STORAGE_KEY));
|
||||||
|
if (storedTheme !== "default") {
|
||||||
|
M.rememberTheme(storedTheme);
|
||||||
|
return storedTheme;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return "default";
|
||||||
|
};
|
||||||
|
|
||||||
|
M.rememberTheme = function(theme) {
|
||||||
|
const normalized = normalizeTheme(theme);
|
||||||
|
if (normalized === "default") {
|
||||||
|
M.clearThemePreference();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
M.setCookieValue(M.THEME_STORAGE_KEY, normalized);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(M.THEME_STORAGE_KEY, normalized);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.clearThemePreference = function() {
|
||||||
|
M.clearCookieValue(M.THEME_STORAGE_KEY);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(M.THEME_STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.applyTheme = function(theme, remember = true) {
|
||||||
|
const normalized = normalizeTheme(theme);
|
||||||
|
document.documentElement.dataset.uiTheme = normalized;
|
||||||
|
document.body.dataset.uiTheme = normalized;
|
||||||
|
|
||||||
|
const selector = document.getElementById("theme-select");
|
||||||
|
if (selector) selector.value = normalized;
|
||||||
|
|
||||||
|
if (remember) M.rememberTheme(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const selector = document.getElementById("theme-select");
|
||||||
|
M.applyTheme(M.getSavedTheme(), false);
|
||||||
|
|
||||||
|
if (selector) {
|
||||||
|
selector.onchange = () => M.applyTheme(selector.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -59,6 +59,7 @@
|
||||||
<span class="track-title">${escapeHtml(title)}</span>
|
<span class="track-title">${escapeHtml(title)}</span>
|
||||||
<span class="track-actions">
|
<span class="track-actions">
|
||||||
<span class="duration">${M.fmt(track.duration)}</span>
|
<span class="duration">${M.fmt(track.duration)}</span>
|
||||||
|
<button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button>
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,39 @@
|
||||||
|
|
||||||
// Active context menu
|
// Active context menu
|
||||||
let activeContextMenu = null;
|
let activeContextMenu = null;
|
||||||
|
const TRACK_DRAG_TYPE = "application/x-blastoise-tracks";
|
||||||
|
|
||||||
|
function getDataTransferTypes(e) {
|
||||||
|
return [...(e.dataTransfer?.types || [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTrackDrag(e, allowedSources = null) {
|
||||||
|
if (dragSource) return !allowedSources || allowedSources.includes(dragSource);
|
||||||
|
return getDataTransferTypes(e).includes(TRACK_DRAG_TYPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDragState(e) {
|
||||||
|
if (dragSource && draggedTrackIds.length > 0) return true;
|
||||||
|
|
||||||
|
const raw = e.dataTransfer?.getData(TRACK_DRAG_TYPE);
|
||||||
|
if (!raw) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(raw);
|
||||||
|
if (!["queue", "library", "playlist"].includes(payload.source)) return false;
|
||||||
|
const trackIds = Array.isArray(payload.trackIds) ? payload.trackIds.filter(Boolean) : [];
|
||||||
|
if (trackIds.length === 0) return false;
|
||||||
|
|
||||||
|
dragSource = payload.source;
|
||||||
|
draggedTrackIds = trackIds;
|
||||||
|
draggedIndices = Array.isArray(payload.indices)
|
||||||
|
? payload.indices.filter(i => Number.isInteger(i))
|
||||||
|
: [];
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a track container manager
|
* Create a track container manager
|
||||||
|
|
@ -41,6 +74,7 @@
|
||||||
* @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
|
* @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
|
||||||
* @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder)
|
* @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder)
|
||||||
* @param {string} [config.playlistId] - Playlist ID (for playlist type)
|
* @param {string} [config.playlistId] - Playlist ID (for playlist type)
|
||||||
|
* @param {string} [config.emptyMessage] - Message when the container has no tracks
|
||||||
* @param {Function} [config.onRender] - Callback after render
|
* @param {Function} [config.onRender] - Callback after render
|
||||||
*/
|
*/
|
||||||
function createContainer(config) {
|
function createContainer(config) {
|
||||||
|
|
@ -52,6 +86,7 @@
|
||||||
canReorder = false,
|
canReorder = false,
|
||||||
isPlaylistOwner = false,
|
isPlaylistOwner = false,
|
||||||
playlistId = null,
|
playlistId = null,
|
||||||
|
emptyMessage = null,
|
||||||
onRender
|
onRender
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
|
|
@ -85,9 +120,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentTracks.length === 0) {
|
if (currentTracks.length === 0) {
|
||||||
const emptyMsg = type === 'queue' ? 'Queue empty - drag tracks here'
|
const emptyMsg = emptyMessage || (type === 'queue' ? 'Queue empty - drag tracks here'
|
||||||
: type === 'library' ? 'No tracks'
|
: type === 'library' ? 'No tracks'
|
||||||
: 'No tracks - drag here to add';
|
: 'No tracks - drag here to add');
|
||||||
element.innerHTML = `<div class="empty">${emptyMsg}</div>`;
|
element.innerHTML = `<div class="empty">${emptyMsg}</div>`;
|
||||||
if (onRender) onRender();
|
if (onRender) onRender();
|
||||||
return;
|
return;
|
||||||
|
|
@ -131,7 +166,7 @@
|
||||||
|
|
||||||
function wirePlaylistContainerDrop(container) {
|
function wirePlaylistContainerDrop(container) {
|
||||||
container.ondragover = (e) => {
|
container.ondragover = (e) => {
|
||||||
if (dragSource === 'queue' || dragSource === 'library' || dragSource === 'playlist') {
|
if (hasTrackDrag(e, ['queue', 'library', 'playlist'])) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
|
e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
|
||||||
container.classList.add("drop-target");
|
container.classList.add("drop-target");
|
||||||
|
|
@ -151,6 +186,7 @@
|
||||||
el.classList.remove("drop-above", "drop-below");
|
el.classList.remove("drop-above", "drop-below");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
restoreDragState(e);
|
||||||
if (draggedTrackIds.length > 0) {
|
if (draggedTrackIds.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
@ -287,6 +323,19 @@
|
||||||
showContextMenu(e, track, originalIndex, canEditQueue);
|
showContextMenu(e, track, originalIndex, canEditQueue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const menuBtn = div.querySelector(".track-menu-btn");
|
||||||
|
if (menuBtn) {
|
||||||
|
menuBtn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const rect = menuBtn.getBoundingClientRect();
|
||||||
|
showContextMenu({
|
||||||
|
preventDefault() {},
|
||||||
|
clientX: Math.min(rect.right, window.innerWidth - 8),
|
||||||
|
clientY: Math.min(rect.bottom, window.innerHeight - 8)
|
||||||
|
}, track, originalIndex, canEditQueue);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Drag start/end handlers - library/playlist always (read access), queue needs edit permission
|
// Drag start/end handlers - library/playlist always (read access), queue needs edit permission
|
||||||
const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue);
|
const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue);
|
||||||
if (canDrag) {
|
if (canDrag) {
|
||||||
|
|
@ -376,6 +425,11 @@
|
||||||
div.classList.add("dragging");
|
div.classList.add("dragging");
|
||||||
// Use "copyMove" to allow both copy and move operations
|
// Use "copyMove" to allow both copy and move operations
|
||||||
e.dataTransfer.effectAllowed = "copyMove";
|
e.dataTransfer.effectAllowed = "copyMove";
|
||||||
|
e.dataTransfer.setData(TRACK_DRAG_TYPE, JSON.stringify({
|
||||||
|
source: type,
|
||||||
|
trackIds: draggedTrackIds,
|
||||||
|
indices: draggedIndices
|
||||||
|
}));
|
||||||
e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`);
|
e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,6 +453,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(e, div, index) {
|
function handleDragOver(e, div, index) {
|
||||||
|
if (!hasTrackDrag(e, ['queue', 'library', 'playlist'])) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Set drop effect based on source
|
// Set drop effect based on source
|
||||||
|
|
@ -429,6 +484,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(e, div, index) {
|
function handleDrop(e, div, index) {
|
||||||
|
restoreDragState(e);
|
||||||
console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`);
|
console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -474,7 +530,7 @@
|
||||||
|
|
||||||
function wireQueueContainerDrop(container) {
|
function wireQueueContainerDrop(container) {
|
||||||
container.ondragover = (e) => {
|
container.ondragover = (e) => {
|
||||||
if (dragSource === 'library' || dragSource === 'playlist') {
|
if (hasTrackDrag(e, ['library', 'playlist'])) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
if (M.queue.length === 0) {
|
if (M.queue.length === 0) {
|
||||||
|
|
@ -491,6 +547,7 @@
|
||||||
|
|
||||||
container.ondrop = (e) => {
|
container.ondrop = (e) => {
|
||||||
container.classList.remove("drop-target");
|
container.classList.remove("drop-target");
|
||||||
|
restoreDragState(e);
|
||||||
if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
|
if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
|
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
|
||||||
|
|
@ -545,6 +602,11 @@
|
||||||
if (type === 'queue') {
|
if (type === 'queue') {
|
||||||
// Jump to track in queue
|
// Jump to track in queue
|
||||||
if (M.synced && M.currentChannelId) {
|
if (M.synced && M.currentChannelId) {
|
||||||
|
if (!M.canControl()) {
|
||||||
|
M.flashPermissionDenied();
|
||||||
|
M.showToast("Sign in to control playback", "warning", 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -617,7 +679,7 @@
|
||||||
const menuItems = [];
|
const menuItems = [];
|
||||||
|
|
||||||
// Play (queue only, single track or single selection)
|
// Play (queue only, single track or single selection)
|
||||||
if (type === 'queue' && selectedCount === 1) {
|
if (type === 'queue' && selectedCount === 1 && (canEditQueue || !M.synced)) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
label: "▶ Play",
|
label: "▶ Play",
|
||||||
action: () => playTrack(track, index)
|
action: () => playTrack(track, index)
|
||||||
|
|
@ -701,7 +763,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to Playlist
|
// Add to Playlist
|
||||||
if (M.playlists && !M.currentUser?.is_guest) {
|
if (M.playlists && !M.currentUser?.isGuest) {
|
||||||
const submenu = M.playlists.showAddToPlaylistMenu(idsForAction);
|
const submenu = M.playlists.showAddToPlaylistMenu(idsForAction);
|
||||||
if (submenu && submenu.length > 0) {
|
if (submenu && submenu.length > 0) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
|
|
@ -870,10 +932,10 @@
|
||||||
// Adjust if off-screen
|
// Adjust if off-screen
|
||||||
const rect = menu.getBoundingClientRect();
|
const rect = menu.getBoundingClientRect();
|
||||||
if (rect.right > window.innerWidth) {
|
if (rect.right > window.innerWidth) {
|
||||||
menu.style.left = (window.innerWidth - rect.width - 5) + "px";
|
menu.style.left = Math.max(5, window.innerWidth - rect.width - 5) + "px";
|
||||||
}
|
}
|
||||||
if (rect.bottom > window.innerHeight) {
|
if (rect.bottom > window.innerHeight) {
|
||||||
menu.style.top = (window.innerHeight - rect.height - 5) + "px";
|
menu.style.top = Math.max(5, window.innerHeight - rect.height - 5) + "px";
|
||||||
}
|
}
|
||||||
|
|
||||||
activeContextMenu = menu;
|
activeContextMenu = menu;
|
||||||
|
|
|
||||||
22
public/ui.js
22
public/ui.js
|
|
@ -14,8 +14,9 @@
|
||||||
// Update general UI state
|
// Update general UI state
|
||||||
M.updateUI = function() {
|
M.updateUI = function() {
|
||||||
const isConnecting = M.wantSync && !M.synced;
|
const isConnecting = M.wantSync && !M.synced;
|
||||||
|
const playbackBlocked = M.synced && M.playbackBlocked && !M.serverPaused;
|
||||||
// While connecting, treat as not playing (paused state)
|
// While connecting, treat as not playing (paused state)
|
||||||
const isPlaying = M.synced ? !M.serverPaused : (!isConnecting && !M.audio.paused);
|
const isPlaying = M.synced ? (!M.serverPaused && !M.audio.paused && !playbackBlocked) : (!isConnecting && !M.audio.paused);
|
||||||
M.$("#btn-sync").classList.toggle("synced", M.wantSync);
|
M.$("#btn-sync").classList.toggle("synced", M.wantSync);
|
||||||
M.$("#btn-sync").classList.toggle("connected", M.synced);
|
M.$("#btn-sync").classList.toggle("connected", M.synced);
|
||||||
M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync";
|
M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync";
|
||||||
|
|
@ -25,11 +26,22 @@
|
||||||
M.$("#progress-bar").classList.toggle("local", !M.synced);
|
M.$("#progress-bar").classList.toggle("local", !M.synced);
|
||||||
M.$("#progress-bar").classList.toggle("muted", M.audio.volume === 0);
|
M.$("#progress-bar").classList.toggle("muted", M.audio.volume === 0);
|
||||||
M.$("#btn-mute").textContent = M.audio.volume === 0 ? "🔇" : "🔊";
|
M.$("#btn-mute").textContent = M.audio.volume === 0 ? "🔇" : "🔊";
|
||||||
M.$("#status-icon").textContent = isPlaying ? "⏸" : "▶";
|
const statusIcon = M.$("#status-icon");
|
||||||
|
statusIcon.textContent = isPlaying ? "⏸" : "▶";
|
||||||
|
statusIcon.classList.toggle("playback-blocked", playbackBlocked);
|
||||||
|
statusIcon.title = playbackBlocked ? "Resume this channel" : (isPlaying ? "Pause" : "Play");
|
||||||
|
|
||||||
// Show/hide controls based on permissions
|
// Show/hide controls based on permissions
|
||||||
const hasControl = M.canControl();
|
const hasControl = M.canControl();
|
||||||
M.$("#status-icon").style.cursor = hasControl || !M.synced ? "pointer" : "default";
|
const controlsDisabled = M.synced && !hasControl;
|
||||||
|
["#status-icon", "#btn-prev", "#btn-next", "#btn-mode", "#progress-container"].forEach(sel => {
|
||||||
|
const el = M.$(sel);
|
||||||
|
if (!el) return;
|
||||||
|
const disabled = controlsDisabled && !(sel === "#status-icon" && playbackBlocked);
|
||||||
|
el.classList.toggle("control-disabled", disabled);
|
||||||
|
if ("ariaDisabled" in el) el.ariaDisabled = disabled ? "true" : "false";
|
||||||
|
});
|
||||||
|
statusIcon.style.cursor = controlsDisabled && !playbackBlocked ? "not-allowed" : "pointer";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update auth-related UI
|
// Update auth-related UI
|
||||||
|
|
@ -49,6 +61,7 @@
|
||||||
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
|
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
|
||||||
// Re-render channel list to update rename/delete buttons
|
// Re-render channel list to update rename/delete buttons
|
||||||
if (M.renderChannelList) M.renderChannelList();
|
if (M.renderChannelList) M.renderChannelList();
|
||||||
|
if (M.updatePermissionUI) M.updatePermissionUI();
|
||||||
} else {
|
} else {
|
||||||
M.$("#login-panel").classList.remove("hidden");
|
M.$("#login-panel").classList.remove("hidden");
|
||||||
M.$("#player-content").classList.remove("visible");
|
M.$("#player-content").classList.remove("visible");
|
||||||
|
|
@ -68,6 +81,7 @@
|
||||||
} else {
|
} else {
|
||||||
M.$("#guest-section").classList.add("hidden");
|
M.$("#guest-section").classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
if (M.updatePermissionUI) M.updatePermissionUI();
|
||||||
}
|
}
|
||||||
M.updateUI();
|
M.updateUI();
|
||||||
};
|
};
|
||||||
|
|
@ -177,7 +191,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
// Restore last active tab
|
// Restore last active tab
|
||||||
const savedTab = localStorage.getItem("blastoise_mobile_tab") || "channels-panel";
|
const savedTab = localStorage.getItem("blastoise_mobile_tab") || "queue-panel";
|
||||||
|
|
||||||
function setActiveTab(panelId) {
|
function setActiveTab(panelId) {
|
||||||
tabs.forEach(t => t.classList.toggle("active", t.dataset.panel === panelId));
|
tabs.forEach(t => t.classList.toggle("active", t.dataset.panel === panelId));
|
||||||
|
|
|
||||||
|
|
@ -106,11 +106,7 @@
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.type === "playlist") {
|
if (data.type === "playlist") {
|
||||||
// Ask user to confirm playlist download
|
const queuePlaylist = async () => {
|
||||||
const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.\n\nA playlist will be created automatically.`);
|
|
||||||
|
|
||||||
if (confirmed) {
|
|
||||||
// Confirm playlist download with title for auto-playlist creation
|
|
||||||
const confirmRes = await fetch("/api/fetch/confirm", {
|
const confirmRes = await fetch("/api/fetch/confirm", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -128,7 +124,13 @@
|
||||||
const err = await confirmRes.json().catch(() => ({}));
|
const err = await confirmRes.json().catch(() => ({}));
|
||||||
M.showToast(err.error || "Failed to queue playlist", "error");
|
M.showToast(err.error || "Failed to queue playlist", "error");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
M.showConfirmToast(
|
||||||
|
`Download playlist "${data.title}" with ${data.count} items? Items download slowly and a playlist will be created automatically.`,
|
||||||
|
queuePlaylist,
|
||||||
|
{ confirmText: "Download", duration: 20000 }
|
||||||
|
);
|
||||||
} else if (data.type === "single") {
|
} else if (data.type === "single") {
|
||||||
M.showToast(`Queued: ${data.title}`);
|
M.showToast(`Queued: ${data.title}`);
|
||||||
// Task will be created by WebSocket progress messages
|
// Task will be created by WebSocket progress messages
|
||||||
|
|
@ -164,17 +166,22 @@
|
||||||
// Drag and drop on library panel
|
// Drag and drop on library panel
|
||||||
let dragCounter = 0;
|
let dragCounter = 0;
|
||||||
|
|
||||||
|
function isFileDrag(e) {
|
||||||
|
return [...(e.dataTransfer?.types || [])].includes("Files");
|
||||||
|
}
|
||||||
|
|
||||||
libraryPanel.ondragenter = (e) => {
|
libraryPanel.ondragenter = (e) => {
|
||||||
if (!M.currentUser) return;
|
if (!M.currentUser) return;
|
||||||
if (!e.dataTransfer.types.includes("Files")) return;
|
if (!isFileDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragCounter++;
|
dragCounter++;
|
||||||
dropzone.classList.remove("hidden");
|
dropzone.classList.remove("hidden");
|
||||||
};
|
};
|
||||||
|
|
||||||
libraryPanel.ondragleave = (e) => {
|
libraryPanel.ondragleave = (e) => {
|
||||||
|
if (!isFileDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragCounter--;
|
dragCounter = Math.max(0, dragCounter - 1);
|
||||||
if (dragCounter === 0) {
|
if (dragCounter === 0) {
|
||||||
dropzone.classList.add("hidden");
|
dropzone.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
@ -182,12 +189,13 @@
|
||||||
|
|
||||||
libraryPanel.ondragover = (e) => {
|
libraryPanel.ondragover = (e) => {
|
||||||
if (!M.currentUser) return;
|
if (!M.currentUser) return;
|
||||||
if (!e.dataTransfer.types.includes("Files")) return;
|
if (!isFileDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
};
|
};
|
||||||
|
|
||||||
libraryPanel.ondrop = (e) => {
|
libraryPanel.ondrop = (e) => {
|
||||||
|
if (!isFileDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragCounter = 0;
|
dragCounter = 0;
|
||||||
dropzone.classList.add("hidden");
|
dropzone.classList.add("hidden");
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@
|
||||||
// Toast history
|
// Toast history
|
||||||
M.toastHistory = [];
|
M.toastHistory = [];
|
||||||
|
|
||||||
|
function pruneVisibleToasts(container) {
|
||||||
|
const limit = window.matchMedia("(max-width: 768px)").matches ? 2 : 3;
|
||||||
|
const toasts = [...container.querySelectorAll(".toast:not(.toast-confirm)")];
|
||||||
|
while (toasts.length > limit) {
|
||||||
|
toasts.shift().remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Toast notifications (log style - multiple visible)
|
// Toast notifications (log style - multiple visible)
|
||||||
M.showToast = function(message, type = "info", duration = 5000) {
|
M.showToast = function(message, type = "info", duration = 5000) {
|
||||||
const container = M.$("#toast-container");
|
const container = M.$("#toast-container");
|
||||||
|
|
@ -25,6 +33,7 @@
|
||||||
toast.className = "toast toast-" + type;
|
toast.className = "toast toast-" + type;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
|
pruneVisibleToasts(container);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.add("fade-out");
|
toast.classList.add("fade-out");
|
||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
|
@ -39,6 +48,56 @@
|
||||||
M.updateToastHistory();
|
M.updateToastHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
M.showConfirmToast = function(message, onConfirm, options = {}) {
|
||||||
|
const container = M.$("#toast-container");
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = "toast toast-warning toast-confirm";
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.className = "toast-confirm-message";
|
||||||
|
text.textContent = message;
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "toast-actions";
|
||||||
|
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.className = "toast-action-cancel";
|
||||||
|
cancel.textContent = options.cancelText || "Cancel";
|
||||||
|
|
||||||
|
const confirm = document.createElement("button");
|
||||||
|
confirm.type = "button";
|
||||||
|
confirm.className = "toast-action-confirm";
|
||||||
|
confirm.textContent = options.confirmText || "Confirm";
|
||||||
|
|
||||||
|
actions.append(cancel, confirm);
|
||||||
|
toast.append(text, actions);
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const close = () => {
|
||||||
|
toast.classList.add("fade-out");
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
};
|
||||||
|
const finish = async (confirmed) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
close();
|
||||||
|
if (confirmed) await onConfirm();
|
||||||
|
};
|
||||||
|
|
||||||
|
cancel.onclick = () => finish(false);
|
||||||
|
confirm.onclick = () => finish(true);
|
||||||
|
setTimeout(() => finish(false), options.duration || 12000);
|
||||||
|
|
||||||
|
M.toastHistory.push({
|
||||||
|
message: `Confirm: ${message}`,
|
||||||
|
type: "warning",
|
||||||
|
time: new Date()
|
||||||
|
});
|
||||||
|
M.updateToastHistory();
|
||||||
|
};
|
||||||
|
|
||||||
// Update toast history panel
|
// Update toast history panel
|
||||||
M.updateToastHistory = function() {
|
M.updateToastHistory = function() {
|
||||||
const list = M.$("#toast-history-list");
|
const list = M.$("#toast-history-list");
|
||||||
|
|
@ -112,10 +171,29 @@
|
||||||
M.canControl = function() {
|
M.canControl = function() {
|
||||||
if (!M.currentUser) return false;
|
if (!M.currentUser) return false;
|
||||||
if (M.currentUser.isAdmin) return true;
|
if (M.currentUser.isAdmin) return true;
|
||||||
|
if (M.currentUser.isGuest) return false;
|
||||||
return M.currentUser.permissions?.some(p =>
|
return M.currentUser.permissions?.some(p =>
|
||||||
p.resource_type === "channel" &&
|
p.resource_type === "channel" &&
|
||||||
(p.resource_id === M.currentChannelId || p.resource_id === null) &&
|
(p.resource_id === M.currentChannelId || p.resource_id === null) &&
|
||||||
p.permission === "control"
|
p.permission === "control"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
M.canCreateUserContent = function() {
|
||||||
|
return !!M.currentUser && !M.currentUser.isGuest;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.updatePermissionUI = function() {
|
||||||
|
const canCreate = M.canCreateUserContent();
|
||||||
|
const newChannel = M.$("#btn-new-channel");
|
||||||
|
const newPlaylist = M.$("#btn-new-playlist");
|
||||||
|
const fetchUrl = M.$("#btn-fetch-url");
|
||||||
|
|
||||||
|
if (newChannel) newChannel.classList.toggle("hidden", !canCreate);
|
||||||
|
if (newPlaylist) newPlaylist.classList.toggle("hidden", !canCreate);
|
||||||
|
if (fetchUrl) {
|
||||||
|
const ytdlpReady = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available;
|
||||||
|
fetchUrl.style.display = canCreate && ytdlpReady ? "" : "none";
|
||||||
|
}
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,781 @@
|
||||||
|
// MusicRoom - Audio visualizer
|
||||||
|
// Pure frontend Web Audio + canvas display for the shared audio element.
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const M = window.MusicRoom;
|
||||||
|
|
||||||
|
const MODES = new Set(["off", "bars", "wave", "radial", "pulse"]);
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 400;
|
||||||
|
const TWO_PI = Math.PI * 2;
|
||||||
|
const PARTICLE_COUNT = 140;
|
||||||
|
|
||||||
|
let mode = "off";
|
||||||
|
let shell = null;
|
||||||
|
let canvas = null;
|
||||||
|
let ctx = null;
|
||||||
|
let selector = null;
|
||||||
|
let fullscreenButton = null;
|
||||||
|
let audioContext = null;
|
||||||
|
let source = null;
|
||||||
|
let analyser = null;
|
||||||
|
let frequencyData = null;
|
||||||
|
let waveformData = null;
|
||||||
|
let animationId = 0;
|
||||||
|
let graphUnavailable = false;
|
||||||
|
let particles = [];
|
||||||
|
let rememberedWidth = 0;
|
||||||
|
let rememberedHeight = 0;
|
||||||
|
let beatMemory = 0;
|
||||||
|
let energyMemory = 0;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
function normalizeMode(value) {
|
||||||
|
return MODES.has(value) ? value : "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeStoredValue(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
M.clearCookieValue(M.VISUALIZER_STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSavedMode() {
|
||||||
|
const cookieMode = normalizeMode(decodeStoredValue(M.getCookieValue(M.VISUALIZER_STORAGE_KEY)));
|
||||||
|
if (cookieMode !== "off") return cookieMode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedMode = normalizeMode(localStorage.getItem(M.VISUALIZER_STORAGE_KEY));
|
||||||
|
if (storedMode !== "off") {
|
||||||
|
M.setCookieValue(M.VISUALIZER_STORAGE_KEY, storedMode, COOKIE_MAX_AGE);
|
||||||
|
return storedMode;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberMode(nextMode) {
|
||||||
|
const normalized = normalizeMode(nextMode);
|
||||||
|
if (normalized === "off") {
|
||||||
|
M.clearCookieValue(M.VISUALIZER_STORAGE_KEY);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(M.VISUALIZER_STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
M.setCookieValue(M.VISUALIZER_STORAGE_KEY, normalized, COOKIE_MAX_AGE);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(M.VISUALIZER_STORAGE_KEY, normalized);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColors() {
|
||||||
|
const styles = getComputedStyle(shell || document.body);
|
||||||
|
const value = (name) => {
|
||||||
|
const raw = styles.getPropertyValue(name).trim();
|
||||||
|
return raw && !raw.includes("var(") ? raw : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
accent: value("--visualizer-accent") || value("--accent") || "#44ee88",
|
||||||
|
secondary: value("--visualizer-secondary") || value("--warning") || "#66aaff",
|
||||||
|
background: value("--visualizer-bg") || "rgba(10, 10, 10, 0.82)",
|
||||||
|
muted: value("--visualizer-muted") || "rgba(255, 255, 255, 0.16)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
const width = Math.max(1, Math.round(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.round(rect.height * dpr));
|
||||||
|
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
if (rememberedWidth !== canvas.clientWidth || rememberedHeight !== canvas.clientHeight) {
|
||||||
|
rememberedWidth = canvas.clientWidth;
|
||||||
|
rememberedHeight = canvas.clientHeight;
|
||||||
|
seedParticles(rememberedWidth, rememberedHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCanvas() {
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
resizeCanvas();
|
||||||
|
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedParticles(width, height) {
|
||||||
|
particles = [];
|
||||||
|
const safeWidth = Math.max(width, 1);
|
||||||
|
const safeHeight = Math.max(height, 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: Math.random() * safeWidth,
|
||||||
|
y: Math.random() * safeHeight,
|
||||||
|
px: Math.random() * safeWidth,
|
||||||
|
py: Math.random() * safeHeight,
|
||||||
|
vx: (Math.random() - 0.5) * 0.55,
|
||||||
|
vy: (Math.random() - 0.5) * 0.55,
|
||||||
|
size: 0.8 + Math.random() * 2.4,
|
||||||
|
phase: Math.random() * TWO_PI,
|
||||||
|
spin: (Math.random() - 0.5) * 0.04,
|
||||||
|
lane: Math.random()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAudioGraph() {
|
||||||
|
if (source) return true;
|
||||||
|
if (graphUnavailable) return false;
|
||||||
|
|
||||||
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioContextCtor) {
|
||||||
|
graphUnavailable = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioContext = audioContext || new AudioContextCtor();
|
||||||
|
analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 2048;
|
||||||
|
analyser.minDecibels = -96;
|
||||||
|
analyser.maxDecibels = -10;
|
||||||
|
analyser.smoothingTimeConstant = 0.68;
|
||||||
|
|
||||||
|
source = audioContext.createMediaElementSource(M.audio);
|
||||||
|
source.connect(analyser);
|
||||||
|
analyser.connect(audioContext.destination);
|
||||||
|
|
||||||
|
frequencyData = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
waveformData = new Uint8Array(analyser.fftSize);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
graphUnavailable = true;
|
||||||
|
console.warn("[Visualizer] Unable to attach audio graph", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVisualizer() {
|
||||||
|
if (animationId || mode === "off") return;
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopVisualizer() {
|
||||||
|
if (animationId) {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
animationId = 0;
|
||||||
|
}
|
||||||
|
clearCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(data, start, end) {
|
||||||
|
let total = 0;
|
||||||
|
const first = Math.max(0, Math.min(start, data.length));
|
||||||
|
const last = Math.max(first + 1, Math.min(end, data.length));
|
||||||
|
for (let i = first; i < last; i++) total += data[i];
|
||||||
|
return total / (last - first);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandAverage(startRatio, endRatio) {
|
||||||
|
if (!frequencyData) return 0;
|
||||||
|
const start = Math.floor(Math.max(0, Math.min(1, startRatio)) * frequencyData.length);
|
||||||
|
const end = Math.floor(Math.max(0, Math.min(1, endRatio)) * frequencyData.length);
|
||||||
|
return average(frequencyData, start, Math.max(start + 1, end)) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandValue(index, count) {
|
||||||
|
if (!frequencyData) return 0;
|
||||||
|
const a = Math.pow(index / count, 1.75);
|
||||||
|
const b = Math.pow((index + 1) / count, 1.75);
|
||||||
|
const start = Math.floor(a * frequencyData.length);
|
||||||
|
const end = Math.max(start + 2, Math.floor(b * frequencyData.length));
|
||||||
|
const raw = average(frequencyData, start, end) / 255;
|
||||||
|
return Math.pow(raw, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAudioState(timestamp, hasLiveGraph) {
|
||||||
|
if (hasLiveGraph) {
|
||||||
|
analyser.getByteFrequencyData(frequencyData);
|
||||||
|
analyser.getByteTimeDomainData(waveformData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bass = hasLiveGraph ? bandAverage(0.005, 0.06) : 0.55 + Math.sin(timestamp / 310) * 0.25;
|
||||||
|
const mids = hasLiveGraph ? bandAverage(0.06, 0.36) : 0.45 + Math.sin(timestamp / 470 + 1.4) * 0.22;
|
||||||
|
const treble = hasLiveGraph ? bandAverage(0.36, 0.92) : 0.38 + Math.sin(timestamp / 230 + 2.2) * 0.18;
|
||||||
|
const energy = Math.max(0, Math.min(1, bass * 0.42 + mids * 0.36 + treble * 0.22));
|
||||||
|
|
||||||
|
beatMemory = Math.max(beatMemory * 0.9, bass);
|
||||||
|
energyMemory = energyMemory * 0.82 + energy * 0.18;
|
||||||
|
|
||||||
|
return {
|
||||||
|
bass,
|
||||||
|
mids,
|
||||||
|
treble,
|
||||||
|
energy,
|
||||||
|
beat: beatMemory,
|
||||||
|
smooth: energyMemory,
|
||||||
|
live: hasLiveGraph,
|
||||||
|
time: timestamp / 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillWithTrails(width, height, colors, state) {
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.globalAlpha = state.live ? 0.24 : 0.42;
|
||||||
|
ctx.fillStyle = colors.background;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
const glow = ctx.createRadialGradient(
|
||||||
|
width * (0.48 + Math.sin(state.time * 0.4) * 0.08),
|
||||||
|
height * (0.52 + Math.cos(state.time * 0.33) * 0.14),
|
||||||
|
1,
|
||||||
|
width * 0.5,
|
||||||
|
height * 0.5,
|
||||||
|
Math.max(width, height) * (0.4 + state.energy * 0.25)
|
||||||
|
);
|
||||||
|
glow.addColorStop(0, colors.secondary);
|
||||||
|
glow.addColorStop(0.38, colors.accent);
|
||||||
|
glow.addColorStop(1, "transparent");
|
||||||
|
ctx.globalAlpha = 0.08 + state.energy * 0.1;
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(width, height, colors, state) {
|
||||||
|
const spacing = Math.max(12, Math.min(34, width / 34));
|
||||||
|
const offset = (state.time * (30 + state.treble * 80)) % spacing;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.11 + state.energy * 0.08;
|
||||||
|
ctx.strokeStyle = colors.muted;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let x = -spacing + offset; x < width + spacing; x += spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x + Math.sin(state.time + x * 0.01) * 18, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = height + spacing - offset; y > -spacing; y -= spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y + Math.cos(state.time + y * 0.02) * 8);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.09 + state.beat * 0.08;
|
||||||
|
ctx.strokeStyle = colors.secondary;
|
||||||
|
for (let y = 0; y < height; y += 4) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticles(width, height, colors, state, intensity = 1) {
|
||||||
|
if (!particles.length) seedParticles(width, height);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
|
||||||
|
for (const p of particles) {
|
||||||
|
p.px = p.x;
|
||||||
|
p.py = p.y;
|
||||||
|
|
||||||
|
const wave = Math.sin(state.time * 2.2 + p.phase) * 0.65 + Math.cos(state.time + p.lane * 8) * 0.35;
|
||||||
|
const speed = (0.6 + state.energy * 3.2) * intensity;
|
||||||
|
p.vx += Math.cos(p.phase + state.time * (0.8 + p.lane)) * 0.015 * speed;
|
||||||
|
p.vy += Math.sin(p.phase * 1.7 + state.time * 1.15) * 0.018 * speed;
|
||||||
|
p.x += p.vx * speed + wave * state.treble * 1.8;
|
||||||
|
p.y += p.vy * speed + Math.sin(state.time * 1.4 + p.x * 0.015) * state.bass * 1.6;
|
||||||
|
p.vx *= 0.965;
|
||||||
|
p.vy *= 0.965;
|
||||||
|
p.phase += p.spin + state.energy * 0.012;
|
||||||
|
|
||||||
|
if (p.x < -20) p.x = width + 20;
|
||||||
|
if (p.x > width + 20) p.x = -20;
|
||||||
|
if (p.y < -20) p.y = height + 20;
|
||||||
|
if (p.y > height + 20) p.y = -20;
|
||||||
|
|
||||||
|
const useSecondary = p.lane > 0.52;
|
||||||
|
ctx.strokeStyle = useSecondary ? colors.secondary : colors.accent;
|
||||||
|
ctx.fillStyle = useSecondary ? colors.secondary : colors.accent;
|
||||||
|
ctx.globalAlpha = 0.14 + state.energy * 0.34;
|
||||||
|
ctx.lineWidth = Math.max(0.8, p.size * (0.5 + state.beat * 1.4));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.px, p.py);
|
||||||
|
ctx.lineTo(p.x, p.y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (frame % 2 === 0 || state.energy > 0.52) {
|
||||||
|
ctx.globalAlpha = 0.16 + state.treble * 0.45;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size * (0.7 + state.beat * 1.8), 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSpectrumRibbon(width, height, colors, state, flipped = false) {
|
||||||
|
const count = Math.max(44, Math.min(150, Math.floor(width / 7)));
|
||||||
|
const step = width / count;
|
||||||
|
const baseY = flipped ? height * 0.74 : height * 0.26;
|
||||||
|
const direction = flipped ? -1 : 1;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = flipped ? colors.secondary : colors.accent;
|
||||||
|
ctx.fillStyle = flipped ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 1.4 + state.energy * 2.4;
|
||||||
|
ctx.globalAlpha = 0.33 + state.energy * 0.25;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
for (let i = 0; i <= count; i++) {
|
||||||
|
const value = state.live ? bandValue(i % count, count) : 0.35 + Math.sin(state.time * 3 + i * 0.27) * 0.26;
|
||||||
|
const x = i * step;
|
||||||
|
const y = baseY + direction * (value * height * 0.3 + Math.sin(state.time * 2 + i * 0.18) * height * 0.035);
|
||||||
|
if (i === 0) ctx.moveTo(x, baseY);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.lineTo(width, baseY);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.78;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWaveformRibbon(width, height, colors, state, amplitude = 0.32, offset = 0, color = colors.accent) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.2 + state.energy * 3.2;
|
||||||
|
ctx.globalAlpha = 0.4 + state.energy * 0.36;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
const samples = state.live && waveformData ? waveformData.length : 720;
|
||||||
|
const step = Math.max(2, Math.floor(samples / Math.max(90, Math.min(240, width / 4))));
|
||||||
|
let first = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i += step) {
|
||||||
|
const t = i / Math.max(1, samples - 1);
|
||||||
|
const raw = state.live ? (waveformData[i] - 128) / 128 : Math.sin(t * TWO_PI * 5 + state.time * 3 + offset) * 0.55 + Math.sin(t * TWO_PI * 17 - state.time * 2) * 0.15;
|
||||||
|
const x = t * width;
|
||||||
|
const y = height * 0.5 + raw * height * amplitude + Math.sin(t * TWO_PI * 2 + state.time + offset) * state.bass * height * 0.08;
|
||||||
|
if (first) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
first = false;
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBars(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
drawSpectrumRibbon(width, height, colors, state, false);
|
||||||
|
drawSpectrumRibbon(width, height, colors, state, true);
|
||||||
|
|
||||||
|
const count = Math.max(56, Math.min(180, Math.floor(width / 5)));
|
||||||
|
const gap = Math.max(1, Math.min(3, width / 380));
|
||||||
|
const barWidth = Math.max(2, width / count - gap);
|
||||||
|
const centerY = height / 2;
|
||||||
|
const gradient = ctx.createLinearGradient(0, height, 0, 0);
|
||||||
|
gradient.addColorStop(0, colors.secondary);
|
||||||
|
gradient.addColorStop(0.5, colors.accent);
|
||||||
|
gradient.addColorStop(1, colors.secondary);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.shadowColor = colors.accent;
|
||||||
|
ctx.shadowBlur = 10 + state.beat * 20;
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const value = state.live ? bandValue(i, count) : 0.28 + Math.sin(state.time * 4 + i * 0.21) * 0.25 + Math.sin(state.time * 2.1 + i * 0.07) * 0.16;
|
||||||
|
const pulse = Math.max(0, Math.min(1, value + state.beat * 0.18));
|
||||||
|
const barHeight = Math.max(2, pulse * height * 0.47);
|
||||||
|
const x = i * (barWidth + gap);
|
||||||
|
const skew = Math.sin(state.time * 3 + i * 0.25) * state.treble * 5;
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.45 + pulse * 0.55;
|
||||||
|
ctx.fillRect(x + skew, centerY - barHeight, barWidth, barHeight);
|
||||||
|
ctx.fillRect(x - skew, centerY, barWidth, barHeight);
|
||||||
|
|
||||||
|
if (i % 3 === 0) {
|
||||||
|
ctx.globalAlpha = 0.35 + state.treble * 0.4;
|
||||||
|
ctx.fillRect(x, centerY - barHeight - 4 - state.treble * 9, barWidth, 2);
|
||||||
|
ctx.fillRect(x, centerY + barHeight + 2 + state.treble * 9, barWidth, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.18, 0, colors.secondary);
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.1, Math.PI, colors.accent);
|
||||||
|
drawParticles(width, height, colors, state, 1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWave(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
drawSpectrumRibbon(width, height, colors, state, false);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
for (let layer = 0; layer < 6; layer++) {
|
||||||
|
const color = layer % 2 ? colors.secondary : colors.accent;
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.13 + layer * 0.045, layer * 0.9 + state.time * 0.4, color);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const nodes = 34;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
for (let i = 0; i < nodes; i++) {
|
||||||
|
const t = i / (nodes - 1);
|
||||||
|
const sampleIndex = state.live ? Math.floor(t * (waveformData.length - 1)) : 0;
|
||||||
|
const wave = state.live ? (waveformData[sampleIndex] - 128) / 128 : Math.sin(t * TWO_PI * 8 + state.time * 3);
|
||||||
|
const value = state.live ? bandValue(i, nodes) : Math.abs(wave);
|
||||||
|
const x = t * width;
|
||||||
|
const y = height * 0.5 + wave * height * 0.28;
|
||||||
|
const radius = 2 + value * 13 + state.beat * 6;
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.14 + value * 0.55;
|
||||||
|
ctx.fillStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawParticles(width, height, colors, state, 1.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRadial(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const radius = Math.max(14, Math.min(width, height) * (0.14 + state.bass * 0.08));
|
||||||
|
const spokes = 192;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate(state.time * (0.18 + state.treble * 0.22));
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
|
||||||
|
for (let ring = 0; ring < 3; ring++) {
|
||||||
|
const ringRadius = radius + ring * Math.min(width, height) * 0.105 + state.beat * 18;
|
||||||
|
ctx.globalAlpha = 0.18 + ring * 0.08;
|
||||||
|
ctx.strokeStyle = ring % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 1 + state.energy * 2.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i <= spokes; i++) {
|
||||||
|
const value = state.live ? bandValue(i % spokes, spokes) : 0.34 + Math.sin(state.time * 4 + i * 0.16 + ring) * 0.24;
|
||||||
|
const angle = i / spokes * TWO_PI;
|
||||||
|
const waveRadius = ringRadius + value * Math.min(width, height) * (0.08 + ring * 0.035);
|
||||||
|
const x = Math.cos(angle) * waveRadius;
|
||||||
|
const y = Math.sin(angle) * waveRadius;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < spokes; i++) {
|
||||||
|
const value = state.live ? bandValue(i, spokes) : 0.28 + Math.sin(state.time * 6 + i * 0.23) * 0.24;
|
||||||
|
const angle = i / spokes * TWO_PI;
|
||||||
|
const start = radius * (0.68 + Math.sin(state.time + i) * 0.04);
|
||||||
|
const end = start + value * Math.min(width, height) * 0.42 + state.beat * 28;
|
||||||
|
|
||||||
|
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.globalAlpha = 0.16 + value * 0.72;
|
||||||
|
ctx.lineWidth = 0.9 + value * 3.8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(Math.cos(angle) * start, Math.sin(angle) * start);
|
||||||
|
ctx.lineTo(Math.cos(angle) * end, Math.sin(angle) * end);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.22 + state.beat * 0.26;
|
||||||
|
ctx.fillStyle = colors.accent;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, radius * (0.55 + state.beat * 0.45), 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawParticles(width, height, colors, state, 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPulse(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const maxRadius = Math.hypot(width, height) * 0.55;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const phase = (state.time * (0.18 + state.energy * 0.24) + i / 10) % 1;
|
||||||
|
const radius = phase * maxRadius;
|
||||||
|
ctx.globalAlpha = (1 - phase) * (0.1 + state.beat * 0.22);
|
||||||
|
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 1 + state.energy * 5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, TWO_PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rays = 96;
|
||||||
|
for (let i = 0; i < rays; i++) {
|
||||||
|
const value = state.live ? bandValue(i, rays) : 0.32 + Math.sin(state.time * 7 + i * 0.31) * 0.25;
|
||||||
|
const angle = i / rays * TWO_PI + state.time * 0.25;
|
||||||
|
const length = Math.min(width, height) * (0.16 + value * 0.7 + state.beat * 0.22);
|
||||||
|
const wobble = Math.sin(state.time * 3 + i * 0.4) * state.treble * 18;
|
||||||
|
ctx.globalAlpha = 0.15 + value * 0.58;
|
||||||
|
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 0.9 + value * 3.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + Math.cos(angle) * 8, cy + Math.sin(angle) * 8);
|
||||||
|
ctx.lineTo(cx + Math.cos(angle) * (length + wobble), cy + Math.sin(angle) * (length - wobble));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreRadius = Math.max(8, Math.min(width, height) * (0.1 + state.beat * 0.18));
|
||||||
|
ctx.globalAlpha = 0.3 + state.beat * 0.45;
|
||||||
|
ctx.fillStyle = colors.secondary;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, coreRadius * 2.2, 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.8;
|
||||||
|
ctx.fillStyle = colors.accent;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, coreRadius, 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.2, state.time, colors.secondary);
|
||||||
|
drawParticles(width, height, colors, state, 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawIdle(width, height, colors, timestamp) {
|
||||||
|
const state = readAudioState(timestamp, false);
|
||||||
|
fillWithTrails(width, height, colors, state);
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
|
||||||
|
if (mode === "radial") drawRadial(width, height, colors, state);
|
||||||
|
else if (mode === "pulse") drawPulse(width, height, colors, state);
|
||||||
|
else if (mode === "wave") drawWave(width, height, colors, state);
|
||||||
|
else drawBars(width, height, colors, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(timestamp) {
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
if (!canvas || !ctx || mode === "off") return;
|
||||||
|
|
||||||
|
frame++;
|
||||||
|
resizeCanvas();
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
const colors = getColors();
|
||||||
|
const hasLiveGraph = analyser
|
||||||
|
&& frequencyData
|
||||||
|
&& waveformData
|
||||||
|
&& audioContext?.state === "running"
|
||||||
|
&& !M.audio.paused
|
||||||
|
&& !!M.audio.src;
|
||||||
|
|
||||||
|
if (!hasLiveGraph) {
|
||||||
|
drawIdle(width, height, colors, timestamp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = readAudioState(timestamp, true);
|
||||||
|
fillWithTrails(width, height, colors, state);
|
||||||
|
|
||||||
|
if (mode === "wave") drawWave(width, height, colors, state);
|
||||||
|
else if (mode === "radial") drawRadial(width, height, colors, state);
|
||||||
|
else if (mode === "pulse") drawPulse(width, height, colors, state);
|
||||||
|
else drawBars(width, height, colors, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullscreen() {
|
||||||
|
return document.fullscreenElement === shell
|
||||||
|
|| document.webkitFullscreenElement === shell
|
||||||
|
|| shell?.classList.contains("is-fullscreen");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFullscreenButton() {
|
||||||
|
if (!fullscreenButton || !shell) return;
|
||||||
|
const active = isFullscreen();
|
||||||
|
shell.classList.toggle("is-fullscreen", active);
|
||||||
|
fullscreenButton.textContent = active ? "×" : "⛶";
|
||||||
|
fullscreenButton.title = active ? "Exit fullscreen visualizer" : "Fullscreen visualizer";
|
||||||
|
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (!shell) return;
|
||||||
|
if (mode === "off") M.applyVisualizer("bars");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement || document.webkitFullscreenElement || shell.classList.contains("is-fullscreen")) {
|
||||||
|
if (document.fullscreenElement && document.exitFullscreen) await document.exitFullscreen();
|
||||||
|
else if (document.webkitFullscreenElement && document.webkitExitFullscreen) await document.webkitExitFullscreen();
|
||||||
|
else shell.classList.remove("is-fullscreen");
|
||||||
|
} else if (shell.requestFullscreen) {
|
||||||
|
await shell.requestFullscreen();
|
||||||
|
} else if (shell.webkitRequestFullscreen) {
|
||||||
|
await shell.webkitRequestFullscreen();
|
||||||
|
} else {
|
||||||
|
shell.classList.add("is-fullscreen");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
shell.classList.toggle("is-fullscreen");
|
||||||
|
console.warn("[Visualizer] Fullscreen request failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
M.resumeVisualizer?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
M.applyVisualizer = function(nextMode, remember = true) {
|
||||||
|
mode = normalizeMode(nextMode);
|
||||||
|
if (selector) selector.value = mode;
|
||||||
|
if (shell) shell.classList.toggle("hidden", mode === "off");
|
||||||
|
document.getElementById("player-content")?.classList.toggle("visualizer-active", mode !== "off");
|
||||||
|
if (remember) rememberMode(mode);
|
||||||
|
|
||||||
|
if (mode === "off") {
|
||||||
|
stopVisualizer();
|
||||||
|
} else {
|
||||||
|
resizeCanvas();
|
||||||
|
startVisualizer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.resumeVisualizer = async function() {
|
||||||
|
if (mode === "off") return false;
|
||||||
|
if (!initAudioGraph()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (audioContext.state === "suspended") {
|
||||||
|
await audioContext.resume();
|
||||||
|
}
|
||||||
|
startVisualizer();
|
||||||
|
return audioContext.state === "running";
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Visualizer] Unable to resume audio context", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.visualizer = {
|
||||||
|
getMode: () => mode,
|
||||||
|
hasGraph: () => !!source,
|
||||||
|
isRunning: () => audioContext?.state === "running",
|
||||||
|
isFullscreen,
|
||||||
|
toggleFullscreen
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
shell = document.getElementById("visualizer-shell");
|
||||||
|
canvas = document.getElementById("visualizer-canvas");
|
||||||
|
selector = document.getElementById("visualizer-select");
|
||||||
|
fullscreenButton = document.getElementById("btn-visualizer-fullscreen");
|
||||||
|
ctx = canvas?.getContext("2d") || null;
|
||||||
|
|
||||||
|
M.applyVisualizer(getSavedMode(), false);
|
||||||
|
updateFullscreenButton();
|
||||||
|
|
||||||
|
if (selector) {
|
||||||
|
selector.onchange = () => {
|
||||||
|
M.applyVisualizer(selector.value);
|
||||||
|
M.resumeVisualizer();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenButton) {
|
||||||
|
fullscreenButton.onclick = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleFullscreen();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.ResizeObserver && shell) {
|
||||||
|
const observer = new ResizeObserver(resizeCanvas);
|
||||||
|
observer.observe(shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", resizeCanvas);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("fullscreenchange", () => {
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
});
|
||||||
|
document.addEventListener("webkitfullscreenchange", () => {
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", () => {
|
||||||
|
if (mode !== "off") M.resumeVisualizer();
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && shell?.classList.contains("is-fullscreen") && !document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||||
|
shell.classList.remove("is-fullscreen");
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode !== "off") M.resumeVisualizer();
|
||||||
|
});
|
||||||
|
|
||||||
|
M.audio.addEventListener("play", () => {
|
||||||
|
if (mode !== "off") {
|
||||||
|
if (audioContext && audioContext.state === "suspended") {
|
||||||
|
audioContext.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
startVisualizer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
M.audio.addEventListener("pause", () => {
|
||||||
|
if (mode !== "off") startVisualizer();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -19,6 +19,7 @@ export function handleGetLibrary(req: Request, server: any): Response {
|
||||||
duration: t.duration,
|
duration: t.duration,
|
||||||
replayGainDb: t.replayGainDb,
|
replayGainDb: t.replayGainDb,
|
||||||
replayPeak: t.replayPeak,
|
replayPeak: t.replayPeak,
|
||||||
|
createdAt: t.created_at,
|
||||||
available: t.available,
|
available: t.available,
|
||||||
}));
|
}));
|
||||||
return Response.json(tracks, { headers });
|
return Response.json(tracks, { headers });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue