diff --git a/apps/web/package.json b/apps/web/package.json
index bc96979..ec4ca4c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@afterlife/shared": "*",
"framer-motion": "^12.34.3",
+ "howler": "^2.2.4",
"next": "^15",
"next-intl": "^4.8.3",
"react": "^19",
@@ -18,6 +19,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/howler": "^2.2.12",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/apps/web/src/app/[locale]/games/[slug]/documentary/page.tsx b/apps/web/src/app/[locale]/games/[slug]/documentary/page.tsx
new file mode 100644
index 0000000..db92c12
--- /dev/null
+++ b/apps/web/src/app/[locale]/games/[slug]/documentary/page.tsx
@@ -0,0 +1,24 @@
+import { notFound } from "next/navigation";
+import { getDocumentaryByGameSlug } from "@/lib/api";
+import { DocumentaryLayout } from "@/components/documentary/DocumentaryLayout";
+
+export default async function DocumentaryPage({
+ params,
+}: {
+ params: Promise<{ locale: string; slug: string }>;
+}) {
+ const { locale, slug } = await params;
+
+ let documentary;
+ try {
+ documentary = await getDocumentaryByGameSlug(slug, locale);
+ } catch {
+ notFound();
+ }
+
+ if (!documentary || !documentary.chapters?.length) {
+ notFound();
+ }
+
+ return ;
+}
diff --git a/apps/web/src/components/documentary/AudioPlayer.tsx b/apps/web/src/components/documentary/AudioPlayer.tsx
new file mode 100644
index 0000000..8df64e2
--- /dev/null
+++ b/apps/web/src/components/documentary/AudioPlayer.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+
+interface AudioPlayerProps {
+ trackTitle: string | null;
+ isPlaying: boolean;
+ progress: number;
+ duration: number;
+ playbackRate: number;
+ continuousMode: boolean;
+ onToggle: () => void;
+ onSeek: (seconds: number) => void;
+ onChangeRate: (rate: number) => void;
+ onToggleContinuous: () => void;
+}
+
+const RATES = [0.5, 0.75, 1, 1.25, 1.5, 2];
+
+function formatTime(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
+
+export function AudioPlayer({
+ trackTitle,
+ isPlaying,
+ progress,
+ duration,
+ playbackRate,
+ continuousMode,
+ onToggle,
+ onSeek,
+ onChangeRate,
+ onToggleContinuous,
+}: AudioPlayerProps) {
+ const t = useTranslations("audio");
+
+ if (!trackTitle) return null;
+
+ const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
+
+ return (
+
+
+ {/* Progress bar */}
+
{
+ const rect = e.currentTarget.getBoundingClientRect();
+ const ratio = (e.clientX - rect.left) / rect.width;
+ onSeek(ratio * duration);
+ }}
+ >
+
+
+
+
+ {/* Play/Pause */}
+
+
+ {/* Track info */}
+
+
{trackTitle}
+
+ {formatTime(progress)} / {formatTime(duration)}
+
+
+
+ {/* Speed selector */}
+
+ {t("speed")}:
+
+
+
+ {/* Continuous mode toggle */}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/documentary/ChapterContent.tsx b/apps/web/src/components/documentary/ChapterContent.tsx
new file mode 100644
index 0000000..472c967
--- /dev/null
+++ b/apps/web/src/components/documentary/ChapterContent.tsx
@@ -0,0 +1,28 @@
+import Image from "next/image";
+import type { Chapter } from "@afterlife/shared";
+
+interface ChapterContentProps {
+ chapter: Chapter;
+}
+
+export function ChapterContent({ chapter }: ChapterContentProps) {
+ return (
+
+ {chapter.coverImage && (
+
+
+
+ )}
+ {chapter.title}
+
+
+ );
+}
diff --git a/apps/web/src/components/documentary/ChapterNav.tsx b/apps/web/src/components/documentary/ChapterNav.tsx
new file mode 100644
index 0000000..827dc34
--- /dev/null
+++ b/apps/web/src/components/documentary/ChapterNav.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import type { Chapter } from "@afterlife/shared";
+import { useTranslations } from "next-intl";
+
+interface ChapterNavProps {
+ chapters: Chapter[];
+ activeChapterId: number;
+ onSelectChapter: (id: number, index: number) => void;
+}
+
+export function ChapterNav({ chapters, activeChapterId, onSelectChapter }: ChapterNavProps) {
+ const t = useTranslations("documentary");
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/documentary/DocumentaryLayout.tsx b/apps/web/src/components/documentary/DocumentaryLayout.tsx
new file mode 100644
index 0000000..9a237d5
--- /dev/null
+++ b/apps/web/src/components/documentary/DocumentaryLayout.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { Documentary, Chapter } from "@afterlife/shared";
+import { ChapterNav } from "./ChapterNav";
+import { ChapterContent } from "./ChapterContent";
+import { AudioPlayer } from "./AudioPlayer";
+import { ReadingProgress } from "./ReadingProgress";
+import { useAudioPlayer } from "@/hooks/useAudioPlayer";
+
+interface DocumentaryLayoutProps {
+ documentary: Documentary;
+}
+
+export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
+ const chapters = [...documentary.chapters].sort((a, b) => a.order - b.order);
+ const [activeChapter, setActiveChapter] = useState(chapters[0]);
+
+ const audio = useAudioPlayer();
+
+ useEffect(() => {
+ const audioTracks = chapters
+ .filter((ch) => ch.audioFile)
+ .map((ch) => ({
+ id: ch.id,
+ title: ch.title,
+ url: ch.audioFile!.url,
+ duration: ch.audioDuration ?? 0,
+ }));
+ audio.setTracks(audioTracks);
+ if (audioTracks.length > 0) {
+ audio.loadTrack(0);
+ }
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ function handleSelectChapter(chapterId: number, index: number) {
+ const chapter = chapters.find((c) => c.id === chapterId);
+ if (chapter) {
+ setActiveChapter(chapter);
+ const trackIndex = audio.tracks.findIndex((t) => t.id === chapterId);
+ if (trackIndex !== -1) {
+ audio.goToTrack(trackIndex);
+ }
+ }
+ }
+
+ return (
+ <>
+
+
+ audio.setContinuousMode(!audio.continuousMode)}
+ />
+ >
+ );
+}
diff --git a/apps/web/src/components/documentary/ReadingProgress.tsx b/apps/web/src/components/documentary/ReadingProgress.tsx
new file mode 100644
index 0000000..04c1364
--- /dev/null
+++ b/apps/web/src/components/documentary/ReadingProgress.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export function ReadingProgress() {
+ const [progress, setProgress] = useState(0);
+
+ useEffect(() => {
+ function handleScroll() {
+ const scrollTop = window.scrollY;
+ const docHeight = document.documentElement.scrollHeight - window.innerHeight;
+ setProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
+ }
+ window.addEventListener("scroll", handleScroll, { passive: true });
+ return () => window.removeEventListener("scroll", handleScroll);
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/hooks/useAudioPlayer.ts b/apps/web/src/hooks/useAudioPlayer.ts
new file mode 100644
index 0000000..12411ae
--- /dev/null
+++ b/apps/web/src/hooks/useAudioPlayer.ts
@@ -0,0 +1,124 @@
+"use client";
+
+import { useState, useRef, useCallback, useEffect } from "react";
+import { Howl } from "howler";
+
+interface AudioTrack {
+ id: number;
+ title: string;
+ url: string;
+ duration: number;
+}
+
+export function useAudioPlayer() {
+ const [tracks, setTracks] = useState([]);
+ const [currentTrackIndex, setCurrentTrackIndex] = useState(0);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [playbackRate, setPlaybackRate] = useState(1);
+ const [continuousMode, setContinuousMode] = useState(false);
+ const howlRef = useRef(null);
+ const animFrameRef = useRef(0);
+
+ const currentTrack = tracks[currentTrackIndex] ?? null;
+
+ const destroyHowl = useCallback(() => {
+ if (howlRef.current) {
+ howlRef.current.unload();
+ howlRef.current = null;
+ }
+ cancelAnimationFrame(animFrameRef.current);
+ }, []);
+
+ const loadTrack = useCallback(
+ (index: number) => {
+ if (!tracks[index]) return;
+ destroyHowl();
+
+ const howl = new Howl({
+ src: [tracks[index].url],
+ html5: true,
+ rate: playbackRate,
+ onplay: () => {
+ setIsPlaying(true);
+ const updateProgress = () => {
+ if (howl.playing()) {
+ setProgress(howl.seek() as number);
+ animFrameRef.current = requestAnimationFrame(updateProgress);
+ }
+ };
+ animFrameRef.current = requestAnimationFrame(updateProgress);
+ },
+ onpause: () => setIsPlaying(false),
+ onstop: () => setIsPlaying(false),
+ onend: () => {
+ setIsPlaying(false);
+ if (continuousMode && index < tracks.length - 1) {
+ setCurrentTrackIndex(index + 1);
+ }
+ },
+ onload: () => {
+ setDuration(howl.duration());
+ },
+ });
+
+ howlRef.current = howl;
+ setCurrentTrackIndex(index);
+ setProgress(0);
+ },
+ [tracks, playbackRate, continuousMode, destroyHowl]
+ );
+
+ const play = useCallback(() => howlRef.current?.play(), []);
+ const pause = useCallback(() => howlRef.current?.pause(), []);
+ const toggle = useCallback(() => {
+ if (isPlaying) pause();
+ else play();
+ }, [isPlaying, play, pause]);
+
+ const seek = useCallback((seconds: number) => {
+ howlRef.current?.seek(seconds);
+ setProgress(seconds);
+ }, []);
+
+ const changeRate = useCallback(
+ (rate: number) => {
+ setPlaybackRate(rate);
+ howlRef.current?.rate(rate);
+ },
+ []
+ );
+
+ const goToTrack = useCallback(
+ (index: number) => {
+ loadTrack(index);
+ setTimeout(() => howlRef.current?.play(), 100);
+ },
+ [loadTrack]
+ );
+
+ useEffect(() => {
+ return () => destroyHowl();
+ }, [destroyHowl]);
+
+ return {
+ tracks,
+ setTracks,
+ currentTrack,
+ currentTrackIndex,
+ isPlaying,
+ progress,
+ duration,
+ playbackRate,
+ continuousMode,
+ setContinuousMode,
+ loadTrack,
+ play,
+ pause,
+ toggle,
+ seek,
+ changeRate,
+ goToTrack,
+ };
+}
diff --git a/package-lock.json b/package-lock.json
index 9a172f6..226e52f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
"dependencies": {
"@afterlife/shared": "*",
"framer-motion": "^12.34.3",
+ "howler": "^2.2.4",
"next": "^15",
"next-intl": "^4.8.3",
"react": "^19",
@@ -45,6 +46,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/howler": "^2.2.12",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -7433,6 +7435,12 @@
"@types/react": "*"
}
},
+ "node_modules/@types/howler": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz",
+ "integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==",
+ "dev": true
+ },
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -13505,6 +13513,11 @@
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="
},
+ "node_modules/howler": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
+ "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
+ },
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",