feat: offline content

This commit is contained in:
gosangam
2026-07-02 21:32:22 +05:30
parent 0b67a1142f
commit 380c23e469
13 changed files with 969 additions and 14 deletions
+203
View File
@@ -0,0 +1,203 @@
"use client";
/**
* /offline-content — the reader's device-local offline library.
*
* Lists everything saved via the header's "Save offline" button and lets them
* open (offline-capable) or remove each item. Items link with a plain <a> (a
* hard navigation) so the service worker can serve the cached HTML document
* when there's no network — a soft client navigation would try the network and
* fail. This page also doubles as the SW's offline navigation fallback.
*/
import { useCallback, useEffect, useState } from "react";
import {
BookOpen,
FileText,
HardDriveDownload,
Mail,
Mic,
Newspaper,
Trash2,
Wifi,
WifiOff,
} from "lucide-react";
import { PageHeader } from "@/components/layout/page-header";
import {
listOffline,
offlineSupported,
removeOffline,
usageBytes,
type OfflineItem,
type OfflineType,
} from "@/lib/offline";
import { cn } from "@/lib/utils";
const TYPE_META: Record<OfflineType, { label: string; icon: React.ReactNode }> = {
verse: { label: "Verse", icon: <BookOpen className="size-4" /> },
prose: { label: "Chapter", icon: <BookOpen className="size-4" /> },
letter: { label: "Letter", icon: <Mail className="size-4" /> },
magazine: { label: "Magazine", icon: <Newspaper className="size-4" /> },
transcript: { label: "Transcript", icon: <Mic className="size-4" /> },
journal: { label: "Journal", icon: <FileText className="size-4" /> },
essay: { label: "Essay", icon: <FileText className="size-4" /> },
document: { label: "Document", icon: <FileText className="size-4" /> },
};
function formatBytes(n: number): string {
if (!n || n < 1024) return `${n || 0} B`;
const units = ["KB", "MB", "GB"];
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
}
function formatDate(ms: number): string {
try {
return new Date(ms).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return "";
}
}
export default function OfflineContentPage() {
const [items, setItems] = useState<OfflineItem[] | null>(null);
const [usage, setUsage] = useState<number | null>(null);
const [online, setOnline] = useState(true);
const [supported, setSupported] = useState(true);
const refresh = useCallback(async () => {
setItems(await listOffline());
setUsage(await usageBytes());
}, []);
useEffect(() => {
if (!offlineSupported()) {
setSupported(false);
setItems([]);
return;
}
setOnline(navigator.onLine);
void refresh();
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, [refresh]);
async function remove(key: string) {
await removeOffline(key);
await refresh();
}
return (
<>
<PageHeader>
<h1 className="text-2xl font-bold text-foreground">Offline library</h1>
<p className="mt-1 text-sm text-muted-foreground">
Pages and lectures you&rsquo;ve saved to read or listen to without a
connection. Stored on this device.
</p>
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
<span
className={cn(
"inline-flex items-center gap-1.5",
online ? "text-muted-foreground" : "text-amber-600",
)}
>
{online ? (
<Wifi className="size-3.5" />
) : (
<WifiOff className="size-3.5" />
)}
{online ? "Online" : "Offline"}
</span>
{usage !== null && (
<span className="inline-flex items-center gap-1.5">
<HardDriveDownload className="size-3.5" />
{formatBytes(usage)} used
</span>
)}
</div>
</PageHeader>
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-8">
{!supported ? (
<p className="py-16 text-center text-sm text-muted-foreground">
This browser doesn&rsquo;t support offline saving.
</p>
) : items === null ? (
<p className="py-16 text-center text-sm text-muted-foreground">
Loading…
</p>
) : items.length === 0 ? (
<div className="py-16 text-center">
<HardDriveDownload className="mx-auto size-8 text-muted-foreground/50" />
<p className="mt-3 text-sm text-muted-foreground">
Nothing saved yet. Tap{" "}
<span className="font-medium text-foreground">Save offline</span>{" "}
on any chapter, letter, magazine, or transcript to keep it here.
</p>
</div>
) : (
<ul className="space-y-2">
{items.map((item) => {
const meta = TYPE_META[item.type] ?? TYPE_META.document;
return (
<li
key={item.key}
className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted/50"
>
{/* Hard navigation so the SW can serve the cached document offline. */}
<a href={item.href} className="flex min-w-0 flex-1 items-center gap-3">
<span className="shrink-0 text-muted-foreground">
{meta.icon}
</span>
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">
{item.title}
</span>
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5">
{meta.label}
</span>
{item.audioKey && (
<span className="inline-flex items-center gap-1">
<Mic className="size-3" /> audio
</span>
)}
<span>{formatDate(item.savedAt)}</span>
<span>· {formatBytes(item.bytes)}</span>
</span>
</span>
</a>
<button
type="button"
onClick={() => remove(item.key)}
aria-label={`Remove ${item.title} from offline`}
title="Remove from offline"
className="shrink-0 rounded-md p-2 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="size-4" />
</button>
</li>
);
})}
</ul>
)}
</main>
</>
);
}
+9
View File
@@ -66,6 +66,15 @@ export default function SettingsPage() {
<NotificationMatrix />
</Section>
<Section
title="Offline"
description="Pages and lectures you've saved to read or listen to without a connection. Saved on this device — open the library to remove downloads and free up space."
>
<ul className="divide-y divide-border">
<LegalLink href="/offline-content" label="Manage offline library" />
</ul>
</Section>
<Section title="Authentication">
{/* Suspense boundary required because LinkResult reads
useSearchParams — Next.js bails out of SSR otherwise. */}
+9 -1
View File
@@ -93,7 +93,15 @@ export default async function TranscriptionPage({
isPartOf: "Lectures & Conversations of Śrīla Prabhupāda",
})}
/>
<PageHeader sticky>
<PageHeader
sticky
offline={{
type: "transcript",
title: t.name,
subtitle: [t.date, t.place].filter(Boolean).join(" · ") || undefined,
audioUrl: t.audioUrl ?? undefined,
}}
>
<Link
href={backHref}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+2
View File
@@ -10,6 +10,7 @@ import { AuthGateProvider } from "@/components/auth/auth-gate";
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
import { SharePostProvider } from "@/components/social/share-post-provider";
import { Toaster } from "@/components/ui/toaster";
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
import "./globals.css";
const geistSans = Geist({
@@ -106,6 +107,7 @@ export default function RootLayout({
route groups. */}
<FloatingPlayer />
<Toaster />
<ServiceWorkerRegistrar />
</SharePostProvider>
</TooltipProvider>
</AudioPlayerProvider>
+25 -5
View File
@@ -18,6 +18,19 @@ import {
syncLocalToRemote,
type ProgressEntry,
} from "@/lib/audio-progress";
import {
primeOfflineAudio,
offlineAudioURL,
releaseOfflineAudio,
} from "@/lib/offline";
/**
* Resolve the media source for a track: prefer a saved offline object URL
* (works with no network + full seeking) and fall back to the remote URL.
*/
function resolveSrc(url: string): string {
return offlineAudioURL(trackKey(url)) ?? url;
}
// ---------------------------------------------------------------------------
// Types
@@ -242,6 +255,13 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
currentIndexRef.current = currentIndex;
}, [currentIndex]);
// Build object URLs for any audio saved offline so `resolveSrc` can prefer
// them synchronously at play time (offline playback + full seeking).
useEffect(() => {
void primeOfflineAudio();
return () => releaseOfflineAudio();
}, []);
// --- Sync volume/muted to the <audio> element ---
useEffect(() => {
const audio = audioRef.current;
@@ -334,7 +354,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
setDuration(0);
setIsLoading(true);
if (audio) {
audio.src = nextTrack.url;
audio.src = resolveSrc(nextTrack.url);
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Auto-play next failed:", err);
@@ -409,7 +429,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
setDuration(0);
setIsLoading(true);
audio.src = track.url;
audio.src = resolveSrc(track.url);
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Play failed:", err);
@@ -438,7 +458,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
setDuration(0);
setIsLoading(true);
audio.src = track.url;
audio.src = resolveSrc(track.url);
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] PlayPlaylist failed:", err);
@@ -545,7 +565,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
setDuration(0);
setIsLoading(true);
audio.src = nextTrack.url;
audio.src = resolveSrc(nextTrack.url);
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Next failed:", err);
@@ -587,7 +607,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
setDuration(0);
setIsLoading(true);
audio.src = prevTrack.url;
audio.src = resolveSrc(prevTrack.url);
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Previous failed:", err);
+57 -2
View File
@@ -27,18 +27,60 @@ import { useEffect, useRef, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Check, Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
import type { OfflineDescriptor } from "@/lib/offline";
const SEARCH_RETURN_KEY = "spsays_from_search";
// Prefixes of the readable content sections. A `sticky` PageHeader on one of
// these routes is a reading page → it gets a Save-offline button automatically
// (pages can still pass an explicit `offline` prop, e.g. transcripts adding
// their audio URL).
const CONTENT_PREFIXES = [
"/books/",
"/letters/",
"/magazines/",
"/transcriptions/",
"/journals/",
"/essays/",
"/other-documents/",
];
function isContentPath(pathname: string): boolean {
return CONTENT_PREFIXES.some((p) => pathname.startsWith(p));
}
function deriveOfflineType(pathname: string): OfflineDescriptor["type"] {
if (pathname.startsWith("/letters/")) return "letter";
if (pathname.startsWith("/magazines/")) return "magazine";
if (pathname.startsWith("/transcriptions/")) return "transcript";
if (pathname.startsWith("/journals/")) return "journal";
if (pathname.startsWith("/essays/")) return "essay";
if (pathname.startsWith("/other-documents/")) return "document";
if (pathname.startsWith("/books/")) {
// A trailing numeric segment is a verse/text; otherwise a prose chapter.
const last = pathname.split("/").filter(Boolean).pop() ?? "";
return /^\d+$/.test(last) ? "verse" : "prose";
}
return "document";
}
export function PageHeader({
children,
className,
bare = false,
sticky = false,
offline,
}: {
children: React.ReactNode;
/** Drop the bordered chrome — useful for pages that want flush title text. */
bare?: boolean;
/**
* When set, a "Save offline" button appears beside "Copy link to this page"
* so the reader can keep this page (and, for transcripts, its audio) for
* offline use. Omit on pages that shouldn't be saveable (static/legal pages).
*/
offline?: OfflineDescriptor;
/**
* Stick to the top below the global SiteHeader. Used by reading pages
* so the chapter title stays visible as the user scrolls long content.
@@ -52,6 +94,16 @@ export function PageHeader({
const router = useRouter();
const pathname = usePathname();
// Reading pages (sticky header on a content route) get a Save-offline button
// automatically; a page can pass `offline` explicitly to add an audio URL or
// a precise title/subtitle. An empty derived title resolves to document.title
// at save time (see lib/offline saveOffline).
const resolvedOffline: OfflineDescriptor | undefined =
offline ??
(sticky && isContentPath(pathname)
? { type: deriveOfflineType(pathname), title: "" }
: undefined);
// Read (and consume) the search-return flag once on first render.
const [fromSearch, setFromSearch] = useState(() => {
if (typeof window === "undefined") return false;
@@ -162,8 +214,8 @@ export function PageHeader({
children
)}
{/* Copy-link-to-this-page control, anchored at the header's bottom. */}
<div className="mt-3">
{/* Page-level controls, anchored at the header's bottom. */}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={copyPageLink}
@@ -183,6 +235,9 @@ export function PageHeader({
)}
{copied ? "Link copied" : "Copy link to this page"}
</button>
{resolvedOffline ? (
<SaveOfflineButton descriptor={resolvedOffline} />
) : null}
</div>
</div>
</header>
+124
View File
@@ -0,0 +1,124 @@
"use client";
/**
* SaveOfflineButton — sits beside "Copy link to this page" in PageHeader.
*
* Saves the current page (and, for transcripts, its audio) for offline
* reading/listening via lib/offline. Shows idle → saving(%) → saved states and
* lets the user remove a saved copy. Renders nothing when the browser can't
* support offline storage.
*/
import { useEffect, useRef, useState } from "react";
import { Check, Download, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
isSaved as isSavedFn,
offlineSupported,
pageKey,
removeOffline,
saveOffline,
type OfflineDescriptor,
} from "@/lib/offline";
type State = "idle" | "saving" | "saved";
export function SaveOfflineButton({
descriptor,
}: {
descriptor: OfflineDescriptor;
}) {
const [supported, setSupported] = useState(false);
const [state, setState] = useState<State>("idle");
const [pct, setPct] = useState<number | null>(null);
const keyRef = useRef<string>("");
useEffect(() => {
if (!offlineSupported()) return;
setSupported(true);
keyRef.current = pageKey();
let alive = true;
isSavedFn(keyRef.current).then((saved) => {
if (alive && saved) setState("saved");
});
return () => {
alive = false;
};
}, []);
if (!supported) return null;
async function onClick() {
if (state === "saving") return;
if (state === "saved") {
// Toggle off — remove the offline copy.
try {
await removeOffline(keyRef.current);
setState("idle");
toast("Removed from offline", { duration: 1800 });
} catch {
toast.error("Couldn't remove the offline copy", { duration: 3000 });
}
return;
}
setState("saving");
setPct(descriptor.audioUrl ? 0 : null);
try {
const res = await saveOffline(descriptor, (f) =>
setPct(Math.round(f * 100)),
);
setState("saved");
if (res.audioError) {
toast(res.audioError, { duration: 4000 });
} else {
toast("Saved for offline", { duration: 1800 });
}
} catch {
setState("idle");
toast.error("Couldn't save this page offline", { duration: 3000 });
} finally {
setPct(null);
}
}
const label =
state === "saving"
? pct !== null
? `Saving… ${pct}%`
: "Saving…"
: state === "saved"
? "Saved offline"
: "Save offline";
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={
state === "saved" ? "Remove offline copy" : "Save this page for offline use"
}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors",
state === "saved"
? "text-foreground border-primary/40 hover:text-destructive hover:border-destructive/40 group"
: "border-border text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{state === "saving" ? (
<Loader2 className="size-3.5 animate-spin" />
) : state === "saved" ? (
<>
<Check className="size-3.5 text-primary group-hover:hidden" />
<Trash2 className="size-3.5 hidden group-hover:block" />
</>
) : (
<Download className="size-3.5" />
)}
{label}
</button>
);
}
+1
View File
@@ -33,6 +33,7 @@ const SECONDARY: NavItem[] = [
{ href: "/journals", label: "Journals" },
{ href: "/magazines", label: "Magazines" },
{ href: "/other-documents", label: "Other Documents" },
{ href: "/offline-content", label: "Offline library" },
{ href: "/about", label: "About" },
];
+6
View File
@@ -31,6 +31,7 @@ import { usePathname } from "next/navigation";
import {
AtSign,
Bookmark,
DownloadCloud,
FolderClosed,
Home,
LogIn,
@@ -95,6 +96,11 @@ const TOP_ITEMS: NavItem[] = [
label: "Mentions",
icon: <AtSign className="size-5" />,
},
{
href: "/offline-content",
label: "Offline",
icon: <DownloadCloud className="size-5" />,
},
];
export function SiteSidebar() {
+44
View File
@@ -0,0 +1,44 @@
"use client";
/**
* ServiceWorkerRegistrar — registers the app's service worker for everyone on
* load (not just when a user opts into push). The same `/sw.js` now also
* powers the Save-offline feature (caching page HTML + assets), so it must be
* active regardless of push. The `?bff=` query matches the push registration
* exactly (see lib/push.ts) so there's a single registration / scope, and push
* subscription management keeps working.
*/
import { useEffect } from "react";
import { precacheOfflineLibrary } from "@/lib/offline";
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export function ServiceWorkerRegistrar() {
useEffect(() => {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
return;
}
const register = () => {
navigator.serviceWorker
.register(`/sw.js?bff=${encodeURIComponent(BFF_BASE)}`)
.then(() => {
// Precache the offline library so it works as the SW navigation
// fallback even if the user never opens it online.
void precacheOfflineLibrary();
})
.catch(() => {
/* registration is best-effort; the app works online without it */
});
};
// Defer past first paint so it never competes with hydration.
if (document.readyState === "complete") {
register();
return;
}
window.addEventListener("load", register, { once: true });
return () => window.removeEventListener("load", register);
}, []);
return null;
}
+401
View File
@@ -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();
}
+8 -5
View File
@@ -110,11 +110,14 @@ export async function enablePush(): Promise<void> {
throw new Error("Notification permission was not granted.");
}
// The BFF origin rides along as a query param so the service worker can
// re-register rotated subscriptions (pushsubscriptionchange) on its own.
const reg = await navigator.serviceWorker.register(
`/sw.js?bff=${encodeURIComponent(BFF_BASE)}`,
);
// The service worker is registered app-wide by ServiceWorkerRegistrar (with
// the same `?bff=` query, so there's a single registration/scope). Reuse it;
// only register here as a fallback if it somehow isn't present yet.
const reg =
(await navigator.serviceWorker.getRegistration()) ??
(await navigator.serviceWorker.register(
`/sw.js?bff=${encodeURIComponent(BFF_BASE)}`,
));
await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
+80 -1
View File
@@ -6,11 +6,90 @@
* { title, body, url, icon, tag, require_interaction }
*/
// --- Offline caching (Save-offline feature) ---------------------------------
// Bump CACHE_VERSION to invalidate old asset/page caches on the next activate.
const CACHE_VERSION = "v1";
const PAGES_CACHE = `offline-pages-${CACHE_VERSION}`; // saved page HTML documents
const ASSETS_CACHE = `offline-assets-${CACHE_VERSION}`; // /_next/* + static files
const OFFLINE_CACHES = [PAGES_CACHE, ASSETS_CACHE];
// The library page doubles as the navigation fallback when offline.
const OFFLINE_FALLBACK = "/offline-content";
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) =>
event.waitUntil(self.clients.claim()),
event.waitUntil(
(async () => {
// Drop stale offline caches from previous CACHE_VERSIONs.
const names = await caches.keys();
await Promise.all(
names
.filter(
(n) => n.startsWith("offline-") && !OFFLINE_CACHES.includes(n),
)
.map((n) => caches.delete(n)),
);
await self.clients.claim();
})(),
),
);
// Treat content-hashed Next assets and static files as immutable → cache-first.
function isCacheableAsset(url) {
if (url.origin !== self.location.origin) return false;
if (url.pathname.startsWith("/_next/")) return true;
return /\.(?:js|css|woff2?|ttf|otf|png|jpe?g|webp|avif|svg|ico|gif)$/.test(
url.pathname,
);
}
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return; // never touch POSTs (push subscribe etc.)
const url = new URL(req.url);
// 1. Page navigations: network-first, fall back to a saved copy, then to the
// offline library. This is what makes a "saved" page open with no network.
if (req.mode === "navigate") {
event.respondWith(
(async () => {
try {
return await fetch(req);
} catch {
const pages = await caches.open(PAGES_CACHE);
const saved =
(await pages.match(req.url)) || (await pages.match(url.pathname));
if (saved) return saved;
const fallback = await pages.match(OFFLINE_FALLBACK);
if (fallback) return fallback;
return new Response(
"<!doctype html><meta charset=utf-8><title>Offline</title><body style=\"font-family:system-ui;padding:2rem\"><h1>You're offline</h1><p>This page hasn't been saved for offline use.</p>",
{ headers: { "Content-Type": "text/html; charset=utf-8" }, status: 503 },
);
}
})(),
);
return;
}
// 2. Immutable same-origin assets: cache-first so the app shell boots offline.
if (isCacheableAsset(url)) {
event.respondWith(
(async () => {
const cache = await caches.open(ASSETS_CACHE);
const hit = await cache.match(req);
if (hit) return hit;
const res = await fetch(req);
if (res && res.ok) cache.put(req, res.clone());
return res;
})(),
);
return;
}
// 3. Everything else (BFF data, RSC, /api): straight to network.
});
// The page registers us as /sw.js?bff=<origin> so subscription management
// works without a client tab open (the auth cookie rides on
// credentials:"include"). Same-origin deployments fall back to our origin.