diff --git a/android/MusicRoom b/android/MusicRoom deleted file mode 160000 index fd6fc4d..0000000 --- a/android/MusicRoom +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3 diff --git a/config.ts b/config.ts index 9868e37..94bd919 100644 --- a/config.ts +++ b/config.ts @@ -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 } }; diff --git a/init.ts b/init.ts index 81c0f66..526f978 100644 --- a/init.ts +++ b/init.ts @@ -215,7 +215,7 @@ export async function init(): Promise { 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 }); }); diff --git a/library.ts b/library.ts index f3a0618..6022c4e 100644 --- a/library.ts +++ b/library.ts @@ -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 = { 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>(); // filepath -> debounce timer private replayGainConfig: Required; private replayGainAvailable: boolean | null = null; + private replayGainAvailabilityCheck: Promise | 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 { 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 { @@ -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(task: () => Promise): Promise { + return new Promise((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; - - 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 diff --git a/public/channelSync.js b/public/channelSync.js index dab7142..3611abf 100644 --- a/public/channelSync.js +++ b/public/channelSync.js @@ -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"); diff --git a/public/controls.js b/public/controls.js index 8b70d45..0f4487b 100644 --- a/public/controls.js +++ b/public/controls.js @@ -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" }, diff --git a/public/init.js b/public/init.js index 9e758b1..c6acbca 100644 --- a/public/init.js +++ b/public/init.js @@ -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 diff --git a/public/playlists.js b/public/playlists.js index 6b947c8..6071760 100644 --- a/public/playlists.js +++ b/public/playlists.js @@ -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'); diff --git a/public/queue.js b/public/queue.js index a0d7ebb..1fd4792 100644 --- a/public/queue.js +++ b/public/queue.js @@ -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" }); } }; diff --git a/public/styles.css b/public/styles.css index f81da0b..81b7e93 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } diff --git a/public/trackComponent.js b/public/trackComponent.js index 0e31b27..7df668a 100644 --- a/public/trackComponent.js +++ b/public/trackComponent.js @@ -59,6 +59,7 @@ ${escapeHtml(title)} ${M.fmt(track.duration)} + `; diff --git a/public/trackContainer.js b/public/trackContainer.js index 3d8cc6e..f4a6b98 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -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({ diff --git a/public/ui.js b/public/ui.js index b5e06c8..0cd90dd 100644 --- a/public/ui.js +++ b/public/ui.js @@ -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(); }; diff --git a/public/upload.js b/public/upload.js index 66dc597..1226647 100644 --- a/public/upload.js +++ b/public/upload.js @@ -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 diff --git a/public/utils.js b/public/utils.js index 68495cb..21aea24 100644 --- a/public/utils.js +++ b/public/utils.js @@ -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"; + } + }; })();