styling and website fixes

This commit is contained in:
peterino2 2026-06-12 09:56:13 -07:00
parent a3334cb2a7
commit 6a62242482
15 changed files with 433 additions and 115 deletions

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

View File

@ -18,6 +18,8 @@ export interface ReplayGainConfig {
command: string;
truePeak: boolean;
timeoutMs: number;
maxConcurrent?: number;
maxOutputBytes?: number;
}
export interface Config {
@ -51,7 +53,9 @@ export const DEFAULT_CONFIG: Config = {
enabled: true,
command: "rsgain",
truePeak: false,
timeoutMs: 120000
timeoutMs: 120000,
maxConcurrent: 1,
maxOutputBytes: 262144
}
};

View File

@ -215,7 +215,7 @@ export async function init(): Promise<void> {
setTimeout(() => checkPendingPlaylistAddition(track), 100);
});
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 });
});

View File

@ -1,5 +1,5 @@
import { Database } from "bun:sqlite";
import { spawn } from "child_process";
import { spawn, type ChildProcess } from "child_process";
import { createHash } from "crypto";
import { watch, type FSWatcher } from "fs";
import { readdir, stat } from "fs/promises";
@ -9,11 +9,13 @@ import { type Track } from "./db";
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
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,
command: "rsgain",
truePeak: false,
timeoutMs: 120000,
maxConcurrent: 1,
maxOutputBytes: 256 * 1024,
};
export interface ReplayGainScanConfig {
@ -21,6 +23,8 @@ export interface ReplayGainScanConfig {
command?: string;
truePeak?: boolean;
timeoutMs?: number;
maxConcurrent?: number;
maxOutputBytes?: number;
}
interface ReplayGainScanResult {
@ -55,6 +59,9 @@ export class Library {
private pendingFiles = new Map<string, ReturnType<typeof setTimeout>>(); // filepath -> debounce timer
private replayGainConfig: Required<ReplayGainScanConfig>;
private replayGainAvailable: boolean | null = null;
private replayGainAvailabilityCheck: Promise<boolean> | null = null;
private replayGainActive = 0;
private replayGainQueue: Array<() => void> = [];
// Scan progress tracking
private _scanProgress = { scanning: false, processed: 0, total: 0 };
@ -75,12 +82,19 @@ export class Library {
enabled: replayGainConfig.enabled ?? DEFAULT_REPLAY_GAIN_CONFIG.enabled,
command: replayGainConfig.command ?? DEFAULT_REPLAY_GAIN_CONFIG.command,
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.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 {
this.cacheDb.run(`
CREATE TABLE IF NOT EXISTS file_cache (
@ -229,19 +243,26 @@ export class Library {
private async ensureReplayGainAvailable(): Promise<boolean> {
if (!this.replayGainConfig.enabled) return false;
if (this.replayGainAvailable !== null) return this.replayGainAvailable;
if (this.replayGainAvailabilityCheck) return this.replayGainAvailabilityCheck;
try {
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
const version = stdout.trim().split(/\r?\n/)[0] || "available";
console.log(`[Library] rsgain found: ${version}`);
this.replayGainAvailable = true;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
this.replayGainAvailable = false;
}
this.replayGainAvailabilityCheck = this.withReplayGainSlot(async () => {
try {
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
const version = stdout.trim().split(/\r?\n/)[0] || "available";
console.log(`[Library] rsgain found: ${version}`);
this.replayGainAvailable = true;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
this.replayGainAvailable = false;
} finally {
this.replayGainAvailabilityCheck = null;
}
return this.replayGainAvailable;
return this.replayGainAvailable;
});
return this.replayGainAvailabilityCheck;
}
private async scanReplayGain(filePath: string): Promise<ReplayGainScanResult | null> {
@ -262,7 +283,9 @@ export class Library {
args.push(filePath);
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);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
@ -295,44 +318,117 @@ export class Library {
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 }> {
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 stderr = "";
let finished = false;
let timeout: ReturnType<typeof setTimeout>;
const finish = (callback: () => void) => {
if (finished) return;
finished = true;
clearTimeout(timeout);
callback();
};
timeout = setTimeout(() => {
proc.kill();
finish(() => reject(new Error(`timed out after ${timeoutMs}ms`)));
let timedOut = false;
let outputExceeded = false;
const maxOutputBytes = this.replayGainConfig.maxOutputBytes;
const timeout = setTimeout(() => {
timedOut = true;
this.terminateProcess(proc);
}, 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) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("error", (e) => {
const onStdout = (data: Buffer) => {
stdout = appendOutput(stdout, data);
};
const onStderr = (data: Buffer) => {
stderr = appendOutput(stderr, data);
};
const onError = (e: Error) => {
finish(() => reject(e));
});
proc.on("close", (code) => {
};
const onClose = (code: number | null) => {
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 });
} else {
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 = null;
}
for (const timer of this.pendingFiles.values()) {
clearTimeout(timer);
}
this.pendingFiles.clear();
}
// Event handling

View File

@ -58,22 +58,28 @@
M.showToast("Cannot delete default channel");
return;
}
if (!confirm(`Delete channel "${channel.name}"?`)) return;
try {
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json();
M.showToast(err.error || "Failed to delete channel");
return;
M.showConfirmToast(`Delete channel "${channel.name}"?`, async () => {
try {
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json();
M.showToast(err.error || "Failed to delete channel", "error");
return;
}
M.showToast(`Channel "${channel.name}" deleted`);
} catch (e) {
M.showToast("Failed to delete channel", "error");
}
M.showToast(`Channel "${channel.name}" deleted`);
} catch (e) {
M.showToast("Failed to delete channel");
}
}, { confirmText: "Delete", duration: 15000 });
};
// New channel creation with slideout input
M.createNewChannel = async function() {
if (!M.canCreateUserContent()) {
M.showToast("Sign in to create channels", "warning");
return;
}
const header = M.$("#channels-panel .panel-header");
const btn = M.$("#btn-new-channel");

11
public/controls.js vendored
View File

@ -3,6 +3,13 @@
(function() {
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
const savedVolume = localStorage.getItem(M.STORAGE_KEY);
@ -19,6 +26,7 @@
if (!M.currentTrackId) return;
if (M.synced) {
if (blockSyncedControlIfNeeded()) return;
if (M.ws && M.ws.readyState === WebSocket.OPEN) {
M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" }));
}
@ -43,6 +51,7 @@
const newIndex = (index + M.queue.length) % M.queue.length;
if (M.synced && M.currentChannelId) {
if (blockSyncedControlIfNeeded()) return;
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -136,6 +145,7 @@
M.updateModeButton();
return;
}
if (blockSyncedControlIfNeeded()) return;
// Synced mode - send to server
const currentIdx = modeOrder.indexOf(M.playbackMode);
@ -175,6 +185,7 @@
const seekTime = pct * dur;
if (M.synced && M.currentChannelId) {
if (blockSyncedControlIfNeeded()) return;
fetch("/api/channels/" + M.currentChannelId + "/seek", {
method: "POST",
headers: { "Content-Type": "application/json" },

View File

@ -117,11 +117,7 @@
// Update UI based on server status
function updateFeatureVisibility() {
const fetchBtn = M.$("#btn-fetch-url");
if (fetchBtn) {
const ytdlpEnabled = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available;
fetchBtn.style.display = ytdlpEnabled ? "" : "none";
}
if (M.updatePermissionUI) M.updatePermissionUI();
}
// Initialize the application

View File

@ -33,6 +33,7 @@
const myContainer = $('#my-playlists');
const sharedContainer = $('#shared-playlists');
if (!myContainer || !sharedContainer) return;
if (M.updatePermissionUI) M.updatePermissionUI();
// My playlists
if (myPlaylists.length === 0) {
@ -107,7 +108,7 @@
}
header.textContent = selectedPlaylist.name;
actions.classList.remove('hidden');
actions.classList.toggle('hidden', !M.canControl());
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
@ -144,18 +145,23 @@
if (!playlist) return;
const items = [];
const canEditQueue = M.canControl();
// Add to queue options
items.push({
label: '▶ Add to Queue',
action: () => addPlaylistToQueue(playlistId)
});
items.push({
label: '⏭ Play Next',
action: () => addPlaylistToQueue(playlistId, true)
});
if (canEditQueue) {
items.push({
label: '▶ Add to Queue',
action: () => addPlaylistToQueue(playlistId)
});
items.push({
label: '⏭ Play Next',
action: () => addPlaylistToQueue(playlistId, true)
});
}
items.push({ separator: true });
if (canEditQueue && (isMine || M.canCreateUserContent())) {
items.push({ separator: true });
}
if (isMine) {
// Rename
@ -183,9 +189,9 @@
items.push({
label: '🗑️ Delete',
action: () => deletePlaylist(playlistId),
className: 'danger'
danger: true
});
} else {
} else if (M.canCreateUserContent()) {
// Copy to my playlists
items.push({
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);
}
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);
if (!playlist || playlist.trackIds.length === 0) {
showToast('Playlist is empty', 'error');
@ -273,21 +289,23 @@
const playlist = myPlaylists.find(p => p.id === playlistId);
if (!playlist) return;
try {
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete playlist');
showToast(`Deleted playlist "${playlist.name}"`);
if (selectedPlaylistId === playlistId) {
selectedPlaylistId = null;
selectedPlaylist = null;
renderPlaylistContents();
M.showConfirmToast(`Delete playlist "${playlist.name}"?`, async () => {
try {
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete playlist');
showToast(`Deleted playlist "${playlist.name}"`);
if (selectedPlaylistId === playlistId) {
selectedPlaylistId = null;
selectedPlaylist = null;
renderPlaylistContents();
}
await loadPlaylists();
} catch (err) {
console.error('Failed to delete playlist:', err);
showToast('Failed to delete playlist', 'error');
}
await loadPlaylists();
} catch (err) {
console.error('Failed to delete playlist:', err);
showToast('Failed to delete playlist', 'error');
}
}, { confirmText: 'Delete', duration: 15000 });
}
function startRenamePlaylist(playlistId) {
@ -448,6 +466,7 @@
// Show "Add to Playlist" submenu
function showAddToPlaylistMenu(trackIds) {
if (!M.canCreateUserContent()) return null;
if (myPlaylists.length === 0) {
showToast('Create a playlist first', 'info');
return null;
@ -471,6 +490,11 @@
const btnNew = $('#btn-new-playlist');
if (btnNew) {
btnNew.onclick = () => {
if (!M.canCreateUserContent()) {
showToast('Sign in to create playlists', 'warning');
return;
}
// Inline input for new playlist name
const container = $('#my-playlists');
const input = document.createElement('input');

View File

@ -282,7 +282,7 @@
const activeTrack = container.querySelector(".track.active");
if (activeTrack) {
activeTrack.scrollIntoView({ behavior: "smooth", block: "center" });
activeTrack.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
}
};

View File

@ -1,8 +1,8 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; min-height: 100vh; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; min-height: 100vh; overflow-x: hidden; }
#app { width: 100%; max-width: 1700px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; }
h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; }
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.visible { display: inline-block; }
#sync-indicator.disconnected { background: #e44; }
@ -26,7 +26,7 @@ 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; }
/* 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; }
#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-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; }
@ -35,7 +35,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-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 .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 .channel-header:hover .btn-delete-channel { opacity: 1; }
#channels-list .btn-delete-channel:hover { color: #e44; }
@ -45,10 +45,10 @@ 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 .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; }
#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; }
#channels-list .listener-mult { color: #666; font-size: 0.55rem; }
#library-panel, #queue-panel { flex: 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; }
#channels-list .listener-mult { color: #999; font-size: 0.55rem; }
#library-panel, #queue-panel { flex: 1 1 0; 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; }
.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.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; }
@ -133,7 +133,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:hover { background: #2a3a2a; }
.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 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; }
@ -152,10 +152,12 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
.track.cached .cache-indicator { background: #4e8; }
.track.not-cached .cache-indicator { background: #ea4; }
.track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; }
.track-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-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: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; }
@ -165,6 +167,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-remove { color: #e44; }
.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-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
@ -211,19 +217,20 @@ 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:hover { opacity: 0.8; }
#status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; }
.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; }
#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; }
#progress-bar.playing.synced { background: #4e8; }
#progress-bar.playing.local { background: #c4f; }
#progress-bar.muted { background: #555 !important; }
#seek-tooltip { position: absolute; bottom: 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 .segment { flex: 1; height: 2px; background: #333; border-radius: 1px; }
#buffer-bar .segment.available { background: #396; }
#buffer-bar .segment.loading { background: #666; animation: throb 0.6s ease-in-out infinite alternate; }
@keyframes throb { from { background: #444; } to { background: #888; } }
#download-speed { font-size: 0.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; }
#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; }
@ -235,8 +242,9 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
/* Common */
button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
button:hover { background: #333; }
#status { margin-top: 0.3rem; font-size: 0.75rem; color: #666; text-align: center; }
.empty { color: #666; font-style: italic; font-size: 0.85rem; }
.hidden { display: none !important; }
#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 { 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; }
@ -267,10 +275,16 @@ button:hover { background: #333; }
::-webkit-scrollbar-thumb:hover { background-color: #555; }
/* 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.toast-warning { background: #3a3a1a; color: #ea4; border-color: #ea4; }
.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; }
@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); } }
@ -352,6 +366,7 @@ button:hover { background: #333; }
#site-header { margin-bottom: 0.3rem; flex-shrink: 0; }
#site-header h1 { font-size: 0.9rem; }
#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; }
#auth-section .user-info { flex-wrap: wrap; gap: 0.3rem; }
@ -374,7 +389,8 @@ button:hover { background: #333; }
color: #666;
font-size: 0.8rem;
font-weight: 600;
padding: 0.5rem;
min-height: 44px;
padding: 0.65rem 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
@ -418,6 +434,9 @@ button:hover { background: #333; }
#library-panel .panel-tabs {
flex-shrink: 0;
}
#library-panel .panel-tab {
min-height: 44px;
}
#library-panel .panel-views {
flex: 1;
min-height: 0;
@ -505,7 +524,41 @@ button:hover { background: #333; }
font-size: 0.85rem;
}
#player-controls { width: 100%; }
#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 {
font-size: 1.2rem;
width: 44px;
@ -528,11 +581,26 @@ button:hover { background: #333; }
padding: 0.2rem 0.4rem;
}
#volume-controls {
justify-content: flex-end;
justify-content: center;
gap: 0.3rem;
width: 100%;
}
#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 */
#btn-history { display: none; }
@ -540,9 +608,11 @@ button:hover { background: #333; }
/* Toast positioning */
#toast-container {
top: auto;
bottom: 5rem;
bottom: calc(9.25rem + env(safe-area-inset-bottom));
left: 0.3rem;
right: 0.3rem;
max-width: none;
max-height: 30vh;
}
.toast { max-width: none; }

View File

@ -59,6 +59,7 @@
<span class="track-title">${escapeHtml(title)}</span>
<span class="track-actions">
<span class="duration">${M.fmt(track.duration)}</span>
<button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button>
</span>
`;

View File

@ -287,6 +287,19 @@
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
const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue);
if (canDrag) {
@ -545,6 +558,11 @@
if (type === 'queue') {
// Jump to track in queue
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", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -617,7 +635,7 @@
const menuItems = [];
// Play (queue only, single track or single selection)
if (type === 'queue' && selectedCount === 1) {
if (type === 'queue' && selectedCount === 1 && (canEditQueue || !M.synced)) {
menuItems.push({
label: "▶ Play",
action: () => playTrack(track, index)
@ -701,7 +719,7 @@
}
// Add to Playlist
if (M.playlists && !M.currentUser?.is_guest) {
if (M.playlists && !M.currentUser?.isGuest) {
const submenu = M.playlists.showAddToPlaylistMenu(idsForAction);
if (submenu && submenu.length > 0) {
menuItems.push({

View File

@ -29,7 +29,14 @@
// Show/hide controls based on permissions
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;
el.classList.toggle("control-disabled", controlsDisabled);
if ("ariaDisabled" in el) el.ariaDisabled = controlsDisabled ? "true" : "false";
});
M.$("#status-icon").style.cursor = controlsDisabled ? "not-allowed" : "pointer";
};
// Update auth-related UI
@ -49,6 +56,7 @@
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
// Re-render channel list to update rename/delete buttons
if (M.renderChannelList) M.renderChannelList();
if (M.updatePermissionUI) M.updatePermissionUI();
} else {
M.$("#login-panel").classList.remove("hidden");
M.$("#player-content").classList.remove("visible");
@ -68,6 +76,7 @@
} else {
M.$("#guest-section").classList.add("hidden");
}
if (M.updatePermissionUI) M.updatePermissionUI();
}
M.updateUI();
};

View File

@ -106,11 +106,7 @@
const data = await res.json();
if (data.type === "playlist") {
// Ask user to confirm playlist download
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 queuePlaylist = async () => {
const confirmRes = await fetch("/api/fetch/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -128,7 +124,13 @@
const err = await confirmRes.json().catch(() => ({}));
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") {
M.showToast(`Queued: ${data.title}`);
// Task will be created by WebSocket progress messages

View File

@ -17,6 +17,14 @@
// Toast history
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)
M.showToast = function(message, type = "info", duration = 5000) {
@ -25,6 +33,7 @@
toast.className = "toast toast-" + type;
toast.textContent = message;
container.appendChild(toast);
pruneVisibleToasts(container);
setTimeout(() => {
toast.classList.add("fade-out");
setTimeout(() => toast.remove(), 300);
@ -38,6 +47,56 @@
});
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
M.updateToastHistory = function() {
@ -112,10 +171,29 @@
M.canControl = function() {
if (!M.currentUser) return false;
if (M.currentUser.isAdmin) return true;
if (M.currentUser.isGuest) return false;
return M.currentUser.permissions?.some(p =>
p.resource_type === "channel" &&
(p.resource_id === M.currentChannelId || p.resource_id === null) &&
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";
}
};
})();