canina/frontend/application/components/PodcastPlayerModal.tsx
parsa aghaei 2ce872969c
Some checks failed
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 6s
Deploy Canina / deploy (push) Has been cancelled
style(podcast): enhance badge contrast with bright cyan text and border on dark background
2026-08-26 16:20:47 +03:30

461 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
import SafeImage from "./SafeImage";
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-cyan-300 bg-cyan-500/20 px-3.5 py-1.5 rounded-full border border-cyan-400/40 shadow-xs backdrop-blur-xs">
<Headphones className="w-4 h-4 animate-pulse text-cyan-300" />
<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">
<SafeImage
src={displayCover}
alt={podcast.title || "کاور پادکست"}
className="w-full h-full"
imgClassName={`w-full h-full object-cover transition-transform duration-700 ${isPlaying ? "scale-105" : "scale-100"}`}
/>
<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>
);
}