canina/frontend/application/components/PodcastInlinePlayer.tsx
parsa aghaei 40176ac60e
Some checks failed
Deploy Canina / deploy (push) Successful in 1m30s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 6s
feat(media): add summary and view more toggle button for video and podcast descriptions
2026-08-26 17:17:19 +03:30

439 lines
18 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,
ChevronDown,
ChevronUp
} 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);
const [isExpandedDesc, setIsExpandedDesc] = 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}
preload="none"
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-cyan-300 bg-cyan-500/20 px-3.5 py-1.5 rounded-full border border-cyan-400/40 shadow-xs flex items-center gap-1.5 backdrop-blur-xs">
<Headphones className="w-3.5 h-3.5 animate-pulse text-cyan-300" />
<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>
{(() => {
const pDesc = podcast.description || "توضیحات صوتی دامپزشک و کادر علمی درباره اثربخشی، مکانیسم اثر و نحوه مصرف دقیق";
const isLong = pDesc.length > 120;
return (
<div className="space-y-1 mt-1">
<p className="text-xs text-white/70 font-medium leading-relaxed">
{isLong && !isExpandedDesc ? `${pDesc.slice(0, 120)}...` : pDesc}
</p>
{isLong && (
<button
type="button"
onClick={() => setIsExpandedDesc(prev => !prev)}
className="text-cyan-300 hover:text-cyan-200 text-[11px] font-bold inline-flex items-center gap-1 cursor-pointer transition-colors"
>
<span>{isExpandedDesc ? 'بستن متن' : 'مشاهده متن کامل'}</span>
{isExpandedDesc ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
)}
</div>
);
})()}
</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="ltr">
<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>
);
}