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.coverImage.alternativeText +
+ )} +

{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",