feat: full site build — Project/Melody schema (Option A), admin CRUD, public sections, uploads, email+SMTP, internal analytics, legal pages, docs
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user