feat: implement authentication control with support for BYO API keys, episode deduplication, and a task queue for async processing.
This commit is contained in:
+110
-236
@@ -1,251 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Gauge,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { useAudio } from "@/contexts/AudioContext";
|
||||
import { Maximize2, Play, Pause } 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() {
|
||||
const {
|
||||
activeEpisode,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
togglePlay,
|
||||
seekTo,
|
||||
openZen,
|
||||
} = useAudio();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!activeEpisode) return null;
|
||||
|
||||
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")}`;
|
||||
if (!secs || isNaN(secs)) return "0:00";
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = Math.floor(secs % 60);
|
||||
return `${m}:${s.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"
|
||||
/>
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
<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"
|
||||
/>
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 bg-white/95 dark:bg-slate-950/95 backdrop-blur-2xl border-t border-slate-200 dark:border-slate-900 text-slate-900 dark:text-slate-100 shadow-2xl safe-bottom transition-colors">
|
||||
{/* Interactive Top Mini Scrubber */}
|
||||
<div
|
||||
className="w-full h-1 bg-slate-200 dark:bg-slate-900 hover:h-2 cursor-pointer transition-all group relative"
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const ratio = Math.max(0, Math.min(clickX / rect.width, 1));
|
||||
seekTo(ratio * duration);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-blue-500 group-hover:bg-blue-400 transition-all relative"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2.5 h-2.5 bg-white rounded-full shadow opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-18 py-2.5 flex items-center justify-between gap-4">
|
||||
{/* Left: Spinning Vinyl Album (Tap to toggle play/pause) + Track Info */}
|
||||
<div className="flex items-center gap-3.5 min-w-0 flex-1">
|
||||
{/* Vinyl Record */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="group relative w-12 h-12 rounded-full shrink-0 flex items-center justify-center select-none shadow-md shadow-black/30 active:scale-95 transition-transform"
|
||||
title={isPlaying ? "Click to Pause" : "Click to Play"}
|
||||
aria-label={isPlaying ? "Pause audio" : "Play audio"}
|
||||
>
|
||||
{/* Outer Vinyl Body with spinning animation */}
|
||||
<div
|
||||
className={`w-full h-full rounded-full bg-gradient-to-tr from-slate-900 via-slate-950 to-slate-900 border border-slate-700/80 p-1 flex items-center justify-center shadow-inner ${
|
||||
isPlaying ? "animate-[spin_5s_linear_infinite]" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Vinyl grooves styling */}
|
||||
<div className="w-full h-full rounded-full border border-slate-800/80 flex items-center justify-center">
|
||||
<div className="w-3/4 h-3/4 rounded-full border border-slate-800/60 flex items-center justify-center">
|
||||
{/* Center Album Art / Label */}
|
||||
<div className="w-5 h-5 rounded-full bg-gradient-to-tr from-blue-500 via-indigo-500 to-purple-600 flex items-center justify-center border border-slate-900 shadow-sm">
|
||||
{/* Center spindle hole */}
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-slate-950" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover / Status Overlay Icon */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full flex items-center justify-center bg-black/40 backdrop-blur-[1px] transition-opacity ${
|
||||
isPlaying ? "opacity-0 group-hover:opacity-100" : "opacity-90"
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-4 h-4 text-white fill-current drop-shadow" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 text-white fill-current translate-x-0.5 drop-shadow" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Track Info (Click to open Zen Player) */}
|
||||
<div
|
||||
onClick={openZen}
|
||||
className="min-w-0 flex-1 cursor-pointer group py-1"
|
||||
title="Click to open Full Screen Zen Mode"
|
||||
>
|
||||
<h4 className="text-sm font-medium truncate text-slate-800 dark:text-slate-200 group-hover:text-blue-600 dark:group-hover:text-blue-300 transition-colors">
|
||||
{activeEpisode.title}
|
||||
</h4>
|
||||
<div className="text-xs font-mono text-slate-500 flex items-center gap-2 mt-0.5">
|
||||
<span className={isPlaying ? "text-blue-500 dark:text-blue-400 font-medium" : "text-slate-400 dark:text-slate-500"}>
|
||||
{isPlaying ? "Playing" : "Paused"}
|
||||
</span>
|
||||
<span className="text-slate-300 dark:text-slate-600">•</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
{/* Right: Full Screen Zen Button */}
|
||||
<div className="flex items-center shrink-0">
|
||||
<button
|
||||
onClick={openZen}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-xl bg-slate-100 dark:bg-slate-900 hover:bg-blue-50 dark:hover:bg-blue-600/15 border border-slate-200 dark:border-slate-800 text-slate-700 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-300 text-xs sm:text-sm font-medium transition active:scale-95 shadow-sm"
|
||||
title="Open Full Screen Zen Mode"
|
||||
aria-label="Open Full Screen Zen Mode"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-500 dark:text-blue-400" />
|
||||
<span className="hidden sm:inline">Zen Mode</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className={`w-9 h-9 rounded-xl border border-transparent ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className={`p-2 rounded-xl border transition-all duration-200 flex items-center justify-center text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 border-slate-200 dark:border-slate-800 ${className}`}
|
||||
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="w-4 h-4 text-amber-400 animate-in fade-in duration-200" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4 text-slate-700 animate-in fade-in duration-200" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useAudio } from "@/contexts/AudioContext";
|
||||
import {
|
||||
ChevronDown,
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
|
||||
export function ZenPlayer() {
|
||||
const {
|
||||
activeEpisode,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
playbackRate,
|
||||
isMuted,
|
||||
isZenOpen,
|
||||
timings,
|
||||
togglePlay,
|
||||
seekTo,
|
||||
jump,
|
||||
setSpeed,
|
||||
toggleMute,
|
||||
closeZen,
|
||||
} = useAudio();
|
||||
|
||||
const activeCueRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
|
||||
const speakerNameMap = React.useMemo(() => {
|
||||
const map: Record<string, string> = {
|
||||
hostA: "Host A",
|
||||
hostB: "Host B",
|
||||
};
|
||||
if (activeEpisode?.scriptContent) {
|
||||
try {
|
||||
const parsed = JSON.parse(activeEpisode.scriptContent);
|
||||
if (Array.isArray(parsed.speakers)) {
|
||||
for (const s of parsed.speakers) {
|
||||
if (s.id && s.name) {
|
||||
map[s.id] = s.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return map;
|
||||
}, [activeEpisode?.scriptContent]);
|
||||
|
||||
// Auto-scroll active cue into center view
|
||||
useEffect(() => {
|
||||
if (activeCueRef.current && isZenOpen) {
|
||||
activeCueRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [currentTime, isZenOpen]);
|
||||
|
||||
if (!isZenOpen || !activeEpisode) return null;
|
||||
|
||||
const formatTime = (secs: number) => {
|
||||
if (!secs || isNaN(secs)) return "0:00";
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = Math.floor(secs % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
seekTo(val);
|
||||
};
|
||||
|
||||
const cycleSpeed = () => {
|
||||
const rates = [0.8, 1.0, 1.25, 1.5, 2.0];
|
||||
const nextIdx = (rates.indexOf(playbackRate) + 1) % rates.length;
|
||||
setSpeed(rates[nextIdx]);
|
||||
};
|
||||
|
||||
// Swipe-down to minimize gesture on mobile
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStartY.current !== null) {
|
||||
const deltaY = e.changedTouches[0].clientY - touchStartY.current;
|
||||
if (deltaY > 100) {
|
||||
closeZen();
|
||||
}
|
||||
touchStartY.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-slate-50 dark:bg-slate-950 flex flex-col text-slate-900 dark:text-slate-100 select-none animate-in fade-in duration-200 transition-colors"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Top Header / Drag Handle */}
|
||||
<header className="px-6 py-4 flex items-center justify-between border-b border-slate-200 dark:border-slate-900 bg-white/80 dark:bg-slate-950/80 backdrop-blur-md shrink-0">
|
||||
<button
|
||||
onClick={closeZen}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800/80 active:bg-slate-200 dark:active:bg-slate-700 text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 transition"
|
||||
aria-label="Minimize Zen Player"
|
||||
>
|
||||
<ChevronDown className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 text-center px-4">
|
||||
<span className="text-[11px] uppercase tracking-widest text-blue-600 dark:text-blue-400 font-semibold font-mono">
|
||||
Zen Listening Mode
|
||||
</span>
|
||||
<h2 className="text-sm font-semibold truncate text-slate-800 dark:text-slate-200 max-w-md mx-auto">
|
||||
{activeEpisode.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Header Right: Theme Toggle */}
|
||||
<div className="flex items-center">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Synchronized Transcript View (No visible scrollbar) */}
|
||||
<main
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-y-auto px-6 sm:px-12 py-8 max-w-3xl mx-auto w-full space-y-4 scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{timings && timings.length > 0 ? (
|
||||
timings.map((cue, idx) => {
|
||||
const isActive = currentTime >= cue.startTime && currentTime <= cue.endTime;
|
||||
const speakerName = cue.speaker ? speakerNameMap[cue.speaker] || cue.speaker : null;
|
||||
const isHostB = cue.speaker === "hostB";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
ref={isActive ? activeCueRef : null}
|
||||
onClick={() => seekTo(cue.startTime)}
|
||||
className={`p-4 rounded-2xl cursor-pointer transition-all duration-300 ${
|
||||
isActive
|
||||
? "bg-blue-50 dark:bg-blue-600/15 border border-blue-400 dark:border-blue-500/30 text-slate-900 dark:text-white shadow-md shadow-blue-500/5 scale-[1.01]"
|
||||
: "text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-100/70 dark:hover:bg-slate-900/40 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-2 text-xs font-mono text-slate-400 dark:text-slate-500">
|
||||
<span className={isActive ? "text-blue-600 dark:text-blue-400 font-semibold" : ""}>
|
||||
{formatTime(cue.startTime)}
|
||||
</span>
|
||||
|
||||
{speakerName && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-md text-[11px] font-sans font-medium tracking-wide ${
|
||||
isHostB
|
||||
? "bg-purple-100 dark:bg-purple-950/80 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/60"
|
||||
: "bg-blue-100 dark:bg-blue-950/80 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800/60"
|
||||
}`}
|
||||
>
|
||||
{speakerName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 dark:bg-blue-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base sm:text-lg leading-relaxed font-sans font-normal">
|
||||
{cue.text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center text-slate-400 dark:text-slate-500 py-16">
|
||||
<FileText className="w-12 h-12 mb-3 opacity-30 text-blue-500 dark:text-blue-400" />
|
||||
<p className="text-base text-slate-700 dark:text-slate-300 font-medium mb-1">
|
||||
Synchronized Transcript Not Available
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 max-w-sm">
|
||||
Enjoy listening to the episode audio using the controls below.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Bottom Control Bar */}
|
||||
<footer className="px-6 py-6 border-t border-slate-200 dark:border-slate-900 bg-white/90 dark:bg-slate-950/90 backdrop-blur-xl shrink-0 safe-bottom">
|
||||
<div className="max-w-xl mx-auto space-y-4">
|
||||
{/* Scrubber Slider */}
|
||||
<div className="space-y-1.5">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
step={0.1}
|
||||
value={currentTime}
|
||||
onChange={handleScrub}
|
||||
className="w-full h-1.5 bg-slate-200 dark:bg-slate-800 rounded-lg appearance-none cursor-pointer accent-blue-600 dark:accent-blue-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs font-mono text-slate-400 dark:text-slate-500">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The 5 Bottom Buttons: Speed, Back, Play/Pause, Forward, Mute */}
|
||||
<div className="flex items-center justify-between sm:justify-center gap-4 sm:gap-8 pt-1">
|
||||
{/* 1. Speed Toggle */}
|
||||
<button
|
||||
onClick={cycleSpeed}
|
||||
className="w-11 h-11 rounded-full text-xs font-mono font-semibold text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white bg-slate-100 dark:bg-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-800 flex items-center justify-center active:scale-95 transition"
|
||||
title="Playback Speed"
|
||||
aria-label="Toggle playback speed"
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
|
||||
{/* 2. Jump Back 15s */}
|
||||
<button
|
||||
onClick={() => jump(-15)}
|
||||
className="p-3 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 active:scale-95 transition"
|
||||
title="Rewind 15 seconds"
|
||||
aria-label="Rewind 15 seconds"
|
||||
>
|
||||
<RotateCcw className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* 3. Play / Pause (Large touch target) */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-16 h-16 rounded-full bg-blue-600 hover:bg-blue-500 active:scale-95 text-white flex items-center justify-center shadow-lg shadow-blue-600/30 transition"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-7 h-7 fill-current" />
|
||||
) : (
|
||||
<Play className="w-7 h-7 fill-current translate-x-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 4. Jump Forward 15s */}
|
||||
<button
|
||||
onClick={() => jump(15)}
|
||||
className="p-3 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 active:scale-95 transition"
|
||||
title="Skip 15 seconds"
|
||||
aria-label="Skip 15 seconds"
|
||||
>
|
||||
<RotateCw className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* 5. Mute Toggle */}
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-3 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 active:scale-95 transition"
|
||||
title={isMuted ? "Unmute" : "Mute"}
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="w-6 h-6 text-red-500 dark:text-red-400" />
|
||||
) : (
|
||||
<Volume2 className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user