diff --git a/docs/16-learning-areas.mdx b/docs/16-learning-areas.mdx index 27561487..924fda95 100644 --- a/docs/16-learning-areas.mdx +++ b/docs/16-learning-areas.mdx @@ -18,7 +18,7 @@ area section is written so one work session can pick it up on its own. | Area | Route | Status | What it is | |---|---|---|---| | Courses | `/catalog`, `/my-courses`, `/courses/...` | Open | Full courses: read, play, listen, prove. | -| Listen | `/listen` | Open | Every course with listen-along audio, its chapters and hours. Videos later. | +| Audiobooks | `/audiobooks`, `/audiobooks/[course]` | Open | Every narrated course as an audiobook, with a player that follows you across pages. | | Labs | `/labs` | Partly open | Visual labs open; guided and shell labs planned. | | Five-minute practice | `/practice` | Coming soon | One topic a few minutes a day, led by questions. | | Puzzles | `/puzzles` | Coming soon | Several kinds of daily puzzle. | @@ -42,13 +42,13 @@ an area, change its `status` there and replace its overview page with the real f **Routes.** The area pages live in the `(catalog)` route group, next to `/catalog`, so they share its layout: public for visitors (a simple header with Courses, Labs and All areas), and inside the learner workspace shell for signed-in learners. They need no API and no database, so they render even when the -API is down. The one exception is `/listen`, which reads the catalog for course titles and shows an +API is down. The one exception is `/audiobooks`, which reads the catalog for course titles and shows an empty state when the API is unreachable. ``` frontend/src/app/(learn)/(catalog)/ catalog/ Courses (existing) - listen/ Listen: courses with audio, read from the live manifests + audiobooks/ Audiobooks: narrated courses as books, read from the live manifests learn/ The hub: every area on one page labs/ Labs overview: the three kinds, open challenges, how a visual lab works visual/ Every visual lab, grouped @@ -85,23 +85,31 @@ gate that route (and drop the slugs from `sitemap.ts`). Unchanged. See [`01-domain-model.mdx`](01-domain-model.mdx) through [`12-course-variants-and-publishing.mdx`](12-course-variants-and-publishing.mdx). Courses stay the spine: every other area links back to the modules it practises. -## Listen (open) +## Audiobooks (open) -Code: `app/(learn)/(catalog)/listen/page.tsx`, `lib/listen-library.ts` (server loader), `lib/listen-summary.ts` -(pure helpers, tested in `tests/unit/lib/listen-summary.test.ts`). +Replaced the Listen area in November 2026. `/listen` and anything under it redirect to `/audiobooks` +(permanent, in `next.config.ts`). -- Course list: the published catalog (`/api/v1/catalog`). Audio: each course's live manifest at +Code: `app/(learn)/(catalog)/audiobooks/page.tsx` (shelf), `audiobooks/[courseSlug]/page.tsx` (book page), +`lib/audiobook-library.ts` (server loader), `lib/audiobooks.ts` and `lib/audiobook-cover.ts` (pure helpers, +tested in `tests/unit/lib/audiobooks.test.ts`), `lib/audiobook-progress.ts` (saved place) and +`components/audiobooks/` (cover, shelf, book page, player). + +- Books: the published catalog (`/api/v1/catalog`) joined with each course's live manifest at `https://audio.edorax.com//manifest.json`, cached for five minutes. `NARRATION_BASE_URL` - (https only) points it elsewhere, as it does for the reader. A course shows up as soon as its - manifest exists; nothing in the database changes. -- Each course card shows its chapter count and total hours, a "Listen from the start" button and the - full chapter list. Links open the reader at `/courses//#listen`, the chapter's - Listen player (`components/learn/chapter-audio.tsx` carries `id="listen"`). -- Courses are grouped by catalog category, in the order of `lib/course-categories.ts`. -- Local development reads reader audio from `public/narration`, which has only a few courses, so a - chapter link can open a reader without a player there. Production has every manifest. -- Videos: shown as coming soon on the page and the landing card. When they exist they can join this - area (a "Watch" tab beside Listen) or become their own area; decide when the first videos ship. + (https only) points it elsewhere. A course shows up as soon as it has a manifest; nothing is generated here. +- Tracks: the manifest's numbered files (`01.02-...mp3`) give the part and chapter; chapter and part titles + come from the public course outline (`/api/v1/catalog/`), falling back to the slug. +- Covers: drawn as SVG from the title, subtitle (after the first colon), category and author (Zeeshan Adil) + on the Edorax palette. The same SVG, turned into a PNG, is the lock-screen artwork. +- Player: `AudiobookPlayerProvider` in the root layout, so audio keeps playing across pages. A mini player + sits above the phone tab bar and beside the desktop sidebar (CSS variables `--audiobook-bar-*` in + `globals.css`); it opens a full player with skip back 15 s and forward 30 s, 0.75x to 2x, a sleep timer, + the chapter list and auto-advance. Media Session handles lock-screen and headphone controls. The + chapter reader's own Listen player and this one pause each other (`data-narration`). +- Saved place: per chapter and per book in `localStorage` (`edorax:audiobooks:v1`), on this device only, + because there is no listening-progress model on the server yet. It also writes the reader player's + per-file key, so stopping in one player resumes in the other. ## Course categories diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 93789a30..555f58ac 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -35,6 +35,13 @@ const nextConfig: NextConfig = { }, ]; }, + async redirects() { + // The Listen area became Audiobooks; old links and bookmarks land on the shelf. + return [ + { source: "/listen", destination: "/audiobooks", permanent: true }, + { source: "/listen/:path*", destination: "/audiobooks", permanent: true }, + ]; + }, async rewrites() { return { beforeFiles: [], diff --git a/frontend/src/app/(learn)/(catalog)/audiobooks/[courseSlug]/page.tsx b/frontend/src/app/(learn)/(catalog)/audiobooks/[courseSlug]/page.tsx new file mode 100644 index 00000000..450d11ee --- /dev/null +++ b/frontend/src/app/(learn)/(catalog)/audiobooks/[courseSlug]/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { SITE_SHARE_IMAGE } from "@/lib/site"; +import { loadAudiobook } from "@/lib/audiobook-library"; +import { categoryLabel, formatBookLength } from "@/lib/audiobooks"; +import { AreaTabs } from "@/components/areas/area-tabs"; +import { BookCover } from "@/components/audiobooks/book-cover"; +import { BookResume, TrackList } from "@/components/audiobooks/book-detail-client"; + +type Params = { params: Promise<{ courseSlug: string }> }; + +export async function generateMetadata({ params }: Params): Promise { + const { courseSlug } = await params; + const book = await loadAudiobook(courseSlug); + if (!book) return { title: "Audiobook not found" }; + return { + title: `${book.title}, the audiobook`, + description: `${book.tracks.length} chapters, ${formatBookLength(book.totalMinutes)}, by ${book.author}.${book.description ? ` ${book.description.slice(0, 200)}` : ""}`, + alternates: { canonical: `/audiobooks/${book.slug}` }, + openGraph: { title: `${book.title}, the audiobook`, url: `/audiobooks/${book.slug}`, images: [SITE_SHARE_IMAGE] }, + }; +} + +export default async function AudiobookPage({ params }: Params) { + const { courseSlug } = await params; + const book = await loadAudiobook(courseSlug); + if (!book) notFound(); + + return ( +
+ + + + All audiobooks + + +
+
+ +
+ +
+

+ {categoryLabel(book.category)} + {book.subcategory ? ` · ${book.subcategory}` : ""} +

+

{book.title}

+

+ Written by + {book.author} +

+

+ {formatBookLength(book.totalMinutes)} · {book.parts.length} {book.parts.length === 1 ? "part" : "parts"} · {book.tracks.length} chapters +

+ + + + {book.description ?

{book.description}

: null} +

+ + Read the course + + : the same chapters with diagrams, labs and quizzes. +

+ +

Chapters

+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/app/(learn)/(catalog)/audiobooks/page.tsx b/frontend/src/app/(learn)/(catalog)/audiobooks/page.tsx new file mode 100644 index 00000000..cd02d51e --- /dev/null +++ b/frontend/src/app/(learn)/(catalog)/audiobooks/page.tsx @@ -0,0 +1,99 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { SITE_SHARE_IMAGE } from "@/lib/site"; +import { areaById } from "@/lib/learn-areas"; +import { COURSE_CATEGORY_INFO } from "@/lib/course-categories"; +import { loadAudiobookLibrary } from "@/lib/audiobook-library"; +import { formatBookLength, type Audiobook } from "@/lib/audiobooks"; +import { AreaHero, AreaPage, AreaSection } from "@/components/areas/area-page"; +import { BookCover } from "@/components/audiobooks/book-cover"; +import { ContinueListening, ShelfProgress } from "@/components/audiobooks/library-client"; +import { Dot } from "@/components/brand/dot"; + +export const metadata: Metadata = { + title: "Audiobooks", + description: "Every narrated Edorax course as an audiobook: chapters as tracks, a player that follows you around the site, and your place kept in every book.", + alternates: { canonical: "/audiobooks" }, + openGraph: { title: "Audiobooks on Edorax", url: "/audiobooks", images: [SITE_SHARE_IMAGE] }, +}; + +function ShelfBook({ book }: { book: Audiobook }) { + return ( +
  • + + +

    {book.title}

    + +

    {book.author}

    +

    + {formatBookLength(book.totalMinutes)}, {book.tracks.length} chapters +

    + +
  • + ); +} + +export default async function AudiobooksPage() { + const area = areaById("audiobooks"); + const { books, catalogReachable } = await loadAudiobookLibrary(); + const minutes = books.reduce((sum, book) => sum + book.totalMinutes, 0); + const chapters = books.reduce((sum, book) => sum + book.tracks.length, 0); + const shelves = COURSE_CATEGORY_INFO.map((category) => ({ + ...category, + books: books.filter((book) => book.category === category.value), + })).filter((shelf) => shelf.books.length > 0); + + return ( + + + {books.length > 0 ? ( +
    + {[ + ["Books", String(books.length)], + ["Chapters", String(chapters)], + ["Listening", formatBookLength(minutes)], + ].map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    + ) : null} +
    + + + + {shelves.length > 0 ? ( + shelves.map((shelf) => ( + +
      + {shelf.books.map((book) => ( + + ))} +
    +
    + )) + ) : ( +
    + +

    {catalogReachable ? "No audiobooks yet" : "Books could not be loaded"}

    +

    + {catalogReachable + ? "A course shows up here as soon as its chapters are read aloud." + : "The course list is not reachable right now. Try again in a moment."} +

    +
    + )} +
    + ); +} diff --git a/frontend/src/app/(learn)/(catalog)/learn/page.tsx b/frontend/src/app/(learn)/(catalog)/learn/page.tsx index 3c7d9245..93b0d527 100644 --- a/frontend/src/app/(learn)/(catalog)/learn/page.tsx +++ b/frontend/src/app/(learn)/(catalog)/learn/page.tsx @@ -33,7 +33,7 @@ export default function LearnHubPage() { href={area.href} status={area.status} icon={} - cta={area.status === "coming-soon" ? "See what is coming" : area.id === "listen" ? "Start listening" : `Open ${area.short.toLowerCase()}`} + cta={area.status === "coming-soon" ? "See what is coming" : area.id === "audiobooks" ? "Open the shelf" : `Open ${area.short.toLowerCase()}`} meta={area.tagline} /> ); diff --git a/frontend/src/app/(learn)/(catalog)/listen/page.tsx b/frontend/src/app/(learn)/(catalog)/listen/page.tsx deleted file mode 100644 index 92fdcc5a..00000000 --- a/frontend/src/app/(learn)/(catalog)/listen/page.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { ChevronDown, Headphones, Play } from "lucide-react"; -import { SITE_SHARE_IMAGE } from "@/lib/site"; -import { areaById } from "@/lib/learn-areas"; -import { COURSE_CATEGORY_INFO } from "@/lib/course-categories"; -import { loadListenLibrary, type ListenCourse } from "@/lib/listen-library"; -import { formatListenTotal, listenHref } from "@/lib/listen-summary"; -import { formatListenMinutes } from "@/lib/narration-format"; -import { AreaHero, AreaPage, AreaSection, ComingSoonPanel, StepList } from "@/components/areas/area-page"; -import { Dot } from "@/components/brand/dot"; -import { buttonVariants } from "@/components/ui/button"; - -export const metadata: Metadata = { - title: "Listen", - description: "Every Edorax course with listen-along audio, with its chapters and hours. Press play in any chapter and read along.", - alternates: { canonical: "/listen" }, - openGraph: { title: "Listen on Edorax", url: "/listen", images: [SITE_SHARE_IMAGE] }, -}; - -function ListenCourseCard({ course }: { course: ListenCourse }) { - const { chapters, totalMinutes } = course.summary; - const first = chapters[0]!; - return ( -
  • -
    - - - - - {chapters.length} {chapters.length === 1 ? "chapter" : "chapters"}, {formatListenTotal(totalMinutes)} - -
    -

    {course.title}

    -

    {course.description ?? ""}

    -
    - - - Listen from the start - - - About the course - -
    -
    - - Every chapter - - -
      - {chapters.map((chapter, i) => ( -
    1. - - {i + 1} - {chapter.title} - {formatListenMinutes(chapter.minutes)} - -
    2. - ))} -
    -
    -
  • - ); -} - -export default async function ListenPage() { - const area = areaById("listen"); - const { courses, catalogReachable } = await loadListenLibrary(); - const chapterCount = courses.reduce((sum, course) => sum + course.summary.chapters.length, 0); - const minutes = courses.reduce((sum, course) => sum + course.summary.totalMinutes, 0); - const sections = COURSE_CATEGORY_INFO.map((category) => ({ - ...category, - courses: courses.filter((course) => course.category === category.value), - })).filter((section) => section.courses.length > 0); - - return ( - - - {courses.length > 0 ? ( -
    - {[ - ["Courses", String(courses.length)], - ["Chapters", String(chapterCount)], - ["Listening", formatListenTotal(minutes)], - ].map(([label, value]) => ( -
    -
    {label}
    -
    {value}
    -
    - ))} -
    - ) : null} -
    - - {sections.length > 0 ? ( - sections.map((section) => ( - - {/* items-start: opening one card's chapter list must not stretch the others in its row. */} -
      - {section.courses.map((course) => ( - - ))} -
    -
    - )) - ) : ( -
    - -

    - {catalogReachable ? "No audio yet" : "Courses could not be loaded"} -

    -

    - {catalogReachable - ? "Courses show up here as soon as their chapters are read aloud." - : "The course list is not reachable right now. Try again in a moment."} -

    -
    - )} - - - - - - - Short videos for the chapters that are easier to see than to read. They will sit inside each course chapter, next to the text and the audio. - -
    - ); -} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 80eb3c02..23699538 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -934,6 +934,39 @@ } +/* Audiobook mini player: it sits above the phone tab bar and beside the desktop sidebar of the + learner workspace, and lifts floating buttons (Ask AI, focus dock) so they stay clear of it. */ +:root { + --audiobook-bar-bottom: 0px; + --audiobook-bar-left: 0px; + --audiobook-bar-safe: env(safe-area-inset-bottom, 0px); + --audiobook-bar-space: 0px; +} +body:has([data-learner-bottom-nav]) { + --audiobook-bar-bottom: calc(4rem + env(safe-area-inset-bottom, 0px)); + --audiobook-bar-safe: 0px; +} +@media (min-width: 64rem) { + body:has([data-learner-bottom-nav]) { + --audiobook-bar-bottom: 0px; + --audiobook-bar-left: 15rem; + --audiobook-bar-safe: env(safe-area-inset-bottom, 0px); + } +} +body:has([data-audiobook-bar]) { + --audiobook-bar-space: 4.75rem; +} + +@keyframes audiobook-eq { + 0%, + 100% { + transform: scaleY(0.35); + } + 50% { + transform: scaleY(1); + } +} + /* * The reading palette lives in the utilities layer: the typography plugin's prose-neutral is a * utility, so values set in the base layer lost to its grey (#404040) and lesson text was not ink. diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 7bff0909..8c90e3fc 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import localFont from "next/font/local"; import { PostHogProvider } from "@/components/posthog-provider"; import { AppToast } from "@/components/ui/app-toast"; import { PwaSupport } from "@/components/pwa-support"; +import { AudiobookPlayerProvider } from "@/components/audiobooks/audiobook-player"; import { ThemeProvider } from "@/components/theme-provider"; import { THEME_INIT_SCRIPT } from "@/lib/theme-init-script"; import { SITE_DESCRIPTION, SITE_NAME, SITE_SHARE_IMAGE, SITE_URL } from "@/lib/site"; @@ -72,7 +73,9 @@ export default function RootLayout({ suppressHydrationWarning /> - {children} + + {children} + diff --git a/frontend/src/components/areas/area-icon.tsx b/frontend/src/components/areas/area-icon.tsx index 0b13f72f..5502a028 100644 --- a/frontend/src/components/areas/area-icon.tsx +++ b/frontend/src/components/areas/area-icon.tsx @@ -3,7 +3,7 @@ import type { LabKindId, LearnAreaId } from "@/lib/learn-areas"; export const AREA_ICON: Record = { courses: BookOpenText, - listen: Headphones, + audiobooks: Headphones, labs: FlaskConical, practice: Sparkles, puzzles: Puzzle, diff --git a/frontend/src/components/audiobooks/audiobook-player.tsx b/frontend/src/components/audiobooks/audiobook-player.tsx new file mode 100644 index 00000000..0b64a8ca --- /dev/null +++ b/frontend/src/components/audiobooks/audiobook-player.tsx @@ -0,0 +1,357 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { coverSvg } from "@/lib/audiobook-cover"; +import { readAudiobookStore, readReaderPosition, saveSpeed, saveTrackState, writeReaderPosition } from "@/lib/audiobook-progress"; +import { SKIP_BACK_SECONDS, SKIP_FORWARD_SECONDS, resumeAt, type Audiobook } from "@/lib/audiobooks"; +import { coverInput } from "./book-cover"; +import { MiniPlayer } from "./mini-player"; +import { NowPlaying } from "./now-playing"; + +// One audiobook player for the whole site. It lives in the root layout, so the audio keeps playing +// while the learner moves between pages, and a mini player sits at the bottom of every page once a +// book is playing. Nothing loads until someone presses play. + +export type PlayerStatus = "loading" | "playing" | "paused" | "ended" | "error"; + +export type AudiobookPlayer = { + book: Audiobook | null; + index: number; + status: PlayerStatus; + time: number; + duration: number; + speed: number; + /** Seconds until the sleep timer stops playback, "end" for the end of this chapter, or null when off. */ + sleep: number | "end" | null; + expanded: boolean; + /** Starts a book at a chapter (default: where it was left). Pressing it on the playing chapter toggles. */ + play: (book: Audiobook, index?: number, at?: number) => void; + toggle: () => void; + seek: (seconds: number) => void; + skip: (seconds: number) => void; + next: () => void; + previous: () => void; + setSpeed: (speed: number) => void; + setSleep: (choice: number | "end" | null) => void; + setExpanded: (open: boolean) => void; + close: () => void; +}; + +const PlayerContext = createContext(null); + +export function useAudiobookPlayer(): AudiobookPlayer { + const player = useContext(PlayerContext); + if (!player) throw new Error("useAudiobookPlayer needs AudiobookPlayerProvider"); + return player; +} + +const SAVE_EVERY_SECONDS = 5; +const artworkCache = new Map(); + +/** Lock screens want a bitmap; draw the SVG cover onto a canvas once per book. */ +async function coverArtwork(book: Audiobook): Promise { + const cached = artworkCache.get(book.slug); + if (cached) return cached; + try { + const svg = coverSvg(coverInput(book)); + const image = new Image(); + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = 400; + canvas.height = 600; + canvas.getContext("2d")?.drawImage(image, 0, 0, 400, 600); + const url = canvas.toDataURL("image/png"); + artworkCache.set(book.slug, url); + return url; + } catch { + return null; + } +} + +export function AudiobookPlayerProvider({ children }: { children: ReactNode }) { + const audio = useRef(null); + const pendingSeek = useRef(0); + const lastSaved = useRef(0); + const sleepAt = useRef(null); + const [book, setBook] = useState(null); + const [index, setIndex] = useState(0); + const [status, setStatus] = useState("paused"); + const [time, setTime] = useState(0); + const [duration, setDuration] = useState(0); + // Speed is only shown once a book is playing, so reading it in the browser cannot mismatch the server HTML. + const [speed, setSpeedState] = useState(() => (typeof window === "undefined" ? 1 : (readAudiobookStore().speed ?? 1))); + const [sleep, setSleepState] = useState(null); + const [expanded, setExpanded] = useState(false); + + const track = book?.tracks[index] ?? null; + + const save = useCallback( + (done = false) => { + const element = audio.current; + if (!element || !book || !track) return; + const d = Number.isFinite(element.duration) ? element.duration : 0; + const p = done ? d : element.currentTime; + saveTrackState(book.slug, track.slug, done ? { p: d, d, done: true } : { p, d }); + writeReaderPosition(track.src, done ? 0 : p); + lastSaved.current = p; + }, + [book, track], + ); + + const load = useCallback((next: Audiobook, nextIndex: number, at: number | undefined) => { + const element = audio.current; + const nextTrack = next.tracks[nextIndex]; + if (!element || !nextTrack) return; + const saved = readAudiobookStore().books[next.slug]?.tracks[nextTrack.slug]; + pendingSeek.current = at ?? (resumeAt(saved) || (saved?.done ? 0 : readReaderPosition(nextTrack.src))); + lastSaved.current = pendingSeek.current; + setBook(next); + setIndex(nextIndex); + setTime(pendingSeek.current); + setDuration(nextTrack.minutes * 60); + setStatus("loading"); + element.src = nextTrack.src; + element.playbackRate = readAudiobookStore().speed ?? element.playbackRate; + void element.play().catch((error: unknown) => { + // A newer load or a pause interrupts this play(); only a real failure is an error. + if (!(error instanceof DOMException && error.name === "AbortError")) setStatus("error"); + }); + }, []); + + const toggle = useCallback(() => { + const element = audio.current; + if (!element || !book) return; + if (!element.paused) { + element.pause(); + return; + } + if (status === "ended" || status === "error") { + load(book, status === "ended" ? 0 : index, status === "ended" ? 0 : element.currentTime); + return; + } + setStatus("loading"); + void element.play().catch(() => setStatus("error")); + }, [book, index, load, status]); + + const play = useCallback( + (next: Audiobook, nextIndex?: number, at?: number) => { + const same = book?.slug === next.slug; + const target = nextIndex ?? (same ? index : undefined); + if (same && target === index && status !== "ended" && status !== "error" && at === undefined) { + toggle(); + return; + } + if (book && track) save(); + if (target === undefined) { + const saved = readAudiobookStore().books[next.slug]; + const last = saved ? next.tracks.findIndex((t) => t.slug === saved.track) : -1; + load(next, Math.max(0, last), undefined); + return; + } + load(next, target, at); + }, + [book, index, load, save, status, toggle, track], + ); + + const seek = useCallback((seconds: number) => { + const element = audio.current; + if (!element) return; + const end = Number.isFinite(element.duration) ? element.duration : seconds; + element.currentTime = Math.max(0, Math.min(end, seconds)); + setTime(element.currentTime); + }, []); + + const skip = useCallback((seconds: number) => seek((audio.current?.currentTime ?? 0) + seconds), [seek]); + + const next = useCallback(() => { + if (book && index + 1 < book.tracks.length) { + save(); + load(book, index + 1, 0); + } + }, [book, index, load, save]); + + const previous = useCallback(() => { + if (!book) return; + // As in every audio app: a few seconds in, "previous" restarts the chapter. + if ((audio.current?.currentTime ?? 0) > 5 || index === 0) { + seek(0); + return; + } + save(); + load(book, index - 1, 0); + }, [book, index, load, save, seek]); + + const setSpeed = useCallback((requested: number) => { + const element = audio.current; + let applied = requested; + if (element) { + try { + element.playbackRate = requested; + } catch { + // A browser that refuses a rate keeps its current one. + } + applied = element.playbackRate; + } + setSpeedState(applied); + saveSpeed(applied); + }, []); + + const setSleep = useCallback((choice: number | "end" | null) => { + sleepAt.current = typeof choice === "number" ? Date.now() + choice * 60_000 : null; + setSleepState(typeof choice === "number" ? choice * 60 : choice); + }, []); + + const close = useCallback(() => { + const element = audio.current; + if (element && book) { + save(); + element.pause(); + element.removeAttribute("src"); + element.load(); + } + setSleep(null); + setExpanded(false); + setBook(null); + }, [book, save, setSleep]); + + // The sleep timer counts down once a second and pauses when it runs out. + useEffect(() => { + if (typeof sleep !== "number") return; + const timer = window.setInterval(() => { + const left = sleepAt.current === null ? 0 : Math.round((sleepAt.current - Date.now()) / 1000); + if (left <= 0) { + audio.current?.pause(); + sleepAt.current = null; + setSleepState(null); + return; + } + setSleepState(left); + }, 1000); + return () => window.clearInterval(timer); + }, [sleep]); + + // Only one voice at a time: the chapter reader's own player and this one pause each other. + useEffect(() => { + const onPlay = (event: Event) => { + const target = event.target; + if (target instanceof HTMLMediaElement && target !== audio.current && target.matches("[data-narration]")) audio.current?.pause(); + }; + document.addEventListener("play", onPlay, true); + return () => document.removeEventListener("play", onPlay, true); + }, []); + + // The full view covers the page; the page behind it should not scroll. + useEffect(() => { + if (!expanded) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [expanded]); + + // Lock screen, headphones and car controls. + const actions = useRef({ toggle, skip, next, previous, seek }); + useEffect(() => { + actions.current = { toggle, skip, next, previous, seek }; + }, [toggle, skip, next, previous, seek]); + + useEffect(() => { + if (!("mediaSession" in navigator) || !book || !track) return; + let cancelled = false; + const metadata = { title: track.title, artist: book.author, album: book.title }; + navigator.mediaSession.metadata = new MediaMetadata(metadata); + void coverArtwork(book).then((art) => { + if (!cancelled && art) navigator.mediaSession.metadata = new MediaMetadata({ ...metadata, artwork: [{ src: art, sizes: "400x600", type: "image/png" }] }); + }); + const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [ + ["play", () => actions.current.toggle()], + ["pause", () => audio.current?.pause()], + ["seekbackward", (details) => actions.current.skip(-(details.seekOffset ?? SKIP_BACK_SECONDS))], + ["seekforward", (details) => actions.current.skip(details.seekOffset ?? SKIP_FORWARD_SECONDS)], + ["previoustrack", () => actions.current.previous()], + ["nexttrack", () => actions.current.next()], + ["seekto", (details) => (details.seekTime === undefined ? undefined : actions.current.seek(details.seekTime))], + ]; + for (const [action, handler] of handlers) { + try { + navigator.mediaSession.setActionHandler(action, handler); + } catch { + // Not every browser supports every action. + } + } + return () => { + cancelled = true; + }; + }, [book, track]); + + const value = useMemo( + () => ({ book, index, status, time, duration, speed, sleep, expanded, play, toggle, seek, skip, next, previous, setSpeed, setSleep, setExpanded, close }), + [book, index, status, time, duration, speed, sleep, expanded, play, toggle, seek, skip, next, previous, setSpeed, setSleep, close], + ); + + return ( + + {children} +