refactor: migrate project from Flask to Next.js with TypeScript, Prisma, and Edge TTS integration
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user