From e46e88297a7c24e583cebfd192f2ba752f3c2e63 Mon Sep 17 00:00:00 2001 From: Zeeshan Adil Date: Fri, 6 Nov 2026 18:04:51 +0500 Subject: [PATCH 1/3] Audiobooks: books, covers and saved place from narration manifests Each narrated course becomes a book: tracks from the numbered manifest files, part and chapter titles from the public course outline, a cover drawn as SVG from the title and category, and the listening state kept per chapter in localStorage (there is no listening-progress model on the server yet). --- frontend/src/lib/audiobook-cover.ts | 130 +++++++++++++ frontend/src/lib/audiobook-library.ts | 74 ++++++++ frontend/src/lib/audiobook-progress.ts | 104 ++++++++++ frontend/src/lib/audiobooks.ts | 210 +++++++++++++++++++++ frontend/tests/unit/lib/audiobooks.test.ts | 148 +++++++++++++++ 5 files changed, 666 insertions(+) create mode 100644 frontend/src/lib/audiobook-cover.ts create mode 100644 frontend/src/lib/audiobook-library.ts create mode 100644 frontend/src/lib/audiobook-progress.ts create mode 100644 frontend/src/lib/audiobooks.ts create mode 100644 frontend/tests/unit/lib/audiobooks.test.ts diff --git a/frontend/src/lib/audiobook-cover.ts b/frontend/src/lib/audiobook-cover.ts new file mode 100644 index 00000000..8d5bed0f --- /dev/null +++ b/frontend/src/lib/audiobook-cover.ts @@ -0,0 +1,130 @@ +// Book covers for the Audiobooks area, drawn from the course itself: title, author and category on +// one of the Edorax brand colours, with a plain shape picked from the course slug. One SVG string is +// used on the page and, turned into a PNG, as lock-screen artwork. No images to download. + +const INK = "#0A0F2C"; +const PAPER = "#F6F7FB"; +const COBALT = "#2434E0"; +const YELLOW = "#FFC933"; +const PERIWINKLE = "#7A8BFF"; + +type Palette = { bg: string; text: string; accent: string; second: string }; + +const PALETTES: readonly Palette[] = [ + { bg: COBALT, text: PAPER, accent: YELLOW, second: PAPER }, + { bg: INK, text: PAPER, accent: PERIWINKLE, second: YELLOW }, + { bg: YELLOW, text: INK, accent: INK, second: COBALT }, + { bg: "#E8EBFF", text: INK, accent: COBALT, second: YELLOW }, + { bg: PERIWINKLE, text: INK, accent: INK, second: PAPER }, + { bg: "#46507A", text: PAPER, accent: YELLOW, second: PERIWINKLE }, +]; + +const MOTIF_COUNT = 6; + +function hash(text: string): number { + let value = 2166136261; + for (let i = 0; i < text.length; i += 1) value = Math.imul(value ^ text.charCodeAt(i), 16777619) >>> 0; + return value; +} + +export function coverPalette(slug: string): Palette { + return PALETTES[hash(slug) % PALETTES.length]!; +} + +function escapeXml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function wrap(text: string, perLine: number): string[] { + const lines: string[] = []; + for (const word of text.trim().split(/\s+/).filter(Boolean)) { + const current = lines[lines.length - 1]; + if (current !== undefined && current.length + 1 + word.length <= perLine) lines[lines.length - 1] = `${current} ${word}`; + else lines.push(word); + } + return lines; +} + +/** "Networking, Hands On: How Your Devices..." is a title and a subtitle, as on a printed cover. */ +export function splitTitle(title: string): { main: string; subtitle: string | null } { + const at = title.indexOf(": "); + if (at <= 0) return { main: title.trim(), subtitle: null }; + return { main: title.slice(0, at).trim(), subtitle: title.slice(at + 2).trim() || null }; +} + +/** + * Greedy word wrap for the main title. Sizes are in cover units (the cover is 200 wide); Unbounded runs + * wide, so a character is taken as 0.64 of the font size. Picks the largest size that fits in four lines + * and in `height` (less room when a subtitle follows). + */ +export function layoutTitle(title: string, width = 150, height = 100): { size: number; lines: string[] } { + const words = title.trim().split(/\s+/).filter(Boolean); + for (const size of [27, 24, 21, 18, 16, 14]) { + const perLine = Math.floor(width / (size * 0.64)); + if (words.some((word) => word.length > perLine)) continue; + const lines = wrap(title, perLine); + if (lines.length <= 4 && size + (lines.length - 1) * size * 1.12 <= height) return { size, lines }; + } + const lines = wrap(title, Math.floor(width / (12 * 0.64))); + return { size: 12, lines: lines.length > 5 ? [...lines.slice(0, 4), `${lines[4]}...`] : lines }; +} + +function motif(index: number, seed: number, p: Palette): string { + switch (index) { + case 0: { + // A sound wave: bar heights come from the slug, so every book has its own. + const bars = Array.from({ length: 13 }, (_, i) => { + const h = 10 + (((seed >>> (i % 24)) ^ (i * 2654435761)) >>> 0) % 50; + return ``; + }); + return bars.join(""); + } + case 1: + return ``; + case 2: + return ``; + case 3: + return ``; + case 4: + return ``; + default: + return ``; + } +} + +export type CoverInput = { slug: string; title: string; author: string; label: string }; + +/** The cover as a standalone SVG document, 200 by 300. */ +export function coverSvg({ slug, title, author, label }: CoverInput, options: { fonts?: { heading: string; body: string } } = {}): string { + const p = coverPalette(slug); + const seed = hash(slug); + const heading = options.fonts?.heading ?? "Unbounded, 'Arial Black', sans-serif"; + const body = options.fonts?.body ?? "'DM Sans', Arial, sans-serif"; + // About 20 capitals fit across at 9.5; a longer label shrinks to fit rather than run off the edge. + const labelSize = Math.max(6, Math.min(9.5, (9.5 * 20) / Math.max(1, label.length))); + const { main, subtitle } = splitTitle(title); + const { size, lines } = layoutTitle(main, 150, subtitle ? 62 : 100); + const lineHeight = size * 1.12; + const titleText = lines + .map((line, i) => `${escapeXml(line)}`) + .join(""); + const subtitleTop = 66 + size + (lines.length - 1) * lineHeight + 20; + const subtitleLines = subtitle ? wrap(subtitle, 26).slice(0, subtitleTop > 135 ? 2 : 3) : []; + const subtitleText = subtitleLines + .map((line, i) => `${escapeXml(line)}`) + .join(""); + return [ + ``, + ``, + // The spine: a darker band and a crease, as on a printed paperback. + ``, + `${escapeXml(label.toUpperCase())}`, + ``, + `${titleText}`, + subtitleText ? `${subtitleText}` : "", + motif(Math.floor(seed / PALETTES.length) % MOTIF_COUNT, seed, p), + `${escapeXml(author)}`, + `EDORAX`, + ``, + ].join(""); +} diff --git a/frontend/src/lib/audiobook-library.ts b/frontend/src/lib/audiobook-library.ts new file mode 100644 index 00000000..bb918f48 --- /dev/null +++ b/frontend/src/lib/audiobook-library.ts @@ -0,0 +1,74 @@ +import "server-only"; +import { serverApiFetch } from "@/lib/api-server"; +import { parseApiResponse } from "@/lib/api-client"; +import { NARRATION_PRODUCTION_BASE } from "@/lib/narration"; +import { buildAudiobook, manifestTracks, type Audiobook, type CourseOutline } from "@/lib/audiobooks"; +import { isSafeAssetUrl } from "@/shared/lib/asset-url"; +import type { CatalogCourseDetail, CatalogCourseSummary } from "@/shared/api/catalog"; + +// The Audiobooks area: every published course that has narration, as a book. Course titles and +// chapter names come from the public catalog; the audio comes from each course's live manifest at +// audio.edorax.com//manifest.json, the same files the chapter reader plays. +// NARRATION_BASE_URL points it somewhere else, as it does for the reader. + +const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const API_URL = (process.env.API_URL ?? "http://localhost:4000").replace(/\/$/, ""); + +export type AudiobookLibrary = { books: Audiobook[]; catalogReachable: boolean }; + +export function audiobookBase(configured: string | undefined = process.env.NARRATION_BASE_URL): string { + const base = configured?.trim().replace(/\/+$/, ""); + return base && /^https:\/\//.test(base) && isSafeAssetUrl(base) ? base : NARRATION_PRODUCTION_BASE; +} + +async function fetchManifest(base: string, slug: string): Promise { + try { + const response = await fetch(`${base}/${slug}/manifest.json`, { next: { revalidate: 300 }, signal: AbortSignal.timeout(8000) }); + return response.ok ? await response.json() : null; + } catch { + return null; + } +} + +/** The public outline (module and chapter titles). Anonymous and cached, since it is the same for everyone. */ +async function fetchOutline(slug: string): Promise { + try { + const response = await fetch(`${API_URL}/api/v1/catalog/${encodeURIComponent(slug)}`, { + next: { revalidate: 300 }, + signal: AbortSignal.timeout(8000), + }); + const detail = await parseApiResponse(response); + return { modules: detail.modules }; + } catch { + return null; + } +} + +async function bookFor(base: string, course: CatalogCourseSummary): Promise { + if (!SLUG.test(course.slug)) return null; + const tracks = manifestTracks(await fetchManifest(base, course.slug), base, course.slug); + if (tracks.length === 0) return null; + return buildAudiobook(course, tracks, await fetchOutline(course.slug)); +} + +/** Never throws: with the API or the audio host down the page still renders, with fewer books. */ +export async function loadAudiobookLibrary(): Promise { + let catalog: CatalogCourseSummary[]; + try { + catalog = (await serverApiFetch<{ courses: CatalogCourseSummary[] }>("/api/v1/catalog")).courses; + } catch { + return { books: [], catalogReachable: false }; + } + const base = audiobookBase(); + const books = await Promise.all(catalog.map((course) => bookFor(base, course))); + return { books: books.filter((book): book is Audiobook => book !== null), catalogReachable: true }; +} + +/** One book, or null when the course is not in the catalog or has no audio. */ +export async function loadAudiobook(slug: string): Promise { + if (!SLUG.test(slug)) return null; + // An unreachable API throws here, so the page shows the error screen rather than "not found". + const catalog = (await serverApiFetch<{ courses: CatalogCourseSummary[] }>("/api/v1/catalog")).courses; + const course = catalog.find((entry) => entry.slug === slug); + return course ? bookFor(audiobookBase(), course) : null; +} diff --git a/frontend/src/lib/audiobook-progress.ts b/frontend/src/lib/audiobook-progress.ts new file mode 100644 index 00000000..b18e11bb --- /dev/null +++ b/frontend/src/lib/audiobook-progress.ts @@ -0,0 +1,104 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import type { BookState, TrackState } from "@/lib/audiobooks"; + +// Where every audiobook was left, kept in this browser. Edorax has no listening-progress model on +// the server yet, so signed-in or not this is per device. Storage can be missing or full (private +// windows, blocked site data); every read and write is guarded and the player works without it. + +const KEY = "edorax:audiobooks:v1"; +const EMPTY: AudiobookStore = { books: {} }; + +export type AudiobookStore = { books: Record; speed?: number }; + +const listeners = new Set<() => void>(); +let cachedRaw: string | null | undefined; +let cachedStore: AudiobookStore = EMPTY; + +function readRaw(): string | null { + try { + return window.localStorage.getItem(KEY); + } catch { + return null; + } +} + +function parse(raw: string | null): AudiobookStore { + if (!raw) return EMPTY; + try { + const value = JSON.parse(raw) as AudiobookStore; + return value && typeof value === "object" && value.books && typeof value.books === "object" ? value : EMPTY; + } catch { + return EMPTY; + } +} + +export function readAudiobookStore(): AudiobookStore { + if (typeof window === "undefined") return EMPTY; + const raw = readRaw(); + if (raw !== cachedRaw) { + cachedRaw = raw; + cachedStore = parse(raw); + } + return cachedStore; +} + +function write(next: AudiobookStore) { + try { + window.localStorage.setItem(KEY, JSON.stringify(next)); + } catch { + // Out of space or blocked: keep it in memory for this visit. + } + cachedRaw = readRaw(); + cachedStore = next; + listeners.forEach((listener) => listener()); +} + +export function saveTrackState(bookSlug: string, trackSlug: string, state: TrackState) { + const store = readAudiobookStore(); + const book = store.books[bookSlug] ?? { track: trackSlug, at: 0, tracks: {} }; + write({ + ...store, + books: { ...store.books, [bookSlug]: { track: trackSlug, at: Date.now(), tracks: { ...book.tracks, [trackSlug]: state } } }, + }); +} + +export function saveSpeed(speed: number) { + write({ ...readAudiobookStore(), speed }); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + const onStorage = (event: StorageEvent) => { + if (event.key === KEY) listener(); + }; + window.addEventListener("storage", onStorage); + return () => { + listeners.delete(listener); + window.removeEventListener("storage", onStorage); + }; +} + +/** The saved state, live: updates as the player saves and when another tab does. Empty on the server. */ +export function useAudiobookStore(): AudiobookStore { + return useSyncExternalStore(subscribe, readAudiobookStore, () => EMPTY); +} + +// The chapter reader's own Listen player keeps a position per audio file under this key. Reading it +// and writing it keeps the two players in step: stop in one, carry on in the other. +export function readReaderPosition(src: string): number { + try { + return Number(window.localStorage.getItem(`buildment:narration:${src}`)) || 0; + } catch { + return 0; + } +} + +export function writeReaderPosition(src: string, seconds: number) { + try { + window.localStorage.setItem(`buildment:narration:${src}`, String(Math.floor(seconds))); + } catch { + // Same as above: a convenience only. + } +} diff --git a/frontend/src/lib/audiobooks.ts b/frontend/src/lib/audiobooks.ts new file mode 100644 index 00000000..80c1d1dc --- /dev/null +++ b/frontend/src/lib/audiobooks.ts @@ -0,0 +1,210 @@ +// Pure helpers for the Audiobooks area: a course's narration manifest becomes a book with parts and +// tracks, and the saved listening state becomes progress and a place to resume. No I/O here, so the +// server loader, the player and the tests share one definition. + +import { COURSE_CATEGORY_INFO } from "@/lib/course-categories"; +import type { CourseCategory } from "@/shared/domain/enums"; + +export const AUDIOBOOK_AUTHOR = "Zeeshan Adil"; + +const AUDIO_FILE = /^[\w.-]+\.mp3$/; +const PART_AND_CHAPTER = /^(\d+)\.(\d+)/; + +export type AudiobookTrack = { + /** The chapter slug, which is also the manifest key. */ + slug: string; + title: string; + minutes: number; + src: string; + /** 1-based part (course module) and chapter number inside it, from the numbered audio file. */ + part: number; + chapter: number; +}; + +export type AudiobookPart = { number: number; title: string | null; firstTrack: number; trackCount: number; minutes: number }; + +export type Audiobook = { + slug: string; + title: string; + author: string; + category: CourseCategory; + subcategory: string | null; + description: string | null; + totalMinutes: number; + tracks: AudiobookTrack[]; + parts: AudiobookPart[]; +}; + +/** Module titles by module order, and chapter titles by "module.chapter", from the public course detail. */ +export type CourseOutline = { modules: { order: number; title: string; chapters: { order: number; title: string }[] }[] }; + +/** "1-2-roles-and-expectations" reads as "Roles and expectations". */ +export function chapterTitleFromSlug(slug: string): string { + const words = slug.replace(/^(?:\d+[.-])+/, "").replace(/-/g, " ").trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : slug; +} + +/** + * The playable tracks of a manifest in course order (the audio files are numbered "01.02-...mp3"), + * or an empty list. Entries without a safe file name or a positive length are skipped. + */ +export function manifestTracks(manifest: unknown, base: string, courseSlug: string): AudiobookTrack[] { + if (!manifest || typeof manifest !== "object") return []; + const chapters = (manifest as { chapters?: unknown }).chapters; + if (!chapters || typeof chapters !== "object") return []; + const rows: (AudiobookTrack & { file: string })[] = []; + for (const [slug, entry] of Object.entries(chapters as Record)) { + if (!entry || typeof entry !== "object") continue; + const { file, minutes } = entry as Record; + if (typeof file !== "string" || !AUDIO_FILE.test(file)) continue; + if (typeof minutes !== "number" || !(minutes > 0)) continue; + const numbers = PART_AND_CHAPTER.exec(file); + rows.push({ + slug, + title: chapterTitleFromSlug(slug), + minutes, + src: `${base}/${courseSlug}/${file}`, + part: numbers ? Number(numbers[1]) : 1, + chapter: numbers ? Number(numbers[2]) : rows.length + 1, + file, + }); + } + rows.sort((a, b) => a.file.localeCompare(b.file, undefined, { numeric: true })); + return rows.map(({ file: _file, ...track }) => track); +} + +/** Builds the book. The outline is optional: without it, titles come from the chapter slugs. */ +export function buildAudiobook( + course: { slug: string; title: string; description: string | null; category: CourseCategory; subcategory: string | null }, + tracks: AudiobookTrack[], + outline: CourseOutline | null = null, +): Audiobook | null { + if (tracks.length === 0) return null; + const moduleByOrder = new Map(outline?.modules.map((m) => [m.order, m]) ?? []); + const titled = tracks.map((track) => { + const chapter = moduleByOrder.get(track.part)?.chapters.find((c) => c.order === track.chapter); + return chapter?.title.trim() ? { ...track, title: chapter.title.trim() } : track; + }); + const parts: AudiobookPart[] = []; + titled.forEach((track, index) => { + const last = parts[parts.length - 1]; + if (last && last.number === track.part) { + last.trackCount += 1; + last.minutes += track.minutes; + return; + } + parts.push({ number: track.part, title: moduleByOrder.get(track.part)?.title.trim() || null, firstTrack: index, trackCount: 1, minutes: track.minutes }); + }); + return { + slug: course.slug, + title: course.title, + // Every Edorax course is published in Zeeshan Adil's name; the catalog's instructor field can name the team. + author: AUDIOBOOK_AUTHOR, + category: course.category, + subcategory: course.subcategory, + description: course.description, + totalMinutes: titled.reduce((sum, track) => sum + track.minutes, 0), + tracks: titled, + parts, + }; +} + +export function categoryLabel(category: CourseCategory): string { + return COURSE_CATEGORY_INFO.find((info) => info.value === category)?.label ?? "Course"; +} + +// ---- Listening state --------------------------------------------------------------- + +/** Saved per chapter: position and duration in seconds, and whether it was heard to the end. */ +export type TrackState = { p: number; d: number; done?: boolean }; +/** Saved per book: the chapter heard last, when, and every chapter's state. */ +export type BookState = { track: string; at: number; tracks: Record }; + +export type BookListening = { + started: boolean; + finished: boolean; + listenedMinutes: number; + leftMinutes: number; + /** 0 to 1. */ + fraction: number; + doneCount: number; +}; + +function trackListenedMinutes(track: AudiobookTrack, state: TrackState | undefined): number { + if (!state) return 0; + if (state.done) return track.minutes; + const seconds = state.d > 0 ? Math.min(state.p, state.d) : state.p; + return Math.min(track.minutes, Math.max(0, seconds) / 60); +} + +export function bookListening(book: Pick, state: BookState | undefined): BookListening { + const listened = book.tracks.reduce((sum, track) => sum + trackListenedMinutes(track, state?.tracks[track.slug]), 0); + const doneCount = book.tracks.filter((track) => state?.tracks[track.slug]?.done).length; + const finished = doneCount === book.tracks.length; + return { + started: listened > 0 || doneCount > 0, + finished, + listenedMinutes: listened, + leftMinutes: finished ? 0 : Math.max(0, book.totalMinutes - listened), + fraction: book.totalMinutes > 0 ? Math.min(1, listened / book.totalMinutes) : 0, + doneCount, + }; +} + +/** Where a saved position is worth resuming: not the first seconds, not the last ones. */ +export function resumeAt(state: TrackState | undefined): number { + if (!state || state.done) return 0; + if (!Number.isFinite(state.p) || state.p <= 3) return 0; + if (state.d > 0 && state.p >= state.d - 3) return 0; + return state.p; +} + +/** + * The chapter and second to start from: the chapter heard last, or the next unfinished one after it + * when it was finished, or the first chapter of a book never opened. `finished` is true when every + * chapter is done, in which case it starts again from the top. + */ +export function resumeTarget(book: Pick, state: BookState | undefined): { index: number; at: number; finished: boolean } { + if (!state) return { index: 0, at: 0, finished: false }; + const last = book.tracks.findIndex((track) => track.slug === state.track); + if (last >= 0 && !state.tracks[state.track]?.done) return { index: last, at: resumeAt(state.tracks[state.track]), finished: false }; + const order = last >= 0 ? [...book.tracks.slice(last + 1), ...book.tracks.slice(0, last)] : book.tracks; + const next = order.find((track) => !state.tracks[track.slug]?.done); + if (!next) return { index: 0, at: 0, finished: book.tracks.length > 0 }; + const index = book.tracks.indexOf(next); + return { index, at: resumeAt(state.tracks[next.slug]), finished: false }; +} + +/** "Part 2, chapter 3" for a track. */ +export function trackLabel(track: Pick): string { + return `Part ${track.part}, chapter ${track.chapter}`; +} + +// ---- Formatting -------------------------------------------------------------------- + +/** "45 min", "1 h", "3 h 20 min". Never "0 min". */ +export function formatBookLength(minutes: number): string { + const total = Math.max(1, Math.round(minutes)); + if (total < 60) return `${total} min`; + const hours = Math.floor(total / 60); + const rest = total % 60; + return rest === 0 ? `${hours} h` : `${hours} h ${rest} min`; +} + +/** "1:05" or "1:02:09" for the scrubber. */ +export function formatPlayerClock(seconds: number): string { + const whole = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0)); + const h = Math.floor(whole / 3600); + const m = Math.floor((whole % 3600) / 60); + const s = String(whole % 60).padStart(2, "0"); + return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`; +} + +/** Speeds offered in the player, slowest first. */ +export const AUDIOBOOK_SPEEDS: readonly number[] = [0.75, 1, 1.25, 1.5, 1.75, 2]; + +/** Sleep timer choices in minutes; "end" stops at the end of the current chapter. */ +export const SLEEP_CHOICES: readonly (number | "end")[] = [5, 15, 30, 45, 60, "end"]; + +export const SKIP_BACK_SECONDS = 15; +export const SKIP_FORWARD_SECONDS = 30; diff --git a/frontend/tests/unit/lib/audiobooks.test.ts b/frontend/tests/unit/lib/audiobooks.test.ts new file mode 100644 index 00000000..a5f4fa90 --- /dev/null +++ b/frontend/tests/unit/lib/audiobooks.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + bookListening, + buildAudiobook, + chapterTitleFromSlug, + formatBookLength, + formatPlayerClock, + manifestTracks, + resumeTarget, + type BookState, +} from "../../../src/lib/audiobooks"; +import { coverPalette, coverSvg, layoutTitle } from "../../../src/lib/audiobook-cover"; +import { COURSE_CATEGORY_INFO, categoryFromParam } from "../../../src/lib/course-categories"; +import { COURSE_CATEGORIES } from "../../../src/shared/domain/enums"; + +const BASE = "https://audio.example.com"; +const MANIFEST = { + version: 1, + chapters: { + "2-1-set-the-scene": { file: "02.01-set-the-scene.mp3", minutes: 2, bytes: 1 }, + "1-10-wrap-up": { file: "01.10-wrap-up.mp3", minutes: 1.5, bytes: 1 }, + "1-2-roles-and-expectations": { file: "01.02-roles-and-expectations.mp3", minutes: 4, bytes: 1 }, + "1-3-broken": { file: "../evil.sh", minutes: 2, bytes: 1 }, + "1-4-empty": { file: "01.04-empty.mp3", minutes: 0, bytes: 1 }, + }, +}; +const COURSE = { slug: "collaboration", title: "Collaboration", description: null, category: "SOFT_SKILLS" as const, subcategory: null }; + +describe("manifestTracks", () => { + it("keeps playable chapters in course order with part and chapter numbers", () => { + const tracks = manifestTracks(MANIFEST, BASE, "collaboration"); + expect(tracks.map((t) => t.slug)).toEqual(["1-2-roles-and-expectations", "1-10-wrap-up", "2-1-set-the-scene"]); + expect(tracks[0]).toMatchObject({ title: "Roles and expectations", part: 1, chapter: 2, src: `${BASE}/collaboration/01.02-roles-and-expectations.mp3` }); + expect(tracks[2]).toMatchObject({ part: 2, chapter: 1 }); + }); + + it("returns nothing for a missing or broken manifest", () => { + expect(manifestTracks(null, BASE, "x")).toEqual([]); + expect(manifestTracks({ chapters: {} }, BASE, "x")).toEqual([]); + expect(manifestTracks("Not Found", BASE, "x")).toEqual([]); + }); + + it("reads titles from slugs", () => { + expect(chapterTitleFromSlug("3.2-why-caches-help")).toBe("Why caches help"); + }); +}); + +describe("buildAudiobook", () => { + const tracks = manifestTracks(MANIFEST, BASE, "collaboration"); + + it("groups tracks into parts and uses the course outline for titles", () => { + const book = buildAudiobook(COURSE, tracks, { + modules: [ + { order: 1, title: "How good teams work", chapters: [{ order: 2, title: "Roles, and expectations" }] }, + { order: 2, title: "Feedback", chapters: [] }, + ], + })!; + expect(book.author).toBe("Zeeshan Adil"); + expect(book.totalMinutes).toBeCloseTo(7.5); + expect(book.tracks[0]!.title).toBe("Roles, and expectations"); + expect(book.tracks[1]!.title).toBe("Wrap up"); + expect(book.parts).toEqual([ + { number: 1, title: "How good teams work", firstTrack: 0, trackCount: 2, minutes: 5.5 }, + { number: 2, title: "Feedback", firstTrack: 2, trackCount: 1, minutes: 2 }, + ]); + }); + + it("has no book without audio", () => { + expect(buildAudiobook(COURSE, [])).toBeNull(); + }); +}); + +describe("listening progress", () => { + const book = buildAudiobook(COURSE, manifestTracks(MANIFEST, BASE, "collaboration"))!; + + it("starts a new book at chapter one", () => { + expect(resumeTarget(book, undefined)).toEqual({ index: 0, at: 0, finished: false }); + expect(bookListening(book, undefined)).toMatchObject({ started: false, fraction: 0, leftMinutes: 7.5 }); + }); + + it("resumes the chapter heard last, mid-way", () => { + const state: BookState = { + track: "1-10-wrap-up", + at: 1, + tracks: { "1-2-roles-and-expectations": { p: 240, d: 240, done: true }, "1-10-wrap-up": { p: 30, d: 90 } }, + }; + expect(resumeTarget(book, state)).toEqual({ index: 1, at: 30, finished: false }); + const listening = bookListening(book, state); + expect(listening.listenedMinutes).toBeCloseTo(4.5); + expect(listening.leftMinutes).toBeCloseTo(3); + expect(listening.doneCount).toBe(1); + }); + + it("moves on to the next unfinished chapter once the last one was finished", () => { + const state: BookState = { track: "1-10-wrap-up", at: 1, tracks: { "1-10-wrap-up": { p: 90, d: 90, done: true } } }; + expect(resumeTarget(book, state)).toEqual({ index: 2, at: 0, finished: false }); + }); + + it("starts again from the top when every chapter is done", () => { + const tracks = Object.fromEntries(book.tracks.map((t) => [t.slug, { p: 1, d: 1, done: true }])); + const state: BookState = { track: "2-1-set-the-scene", at: 1, tracks }; + expect(resumeTarget(book, state)).toEqual({ index: 0, at: 0, finished: true }); + expect(bookListening(book, state)).toMatchObject({ finished: true, leftMinutes: 0, fraction: 1 }); + }); + + it("ignores a position in the first or last seconds", () => { + const state: BookState = { track: "1-2-roles-and-expectations", at: 1, tracks: { "1-2-roles-and-expectations": { p: 2, d: 240 } } }; + expect(resumeTarget(book, state).at).toBe(0); + state.tracks["1-2-roles-and-expectations"] = { p: 238.5, d: 240 }; + expect(resumeTarget(book, state).at).toBe(0); + }); +}); + +describe("formatting", () => { + it("writes book lengths and clocks", () => { + expect(formatBookLength(0.2)).toBe("1 min"); + expect(formatBookLength(45)).toBe("45 min"); + expect(formatBookLength(60)).toBe("1 h"); + expect(formatBookLength(200)).toBe("3 h 20 min"); + expect(formatPlayerClock(65)).toBe("1:05"); + expect(formatPlayerClock(3729)).toBe("1:02:09"); + expect(formatPlayerClock(Number.NaN)).toBe("0:00"); + }); +}); + +describe("covers", () => { + it("wraps long titles into at most four lines without breaking words", () => { + const { lines } = layoutTitle("Collaboration, Feedback & Conflict Resolution"); + expect(lines.length).toBeLessThanOrEqual(4); + expect(lines.join(" ")).toBe("Collaboration, Feedback & Conflict Resolution"); + }); + + it("is the same for the same course and escapes text", () => { + const input = { slug: "collaboration", title: "A & C", author: "Zeeshan Adil", label: "Teamwork & Collaboration" }; + expect(coverSvg(input)).toBe(coverSvg(input)); + expect(coverSvg(input)).toContain("<b>"); + expect(coverSvg(input)).toContain("TEAMWORK & COLLABORATION"); + expect(coverSvg(input)).not.toContain(""); + expect(coverPalette("collaboration")).toEqual(coverPalette("collaboration")); + }); +}); + +describe("course categories", () => { + it("has a shelf for every category, Cloud and DevOps included", () => { + expect(COURSE_CATEGORY_INFO.map((c) => c.value).sort()).toEqual([...COURSE_CATEGORIES].sort()); + expect(categoryFromParam("cloud-devops")).toBe("CLOUD_DEVOPS"); + }); +}); From 3be583093c54ece6232774f379a0c0557df36b97 Mon Sep 17 00:00:00 2001 From: Zeeshan Adil Date: Fri, 6 Nov 2026 18:04:52 +0500 Subject: [PATCH 2/3] Audiobooks: one player that follows the learner across pages The provider lives in the root layout, so audio keeps playing between pages. A mini player sits above the phone tab bar and beside the desktop sidebar and opens a full player: skip back 15s and forward 30s, 0.75x to 2x, sleep timer, chapter list, auto-advance and lock-screen controls. The chapter reader's Listen player and this one pause each other. --- frontend/src/app/globals.css | 33 ++ frontend/src/app/layout.tsx | 5 +- .../audiobooks/audiobook-player.tsx | 357 ++++++++++++++++++ .../src/components/audiobooks/book-cover.tsx | 20 + .../src/components/audiobooks/mini-player.tsx | 82 ++++ .../src/components/audiobooks/now-playing.tsx | 260 +++++++++++++ .../components/audiobooks/player-controls.tsx | 34 ++ .../src/components/learn/ask-ai-floating.tsx | 2 +- .../src/components/learn/chapter-audio.tsx | 1 + .../learn/mentee-workspace-shell.tsx | 2 +- .../components/learn/reader-focus-dock.tsx | 2 +- 11 files changed, 794 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/audiobooks/audiobook-player.tsx create mode 100644 frontend/src/components/audiobooks/book-cover.tsx create mode 100644 frontend/src/components/audiobooks/mini-player.tsx create mode 100644 frontend/src/components/audiobooks/now-playing.tsx create mode 100644 frontend/src/components/audiobooks/player-controls.tsx 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/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} +