refactor: migrate project from Flask to Next.js with TypeScript, Prisma, and Edge TTS integration

This commit is contained in:
2026-09-03 00:16:04 -07:00
parent 84f79dc5f9
commit f033a6045c
60 changed files with 5340 additions and 2179 deletions
+253
View File
@@ -0,0 +1,253 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import {
Play,
Pause,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
Gauge,
Radio,
} from "lucide-react";
interface AudioPlayerProps {
title: string;
audioUrl: string;
durationSeconds?: number | null;
onTimeUpdate?: (currentTime: number) => void;
onSeek?: (seekTo: number) => void;
seekTime?: number | null;
}
export function AudioPlayer({
title,
audioUrl,
durationSeconds,
onTimeUpdate,
seekTime,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(durationSeconds || 0);
const [playbackRate, setPlaybackRate] = useState(1);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
// Handle external seek requests (e.g. clicking transcript)
useEffect(() => {
if (
seekTime !== undefined &&
seekTime !== null &&
audioRef.current &&
Math.abs(audioRef.current.currentTime - seekTime) > 0.5
) {
audioRef.current.currentTime = seekTime;
setCurrentTime(seekTime);
if (!isPlaying) {
audioRef.current.play().catch(console.error);
setIsPlaying(true);
}
}
}, [seekTime]);
// Handle source changes
useEffect(() => {
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
setCurrentTime(0);
audioRef.current.load();
}
}, [audioUrl]);
const togglePlay = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
audioRef.current.play().then(() => setIsPlaying(true)).catch(console.error);
}
};
const jump = (seconds: number) => {
if (!audioRef.current) return;
const newTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration || 999999));
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
};
const handleTimeUpdate = () => {
if (!audioRef.current) return;
const time = audioRef.current.currentTime;
setCurrentTime(time);
onTimeUpdate?.(time);
};
const handleLoadedMetadata = () => {
if (!audioRef.current) return;
if (audioRef.current.duration && isFinite(audioRef.current.duration)) {
setDuration(audioRef.current.duration);
}
};
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = parseFloat(e.target.value);
setCurrentTime(time);
if (audioRef.current) {
audioRef.current.currentTime = time;
}
};
const togglePlaybackRate = () => {
const rates = [0.8, 1.0, 1.25, 1.5, 2.0];
const nextIdx = (rates.indexOf(playbackRate) + 1) % rates.length;
const newRate = rates[nextIdx];
setPlaybackRate(newRate);
if (audioRef.current) {
audioRef.current.playbackRate = newRate;
}
};
const toggleMute = () => {
if (!audioRef.current) return;
const newMuted = !isMuted;
setIsMuted(newMuted);
audioRef.current.muted = newMuted;
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (audioRef.current) {
audioRef.current.volume = val;
if (val === 0) {
setIsMuted(true);
} else if (isMuted) {
setIsMuted(false);
}
}
};
const formatTime = (secs: number) => {
if (isNaN(secs) || !isFinite(secs)) return "00:00";
const minutes = Math.floor(secs / 60);
const seconds = Math.floor(secs % 60);
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-slate-900/95 backdrop-blur-md border-t border-slate-800 shadow-2xl px-4 py-3 sm:px-6">
<audio
ref={audioRef}
src={audioUrl}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
preload="metadata"
/>
<div className="max-w-6xl mx-auto flex flex-col gap-2">
{/* Scrubber and Timestamps */}
<div className="flex items-center gap-3 w-full">
<span className="text-xs font-mono text-slate-400 w-12 text-right">
{formatTime(currentTime)}
</span>
<div className="relative flex-1 group">
<input
type="range"
min="0"
max={duration || 100}
step="0.1"
value={currentTime}
onChange={handleScrub}
className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500 hover:h-2 transition-all"
/>
</div>
<span className="text-xs font-mono text-slate-400 w-12">
{formatTime(duration)}
</span>
</div>
{/* Controls Bar */}
<div className="flex items-center justify-between">
{/* Track Info */}
<div className="flex items-center gap-3 w-1/3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
<Radio className="w-5 h-5 animate-pulse" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{title}</p>
<p className="text-xs text-slate-400 truncate">Be My Anchor • Episode</p>
</div>
</div>
{/* Main Playback Buttons */}
<div className="flex items-center gap-3 sm:gap-4 justify-center">
<button
onClick={() => jump(-15)}
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
title="Jump backward 15s"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
className="p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full shadow-lg shadow-blue-600/30 transition-transform active:scale-95"
title={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
</button>
<button
onClick={() => jump(15)}
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
title="Jump forward 15s"
>
<RotateCw className="w-4 h-4" />
</button>
</div>
{/* Aux Controls: Speed & Volume */}
<div className="flex items-center gap-3 w-1/3 justify-end">
<button
onClick={togglePlaybackRate}
className="flex items-center gap-1 text-xs font-medium px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded border border-slate-700 transition"
title="Adjust Speed"
>
<Gauge className="w-3.5 h-3.5 text-blue-400" />
<span>{playbackRate}x</span>
</button>
<div className="hidden sm:flex items-center gap-2">
<button
onClick={toggleMute}
className="text-slate-400 hover:text-slate-200 transition"
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? (
<VolumeX className="w-4 h-4 text-red-400" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-slate-700 rounded appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
</div>
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import React, { useEffect, useRef } from "react";
import { TimingCue } from "@/lib/tts/types";
import { Clock, PlayCircle } from "lucide-react";
interface TranscriptViewerProps {
timings: TimingCue[];
currentTime: number;
onSeek: (time: number) => void;
}
export function TranscriptViewer({
timings,
currentTime,
onSeek,
}: TranscriptViewerProps) {
const activeRef = useRef<HTMLDivElement | null>(null);
// Auto-scroll active cue into view smoothly
useEffect(() => {
if (activeRef.current) {
activeRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [currentTime]);
const formatSeconds = (secs: number) => {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
};
if (!timings || timings.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 text-slate-500 border border-dashed border-slate-800 rounded-xl">
<Clock className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">No synchronized transcript available for this episode.</p>
</div>
);
}
return (
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-2">
{timings.map((cue, idx) => {
const isActive = currentTime >= cue.startTime && currentTime <= cue.endTime;
return (
<div
key={idx}
ref={isActive ? activeRef : null}
onClick={() => onSeek(cue.startTime)}
className={`group flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200 border ${
isActive
? "bg-blue-950/40 border-blue-500/40 text-blue-100 shadow-sm"
: "bg-slate-900/30 border-slate-800/60 hover:bg-slate-850 hover:border-slate-700 text-slate-300"
}`}
>
{/* Timestamp badge */}
<span
className={`text-xs font-mono px-2 py-0.5 rounded shrink-0 flex items-center gap-1 ${
isActive
? "bg-blue-600 text-white font-semibold"
: "bg-slate-800 text-slate-400 group-hover:bg-slate-700 group-hover:text-slate-200"
}`}
>
<PlayCircle className="w-3 h-3" />
{formatSeconds(cue.startTime)}
</span>
{/* Cue text */}
<p className="text-sm leading-relaxed flex-1">
{cue.text}
</p>
</div>
);
})}
</div>
);
}