canina/frontend/application/components/PodcastInlinePlayer.tsx
2026-08-24 01:20:33 +03:30

417 lines
17 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, useMemo } from "react";
import {
Play,
Pause,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
Download,
Share2,
Check,
Headphones,
Loader2,
Sparkles
} from "lucide-react";
import SafeImage from "./SafeImage";
interface PodcastInlinePlayerProps {
podcast: {
audioUrl?: string;
title?: string;
description?: string;
cover?: string;
productName?: string;
fallbackCover?: string;
};
}
const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5];
const WAVEFORM_BAR_COUNT = 44;
export default function PodcastInlinePlayer({ podcast }: PodcastInlinePlayerProps) {
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 when clicking outside
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;
return Math.max(22, Math.min(95, Math.floor(pseudo * 75 + 20)));
});
}, [podcast?.title]);
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 = typeof window !== 'undefined' ? window.location.href : '';
if (navigator.share) {
try {
await navigator.share({
title: podcast?.title || 'پادکست و بررسی بالینی مکمل کنینا',
text: podcast?.description || '',
url: shareUrl
});
} catch {
// Fallback
}
} 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")}`;
};
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 (
<div className="bg-gradient-to-br from-neutral-900 via-neutral-900 to-black rounded-3xl overflow-hidden shadow-xl border border-white/10 p-6 text-white group" dir="rtl">
{/* Hidden Audio Tag */}
<audio
ref={audioRef}
src={podcast.audioUrl}
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)}
/>
<div className="flex flex-col md:flex-row items-center gap-6">
{/* Artwork / Cover */}
<div
onClick={togglePlay}
className="relative w-full md:w-56 sm:w-64 aspect-square rounded-2xl overflow-hidden cursor-pointer bg-neutral-800 border border-white/15 group-hover:border-canina-blue/50 transition-all flex items-center justify-center shrink-0 shadow-lg"
>
<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-black/40 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-canina-blue text-white flex items-center justify-center shadow-xl shadow-canina-blue/50 group-hover:scale-110 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-5 h-5 fill-white" /> : <Play className="w-5 h-5 fill-white ml-0.5" />}
</div>
</div>
{isBuffering && (
<div className="absolute inset-0 bg-black/50 backdrop-blur-2xs flex items-center justify-center">
<Loader2 className="w-8 h-8 text-white animate-spin" />
</div>
)}
</div>
{/* Info & Soundcloud Waveform Seeker Controls */}
<div className="flex-1 w-full text-center md:text-right space-y-3">
{/* Header Row: Badge & Action buttons */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs font-black text-canina-blue bg-canina-blue/15 px-3 py-1 rounded-full border border-canina-blue/30 flex items-center gap-1.5">
<Headphones className="w-3.5 h-3.5 animate-pulse text-canina-blue" />
<span>پادکست و تحلیل صوتی بالینی</span>
</span>
{podcast.productName && (
<span className="hidden sm:inline-flex text-xs font-bold text-white/70 items-center gap-1">
<Sparkles className="w-3 h-3 text-canina-gold" />
<span>{podcast.productName}</span>
</span>
)}
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={handleShare}
className="p-2 rounded-xl 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 rounded-xl bg-white/10 hover:bg-white/20 text-white transition-all cursor-pointer"
title="دانلود پادکست"
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
<div>
<h4 className="text-base sm:text-lg font-black text-white line-clamp-1">
{podcast.title || "پادکست و بررسی صوتی بالینی مکمل"}
</h4>
<p className="text-xs text-white/70 font-medium leading-relaxed line-clamp-2 mt-1">
{podcast.description || "توضیحات صوتی دامپزشک و کادر علمی درباره اثربخشی، مکانیسم اثر و نحوه مصرف دقیق"}
</p>
</div>
{/* Soundcloud-Style Waveform Visualizer & Track */}
<div className="w-full space-y-1.5 pt-1">
<div
ref={waveformRef}
onClick={handleWaveformClick}
className="w-full h-11 flex items-center justify-between gap-[3px] px-2.5 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-80"}`}
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>
{/* Controls Bottom Bar */}
<div className="flex items-center justify-between pt-1 flex-wrap gap-2">
{/* Speed Selector */}
<div className="relative" ref={rateMenuRef}>
<button
type="button"
onClick={() => setShowRateMenu(!showRateMenu)}
className="px-2.5 py-1 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-[75px]">
{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 in Center, +15s on Right in RTL */}
<div className="flex items-center gap-2 sm:gap-3" dir="rtl">
<button
type="button"
onClick={togglePlay}
className="w-10 h-10 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-lg shadow-canina-blue/30 border border-white/10"
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
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
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: Directed towards 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>
</div>
</div>
</div>
);
}