/** * Save-offline engine — lets a reader keep any content page (and, for * transcripts, its audio) available with no network. * * Two persistence layers, both device-local (Cache Storage + IndexedDB are * per-browser, so saved bytes can't roam across devices): * - **Page HTML + `/_next` assets** → Cache Storage, served back by the * service worker (`public/sw.js`) on navigation when offline. * - **Transcript audio** → a Blob in IndexedDB, played through a pre-created * object URL (full native seeking; see `primeOfflineAudio`). * * An `items` index (IndexedDB) records what's saved so `/downloads` can * list it and the header button can show saved state. */ import { trackKey } from "@/lib/audio-progress"; // Must match CACHE_VERSION / cache names in public/sw.js. const CACHE_VERSION = "v1"; const PAGES_CACHE = `offline-pages-${CACHE_VERSION}`; const ASSETS_CACHE = `offline-assets-${CACHE_VERSION}`; const IDB_NAME = "spsays-offline"; const IDB_VERSION = 1; const ITEMS_STORE = "items"; const AUDIO_STORE = "audio"; export type OfflineType = | "verse" | "prose" | "letter" | "magazine" | "transcript" | "journal" | "essay" | "document"; /** What a content page hands the Save-offline button. */ export type OfflineDescriptor = { type: OfflineType; title: string; subtitle?: string; /** Present for transcripts — the audio is downloaded too. */ audioUrl?: string; }; /** A saved item as stored in the `items` index. */ export type OfflineItem = { key: string; // location.pathname — stable id + how the UI checks saved state href: string; // absolute page URL that was cached type: OfflineType; title: string; subtitle?: string; audioKey?: string; // trackKey(audioUrl) when audio was saved savedAt: number; bytes: number; // approximate footprint (html + assets + audio) }; type AudioRecord = { key: string; blob: Blob; type: string }; // --------------------------------------------------------------------------- // IndexedDB // --------------------------------------------------------------------------- function openDB(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(ITEMS_STORE)) { db.createObjectStore(ITEMS_STORE, { keyPath: "key" }); } if (!db.objectStoreNames.contains(AUDIO_STORE)) { db.createObjectStore(AUDIO_STORE, { keyPath: "key" }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function tx( store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest, ): Promise { return openDB().then( (db) => new Promise((resolve, reject) => { const t = db.transaction(store, mode); const req = run(t.objectStore(store)); t.oncomplete = () => { db.close(); resolve(req.result as T); }; t.onerror = () => reject(t.error); }), ); } // --------------------------------------------------------------------------- // Availability // --------------------------------------------------------------------------- export function offlineSupported(): boolean { return ( typeof window !== "undefined" && "caches" in window && "indexedDB" in window && "serviceWorker" in navigator ); } /** Item key for the page currently being viewed. */ export function pageKey(): string { return window.location.pathname; } // --------------------------------------------------------------------------- // Save / remove // --------------------------------------------------------------------------- /** Stream a URL into a Blob, reporting 0..1 progress when Content-Length is * known. Throws on network / CORS failure (caller surfaces it). */ async function downloadBlob( url: string, onProgress?: (fraction: number) => void, ): Promise { const res = await fetch(url); // CORS mode — needed to read the bytes if (!res.ok) throw new Error(`audio ${res.status}`); const type = res.headers.get("content-type") || "audio/mpeg"; const total = Number(res.headers.get("content-length")) || 0; if (!res.body || !total) return await res.blob(); const reader = res.body.getReader(); const chunks: Uint8Array[] = []; let received = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.length; onProgress?.(received / total); } return new Blob(chunks as BlobPart[], { type }); } /** Same-origin `/_next` + static asset URLs referenced by a page's HTML. */ function extractAssetUrls(html: string, baseHref: string): string[] { const urls = new Set(); const push = (raw: string | null) => { if (!raw) return; try { const u = new URL(raw, baseHref); if (u.origin !== window.location.origin) return; if ( u.pathname.startsWith("/_next/") || /\.(?:js|css|woff2?|ttf|otf|png|jpe?g|webp|avif|svg|ico|gif)$/.test( u.pathname, ) ) { urls.add(u.href); } } catch { /* ignore malformed */ } }; const doc = new DOMParser().parseFromString(html, "text/html"); doc.querySelectorAll("script[src]").forEach((el) => push(el.getAttribute("src"))); doc .querySelectorAll("link[href]") .forEach((el) => { const rel = (el.getAttribute("rel") || "").toLowerCase(); if (/stylesheet|preload|modulepreload|prefetch/.test(rel)) { push(el.getAttribute("href")); } }); return [...urls]; } /** Cache a page's HTML document + its referenced /_next & static assets so the * service worker can serve it (and boot the app shell) offline. Returns rough * sizes for the saved-item footprint. */ async function cachePageAndAssets( pageUrl: string, ): Promise<{ htmlLength: number; assetCount: number }> { // Read once, cache a fresh Response, reuse the text for asset discovery. const pageRes = await fetch(pageUrl, { credentials: "same-origin" }); if (!pageRes.ok) throw new Error(`Couldn't fetch the page (${pageRes.status}).`); const html = await pageRes.text(); const contentType = pageRes.headers.get("content-type") || "text/html; charset=utf-8"; const pagesCache = await caches.open(PAGES_CACHE); await pagesCache.put( pageUrl, new Response(html, { headers: { "Content-Type": contentType } }), ); const assetUrls = extractAssetUrls(html, pageUrl); const assetsCache = await caches.open(ASSETS_CACHE); await Promise.all( assetUrls.map(async (u) => { try { if (await assetsCache.match(u)) return; const r = await fetch(u); if (r.ok) await assetsCache.put(u, r.clone()); } catch { /* a missing asset shouldn't fail the whole save */ } }), ); return { htmlLength: html.length, assetCount: assetUrls.length }; } /** Precache the /downloads library page so it works as the SW's offline * navigation fallback even if the user never opened it online. Best-effort. */ export async function precacheOfflineLibrary(): Promise { if (!offlineSupported()) return; try { await cachePageAndAssets( new URL("/downloads", window.location.origin).href, ); } catch { /* best-effort */ } } export type SaveResult = { item: OfflineItem; audioSaved: boolean; audioError?: string; }; /** * Save the page currently being viewed for offline use. Reads window.location * for the URL/key, so callers pass only the descriptive metadata. */ export async function saveOffline( descriptor: OfflineDescriptor, onAudioProgress?: (fraction: number) => void, ): Promise { if (!offlineSupported()) throw new Error("Offline saving isn't supported here."); const href = window.location.href; const key = window.location.pathname; // Best-effort: ask the browser not to evict our storage. try { await navigator.storage?.persist?.(); } catch { /* ignore */ } // 1 + 2. Page HTML + its /_next & static assets. const { htmlLength, assetCount } = await cachePageAndAssets(href); // 3. Audio (transcripts). let audioKey: string | undefined; let audioBytes = 0; let audioSaved = false; let audioError: string | undefined; if (descriptor.audioUrl) { try { const blob = await downloadBlob(descriptor.audioUrl, onAudioProgress); audioKey = trackKey(descriptor.audioUrl); await tx(AUDIO_STORE, "readwrite", (s) => s.put({ key: audioKey, blob, type: blob.type } as AudioRecord), ); audioBytes = blob.size; audioSaved = true; // Make it playable this session without a reload. cacheAudioObjectURL(audioKey, blob); } catch { // Text is still saved; the audio host likely lacks CORS headers. audioError = "Audio couldn't be downloaded (the page text is saved). Try again on a better connection."; } } const bytes = htmlLength + assetCount * 40_000 /* rough */ + audioBytes; const item: OfflineItem = { key, href, type: descriptor.type, // Fall back to the live document title when the header derived the // descriptor without an explicit title. title: descriptor.title || document.title || href, subtitle: descriptor.subtitle, audioKey, savedAt: Date.now(), bytes, }; await tx(ITEMS_STORE, "readwrite", (s) => s.put(item)); return { item, audioSaved, audioError }; } /** Remove a saved item. Shared `/_next` assets are intentionally left in the * assets cache (other saved pages may need them; they're purged on version * bump). */ export async function removeOffline(key: string): Promise { const item = await tx(ITEMS_STORE, "readonly", (s) => s.get(key), ); if (item) { try { const pages = await caches.open(PAGES_CACHE); await pages.delete(item.href); } catch { /* ignore */ } if (item.audioKey) { await tx(AUDIO_STORE, "readwrite", (s) => s.delete(item.audioKey!)); revokeAudioObjectURL(item.audioKey); } } await tx(ITEMS_STORE, "readwrite", (s) => s.delete(key)); } export async function listOffline(): Promise { if (!offlineSupported()) return []; try { const all = await tx(ITEMS_STORE, "readonly", (s) => s.getAll()); return (all ?? []).sort((a, b) => b.savedAt - a.savedAt); } catch { return []; } } export async function isSaved(key: string): Promise { if (!offlineSupported()) return false; try { const item = await tx(ITEMS_STORE, "readonly", (s) => s.get(key), ); return !!item; } catch { return false; } } /** Total bytes our storage occupies, if the browser reports it. */ export async function usageBytes(): Promise { try { const est = await navigator.storage?.estimate?.(); return est?.usage ?? null; } catch { return null; } } // --------------------------------------------------------------------------- // Offline audio object-URL cache (synchronous lookup for the player) // --------------------------------------------------------------------------- const audioObjectURLs = new Map(); function cacheAudioObjectURL(key: string, blob: Blob) { const existing = audioObjectURLs.get(key); if (existing) URL.revokeObjectURL(existing); audioObjectURLs.set(key, URL.createObjectURL(blob)); } function revokeAudioObjectURL(key: string) { const url = audioObjectURLs.get(key); if (url) { URL.revokeObjectURL(url); audioObjectURLs.delete(key); } } /** * Build object URLs for every saved audio blob up front, so the player can look * one up **synchronously** at play time (an async blob fetch inside a click * handler would break the iOS autoplay user-gesture requirement). */ export async function primeOfflineAudio(): Promise { if (!offlineSupported()) return; try { const records = await tx(AUDIO_STORE, "readonly", (s) => s.getAll(), ); for (const rec of records ?? []) { if (!audioObjectURLs.has(rec.key)) cacheAudioObjectURL(rec.key, rec.blob); } } catch { /* no offline audio */ } } /** Synchronous object-URL lookup for a track key, or undefined if not saved. */ export function offlineAudioURL(key: string): string | undefined { return audioObjectURLs.get(key); } /** Revoke all object URLs (call on player unmount). */ export function releaseOfflineAudio(): void { for (const url of audioObjectURLs.values()) URL.revokeObjectURL(url); audioObjectURLs.clear(); }