styling and website fixes
This commit is contained in:
parent
cd8c1814ca
commit
ac740a46bb
|
|
@ -1 +0,0 @@
|
|||
Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3
|
||||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
2
init.ts
2
init.ts
|
|
@ -215,7 +215,7 @@ export async function init(): Promise<void> {
|
|||
setTimeout(() => checkPendingPlaylistAddition(track), 100);
|
||||
});
|
||||
library.on("changed", (track) => {
|
||||
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });
|
||||
console.log(`Track metadata updated: ${track.title || track.filename}`);
|
||||
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
|
||||
});
|
||||
|
||||
|
|
|
|||
178
library.ts
178
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<ReplayGainScanConfig> = {
|
||||
enabled: true,
|
||||
command: "rsgain",
|
||||
truePeak: false,
|
||||
timeoutMs: 120000,
|
||||
maxConcurrent: 1,
|
||||
maxOutputBytes: 256 * 1024,
|
||||
};
|
||||
|
||||
export interface ReplayGainScanConfig {
|
||||
|
|
@ -21,6 +23,8 @@ export interface ReplayGainScanConfig {
|
|||
command?: string;
|
||||
truePeak?: boolean;
|
||||
timeoutMs?: number;
|
||||
maxConcurrent?: number;
|
||||
maxOutputBytes?: number;
|
||||
}
|
||||
|
||||
interface ReplayGainScanResult {
|
||||
|
|
@ -55,6 +59,9 @@ export class Library {
|
|||
private pendingFiles = new Map<string, ReturnType<typeof setTimeout>>(); // filepath -> debounce timer
|
||||
private replayGainConfig: Required<ReplayGainScanConfig>;
|
||||
private replayGainAvailable: boolean | null = null;
|
||||
private replayGainAvailabilityCheck: Promise<boolean> | null = null;
|
||||
private replayGainActive = 0;
|
||||
private replayGainQueue: Array<() => void> = [];
|
||||
|
||||
// Scan progress tracking
|
||||
private _scanProgress = { scanning: false, processed: 0, total: 0 };
|
||||
|
|
@ -75,12 +82,19 @@ export class Library {
|
|||
enabled: replayGainConfig.enabled ?? DEFAULT_REPLAY_GAIN_CONFIG.enabled,
|
||||
command: replayGainConfig.command ?? DEFAULT_REPLAY_GAIN_CONFIG.command,
|
||||
truePeak: replayGainConfig.truePeak ?? DEFAULT_REPLAY_GAIN_CONFIG.truePeak,
|
||||
timeoutMs: replayGainConfig.timeoutMs ?? DEFAULT_REPLAY_GAIN_CONFIG.timeoutMs,
|
||||
timeoutMs: this.normalizePositiveInteger(replayGainConfig.timeoutMs, DEFAULT_REPLAY_GAIN_CONFIG.timeoutMs!),
|
||||
maxConcurrent: this.normalizePositiveInteger(replayGainConfig.maxConcurrent, DEFAULT_REPLAY_GAIN_CONFIG.maxConcurrent!),
|
||||
maxOutputBytes: this.normalizePositiveInteger(replayGainConfig.maxOutputBytes, DEFAULT_REPLAY_GAIN_CONFIG.maxOutputBytes!),
|
||||
};
|
||||
this.cacheDb.run("PRAGMA journal_mode = WAL");
|
||||
this.initCacheDb();
|
||||
}
|
||||
|
||||
private normalizePositiveInteger(value: number | undefined, fallback: number): number {
|
||||
if (value == null || !Number.isFinite(value)) return fallback;
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
private initCacheDb(): void {
|
||||
this.cacheDb.run(`
|
||||
CREATE TABLE IF NOT EXISTS file_cache (
|
||||
|
|
@ -229,19 +243,26 @@ export class Library {
|
|||
private async ensureReplayGainAvailable(): Promise<boolean> {
|
||||
if (!this.replayGainConfig.enabled) return false;
|
||||
if (this.replayGainAvailable !== null) return this.replayGainAvailable;
|
||||
if (this.replayGainAvailabilityCheck) return this.replayGainAvailabilityCheck;
|
||||
|
||||
try {
|
||||
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
|
||||
const version = stdout.trim().split(/\r?\n/)[0] || "available";
|
||||
console.log(`[Library] rsgain found: ${version}`);
|
||||
this.replayGainAvailable = true;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
|
||||
this.replayGainAvailable = false;
|
||||
}
|
||||
this.replayGainAvailabilityCheck = this.withReplayGainSlot(async () => {
|
||||
try {
|
||||
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
|
||||
const version = stdout.trim().split(/\r?\n/)[0] || "available";
|
||||
console.log(`[Library] rsgain found: ${version}`);
|
||||
this.replayGainAvailable = true;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
|
||||
this.replayGainAvailable = false;
|
||||
} finally {
|
||||
this.replayGainAvailabilityCheck = null;
|
||||
}
|
||||
|
||||
return this.replayGainAvailable;
|
||||
return this.replayGainAvailable;
|
||||
});
|
||||
|
||||
return this.replayGainAvailabilityCheck;
|
||||
}
|
||||
|
||||
private async scanReplayGain(filePath: string): Promise<ReplayGainScanResult | null> {
|
||||
|
|
@ -262,7 +283,9 @@ export class Library {
|
|||
args.push(filePath);
|
||||
|
||||
try {
|
||||
const { stdout } = await this.runCommand(this.replayGainConfig.command, args, this.replayGainConfig.timeoutMs);
|
||||
const { stdout } = await this.withReplayGainSlot(() =>
|
||||
this.runCommand(this.replayGainConfig.command, args, this.replayGainConfig.timeoutMs)
|
||||
);
|
||||
return this.parseReplayGainOutput(stdout);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
|
|
@ -295,44 +318,117 @@ export class Library {
|
|||
return { replayGainDb, replayPeak };
|
||||
}
|
||||
|
||||
private withReplayGainSlot<T>(task: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const run = () => {
|
||||
this.replayGainActive++;
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
this.replayGainActive--;
|
||||
this.replayGainQueue.shift()?.();
|
||||
});
|
||||
};
|
||||
|
||||
if (this.replayGainActive < this.replayGainConfig.maxConcurrent) {
|
||||
run();
|
||||
} else {
|
||||
this.replayGainQueue.push(run);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private terminateProcess(proc: ChildProcess): void {
|
||||
if (process.platform === "win32" && proc.pid) {
|
||||
try {
|
||||
const killer = spawn("taskkill", ["/pid", String(proc.pid), "/t", "/f"], {
|
||||
windowsHide: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
killer.unref();
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to direct child termination below.
|
||||
}
|
||||
}
|
||||
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
|
||||
private runCommand(command: string, args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { windowsHide: true });
|
||||
const proc = spawn(command, args, {
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let finished = false;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const finish = (callback: () => void) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeout);
|
||||
callback();
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
proc.kill();
|
||||
finish(() => reject(new Error(`timed out after ${timeoutMs}ms`)));
|
||||
let timedOut = false;
|
||||
let outputExceeded = false;
|
||||
const maxOutputBytes = this.replayGainConfig.maxOutputBytes;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
this.terminateProcess(proc);
|
||||
}, timeoutMs);
|
||||
const killGraceTimeout = setTimeout(() => {
|
||||
if (!finished && timedOut) {
|
||||
finish(() => reject(new Error(`timed out after ${timeoutMs}ms and did not exit after termination`)));
|
||||
}
|
||||
}, timeoutMs + 5000);
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on("error", (e) => {
|
||||
const onStdout = (data: Buffer) => {
|
||||
stdout = appendOutput(stdout, data);
|
||||
};
|
||||
const onStderr = (data: Buffer) => {
|
||||
stderr = appendOutput(stderr, data);
|
||||
};
|
||||
const onError = (e: Error) => {
|
||||
finish(() => reject(e));
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
};
|
||||
const onClose = (code: number | null) => {
|
||||
finish(() => {
|
||||
if (code === 0) {
|
||||
if (timedOut) {
|
||||
reject(new Error(`timed out after ${timeoutMs}ms`));
|
||||
} else if (outputExceeded) {
|
||||
reject(new Error(`output exceeded ${maxOutputBytes} bytes`));
|
||||
} else if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const finish = (callback: () => void) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(killGraceTimeout);
|
||||
proc.stdout.off("data", onStdout);
|
||||
proc.stderr.off("data", onStderr);
|
||||
proc.off("error", onError);
|
||||
proc.off("close", onClose);
|
||||
callback();
|
||||
};
|
||||
|
||||
const appendOutput = (current: string, data: Buffer): string => {
|
||||
if (outputExceeded) return current;
|
||||
|
||||
const text = data.toString();
|
||||
const next = current + text;
|
||||
if (next.length <= maxOutputBytes) return next;
|
||||
|
||||
outputExceeded = true;
|
||||
this.terminateProcess(proc);
|
||||
return next.slice(0, maxOutputBytes);
|
||||
};
|
||||
|
||||
proc.stdout.on("data", onStdout);
|
||||
proc.stderr.on("data", onStderr);
|
||||
proc.on("error", onError);
|
||||
proc.on("close", onClose);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -633,6 +729,10 @@ export class Library {
|
|||
this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
for (const timer of this.pendingFiles.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.pendingFiles.clear();
|
||||
}
|
||||
|
||||
// Event handling
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
<span class="track-title">${escapeHtml(title)}</span>
|
||||
<span class="track-actions">
|
||||
<span class="duration">${M.fmt(track.duration)}</span>
|
||||
<button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button>
|
||||
</span>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
11
public/ui.js
11
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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue