feat: offline content
This commit is contained in:
+401
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* 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 `/offline-content` 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 /offline-content 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("/offline-content", 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();
|
||||
}
|
||||
Reference in New Issue
Block a user