/**
* 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 = ``;
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