// ─── Persistent store (tauri-plugin-store via custom commands) ─── // // Falls back to localStorage when running outside Tauri (browser dev mode). // The store is backed by ~/Library/Application Support/app.stemdeck.desktop/user-data.json // on macOS — outside WebKit's reach, so WebView resets can never destroy user data. export async function storeGet(key, fallback = null) { if (window.__TAURI__?.core?.invoke) { try { const val = await window.__TAURI__.core.invoke("store_get", { key }); return val ?? fallback; } catch (e) { console.warn("[store] get failed for", key, e); return fallback; } } try { const raw = localStorage.getItem(key); return raw !== null ? JSON.parse(raw) : fallback; } catch (e) { console.warn("[store] localStorage get failed for", key, e); return fallback; } } export async function storeSet(key, value) { if (window.__TAURI__?.core?.invoke) { try { await window.__TAURI__.core.invoke("store_set", { key, value }); } catch (e) { console.warn("[store] set failed for", key, e); } return; } try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.warn("[store] localStorage set failed", e); } } // Debounced variant — coalesces rapid writes (e.g. mixer slider moves) into // a single store write ~300ms after the last call. Each call snapshots the // value so no mutation races occur. const _storePending = new Map(); export function storeSetDebounced(key, value, delayMs = 300) { if (_storePending.has(key)) clearTimeout(_storePending.get(key)); const snapshot = structuredClone(value); _storePending.set(key, setTimeout(() => { _storePending.delete(key); storeSet(key, snapshot); }, delayMs)); } // Keys that hold critical user data and must be migrated from localStorage // to the store on first launch after this feature ships. const _MIGRATE_KEYS = [ "stemdeck.folders", "stemdeck.deleted_jobs", "stemdeck:selected-stems", ]; // One-time bootstrap: copy localStorage → store for existing users. // On fresh installs (no localStorage data) this is a no-op that just writes // the migration flag so future upgrades can safely clear WebKit. export async function runStoreMigrationIfNeeded() { if (!window.__TAURI__?.core?.invoke) return; try { // Check if any critical key is absent from the store but present in localStorage. const needs = await Promise.all( _MIGRATE_KEYS.map(async (k) => { const inStore = await window.__TAURI__.core.invoke("store_get", { key: k }); return inStore === null && localStorage.getItem(k) !== null; }) ).then((r) => r.some(Boolean)); if (needs) { // Migrate fixed keys. for (const k of _MIGRATE_KEYS) { try { const raw = localStorage.getItem(k); if (raw !== null) { await window.__TAURI__.core.invoke("store_set", { key: k, value: JSON.parse(raw) }); } } catch (e) { console.warn("[store] migration failed for key", k, e); } } // Migrate per-job mix keys (stemdeck:mix:). for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k?.startsWith("stemdeck:mix:")) { try { const raw = localStorage.getItem(k); if (raw !== null) { await window.__TAURI__.core.invoke("store_set", { key: k, value: JSON.parse(raw) }); } } catch (e) { console.warn("[store] migration failed for key", k, e); } } } } // Write the flag regardless of whether migration was needed. This covers // fresh installs (no localStorage data) so future upgrades clear WebKit. await window.__TAURI__.core.invoke("mark_store_migration_done").catch((e) => console.warn("[store] mark_store_migration_done failed:", e) ); } catch (e) { console.warn("[store] migration error:", e); } } export function fmtTime(s) { if (!isFinite(s) || s < 0) return "00:00"; const m = Math.floor(s / 60); const sec = Math.floor(s % 60).toString().padStart(2, "0"); return `${m.toString().padStart(2, "0")}:${sec}`; } // Ruler tick: M:SS with no leading zero on the minutes digit ("0:30", "1:00", "12:30"). export function fmtTickLabel(s) { if (!isFinite(s) || s < 0) return "0:00"; const m = Math.floor(s / 60); const sec = Math.floor(s % 60).toString().padStart(2, "0"); return `${m}:${sec}`; } // Millisecond-precise timecode "mm:ss.mmm" for the exact-loop inputs. Integer-ms // math avoids a rounding carry bug (e.g. 0.9999s -> "00:01.000", not "00:00.1000"). export function fmtTimeMs(s) { if (!isFinite(s) || s < 0) return "00:00.000"; const totalMs = Math.round(s * 1000); const m = Math.floor(totalMs / 60000); const sec = Math.floor((totalMs % 60000) / 1000); const ms = totalMs % 1000; return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}.${ms .toString() .padStart(3, "0")}`; } // Parse a user-typed loop time. Accepts "mm:ss(.mmm)" (seconds field 0-59) or a // plain decimal-seconds value ("12.48"). Returns seconds, or null if unparseable. export function parseTimecode(str) { const t = String(str ?? "").trim(); const colon = /^(\d+):([0-5]?\d(?:\.\d{1,3})?)$/.exec(t); if (colon) return parseInt(colon[1], 10) * 60 + parseFloat(colon[2]); if (/^\d+(?:\.\d+)?$/.test(t)) return parseFloat(t); return null; } export const $ = (id) => document.getElementById(id);