"use client"; /** * Podcast episodes backed by localStorage — the same tiny observable store as * the homework history. An episode is the script the voice reads, the notes it * came from (so it can be regenerated) and the word the listener got to. * * Photos and documents were reduced to text on the client before this point; * originals are never stored. */ import { podcastSchema, type Podcast } from "./schemas"; import { markSyncDirty } from "./sync-state"; const STORAGE_KEY = "freezmo.podcasts.v1"; const EMPTY: Podcast[] = []; /** Enough to regenerate from, while staying well under the storage quota. */ export const MAX_STORED_SOURCE = 24_000; type Listener = () => void; let episodes: Podcast[] | null = null; let cached: Podcast[] = EMPTY; const listeners = new Set(); function read(): Podcast[] { if (typeof window === "undefined") return EMPTY; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return EMPTY; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return EMPTY; const valid: Podcast[] = []; for (const entry of parsed) { const result = podcastSchema.safeParse(entry); if (result.success) valid.push(result.data); else console.warn("[freezmo] skipping invalid podcast", result.error.issues); } return valid; } catch (error) { console.warn("[freezmo] could not read the podcasts", error); return EMPTY; } } function ensureLoaded(): void { if (episodes !== null) return; episodes = read(); cached = episodes; } function write(next: Podcast[]): void { episodes = next; cached = next; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch (error) { console.warn("[freezmo] could not save the podcasts (storage full?)", error); } /* An episode change with sync on pushes itself (see lib/sync-auto). */ if (markSyncDirty()) void import("./sync-auto").then((module) => module.scheduleAutoSync()); listeners.forEach((listener) => listener()); } /** Used by backup import and sync: wholesale replacement of the list. */ export function replacePodcasts(next: Podcast[]): void { const valid: Podcast[] = []; for (const entry of next) { const result = podcastSchema.safeParse(entry); if (result.success) valid.push(entry); else console.warn("[freezmo] skipping invalid podcast in import", result.error.issues); } write(valid); } function onStorage(event: StorageEvent): void { if (event.key === STORAGE_KEY) { episodes = null; ensureLoaded(); listeners.forEach((listener) => listener()); } } export const podcastStore = { subscribe(listener: Listener): () => void { ensureLoaded(); if (listeners.size === 0) window.addEventListener("storage", onStorage); listeners.add(listener); return () => { listeners.delete(listener); if (listeners.size === 0) window.removeEventListener("storage", onStorage); }; }, getSnapshot(): Podcast[] { ensureLoaded(); return cached; }, getServerSnapshot(): Podcast[] { return EMPTY; }, }; /** Newest first. */ export function sortPodcasts(list: Podcast[]): Podcast[] { return [...list].sort((a, b) => b.updatedAt - a.updatedAt); } export function getPodcast(id: string): Podcast | undefined { return podcastStore.getSnapshot().find((episode) => episode.id === id); } export function upsertPodcast(episode: Podcast): void { ensureLoaded(); const current = podcastStore.getSnapshot(); const index = current.findIndex((entry) => entry.id === episode.id); const next = index === -1 ? [episode, ...current] : current.map((entry) => (entry.id === episode.id ? episode : entry)); write(next); } export function renamePodcast(id: string, title: string): void { ensureLoaded(); write( podcastStore.getSnapshot().map((entry) => entry.id === id ? { ...entry, title, updatedAt: Date.now() } : entry, ), ); } export function deletePodcast(id: string): void { ensureLoaded(); write(podcastStore.getSnapshot().filter((entry) => entry.id !== id)); } /** * Where the listener got to. Written on pause/leave rather than per word, so * a long episode does not thrash localStorage. */ export function savePodcastProgress(id: string, progress: { section: number; word: number }): void { ensureLoaded(); const current = podcastStore.getSnapshot(); const episode = current.find((entry) => entry.id === id); if (!episode) return; const at = Date.now(); const previous = episode.progress; /* First words of a section are the same place a retry would start: skip the write when nothing meaningfully moved. */ if (previous && previous.section === progress.section && Math.abs(previous.word - progress.word) < 2) { return; } const next = current.map((entry) => entry.id === id ? { ...entry, progress: { ...progress, at } } : entry, ); /* updatedAt deliberately untouched: resuming must not reorder the library. */ episodes = next; cached = next; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* storage full — progress is a nicety, not worth a warning */ } } /** Rough spoken length of the whole script, in minutes (150 words a minute). */ export function podcastMinutes(episode: Podcast): number { const words = episode.sections.reduce((total, section) => total + section.text.split(/\s+/).filter(Boolean).length, 0); return Math.max(1, Math.round(words / 150)); } /** The episode's script as one string (used by tests and fallbacks). */ export function podcastScript(episode: Podcast): string { return episode.sections.map((section) => section.text).join("\n\n"); }