Compare commits

...

9 Commits

20 changed files with 2371 additions and 190 deletions

@ -1 +0,0 @@
Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3

View File

@ -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
View File

@ -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
}); });
}); });

View File

@ -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,7 +243,9 @@ 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;
this.replayGainAvailabilityCheck = this.withReplayGainSlot(async () => {
try { try {
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000); const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
const version = stdout.trim().split(/\r?\n/)[0] || "available"; const version = stdout.trim().split(/\r?\n/)[0] || "available";
@ -239,9 +255,14 @@ export class Library {
const message = e instanceof Error ? e.message : String(e); const message = e instanceof Error ? e.message : String(e);
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`); console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
this.replayGainAvailable = false; 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

View File

@ -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`); M.showToast(`Channel "${channel.name}" deleted`);
} catch (e) { } catch (e) {
M.showToast("Failed to delete channel"); M.showToast("Failed to delete channel", "error");
} }
}, { confirmText: "Delete", duration: 15000 });
}; };
// 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();
} }

17
public/controls.js vendored
View File

@ -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" },

View File

@ -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 {}
};
})();

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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>

View File

@ -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

View File

@ -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,8 +145,10 @@
if (!playlist) return; if (!playlist) return;
const items = []; const items = [];
const canEditQueue = M.canControl();
// Add to queue options // Add to queue options
if (canEditQueue) {
items.push({ items.push({
label: '▶ Add to Queue', label: '▶ Add to Queue',
action: () => addPlaylistToQueue(playlistId) action: () => addPlaylistToQueue(playlistId)
@ -154,8 +157,11 @@
label: '⏭ Play Next', label: '⏭ Play Next',
action: () => addPlaylistToQueue(playlistId, true) action: () => addPlaylistToQueue(playlistId, true)
}); });
}
if (canEditQueue && (isMine || M.canCreateUserContent())) {
items.push({ separator: true }); 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,6 +289,7 @@
const playlist = myPlaylists.find(p => p.id === playlistId); const playlist = myPlaylists.find(p => p.id === playlistId);
if (!playlist) return; if (!playlist) return;
M.showConfirmToast(`Delete playlist "${playlist.name}"?`, async () => {
try { try {
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' }); const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete playlist'); if (!res.ok) throw new Error('Failed to delete playlist');
@ -288,6 +305,7 @@
console.error('Failed to delete playlist:', err); console.error('Failed to delete playlist:', err);
showToast('Failed to delete playlist', 'error'); showToast('Failed to delete playlist', 'error');
} }
}, { confirmText: 'Delete', duration: 15000 });
} }
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');

View File

@ -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
.map((track, i) => ({ track, originalIndex: i }))
.filter(({ track }) => {
const title = track.title?.trim() || track.filename || '';
return title.toLowerCase().includes(query);
}); });
} }
if (recentLibraryEl && !recentLibraryContainer) {
recentLibraryContainer = M.trackContainer.createContainer({
type: 'library',
element: recentLibraryEl,
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" });
}
} }
}; };

View File

@ -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;
}
}

91
public/themes.js Normal file
View File

@ -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);
}
});
})();

View File

@ -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>
`; `;

View File

@ -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;

View File

@ -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));

View File

@ -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");

View File

@ -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";
}
};
})(); })();

781
public/visualizer.js Normal file
View File

@ -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();
});
})();

View File

@ -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 });