// src/lib/stores/preferences.svelte.js import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { AccessibilityPreferences, Preferences } from "$lib/types/app"; import { errorMessage } from "$lib/utils/errors.js"; import { toasts } from "./toasts.svelte.ts"; export const PREFERENCES_CHANGED_EVENT = "lumotia:preferences-changed"; type FontFamilies = Record; function currentWindowLabel() { try { return getCurrentWindow().label; } catch { return null; } } function broadcastPreferences(prefs: Preferences) { const source = currentWindowLabel(); if (source === null) return; // Fire-and-forget — cross-window sync must never block the local apply path. emit(PREFERENCES_CHANGED_EVENT, { source, prefs: JSON.parse(JSON.stringify(prefs)) }) .catch(() => {}); } const DEFAULTS: Preferences = { theme: "dark", zone: "default", recordActivationEvents: true, accessibility: { fontFamily: "lexend", fontSize: 16, letterSpacing: 0, lineHeight: 1.5, transcriptSize: 16, bionicReading: false, reduceMotion: "system", }, }; const FONT_FAMILIES: FontFamilies = { lexend: "'Lexend', system-ui, sans-serif", atkinson: "'Atkinson Hyperlegible Next', system-ui, sans-serif", opendyslexic: "'OpenDyslexic', system-ui, sans-serif", }; function readFromDOM(): Preferences { const el = document.documentElement; return { theme: (el.dataset.theme || DEFAULTS.theme) as Preferences["theme"], zone: el.dataset.zone || DEFAULTS.zone, recordActivationEvents: el.dataset.recordActivationEvents !== "false", accessibility: { fontFamily: (el.dataset.fontFamily || DEFAULTS.accessibility.fontFamily) as AccessibilityPreferences["fontFamily"], fontSize: parseFloat(el.style.getPropertyValue('--font-size-body')) || DEFAULTS.accessibility.fontSize, letterSpacing: parseFloat(el.style.getPropertyValue('--letter-spacing-body')) || DEFAULTS.accessibility.letterSpacing, lineHeight: parseFloat(el.style.getPropertyValue('--line-height-body')) || DEFAULTS.accessibility.lineHeight, transcriptSize: DEFAULTS.accessibility.transcriptSize, bionicReading: el.dataset.bionicReading === "true", reduceMotion: (el.dataset.reduceMotion || DEFAULTS.accessibility.reduceMotion) as AccessibilityPreferences["reduceMotion"], }, }; } function applyToDOM(prefs: Preferences) { const el = document.documentElement; // Theme — resolve 'system' to actual value el.dataset.theme = prefs.theme === "system" ? (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : prefs.theme; // Zone if (prefs.zone === "default") { delete el.dataset.zone; } else { el.dataset.zone = prefs.zone; } // recordActivationEvents — persisted via save_preferences, mirrored to DOM for readFromDOM el.dataset.recordActivationEvents = String(prefs.recordActivationEvents); // Accessibility — inline styles for highest specificity const a = prefs.accessibility; el.style.setProperty('--font-family-body', FONT_FAMILIES[a.fontFamily] || FONT_FAMILIES.lexend); el.style.setProperty('--font-size-body', `${a.fontSize}px`); el.style.setProperty('--letter-spacing-body', `${a.letterSpacing}em`); el.style.setProperty('--line-height-body', String(a.lineHeight)); el.dataset.bionicReading = String(a.bionicReading); el.dataset.fontFamily = a.fontFamily; // Reduce motion — three-value resolution const motionReduced = a.reduceMotion === "on" || (a.reduceMotion === "system" && window.matchMedia("(prefers-reduced-motion: reduce)").matches); if (motionReduced) { el.dataset.reduceMotion = "true"; } else { delete el.dataset.reduceMotion; } } let saveTimeout: ReturnType | undefined; // Show the failure toast at most once per process so a stuck SQLite path // doesn't spam the user every time they nudge a slider. let saveFailureToastShown = false; function persistToSQLite(prefs: Preferences) { clearTimeout(saveTimeout); saveTimeout = setTimeout(async () => { try { await invoke("save_preferences", { preferences: JSON.stringify(prefs) }); saveFailureToastShown = false; } catch (e) { console.error("Failed to save preferences:", e); if (!saveFailureToastShown) { toasts.warn( "Could not save preferences", `${errorMessage(e)}. Your changes still apply for this session.`, ); saveFailureToastShown = true; } } }, 500); } // Export as const so it is never reassigned — consumers hold a stable reference // and mutations are tracked by Svelte 5's deep reactivity. export const preferences = $state(readFromDOM()); // Ensure data-theme and zone attributes are always present on the DOM, even // when there is no Tauri webview injection script (e.g. browser dev mode). if (typeof window !== 'undefined') { applyToDOM(preferences); } /** @deprecated Use `preferences` directly — kept for backwards compatibility */ export function getPreferences() { return preferences; } export function updatePreferences(updates: Partial) { Object.assign(preferences, updates); applyToDOM(preferences); persistToSQLite(preferences); broadcastPreferences(preferences); } export function updateAccessibility(updates: Partial) { Object.assign(preferences.accessibility, updates); applyToDOM(preferences); persistToSQLite(preferences); broadcastPreferences(preferences); } // Apply preferences received from another Tauri window. Mutates local state // and DOM only — never persists or re-broadcasts, so there is no echo loop. export function applyExternalPreferences(prefs: Partial | null | undefined) { if (!prefs || typeof prefs !== "object") return; Object.assign(preferences, prefs); if (prefs.accessibility) { Object.assign(preferences.accessibility, prefs.accessibility); } applyToDOM(preferences); } // Re-resolve when OS preferences change if (typeof window !== "undefined") { window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (preferences.theme === "system") applyToDOM(preferences); }); window.matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", () => { if (preferences.accessibility.reduceMotion === "system") applyToDOM(preferences); }); }