/** * tiddl-web — settings module * * Handles loading/saving all user preferences, theme switching, accent colour * management, and the drag-and-drop file-path template builder. */ import { getCorsProxy, setCorsProxy, getClientId, setClientId, getClientSecret, setClientSecret, getTheme, setTheme, getAccentColor, setAccentColor, getUiEffects, setUiEffects, getTrackQuality, setTrackQuality, getVideoQuality, setVideoQuality, getThreadsCount, setThreadsCount, getSkipExisting, setSkipExisting, getSinglesFilter, setSinglesFilter, getVideosFilter, setVideosFilter, getUpdateMtime, setUpdateMtime, getRewriteMetadata, setRewriteMetadata, getMetadataEnable, setMetadataEnable, getMetadataLyrics, setMetadataLyrics, getMetadataCover, setMetadataCover, getMetadataAlbumReview, setMetadataAlbumReview, getCoverSave, setCoverSave, getCoverSize, setCoverSize, getCoverAllowed, setCoverAllowed, getM3uSave, setM3uSave, getM3uAllowed, setM3uAllowed, getAdvancedMode, setAdvancedMode, getAllQualitiesMode, setAllQualitiesMode, getPreferredFormat, setPreferredFormat, QUALITY_LABELS, QUALITY_STANDARD, getTemplate, setTemplate, TEMPLATE_DEFAULTS, } from "./config.js"; // ─── UI Effects ─────────────────────────────────────────────────────────────── /** Apply a UI effects level ("full" | "reduced" | "minimal" | "classic") to . */ export function applyUiEffects(level) { const root = document.documentElement; const validLevels = ["full", "reduced", "minimal", "classic"]; const safeLevel = validLevels.includes(level) ? level : "full"; root.setAttribute("data-effects", safeLevel); document.querySelectorAll(".seg-btn[data-effects-val]").forEach((btn) => { btn.classList.toggle("active", btn.dataset.effectsVal === safeLevel); }); } // ─── Browser chrome: dynamic favicon + Safari/Chrome tab colour ────────────── function _resolveEffectiveTheme() { const attr = document.documentElement.getAttribute("data-theme"); if (attr === "light" || attr === "dark") return attr; return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function _updateBrowserChrome() { const rawAccent = getAccentColor(); const isDark = _resolveEffectiveTheme() === "dark"; // Body background colour used for the favicon tile (matches --color-bg) const faviconBg = isDark ? "#111120" : "#f2f2fa"; // Sanitize: only allow valid 3- or 6-digit hex colours before interpolating into SVG const safeAccent = /^#[0-9a-fA-F]{3,6}$/.test(rawAccent) ? rawAccent : "#4fd08c"; const safeFaviconBg = /^#[0-9a-fA-F]{3,6}$/.test(faviconBg) ? faviconBg : "#111120"; // SVG favicon: rounded square + bold "t" in accent colour const svg = `t`; const faviconEl = document.getElementById("favicon-link"); if (faviconEl) faviconEl.href = `data:image/svg+xml,${encodeURIComponent(svg)}`; // Safari / Chrome toolbar colour — use the header's surface colour (--color-surface), // not the body background, so the toolbar matches the actual header element. // Read the computed CSS variable for the currently-active theme; fall back to // known defaults for the opposite scheme (can't read both at once via getComputedStyle). const computedSurface = getComputedStyle(document.documentElement) .getPropertyValue("--color-surface").trim(); const currentSurface = /^#[0-9a-fA-F]{3,6}$/.test(computedSurface) ? computedSurface : (isDark ? "#111120" : "#ffffff"); const darkSurface = isDark ? currentSurface : "#111120"; const lightSurface = isDark ? "#ffffff" : currentSurface; // For the explicit dark/light themes, both tags get the same surface colour so // the toolbar is correct regardless of the OS colour-scheme preference. // For the "system" theme, each tag covers its own scheme so Safari auto-switches. const theme = getTheme(); // "dark" | "light" | "system" const darkContent = theme === "light" ? lightSurface : darkSurface; const lightContent = theme === "dark" ? darkSurface : lightSurface; const darkMetaEl = document.getElementById("theme-color-meta-dark"); const lightMetaEl = document.getElementById("theme-color-meta-light"); if (darkMetaEl) darkMetaEl.setAttribute("content", darkContent); if (lightMetaEl) lightMetaEl.setAttribute("content", lightContent); } /** Call once at startup to install the OS-theme change listener. */ export function initBrowserChrome() { applyUiEffects(getUiEffects()); _updateBrowserChrome(); window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => { if (!document.documentElement.getAttribute("data-theme")) _updateBrowserChrome(); }); } // ─── Accent colour helpers ───────────────────────────────────────────────── /** Hex → { r, g, b } */ function hexToRgb(hex) { const m = hex.replace("#", "").match(/.{2}/g); return m ? { r: parseInt(m[0], 16), g: parseInt(m[1], 16), b: parseInt(m[2], 16) } : null; } /** Scale lightness: positive = lighter, negative = darker (rough approach). */ function adjustHex(hex, amount) { const { r, g, b } = hexToRgb(hex) || { r: 79, g: 208, b: 140 }; const clamp = (v) => Math.max(0, Math.min(255, v)); const rr = clamp(r + amount).toString(16).padStart(2, "0"); const gg = clamp(g + amount).toString(16).padStart(2, "0"); const bb = clamp(b + amount).toString(16).padStart(2, "0"); return `#${rr}${gg}${bb}`; } /** Apply the chosen accent colour to the CSS custom properties on . */ export function applyAccentColor(hex) { const root = document.documentElement; root.style.setProperty("--color-accent", hex); root.style.setProperty("--color-accent-dark", adjustHex(hex, -30)); root.style.setProperty("--color-accent-light", adjustHex(hex, 30)); const { r, g, b } = hexToRgb(hex) || { r: 79, g: 208, b: 140 }; root.style.setProperty("--color-accent-soft", `rgba(${r},${g},${b},0.14)`); root.style.setProperty("--color-accent-glow", `rgba(${r},${g},${b},0.25)`); root.style.setProperty("--color-success", hex); _updateBrowserChrome(); } // ─── Theme ──────────────────────────────────────────────────────────────────── /** Apply a theme value ("dark" | "light" | "system") to . */ export function applyTheme(theme) { const root = document.documentElement; if (theme === "system") { root.removeAttribute("data-theme"); } else { root.setAttribute("data-theme", theme); } // Sync the segmented control if it exists document.querySelectorAll(".seg-btn[data-theme-val]").forEach((btn) => { btn.classList.toggle("active", btn.dataset.themeVal === theme); }); _updateBrowserChrome(); } // ─── Template token definitions ─────────────────────────────────────────────── const TOKENS = { default: [ { label: "item.title", val: "{item.title}" }, { label: "item.artist", val: "{item.artist}" }, { label: "item.number", val: "{item.number}" }, { label: "item.id", val: "{item.id}" }, { label: "album.title", val: "{album.title}" }, { label: "album.artist", val: "{album.artist}" }, { label: "album.date", val: "{album.date}" }, { label: "/", val: "/", sep: true }, { label: " - ", val: " - ", sep: true }, { label: ". ", val: ". ", sep: true }, ], track: [ { label: "item.title", val: "{item.title}" }, { label: "item.artist", val: "{item.artist}" }, { label: "item.number", val: "{item.number:02d}" }, { label: "item.volume", val: "{item.volume}" }, { label: "item.id", val: "{item.id}" }, { label: "item.isrc", val: "{item.isrc}" }, { label: "item.quality", val: "{item.quality}" }, { label: "album.title", val: "{album.title}" }, { label: "album.artist", val: "{album.artist}" }, { label: "album.date", val: "{album.date}" }, { label: "/", val: "/", sep: true }, { label: " - ", val: " - ", sep: true }, { label: ". ", val: ". ", sep: true }, ], album: [ { label: "item.title", val: "{item.title}" }, { label: "item.artist", val: "{item.artist}" }, { label: "item.number", val: "{item.number:02d}" }, { label: "item.id", val: "{item.id}" }, { label: "album.title", val: "{album.title}" }, { label: "album.artist", val: "{album.artist}" }, { label: "album.date", val: "{album.date}" }, { label: "album.release", val: "{album.release}" }, { label: "/", val: "/", sep: true }, { label: " - ", val: " - ", sep: true }, { label: ". ", val: ". ", sep: true }, ], playlist: [ { label: "item.title", val: "{item.title}" }, { label: "item.artist", val: "{item.artist}" }, { label: "item.id", val: "{item.id}" }, { label: "playlist.title", val: "{playlist.title}" }, { label: "playlist.index", val: "{playlist.index}" }, { label: "playlist.created", val: "{playlist.created}" }, { label: "album.title", val: "{album.title}" }, { label: "album.artist", val: "{album.artist}" }, { label: "/", val: "/", sep: true }, { label: " - ", val: " - ", sep: true }, { label: ". ", val: ". ", sep: true }, ], mix: [ { label: "item.title", val: "{item.title}" }, { label: "item.artist", val: "{item.artist}" }, { label: "item.id", val: "{item.id}" }, { label: "mix_id", val: "{mix_id}" }, { label: "/", val: "/", sep: true }, { label: " - ", val: " - ", sep: true }, ], video: [ { label: "item.title", val: "{item.title}" }, { label: "item.artist", val: "{item.artist}" }, { label: "item.id", val: "{item.id}" }, { label: "item.quality", val: "{item.quality}" }, { label: "/", val: "/", sep: true }, { label: " - ", val: " - ", sep: true }, ], }; // Preview example values for each token const PREVIEW_VALUES = { "{item.title}": "Harder Better Faster Stronger", "{item.artist}": "Daft Punk", "{item.artists}": "Daft Punk", "{item.number}": "3", "{item.number:02d}": "03", "{item.volume}": "1", "{item.id}": "12345678", "{item.isrc}": "USQX91501234", "{item.quality}": "LOSSLESS", "{album.title}": "Discovery", "{album.artist}": "Daft Punk", "{album.artists}": "Daft Punk", "{album.date}": "2001-03-13", "{album.release}": "ALBUM", "{playlist.title}": "My Favorites", "{playlist.index}": "5", "{playlist.created}": "2024-01-15", "{mix_id}": "0123456789abcdef", }; function renderPreview(template) { if (!template) return "(using default template)"; let result = template; for (const [token, val] of Object.entries(PREVIEW_VALUES)) { result = result.replaceAll(token, val); } return result + ".flac"; } // ─── Template builder ───────────────────────────────────────────────────────── /** Build the chip palette HTML for a given type. */ function buildPaletteHtml(type) { const tokens = TOKENS[type] || TOKENS.default; return tokens.map((t) => { const cls = t.sep ? "token-chip token-chip--sep" : "token-chip"; return `${escHtml(t.label)}`; }).join(""); } function escHtml(str) { return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function escAttr(str) { return String(str).replace(/"/g, """); } /** Insert text at the cursor position of an or