styling and website fixes

This commit is contained in:
peterino2 2026-06-12 09:56:13 -07:00
parent cd8c1814ca
commit ac740a46bb
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; 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
} }
}; };

View File

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

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

@ -58,22 +58,28 @@
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;
} }
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");

11
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) {
@ -19,6 +26,7 @@
if (!M.currentTrackId) return; if (!M.currentTrackId) return;
if (M.synced) { if (M.synced) {
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" }));
} }
@ -43,6 +51,7 @@
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 +145,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 +185,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

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

@ -282,7 +282,7 @@
const activeTrack = container.querySelector(".track.active"); const activeTrack = container.querySelector(".track.active");
if (activeTrack) { 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; } * { 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; } #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; } h1 { font-size: 1rem; color: #aaa; 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; } 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,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; } #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; }
#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 { 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 { 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; }
@ -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-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 +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 .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; 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-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 +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 { 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; }
@ -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; } .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 +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-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; }
@ -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.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; }
.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-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; }
#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; } #progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; }
#progress-bar.playing.synced { background: #4e8; } #progress-bar.playing.synced { background: #4e8; }
#progress-bar.playing.local { background: #c4f; } #progress-bar.playing.local { background: #c4f; }
#progress-bar.muted { background: #555 !important; } #progress-bar.muted { background: #555 !important; }
#seek-tooltip { position: absolute; bottom: 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; }
#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; }
@ -235,8 +242,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; }
@ -267,10 +275,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); } }
@ -352,6 +366,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 +389,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 +434,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;
@ -505,7 +524,41 @@ button:hover { background: #333; }
font-size: 0.85rem; font-size: 0.85rem;
} }
#player-controls { width: 100%; } #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 { #status-icon {
font-size: 1.2rem; font-size: 1.2rem;
width: 44px; width: 44px;
@ -528,11 +581,26 @@ button:hover { background: #333; }
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
} }
#volume-controls { #volume-controls {
justify-content: flex-end; justify-content: center;
gap: 0.3rem; gap: 0.3rem;
width: 100%;
} }
#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,9 +608,11 @@ 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; }

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

@ -287,6 +287,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) {
@ -545,6 +558,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 +635,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 +719,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({

View File

@ -29,7 +29,14 @@
// 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;
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 // Update auth-related UI
@ -49,6 +56,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 +76,7 @@
} else { } else {
M.$("#guest-section").classList.add("hidden"); M.$("#guest-section").classList.add("hidden");
} }
if (M.updatePermissionUI) M.updatePermissionUI();
} }
M.updateUI(); M.updateUI();
}; };

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

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