Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
interface AboutSectionProps {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function AboutSection({ description }: AboutSectionProps) {
|
||||
// Skip if description is just narrator info
|
||||
if (description.match(/^(Read by|Narrated by):/i)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-6">About</h2>
|
||||
<Card className="p-6">
|
||||
<div
|
||||
className="text-gray-300 text-sm leading-relaxed prose prose-invert prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: description }}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Play, Pause, RotateCcw, CheckCircle, Shuffle } from "lucide-react";
|
||||
|
||||
interface AudiobookActionBarProps {
|
||||
audiobook: any;
|
||||
isThisBookPlaying?: boolean;
|
||||
isPlaying?: boolean;
|
||||
currentTime?: number;
|
||||
onPlayPause?: () => void;
|
||||
onResetProgress: () => void;
|
||||
onMarkAsCompleted: () => void;
|
||||
formatTime?: (seconds: number) => string;
|
||||
}
|
||||
|
||||
export function AudiobookActionBar({
|
||||
audiobook,
|
||||
isThisBookPlaying = false,
|
||||
isPlaying = false,
|
||||
currentTime = 0,
|
||||
onPlayPause,
|
||||
onResetProgress,
|
||||
onMarkAsCompleted,
|
||||
formatTime = (s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`,
|
||||
}: AudiobookActionBarProps) {
|
||||
const hasProgress = audiobook.progress && audiobook.progress.progress > 0;
|
||||
const isFinished = audiobook.progress?.isFinished;
|
||||
const showingPause = isThisBookPlaying && isPlaying;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Play/Pause Button - Yellow like other pages */}
|
||||
{onPlayPause && (
|
||||
<button
|
||||
onClick={onPlayPause}
|
||||
className="w-14 h-14 rounded-full bg-[#ecb200] hover:bg-[#ffc61a] hover:scale-105 transition-all flex items-center justify-center shadow-lg"
|
||||
title={showingPause ? "Pause" : (hasProgress && !isFinished ? "Resume" : "Play")}
|
||||
>
|
||||
{showingPause ? (
|
||||
<Pause className="w-6 h-6 text-black" />
|
||||
) : (
|
||||
<Play className="w-6 h-6 text-black ml-1" fill="black" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Progress indicator - compact inline */}
|
||||
{hasProgress && !isFinished && (
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
<div className="text-sm">
|
||||
<span className="text-white/60">
|
||||
{formatTime(isThisBookPlaying ? currentTime : audiobook.progress.currentTime)}
|
||||
</span>
|
||||
<span className="text-white/40 mx-1">/</span>
|
||||
<span className="text-white/60">{formatTime(audiobook.duration)}</span>
|
||||
</div>
|
||||
<div className="w-24 h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#ecb200] rounded-full transition-all"
|
||||
style={{
|
||||
width: `${isThisBookPlaying ? (currentTime / audiobook.duration) * 100 : audiobook.progress.progress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Action buttons - right side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{hasProgress && !isFinished && (
|
||||
<>
|
||||
<button
|
||||
onClick={onResetProgress}
|
||||
className="p-2.5 rounded-full text-white/60 hover:text-white hover:bg-white/10 transition-all"
|
||||
title="Reset progress"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onMarkAsCompleted}
|
||||
className="p-2.5 rounded-full text-green-400/80 hover:text-green-400 hover:bg-green-500/10 transition-all"
|
||||
title="Mark as completed"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isFinished && (
|
||||
<button
|
||||
onClick={onResetProgress}
|
||||
className="px-4 py-2 rounded-full text-sm font-medium bg-white/5 text-white/70 hover:bg-white/10 hover:text-white transition-all flex items-center gap-2"
|
||||
title="Listen again"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>Listen Again</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { Book } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface AudiobookHeroProps {
|
||||
audiobook: any;
|
||||
heroImage: string | null;
|
||||
colors: any;
|
||||
metadata: {
|
||||
narrator: string | null;
|
||||
genre: string | null;
|
||||
publishedYear: string | null;
|
||||
description: string | null;
|
||||
} | null;
|
||||
formatTime: (seconds: number) => string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function AudiobookHero({
|
||||
audiobook,
|
||||
heroImage,
|
||||
colors,
|
||||
metadata,
|
||||
formatTime,
|
||||
children,
|
||||
}: AudiobookHeroProps) {
|
||||
const progressPercent = audiobook.progress?.progress || 0;
|
||||
const isFinished = audiobook.progress?.isFinished;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Background with VibrantJS gradient */}
|
||||
{heroImage ? (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 scale-110 blur-md opacity-50">
|
||||
<Image
|
||||
src={heroImage}
|
||||
alt={audiobook.title}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: colors
|
||||
? `linear-gradient(to bottom, ${colors.vibrant}30 0%, ${colors.darkVibrant}60 40%, ${colors.darkMuted}90 70%, #0a0a0a 100%)`
|
||||
: "linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.7) 40%, #0a0a0a 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0a0a] via-transparent to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: colors
|
||||
? `linear-gradient(to bottom, ${colors.vibrant}40 0%, ${colors.darkVibrant}80 50%, #0a0a0a 100%)`
|
||||
: "linear-gradient(to bottom, #3d2a1e 0%, #1a1a1a 50%, #0a0a0a 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Compact Hero Content - Full Width */}
|
||||
<div className="relative px-4 md:px-8 pt-16 pb-6">
|
||||
<div className="flex items-end gap-6">
|
||||
{/* Cover Art - Square for audiobooks */}
|
||||
<div className="w-[140px] h-[140px] md:w-[192px] md:h-[192px] bg-[#282828] rounded-lg shadow-2xl shrink-0 overflow-hidden relative">
|
||||
{heroImage ? (
|
||||
<Image
|
||||
src={heroImage}
|
||||
alt={audiobook.title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 140px, 192px"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Book className="w-16 h-16 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info - Bottom Aligned */}
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<p className="text-xs font-medium text-white/90 mb-1">
|
||||
Audiobook
|
||||
</p>
|
||||
<h1 className="text-2xl md:text-4xl lg:text-5xl font-bold text-white leading-tight line-clamp-2 mb-2">
|
||||
{audiobook.title}
|
||||
</h1>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-sm text-white/70">
|
||||
<span className="font-semibold text-white">{audiobook.author}</span>
|
||||
{metadata?.narrator && (
|
||||
<>
|
||||
<span className="mx-1">•</span>
|
||||
<span>{metadata.narrator}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="mx-1">•</span>
|
||||
<span>{formatTime(audiobook.duration)}</span>
|
||||
<span className="mx-1">•</span>
|
||||
<span className={isFinished ? "text-green-400" : "text-[#ecb200]"}>
|
||||
{isFinished ? "Finished" : `${Math.round(progressPercent)}% complete`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Series & Genre tags - compact */}
|
||||
{(audiobook.series || audiobook.genres?.length > 0) && (
|
||||
<div className="hidden md:flex flex-wrap gap-1.5 mt-3">
|
||||
{audiobook.series && (
|
||||
<span className="px-2.5 py-0.5 bg-white/10 rounded-full text-xs text-white/80">
|
||||
{audiobook.series.name} #{audiobook.series.sequence}
|
||||
</span>
|
||||
)}
|
||||
{audiobook.genres?.slice(0, 3).map((genre: string) => (
|
||||
<span
|
||||
key={genre}
|
||||
className="px-2.5 py-0.5 bg-white/10 rounded-full text-xs text-white/80"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Bar - Full Width */}
|
||||
{children && (
|
||||
<div className="relative px-4 md:px-8 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
interface ChapterListProps {
|
||||
chapters: any[];
|
||||
onSeekToChapter: (startTime: number) => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
}
|
||||
|
||||
export function ChapterList({
|
||||
chapters,
|
||||
onSeekToChapter,
|
||||
formatTime,
|
||||
}: ChapterListProps) {
|
||||
// Hide if >50 chapters (likely multi-file audiobook)
|
||||
if (!chapters || chapters.length === 0 || chapters.length > 50) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-6">Chapters</h2>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-2">
|
||||
{chapters.map((chapter: any, index: number) => (
|
||||
<button
|
||||
key={chapter.id}
|
||||
onClick={() => onSeekToChapter(chapter.start)}
|
||||
className="w-full text-left p-3 rounded-md hover:bg-[#1a1a1a] transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 mr-2">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="text-sm text-white group-hover:text-purple-400">
|
||||
{chapter.title}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatTime(chapter.start)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Play, Pause, Book } from "lucide-react";
|
||||
|
||||
interface PlayControlsProps {
|
||||
audiobook: any;
|
||||
isThisBookPlaying: boolean;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
onPlayPause: () => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
}
|
||||
|
||||
export function PlayControls({
|
||||
audiobook,
|
||||
isThisBookPlaying,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onPlayPause,
|
||||
formatTime,
|
||||
}: PlayControlsProps) {
|
||||
const hasProgress = audiobook.progress && audiobook.progress.progress > 0;
|
||||
const isFinished = audiobook.progress?.isFinished;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-16 px-8 rounded-full shadow-lg hover:scale-105 transition-transform"
|
||||
onClick={onPlayPause}
|
||||
>
|
||||
{isThisBookPlaying && isPlaying ? (
|
||||
<>
|
||||
<Pause className="w-6 h-6 mr-2" />
|
||||
<span className="font-semibold">Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-6 h-6 mr-2 fill-current" />
|
||||
<span className="font-semibold">
|
||||
{hasProgress && !isFinished ? "Resume" : "Play"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{hasProgress && !isFinished && (
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-sm text-gray-400">Continue listening</div>
|
||||
<div className="text-white font-medium">
|
||||
{formatTime(
|
||||
isThisBookPlaying ? currentTime : audiobook.progress.currentTime
|
||||
)}{" "}
|
||||
/ {formatTime(audiobook.duration)}
|
||||
</div>
|
||||
<div className="w-48 h-1 bg-[#181818] rounded-full mt-2">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${
|
||||
isThisBookPlaying
|
||||
? (currentTime / audiobook.duration) * 100
|
||||
: audiobook.progress.progress
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFinished && (
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<Book className="w-5 h-5" />
|
||||
<span className="font-medium">Finished</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-400 mt-4">
|
||||
Use the player controls in the sidebar or bottom bar for playback speed,
|
||||
seeking, and volume.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export { AudiobookHero } from "./AudiobookHero";
|
||||
export { AudiobookActionBar } from "./AudiobookActionBar";
|
||||
export { PlayControls } from "./PlayControls";
|
||||
export { ChapterList } from "./ChapterList";
|
||||
export { AboutSection } from "./AboutSection";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user