canina/frontend/application/components/PodcastPlayerModal.tsx
2026-08-18 11:34:04 +03:30

392 lines
14 KiB
TypeScript
Raw 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 } 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];
export default function PodcastPlayerModal({ isOpen, onClose, podcast }: PodcastPlayerModalProps) {
const audioRef = useRef<HTMLAudioElement>(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);
// 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 handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = Number(e.target.value);
if (audioRef.current) {
audioRef.current.currentTime = time;
setCurrentTime(time);
}
};
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';
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-6">
<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" />
<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-48 h-48 sm:w-56 sm:h-56 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-5 mb-4 space-y-1.5">
<h3 className="text-lg sm:text-xl 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" />
<span>محصول: {podcast.productName}</span>
</p>
)}
{podcast.description && (
<p className="text-neutral-300 text-xs line-clamp-2 px-2 pt-1 font-medium leading-relaxed">
{podcast.description}
</p>
)}
</div>
{/* Seeker / Progress Bar */}
<div className="w-full space-y-1.5 mt-2 mb-4">
<div className="flex items-center gap-3 dir-ltr" dir="ltr">
<span className="text-[11px] font-mono font-bold text-white/60 w-10 text-right">
{formatTime(currentTime)}
</span>
<div className="relative flex-1 flex items-center">
<input
type="range"
min={0}
max={duration || 100}
step={0.1}
value={currentTime}
onChange={handleSeek}
className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue hover:h-2.5 transition-all"
/>
</div>
<span className="text-[11px] font-mono font-bold text-white/60 w-10 text-left">
{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">
<button
type="button"
onClick={() => setShowRateMenu(!showRateMenu)}
className="px-3 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, Play/Pause, +15s */}
<div className="flex items-center gap-3 sm:gap-4">
{/* -15s */}
<button
type="button"
onClick={() => skipTime(-15)}
className="p-2.5 text-white/80 hover:text-white rounded-full hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-0.5 text-xs font-mono font-bold"
title="۱۵ ثانیه عقب"
>
<RotateCcw className="w-5 h-5" />
<span className="text-[10px]">15s</span>
</button>
{/* Play / Pause button */}
<button
type="button"
onClick={togglePlay}
className="w-14 h-14 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-6 h-6 fill-white" /> : <Play className="w-6 h-6 fill-white ml-0.5" />}
</button>
{/* +15s */}
<button
type="button"
onClick={() => skipTime(15)}
className="p-2.5 text-white/80 hover:text-white rounded-full hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-0.5 text-xs font-mono font-bold"
title="۱۵ ثانیه جلو"
>
<RotateCw className="w-5 h-5" />
<span className="text-[10px]">15s</span>
</button>
</div>
{/* Volume Control */}
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={toggleMute}
className="p-2 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>
<input
type="range"
min={0}
max={1}
step={0.05}
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-14 h-1.5 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue hidden sm:block"
/>
</div>
</div>
</motion.div>
</div>
</AnimatePresence>
);
}