The third segment of the note-powered wizards (Study deck / Homework / Podcast) takes the same intake - photos, documents, pasted text, the share target - and turns it into a spoken episode. - prompts: PODCAST_SYSTEM_PROMPT writes for the ear (plain prose, no markdown, no visual references), chaptered into 3-8 sections around a length target. - /api/podcast mirrors /api/generate's plumbing (rate limit, model gate, provider keys, heartbeats, the 2x deadline, the optional vision pass). - storage: frezmo.podcasts.v1, zod-validated, included in the backup and the encrypted sync payload; a new library tab lists episodes with listen / rename / delete-with-undo, and the wizard switch deep-links in. - player: the transcript is word-wrapped spans and the word under the voice rides a lyric-style highlight that keeps itself centred. Two clocks: exact onboundary events where the browser reports them, a per-word length timer where it does not (iOS Safari); the first boundary retires the timer. Pause = cancel + re-speak from the word, which makes resume, chapter jumps and tap-any-word-to-seek the same operation; the position saves on pause, page-hide and unmount, so reopening resumes. Speed rebuilds the engine and keeps playing; the Aero soundtrack pauses itself when the voice starts; a browser with no voice gets an honest note and a readable transcript. - the script sits on a glass pane (plain paragraphs over Aero's grass failed contrast; the scan now covers the player too). tests/podcast-test.mjs (17th suite) stubs the model and the speech engine and drives the highlight by hand: 26 checks. All suites green, contrast clean. Version 0.7.3, SW v95.
172 lines
5.6 KiB
TypeScript
172 lines
5.6 KiB
TypeScript
"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<Listener>();
|
|
|
|
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");
|
|
}
|