402 lines
12 KiB
TypeScript
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();
|
|
}
|