This commit is contained in:
429Enjoyer
2026-05-20 05:56:51 +07:00
parent 254022d81d
commit 0c3a7b70af
460 changed files with 51905 additions and 0 deletions
+822
View File
@@ -0,0 +1,822 @@
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App";
export type BuiltInFontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
export type CustomFontFamily = `custom-${string}`;
export type FontFamily = BuiltInFontFamily | CustomFontFamily;
export interface CustomFontOption {
value: CustomFontFamily;
label: string;
fontFamily: string;
url: string;
}
export type FontOption = {
value: FontFamily;
label: string;
fontFamily: string;
url?: string;
};
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
export type ExistingFileCheckMode = "filename" | "isrc";
export interface Settings {
downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon";
customTidalApi: string;
linkResolver: "songstats" | "songlink";
allowResolverFallback: boolean;
theme: string;
themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily;
customFonts: CustomFontOption[];
folderPreset: FolderPreset;
folderTemplate: string;
filenamePreset: FilenamePreset;
filenameTemplate: string;
filenameFormat?: "title-artist" | "artist-title" | "title";
artistSubfolder?: boolean;
albumSubfolder?: boolean;
trackNumber: boolean;
sfxEnabled: boolean;
embedLyrics: boolean;
embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
amazonQuality: "original";
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string;
autoQuality: "16" | "24";
allowFallback: boolean;
createPlaylistFolder: boolean;
playlistOwnerFolderName: boolean;
createM3u8File: boolean;
previewVolume: number;
existingFileCheckMode: ExistingFileCheckMode;
useFirstArtistOnly: boolean;
useSingleGenre: boolean;
embedGenre: boolean;
redownloadWithSuffix: boolean;
separator: "comma" | "semicolon";
}
export const FOLDER_PRESETS: Record<FolderPreset, {
label: string;
template: string;
}> = {
none: { label: "No Subfolder", template: "" },
artist: { label: "Artist", template: "{artist}" },
album: { label: "Album", template: "{album}" },
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
"year-artist-album": {
label: "[Year] Artist - Album",
template: "[{year}] {artist} - {album}",
},
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": {
label: "Artist / [Year] Album",
template: "{artist}/[{year}] {album}",
},
"artist-year-nested-album": {
label: "Artist / Year / Album",
template: "{artist}/{year}/{album}",
},
"album-artist": { label: "Album Artist", template: "{album_artist}" },
"album-artist-album": {
label: "Album Artist / Album",
template: "{album_artist}/{album}",
},
"album-artist-year-album": {
label: "Album Artist / [Year] Album",
template: "{album_artist}/[{year}] {album}",
},
"album-artist-year-nested-album": {
label: "Album Artist / Year / Album",
template: "{album_artist}/{year}/{album}",
},
year: { label: "Year", template: "{year}" },
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
custom: { label: "Custom...", template: "{artist}/{album}" },
};
export const FILENAME_PRESETS: Record<FilenamePreset, {
label: string;
template: string;
}> = {
title: { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": {
label: "Track. Title - Artist",
template: "{track}. {title} - {artist}",
},
"track-artist-title": {
label: "Track. Artist - Title",
template: "{track}. {artist} - {title}",
},
"title-album-artist": {
label: "Title - Album Artist",
template: "{title} - {album_artist}",
},
"track-title-album-artist": {
label: "Track. Title - Album Artist",
template: "{track}. {title} - {album_artist}",
},
"artist-album-title": {
label: "Artist - Album - Title",
template: "{artist} - {album} - {title}",
},
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": {
label: "Disc-Track. Title",
template: "{disc}-{track}. {title}",
},
"disc-track-title-artist": {
label: "Disc-Track. Title - Artist",
template: "{disc}-{track}. {title} - {artist}",
},
custom: { label: "Custom...", template: "{title} - {artist}" },
};
export const TEMPLATE_VARIABLES = [
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{
key: "{album_artist}",
description: "Album artist",
example: "Taylor Swift",
},
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" },
{
key: "{date}",
description: "Release date (YYYY-MM-DD)",
example: "2014-10-27",
},
{
key: "{isrc}",
description: "Track ISRC",
example: "USUM71412345",
},
];
function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase();
if (platform.includes("win")) {
return "Windows";
}
return "linux/MacOS";
}
export const DEFAULT_SETTINGS: Settings = {
downloadPath: "",
downloader: "auto",
customTidalApi: "",
linkResolver: "songlink",
allowResolverFallback: true,
theme: "yellow",
themeMode: "auto",
fontFamily: "google-sans",
customFonts: [],
folderPreset: "none",
folderTemplate: "",
filenamePreset: "title-artist",
filenameTemplate: "{title} - {artist}",
trackNumber: false,
sfxEnabled: true,
embedLyrics: false,
embedMaxQualityCover: false,
operatingSystem: detectOS(),
tidalQuality: "LOSSLESS",
qobuzQuality: "6",
amazonQuality: "original",
autoOrder: "qobuz-amazon",
autoQuality: "16",
allowFallback: true,
createPlaylistFolder: true,
playlistOwnerFolderName: false,
createM3u8File: false,
previewVolume: 100,
existingFileCheckMode: "filename",
useFirstArtistOnly: false,
useSingleGenre: false,
embedGenre: false,
redownloadWithSuffix: false,
separator: "semicolon",
};
export const FONT_OPTIONS: FontOption[] = [
{
value: "bricolage-grotesque",
label: "Bricolage Grotesque",
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif',
},
{
value: "dm-sans",
label: "DM Sans",
fontFamily: '"DM Sans", system-ui, sans-serif',
},
{
value: "figtree",
label: "Figtree",
fontFamily: '"Figtree", system-ui, sans-serif',
},
{
value: "geist-sans",
label: "Geist Sans",
fontFamily: '"Geist", system-ui, sans-serif',
},
{
value: "google-sans",
label: "Google Sans",
fontFamily: '"Google Sans", system-ui, sans-serif',
},
{
value: "inter",
label: "Inter",
fontFamily: '"Inter", system-ui, sans-serif',
},
{
value: "jetbrains-mono",
label: "JetBrains Mono",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
},
{
value: "manrope",
label: "Manrope",
fontFamily: '"Manrope", system-ui, sans-serif',
},
{
value: "noto-sans",
label: "Noto Sans",
fontFamily: '"Noto Sans", system-ui, sans-serif',
},
{
value: "nunito-sans",
label: "Nunito Sans",
fontFamily: '"Nunito Sans", system-ui, sans-serif',
},
{
value: "outfit",
label: "Outfit",
fontFamily: '"Outfit", system-ui, sans-serif',
},
{
value: "plus-jakarta-sans",
label: "Plus Jakarta Sans",
fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif',
},
{
value: "poppins",
label: "Poppins",
fontFamily: '"Poppins", system-ui, sans-serif',
},
{
value: "public-sans",
label: "Public Sans",
fontFamily: '"Public Sans", system-ui, sans-serif',
},
{
value: "raleway",
label: "Raleway",
fontFamily: '"Raleway", system-ui, sans-serif',
},
{
value: "roboto",
label: "Roboto",
fontFamily: '"Roboto", system-ui, sans-serif',
},
{
value: "space-grotesk",
label: "Space Grotesk",
fontFamily: '"Space Grotesk", system-ui, sans-serif',
},
];
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value));
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-";
const GOOGLE_FONTS_CSS_HOST = "fonts.googleapis.com";
const GOOGLE_FONTS_SPECIMEN_HOST = "fonts.google.com";
const SETTINGS_KEY = "spotiflac-settings";
let cachedSettings: Settings | null = null;
type SettingsPayload = Partial<Settings> & {
darkMode?: boolean;
[key: string]: unknown;
};
const KNOWN_SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>;
function extractGoogleFontInputUrl(input: string): string {
const trimmed = input.trim();
const hrefMatch = trimmed.match(/\bhref=["']([^"']+)["']/i);
if (hrefMatch?.[1]) {
return hrefMatch[1];
}
const importMatch = trimmed.match(/@import\s+url\(["']?([^"')]+)["']?\)/i);
if (importMatch?.[1]) {
return importMatch[1];
}
return trimmed;
}
function coerceGoogleFontUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
if (/^(fonts\.googleapis\.com|fonts\.google\.com)\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
}
function normalizeFontLabel(label: string): string {
return label.replace(/\+/g, " ").replace(/\s+/g, " ").trim();
}
function slugifyFontLabel(label: string): string {
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "font";
}
function toFontFamilyCss(label: string): string {
const escapedLabel = label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `"${escapedLabel}", system-ui, sans-serif`;
}
function buildGoogleFontsCssUrl(label: string): string {
const url = new URL("https://fonts.googleapis.com/css2");
url.searchParams.set("family", label);
url.searchParams.set("display", "swap");
return url.toString();
}
function extractSpecimenFontLabel(parsed: URL): string {
const segments = parsed.pathname.split("/").filter(Boolean);
const specimenIndex = segments.findIndex((segment) => segment.toLowerCase() === "specimen");
const specimenName = specimenIndex >= 0 ? segments[specimenIndex + 1] : "";
return normalizeFontLabel(decodeURIComponent(specimenName || ""));
}
function normalizeGoogleFontCssUrl(rawUrl: string): string | null {
try {
const parsed = new URL(coerceGoogleFontUrl(extractGoogleFontInputUrl(rawUrl)));
if (parsed.protocol !== "https:") {
return null;
}
if (parsed.hostname === GOOGLE_FONTS_SPECIMEN_HOST) {
const label = extractSpecimenFontLabel(parsed);
return label ? buildGoogleFontsCssUrl(label) : null;
}
if (parsed.hostname !== GOOGLE_FONTS_CSS_HOST ||
(parsed.pathname !== "/css" && parsed.pathname !== "/css2")) {
return null;
}
if (parsed.searchParams.getAll("family").length === 0) {
return null;
}
if (!parsed.searchParams.has("display")) {
parsed.searchParams.set("display", "swap");
}
return parsed.toString();
}
catch {
return null;
}
}
export function parseGoogleFontUrl(rawUrl: string): CustomFontOption | null {
const normalizedUrl = normalizeGoogleFontCssUrl(rawUrl);
if (!normalizedUrl) {
return null;
}
const parsed = new URL(normalizedUrl);
const family = parsed.searchParams.getAll("family")[0];
const label = normalizeFontLabel((family || "").split(":")[0] || "");
if (!label) {
return null;
}
return {
value: `custom-${slugifyFontLabel(label)}` as CustomFontFamily,
label,
fontFamily: toFontFamilyCss(label),
url: normalizedUrl,
};
}
function normalizeCustomFonts(customFonts: unknown): CustomFontOption[] {
if (!Array.isArray(customFonts)) {
return [];
}
const normalizedFonts: CustomFontOption[] = [];
const seenValues = new Set<string>();
const seenUrls = new Set<string>();
for (const item of customFonts) {
if (!item || typeof item !== "object") {
continue;
}
const rawUrl = (item as {
url?: unknown;
}).url;
if (typeof rawUrl !== "string") {
continue;
}
const parsed = parseGoogleFontUrl(rawUrl);
if (!parsed || seenValues.has(parsed.value) || seenUrls.has(parsed.url)) {
continue;
}
seenValues.add(parsed.value);
seenUrls.add(parsed.url);
normalizedFonts.push(parsed);
}
return normalizedFonts;
}
function normalizeFontFamily(fontFamily: unknown, customFonts: CustomFontOption[]): FontFamily {
if (typeof fontFamily !== "string") {
return DEFAULT_SETTINGS.fontFamily;
}
if (BUILT_IN_FONT_VALUES.has(fontFamily as BuiltInFontFamily)) {
return fontFamily as BuiltInFontFamily;
}
const customFont = customFonts.find((font) => font.value === fontFamily);
return customFont ? customFont.value : DEFAULT_SETTINGS.fontFamily;
}
export function getFontOptions(customFonts: CustomFontOption[] = []): FontOption[] {
return [...FONT_OPTIONS, ...normalizeCustomFonts(customFonts)];
}
export function loadGoogleFontUrl(url: string, id = `${GOOGLE_FONT_LINK_ID_PREFIX}preview`): void {
const normalizedUrl = normalizeGoogleFontCssUrl(url);
if (!normalizedUrl) {
return;
}
let link = document.getElementById(id) as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
document.head.appendChild(link);
}
if (link.href !== normalizedUrl) {
link.href = normalizedUrl;
}
}
function loadCustomFontStylesheets(customFonts: CustomFontOption[]): void {
for (const font of normalizeCustomFonts(customFonts)) {
loadGoogleFontUrl(font.url, `${GOOGLE_FONT_LINK_ID_PREFIX}${font.value}`);
}
}
export function applyFont(fontFamily: FontFamily, customFonts: CustomFontOption[] = []): void {
const fontOptions = getFontOptions(customFonts);
loadCustomFontStylesheets(customFonts);
const font = fontOptions.find((option) => option.value === fontFamily) ||
FONT_OPTIONS.find((option) => option.value === DEFAULT_SETTINGS.fontFamily);
if (font) {
document.documentElement.style.setProperty("--font-sans", font.fontFamily);
document.body.style.fontFamily = font.fontFamily;
}
}
async function persistCustomFontsInternal(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
const normalizedFonts = normalizeCustomFonts(customFonts);
await SaveFontsToBackend(normalizedFonts as unknown as Array<Record<string, unknown>>);
if (cachedSettings) {
cachedSettings = toNormalizedSettings({
...cachedSettings,
customFonts: normalizedFonts,
});
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cachedSettings));
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: cachedSettings }));
}
return normalizedFonts;
}
async function loadStoredCustomFonts(fallbackFonts?: unknown): Promise<CustomFontOption[]> {
try {
const storedFonts = await LoadFontsFromBackend();
if (storedFonts !== null) {
return normalizeCustomFonts(storedFonts);
}
}
catch (error) {
console.error("Failed to load custom fonts:", error);
}
const migratedFonts = normalizeCustomFonts(fallbackFonts);
if (migratedFonts.length > 0) {
try {
return await persistCustomFontsInternal(migratedFonts);
}
catch (error) {
console.error("Failed to migrate custom fonts:", error);
}
}
return migratedFonts;
}
export async function loadCustomFonts(): Promise<CustomFontOption[]> {
return loadStoredCustomFonts(getSettings().customFonts);
}
export async function saveCustomFonts(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
return persistCustomFontsInternal(customFonts);
}
function keepKnownSettings(settings: SettingsPayload): SettingsPayload {
const normalized: Record<string, unknown> = {};
for (const key of KNOWN_SETTINGS_KEYS) {
if (key in settings) {
normalized[key] = settings[key];
}
}
return normalized as SettingsPayload;
}
function normalizePreviewVolume(volume: unknown): number {
const parsed = typeof volume === "number"
? volume
: typeof volume === "string"
? Number.parseFloat(volume)
: Number.NaN;
if (!Number.isFinite(parsed)) {
return DEFAULT_SETTINGS.previewVolume;
}
return Math.min(100, Math.max(0, Math.round(parsed)));
}
function normalizeCustomTidalApi(value: unknown): string {
return typeof value === "string"
? value.trim().replace(/\/+$/g, "")
: "";
}
export function hasConfiguredCustomTidalApi(value: unknown): boolean {
return normalizeCustomTidalApi(value).startsWith("https://");
}
export function sanitizeAutoOrder(order: unknown, allowTidal: boolean): string {
const allowedServices = allowTidal
? new Set(["tidal", "qobuz", "amazon"])
: new Set(["qobuz", "amazon"]);
const fallbackOrder = allowTidal ? "tidal-qobuz-amazon" : "qobuz-amazon";
if (typeof order !== "string") {
return fallbackOrder;
}
const normalized = order
.split("-")
.map((part) => part.trim().toLowerCase())
.filter((part, index, parts) => part !== "" && allowedServices.has(part) && parts.indexOf(part) === index);
return normalized.length >= 2 ? normalized.join("-") : fallbackOrder;
}
function normalizeDownloader(value: unknown, allowTidal: boolean): Settings["downloader"] {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "tidal") {
return allowTidal ? "tidal" : "auto";
}
if (normalized === "qobuz" || normalized === "amazon" || normalized === "auto") {
return normalized;
}
return DEFAULT_SETTINGS.downloader;
}
function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
case "isrc":
case "upc":
return "isrc";
default:
return "filename";
}
}
function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
const normalized: SettingsPayload = { ...settings };
if ("darkMode" in normalized && !("themeMode" in normalized)) {
normalized.themeMode = normalized.darkMode ? "dark" : "light";
delete normalized.darkMode;
}
if (!("folderPreset" in normalized) &&
("artistSubfolder" in normalized || "albumSubfolder" in normalized)) {
const hasArtist = Boolean(normalized.artistSubfolder);
const hasAlbum = Boolean(normalized.albumSubfolder);
if (hasArtist && hasAlbum) {
normalized.folderPreset = "artist-album";
normalized.folderTemplate = "{artist}/{album}";
}
else if (hasArtist) {
normalized.folderPreset = "artist";
normalized.folderTemplate = "{artist}";
}
else if (hasAlbum) {
normalized.folderPreset = "album";
normalized.folderTemplate = "{album}";
}
else {
normalized.folderPreset = "none";
normalized.folderTemplate = "";
}
}
if (!("filenamePreset" in normalized) && "filenameFormat" in normalized) {
const format = normalized.filenameFormat;
if (format === "title-artist") {
normalized.filenamePreset = "artist-title";
normalized.filenameTemplate = "{artist} - {title}";
}
else if (format === "artist-title") {
normalized.filenamePreset = "artist-title";
normalized.filenameTemplate = "{artist} - {title}";
}
else {
normalized.filenamePreset = "title";
normalized.filenameTemplate = "{title}";
}
}
delete normalized.tidalVariant;
if (!("tidalQuality" in normalized)) {
normalized.tidalQuality = "LOSSLESS";
}
if (!("qobuzQuality" in normalized)) {
normalized.qobuzQuality = "6";
}
if (!("amazonQuality" in normalized)) {
normalized.amazonQuality = "original";
}
if (!("autoOrder" in normalized)) {
normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
}
if (!("autoQuality" in normalized)) {
normalized.autoQuality = "16";
}
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
const allowTidal = hasConfiguredCustomTidalApi(normalized.customTidalApi);
normalized.downloader = normalizeDownloader(normalized.downloader, allowTidal);
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder, allowTidal);
if (!("allowFallback" in normalized)) {
normalized.allowFallback = true;
}
if (!("linkResolver" in normalized)) {
normalized.linkResolver = "songlink";
}
if (!("allowResolverFallback" in normalized)) {
normalized.allowResolverFallback = true;
}
if (!("createPlaylistFolder" in normalized)) {
normalized.createPlaylistFolder = true;
}
if (!("playlistOwnerFolderName" in normalized)) {
normalized.playlistOwnerFolderName = false;
}
if (!("createM3u8File" in normalized)) {
normalized.createM3u8File = false;
}
normalized.previewVolume = normalizePreviewVolume(normalized.previewVolume);
normalized.existingFileCheckMode = normalizeExistingFileCheckMode(normalized.existingFileCheckMode);
if (!("useFirstArtistOnly" in normalized)) {
normalized.useFirstArtistOnly = false;
}
if (!("useSingleGenre" in normalized)) {
normalized.useSingleGenre = false;
}
if (!("embedGenre" in normalized)) {
normalized.embedGenre = false;
}
if (!("separator" in normalized)) {
normalized.separator = "semicolon";
}
if (!("redownloadWithSuffix" in normalized)) {
normalized.redownloadWithSuffix = false;
}
normalized.operatingSystem = detectOS();
const normalizedCustomFonts = normalizeCustomFonts(normalized.customFonts);
normalized.customFonts = normalizedCustomFonts;
normalized.fontFamily = normalizeFontFamily(normalized.fontFamily, normalizedCustomFonts);
return normalized;
}
function toNormalizedSettings(settings: SettingsPayload): Settings {
return {
...DEFAULT_SETTINGS,
...keepKnownSettings(normalizeSettingsPayload(settings)),
} as Settings;
}
async function persistSettingsInternal(settings: Settings, notify = true): Promise<void> {
cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
const settingsForBackend = { ...settings } as Record<string, unknown>;
delete settingsForBackend.customFonts;
await SaveToBackend(settingsForBackend);
if (notify) {
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: settings }));
}
}
async function fetchDefaultPath(): Promise<string> {
try {
const data = await GetDefaults();
return data.downloadPath || "";
}
catch (error) {
console.error("Failed to fetch default path:", error);
return "";
}
}
function getSettingsFromLocalStorage(): Settings {
try {
const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) {
return toNormalizedSettings(JSON.parse(stored) as SettingsPayload);
}
}
catch (error) {
console.error("Failed to load settings from local storage:", error);
}
return DEFAULT_SETTINGS;
}
export function getSettings(): Settings {
if (cachedSettings) {
return cachedSettings;
}
return getSettingsFromLocalStorage();
}
export async function loadSettings(): Promise<Settings> {
try {
const backendSettings = await LoadSettings();
if (backendSettings) {
const parsed = backendSettings as SettingsPayload;
const customFonts = await loadStoredCustomFonts(parsed.customFonts);
cachedSettings = toNormalizedSettings({
...parsed,
customFonts,
});
if ("customFonts" in parsed) {
await persistSettingsInternal(cachedSettings, false);
}
return cachedSettings;
}
}
catch (error) {
console.error("Failed to load settings from backend:", error);
}
const local = getSettingsFromLocalStorage();
try {
const customFonts = await loadStoredCustomFonts(local.customFonts);
const localWithFonts = toNormalizedSettings({
...local,
customFonts,
});
await persistSettingsInternal(localWithFonts, false);
cachedSettings = localWithFonts;
return localWithFonts;
}
catch (error) {
console.error("Failed to migrate settings to backend:", error);
}
cachedSettings = local;
return local;
}
export interface TemplateData {
artist?: string;
album?: string;
album_artist?: string;
title?: string;
isrc?: string;
track?: number;
disc?: number;
year?: string;
date?: string;
playlist?: string;
}
export function parseTemplate(template: string, data: TemplateData): string {
if (!template) {
return "";
}
let result = template;
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist");
result = result.replace(/\{isrc\}/g, data.isrc || "");
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
result = result.replace(/\{year\}/g, data.year || "0000");
result = result.replace(/\{date\}/g, data.date || "0000-00-00");
result = result.replace(/\{playlist\}/g, data.playlist || "");
return result;
}
export async function getSettingsWithDefaults(): Promise<Settings> {
const settings = await loadSettings();
if (!settings.downloadPath) {
settings.downloadPath = await fetchDefaultPath();
await saveSettings(settings);
}
return settings;
}
export async function saveSettings(settings: Settings): Promise<void> {
try {
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload);
await persistSettingsInternal(normalizedSettings);
}
catch (error) {
console.error("Failed to save settings:", error);
}
}
export async function updateSettings(partial: Partial<Settings>): Promise<Settings> {
const current = getSettings();
const updated = { ...current, ...partial };
await saveSettings(updated);
return updated;
}
export async function resetToDefaultSettings(): Promise<Settings> {
const defaultPath = await fetchDefaultPath();
const customFonts = await loadCustomFonts();
const defaultSettings = {
...DEFAULT_SETTINGS,
downloadPath: defaultPath,
customFonts,
};
await saveSettings(defaultSettings);
return defaultSettings;
}
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
if (mode === "auto") {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) {
document.documentElement.classList.add("dark");
}
else {
document.documentElement.classList.remove("dark");
}
}
else if (mode === "dark") {
document.documentElement.classList.add("dark");
}
else {
document.documentElement.classList.remove("dark");
}
}