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;
|
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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
2
init.ts
2
init.ts
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
156
library.ts
156
library.ts
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
11
public/ui.js
11
public/ui.js
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue