Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
@@ -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";