462 lines
18 KiB
TypeScript
462 lines
18 KiB
TypeScript
"use client";
|
||
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||
import { motion, AnimatePresence } from "motion/react";
|
||
import {
|
||
Play,
|
||
Pause,
|
||
RotateCcw,
|
||
RotateCw,
|
||
Volume2,
|
||
VolumeX,
|
||
Download,
|
||
Share2,
|
||
X,
|
||
Check,
|
||
Headphones,
|
||
Loader2,
|
||
Sparkles
|
||
} from "lucide-react";
|
||
|
||
interface PodcastPlayerModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
podcast: {
|
||
audioUrl?: string;
|
||
title?: string;
|
||
description?: string;
|
||
cover?: string;
|
||
productName?: string;
|
||
fallbackCover?: string;
|
||
} | null;
|
||
}
|
||
|
||
const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5];
|
||
|
||
// Generate dynamic waveform bars for Soundcloud-like UI
|
||
const WAVEFORM_BAR_COUNT = 48;
|
||
|
||
export default function PodcastPlayerModal({ isOpen, onClose, podcast }: PodcastPlayerModalProps) {
|
||
const audioRef = useRef<HTMLAudioElement>(null);
|
||
const waveformRef = useRef<HTMLDivElement>(null);
|
||
const rateMenuRef = useRef<HTMLDivElement>(null);
|
||
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
const [duration, setDuration] = useState(0);
|
||
const [volume, setVolume] = useState(1);
|
||
const [isMuted, setIsMuted] = useState(false);
|
||
const [playbackRate, setPlaybackRate] = useState(1);
|
||
const [showRateMenu, setShowRateMenu] = useState(false);
|
||
const [isBuffering, setIsBuffering] = useState(false);
|
||
const [isCopied, setIsCopied] = useState(false);
|
||
|
||
// Close rate menu on outside click
|
||
useEffect(() => {
|
||
function handleClickOutside(e: MouseEvent) {
|
||
if (rateMenuRef.current && !rateMenuRef.current.contains(e.target as Node)) {
|
||
setShowRateMenu(false);
|
||
}
|
||
}
|
||
if (showRateMenu) {
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
}
|
||
return () => {
|
||
document.removeEventListener("mousedown", handleClickOutside);
|
||
};
|
||
}, [showRateMenu]);
|
||
|
||
// Deterministic heights for waveform visualization
|
||
const waveformHeights = useMemo(() => {
|
||
const seed = (podcast?.title || "canina").split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||
return Array.from({ length: WAVEFORM_BAR_COUNT }).map((_, i) => {
|
||
const pseudo = Math.sin((i + 1) * 0.45 + seed) * 0.5 + 0.5;
|
||
const heightPercent = Math.max(20, Math.min(95, Math.floor(pseudo * 80 + 15)));
|
||
return heightPercent;
|
||
});
|
||
}, [podcast?.title]);
|
||
|
||
// Reset states on open/close
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
setIsPlaying(true);
|
||
setShowRateMenu(false);
|
||
setIsBuffering(false);
|
||
} else {
|
||
setIsPlaying(false);
|
||
setCurrentTime(0);
|
||
}
|
||
}, [isOpen, podcast]);
|
||
|
||
const togglePlay = useCallback(() => {
|
||
if (!audioRef.current) return;
|
||
if (audioRef.current.paused) {
|
||
audioRef.current.play().catch(console.error);
|
||
setIsPlaying(true);
|
||
} else {
|
||
audioRef.current.pause();
|
||
setIsPlaying(false);
|
||
}
|
||
}, []);
|
||
|
||
const seekToPercent = (percent: number) => {
|
||
if (audioRef.current && duration > 0) {
|
||
const newTime = (percent / 100) * duration;
|
||
audioRef.current.currentTime = Math.max(0, Math.min(duration, newTime));
|
||
setCurrentTime(newTime);
|
||
}
|
||
};
|
||
|
||
const handleWaveformClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
if (!waveformRef.current || duration <= 0) return;
|
||
const rect = waveformRef.current.getBoundingClientRect();
|
||
const clickX = e.clientX - rect.left;
|
||
const percent = Math.max(0, Math.min(100, (clickX / rect.width) * 100));
|
||
seekToPercent(percent);
|
||
};
|
||
|
||
const skipTime = (seconds: number) => {
|
||
if (audioRef.current) {
|
||
audioRef.current.currentTime = Math.max(0, Math.min(audioRef.current.duration || 0, audioRef.current.currentTime + seconds));
|
||
}
|
||
};
|
||
|
||
const handleRateChange = (rate: number) => {
|
||
if (audioRef.current) {
|
||
audioRef.current.playbackRate = rate;
|
||
setPlaybackRate(rate);
|
||
setShowRateMenu(false);
|
||
}
|
||
};
|
||
|
||
const toggleMute = () => {
|
||
if (audioRef.current) {
|
||
audioRef.current.muted = !isMuted;
|
||
setIsMuted(!isMuted);
|
||
}
|
||
};
|
||
|
||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const val = Number(e.target.value);
|
||
setVolume(val);
|
||
if (audioRef.current) {
|
||
audioRef.current.volume = val;
|
||
if (val === 0) {
|
||
setIsMuted(true);
|
||
audioRef.current.muted = true;
|
||
} else {
|
||
setIsMuted(false);
|
||
audioRef.current.muted = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleShare = async () => {
|
||
const shareUrl = window.location.href;
|
||
if (navigator.share) {
|
||
try {
|
||
await navigator.share({
|
||
title: podcast?.title || 'پادکست و بررسی بالینی مکمل کنینا',
|
||
text: podcast?.description || '',
|
||
url: shareUrl
|
||
});
|
||
} catch {
|
||
// Fallback or dismissed
|
||
}
|
||
} else {
|
||
try {
|
||
await navigator.clipboard.writeText(shareUrl);
|
||
setIsCopied(true);
|
||
setTimeout(() => setIsCopied(false), 2500);
|
||
} catch (err) {
|
||
console.error('Copy failed:', err);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleDownload = () => {
|
||
if (!podcast?.audioUrl) return;
|
||
const a = document.createElement('a');
|
||
a.href = podcast.audioUrl;
|
||
a.download = `${podcast.title || 'canina-podcast'}.mp3`;
|
||
a.target = '_blank';
|
||
a.rel = 'noopener noreferrer';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
};
|
||
|
||
const formatTime = (secs: number) => {
|
||
if (isNaN(secs) || secs < 0) return "00:00";
|
||
const mins = Math.floor(secs / 60);
|
||
const remainingSecs = Math.floor(secs % 60);
|
||
return `${mins.toString().padStart(2, "0")}:${remainingSecs.toString().padStart(2, "0")}`;
|
||
};
|
||
|
||
if (!isOpen || !podcast) return null;
|
||
|
||
const displayCover = podcast.cover || podcast.fallbackCover || '/images/default-podcast.png';
|
||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||
const volumePercent = isMuted ? 0 : volume * 100;
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
<div className="fixed inset-0 z-[120] flex items-center justify-center p-3 sm:p-6 font-vazir" dir="rtl">
|
||
{/* Backdrop */}
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={onClose}
|
||
className="absolute inset-0 bg-black/85 backdrop-blur-xl"
|
||
/>
|
||
|
||
{/* Modal Content */}
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||
transition={{ duration: 0.25 }}
|
||
className="relative bg-gradient-to-b from-neutral-900 via-neutral-900 to-black w-full max-w-lg rounded-[2.5rem] overflow-hidden shadow-2xl border border-white/15 p-6 sm:p-8 flex flex-col items-center select-none text-white z-10"
|
||
>
|
||
{/* Top Bar (Header + Close) */}
|
||
<div className="w-full flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-2.5 text-xs font-black text-canina-blue bg-canina-blue/10 px-3.5 py-1.5 rounded-full border border-canina-blue/20">
|
||
<Headphones className="w-4 h-4 animate-pulse text-canina-blue" />
|
||
<span>پادکست و تحلیل صوتی بالینی</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={handleShare}
|
||
className="p-2.5 rounded-full bg-white/10 hover:bg-white/20 text-white transition-all cursor-pointer"
|
||
title="اشتراکگذاری"
|
||
>
|
||
{isCopied ? <Check className="w-4 h-4 text-emerald-400" /> : <Share2 className="w-4 h-4" />}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleDownload}
|
||
className="p-2.5 rounded-full bg-white/10 hover:bg-white/20 text-white transition-all cursor-pointer"
|
||
title="دانلود فایل صوتی"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="p-2.5 rounded-full bg-white/10 hover:bg-red-500/80 text-white transition-all cursor-pointer mr-1"
|
||
title="بستن"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hidden Audio Tag */}
|
||
<audio
|
||
ref={audioRef}
|
||
src={podcast.audioUrl}
|
||
autoPlay
|
||
onPlay={() => setIsPlaying(true)}
|
||
onPause={() => setIsPlaying(false)}
|
||
onTimeUpdate={() => {
|
||
if (audioRef.current) setCurrentTime(audioRef.current.currentTime);
|
||
}}
|
||
onLoadedMetadata={() => {
|
||
if (audioRef.current) setDuration(audioRef.current.duration);
|
||
setIsBuffering(false);
|
||
}}
|
||
onWaiting={() => setIsBuffering(true)}
|
||
onPlaying={() => setIsBuffering(false)}
|
||
onEnded={() => setIsPlaying(false)}
|
||
/>
|
||
|
||
{/* Cover Art Artwork */}
|
||
<div className="relative group my-2">
|
||
<div className="w-40 h-40 sm:w-48 sm:h-48 rounded-3xl overflow-hidden shadow-2xl border-2 border-white/20 relative bg-neutral-800 flex items-center justify-center">
|
||
<img
|
||
src={displayCover}
|
||
alt={podcast.title || "کاور پادکست"}
|
||
className={`w-full h-full object-cover transition-transform duration-700 ${isPlaying ? "scale-105" : "scale-100"}`}
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).src = '/images/default-podcast.png';
|
||
}}
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
|
||
|
||
{isBuffering && (
|
||
<div className="absolute inset-0 bg-black/40 backdrop-blur-xs flex items-center justify-center">
|
||
<Loader2 className="w-10 h-10 text-white animate-spin" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Title and Metadata */}
|
||
<div className="w-full text-center mt-3 mb-3 space-y-1">
|
||
<h3 className="text-base sm:text-lg font-black text-white line-clamp-1">
|
||
{podcast.title || "بررسی بالینی و نحوه مصرف مکمل"}
|
||
</h3>
|
||
{podcast.productName && (
|
||
<p className="text-canina-blue text-xs font-bold flex items-center justify-center gap-1">
|
||
<Sparkles className="w-3.5 h-3.5 text-canina-gold" />
|
||
<span>محصول: {podcast.productName}</span>
|
||
</p>
|
||
)}
|
||
{podcast.description && (
|
||
<p className="text-neutral-300 text-xs line-clamp-2 px-2 pt-0.5 font-medium leading-relaxed">
|
||
{podcast.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Soundcloud-like Waveform Visualizer & Seeker */}
|
||
<div className="w-full space-y-2 mt-2 mb-3">
|
||
<div
|
||
ref={waveformRef}
|
||
onClick={handleWaveformClick}
|
||
className="w-full h-12 flex items-center justify-between gap-[3px] px-2 py-1 bg-white/5 hover:bg-white/10 rounded-2xl cursor-pointer transition-all relative overflow-hidden group/wave border border-white/5"
|
||
title="برای رفتن به بخش مورد نظر کلیک کنید"
|
||
dir="ltr"
|
||
>
|
||
{waveformHeights.map((h, i) => {
|
||
const barPercent = (i / (WAVEFORM_BAR_COUNT - 1)) * 100;
|
||
const isPassed = barPercent <= progressPercent;
|
||
return (
|
||
<div
|
||
key={i}
|
||
className="flex-1 flex items-center justify-center h-full"
|
||
>
|
||
<div
|
||
className={`w-full rounded-full transition-all duration-150 ${
|
||
isPassed
|
||
? "bg-gradient-to-t from-canina-blue to-cyan-400 shadow-xs"
|
||
: "bg-white/20 group-hover/wave:bg-white/30"
|
||
} ${isPlaying && isPassed ? "opacity-100" : "opacity-85"}`}
|
||
style={{
|
||
height: `${h}%`,
|
||
transform: isPlaying && isPassed ? "scaleY(1.05)" : "scaleY(1)"
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Time Stamp Row */}
|
||
<div className="flex items-center justify-between text-[11px] font-mono font-bold text-white/60 px-1 dir-ltr" dir="ltr">
|
||
<span>{formatTime(currentTime)}</span>
|
||
<span>{formatTime(duration)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Controls Row */}
|
||
<div className="w-full flex items-center justify-between pt-2">
|
||
{/* Speed Selector Button */}
|
||
<div className="relative" ref={rateMenuRef}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowRateMenu(!showRateMenu)}
|
||
className="px-2.5 py-1.5 rounded-xl bg-white/10 hover:bg-white/20 text-white text-xs font-mono font-bold transition-all cursor-pointer flex items-center gap-1 border border-white/10"
|
||
title="سرعت پخش"
|
||
>
|
||
<span>{playbackRate}x</span>
|
||
</button>
|
||
|
||
{showRateMenu && (
|
||
<div className="absolute bottom-full right-0 mb-2 bg-neutral-900/95 backdrop-blur-md border border-white/15 rounded-2xl p-1.5 shadow-2xl z-30 flex flex-col gap-1 min-w-[80px]">
|
||
{PLAYBACK_RATES.map((rate) => (
|
||
<button
|
||
key={rate}
|
||
type="button"
|
||
onClick={() => handleRateChange(rate)}
|
||
className={`px-3 py-1 text-xs font-mono font-bold rounded-xl text-center transition-colors cursor-pointer ${
|
||
playbackRate === rate
|
||
? "bg-canina-blue text-white"
|
||
: "text-white/70 hover:bg-white/10 hover:text-white"
|
||
}`}
|
||
>
|
||
{rate}x
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Central Controls: -15s on Left, Play/Pause in Middle, +15s on Right in RTL */}
|
||
<div className="flex items-center gap-2 sm:gap-3" dir="rtl">
|
||
{/* Play / Pause button */}
|
||
<button
|
||
type="button"
|
||
onClick={togglePlay}
|
||
className="w-12 h-12 rounded-full bg-canina-blue hover:bg-canina-blue/90 text-white flex items-center justify-center transition-transform hover:scale-105 active:scale-95 cursor-pointer shadow-xl shadow-canina-blue/40 border border-white/20"
|
||
title={isPlaying ? "توقف" : "پخش"}
|
||
>
|
||
{isPlaying ? <Pause className="w-5 h-5 fill-white" /> : <Play className="w-5 h-5 fill-white ml-0.5" />}
|
||
</button>
|
||
|
||
{/* -15s button (left) */}
|
||
<button
|
||
type="button"
|
||
onClick={() => skipTime(-15)}
|
||
className="px-2.5 py-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold border border-white/10"
|
||
title="۱۵ ثانیه عقب"
|
||
>
|
||
<RotateCcw className="w-3.5 h-3.5" />
|
||
<span>-15s</span>
|
||
</button>
|
||
|
||
{/* +15s button (right) */}
|
||
<button
|
||
type="button"
|
||
onClick={() => skipTime(15)}
|
||
className="px-2.5 py-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold border border-white/10"
|
||
title="۱۵ ثانیه جلو"
|
||
>
|
||
<RotateCw className="w-3.5 h-3.5" />
|
||
<span>+15s</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Volume Control: Increases towards the speaker icon */}
|
||
<div className="flex items-center gap-2" dir="ltr">
|
||
<div className="relative w-16 hidden sm:flex items-center h-4">
|
||
<div className="absolute inset-x-0 h-1.5 bg-white/20 rounded-lg overflow-hidden pointer-events-none">
|
||
<div
|
||
className="h-full bg-canina-blue transition-all"
|
||
style={{ width: `${volumePercent}%` }}
|
||
/>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={1}
|
||
step={0.05}
|
||
value={isMuted ? 0 : volume}
|
||
onChange={handleVolumeChange}
|
||
className="w-full h-4 opacity-0 absolute inset-0 cursor-pointer z-10"
|
||
/>
|
||
<div
|
||
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow pointer-events-none -translate-x-1/2"
|
||
style={{ left: `${volumePercent}%` }}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={toggleMute}
|
||
className="p-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer"
|
||
title={isMuted ? "صدا وصل" : "بیصدا"}
|
||
>
|
||
{isMuted || volume === 0 ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
</AnimatePresence>
|
||
);
|
||
}
|