Files

402 lines
12 KiB
TypeScript

/**
* 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<IDBDatabase> {
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<T>(
store: string,
mode: IDBTransactionMode,
run: (s: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return openDB().then(
(db) =>
new Promise<T>((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<Blob> {
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<string>();
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<void> {
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<SaveResult> {
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<void> {
const item = await tx<OfflineItem | undefined>(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<OfflineItem[]> {
if (!offlineSupported()) return [];
try {
const all = await tx<OfflineItem[]>(ITEMS_STORE, "readonly", (s) => s.getAll());
return (all ?? []).sort((a, b) => b.savedAt - a.savedAt);
} catch {
return [];
}
}
export async function isSaved(key: string): Promise<boolean> {
if (!offlineSupported()) return false;
try {
const item = await tx<OfflineItem | undefined>(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<number | null> {
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<string, string>();
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<void> {
if (!offlineSupported()) return;
try {
const records = await tx<AudioRecord[]>(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();
}