107 lines
4.7 KiB
TypeScript
107 lines
4.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
import { trackClientEvent } from "@/lib/analytics-client";
|
||
|
||
type AudioPlayerProps = {
|
||
src: string;
|
||
title: string;
|
||
locale: "ar" | "en";
|
||
downloadable?: boolean;
|
||
analytics?: { entityId: string };
|
||
className?: string;
|
||
};
|
||
|
||
function formatTime(value: number) {
|
||
if (!Number.isFinite(value) || value < 0) return "0:00";
|
||
const minutes = Math.floor(value / 60);
|
||
const seconds = Math.floor(value % 60).toString().padStart(2, "0");
|
||
return `${minutes}:${seconds}`;
|
||
}
|
||
|
||
export default function AudioPlayer({ src, title, locale, downloadable = false, analytics, className }: AudioPlayerProps) {
|
||
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 hasTrackedPlay = useRef(false);
|
||
const labels = locale === "ar"
|
||
? { play: "تشغيل", pause: "إيقاف مؤقت", back: "تأخير 10 ثوانٍ", forward: "تقديم 10 ثوانٍ", seek: "موضع التشغيل", volume: "مستوى الصوت", download: "تحميل الصوت" }
|
||
: { play: "Play", pause: "Pause", back: "Back 10 seconds", forward: "Forward 10 seconds", seek: "Playback position", volume: "Volume", download: "Download audio" };
|
||
|
||
useEffect(() => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
const onLoadedMetadata = () => setDuration(audio.duration);
|
||
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
|
||
const onEnded = () => setIsPlaying(false);
|
||
audio.addEventListener("loadedmetadata", onLoadedMetadata);
|
||
audio.addEventListener("timeupdate", onTimeUpdate);
|
||
audio.addEventListener("ended", onEnded);
|
||
audio.volume = volume;
|
||
return () => {
|
||
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||
audio.removeEventListener("timeupdate", onTimeUpdate);
|
||
audio.removeEventListener("ended", onEnded);
|
||
};
|
||
}, [volume]);
|
||
|
||
const togglePlayback = async () => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
if (audio.paused) {
|
||
await audio.play();
|
||
setIsPlaying(true);
|
||
if (analytics && !hasTrackedPlay.current) {
|
||
hasTrackedPlay.current = true;
|
||
trackClientEvent({ type: "MELODY_PLAY", entityId: analytics.entityId });
|
||
}
|
||
} else {
|
||
audio.pause();
|
||
setIsPlaying(false);
|
||
}
|
||
};
|
||
|
||
const seekBy = (seconds: number) => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
audio.currentTime = Math.max(0, Math.min(audio.duration || 0, audio.currentTime + seconds));
|
||
setCurrentTime(audio.currentTime);
|
||
};
|
||
|
||
const updateTime = (value: number) => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
audio.currentTime = value;
|
||
setCurrentTime(value);
|
||
};
|
||
|
||
const updateVolume = (value: number) => {
|
||
setVolume(value);
|
||
if (audioRef.current) audioRef.current.volume = value;
|
||
};
|
||
|
||
return (
|
||
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
|
||
<audio ref={audioRef} src={src} preload="metadata" />
|
||
<div className="flex items-center gap-2">
|
||
<button type="button" onClick={togglePlayback} aria-label={isPlaying ? labels.pause : labels.play} className="inline-flex h-10 min-w-10 items-center justify-center rounded-full bg-primary px-3 text-sm font-medium text-primary-foreground hover:opacity-90">
|
||
{isPlaying ? "Ⅱ" : "▶"}
|
||
</button>
|
||
<button type="button" onClick={() => seekBy(-10)} aria-label={labels.back} className="rounded-md border border-border px-2 py-2 text-xs hover:bg-accent">−10</button>
|
||
<button type="button" onClick={() => seekBy(10)} aria-label={labels.forward} className="rounded-md border border-border px-2 py-2 text-xs hover:bg-accent">+10</button>
|
||
<span className="ms-auto text-xs tabular-nums text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||
</div>
|
||
<input type="range" min={0} max={duration || 0} step="any" value={Math.min(currentTime, duration || 0)} onChange={(event) => updateTime(event.target.valueAsNumber)} aria-label={labels.seek} className="mt-4 w-full accent-primary" />
|
||
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
|
||
<span aria-hidden="true">🔊</span>
|
||
<input type="range" min={0} max={1} step="0.05" value={volume} onChange={(event) => updateVolume(event.target.valueAsNumber)} aria-label={labels.volume} className="w-28 accent-primary" />
|
||
{downloadable ? <a href={src} download className="ms-auto font-medium text-brand-2 hover:underline">{labels.download}</a> : null}
|
||
</div>
|
||
<p className="sr-only">{title}</p>
|
||
</div>
|
||
);
|
||
}
|