feat: offline content
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user