mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-06 03:33:11 +07:00
hoàn thành tính năng mp3 player
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AudioTrack } from "@/types";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
pdfId: number;
|
||||
tracks: AudioTrack[];
|
||||
/** Called by parent after a successful upload so the track list refreshes */
|
||||
onTracksChange: (tracks: AudioTrack[]) => void;
|
||||
userRole: "admin" | "teacher" | "student";
|
||||
}
|
||||
|
||||
function fmt(sec: number): string {
|
||||
if (!isFinite(sec)) return "0:00";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, userRole }: Props) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [currentIdx, setCurrentIdx] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [showPlaylist, setShowPlaylist] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const canManage = userRole === "admin" || userRole === "teacher";
|
||||
const multiTrack = tracks.length > 1;
|
||||
const currentTrack = tracks[currentIdx] ?? null;
|
||||
const srcUrl = currentTrack ? api.audioFileUrl(pdfId, currentTrack.id) : null;
|
||||
|
||||
// Sync audio element src when track changes
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !srcUrl) return;
|
||||
audio.pause();
|
||||
audio.load();
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
}, [srcUrl]);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
} else {
|
||||
await audio.play().catch(() => {});
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const rewind5 = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.currentTime = Math.max(0, audio.currentTime - 5);
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.currentTime = Number(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
setCurrentTime(audioRef.current?.currentTime ?? 0);
|
||||
}, []);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
setDuration(audioRef.current?.duration ?? 0);
|
||||
}, []);
|
||||
|
||||
const handleEnded = useCallback(() => {
|
||||
if (multiTrack && currentIdx < tracks.length - 1) {
|
||||
setCurrentIdx((i) => i + 1);
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [multiTrack, currentIdx, tracks.length]);
|
||||
|
||||
// Auto-play next track when index changes
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
audioRef.current?.play().catch(() => {});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentIdx]);
|
||||
|
||||
// File upload handler
|
||||
const handleFileUpload = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const meta = files.map((f, i) => ({
|
||||
trackName: f.name.replace(/\.[^.]+$/, ""),
|
||||
sortOrder: tracks.length + i + 1,
|
||||
}));
|
||||
const results = await api.uploadAudio(pdfId, files, meta);
|
||||
const newTracks = results.filter((r) => r.success && r.audio).map((r) => r.audio!);
|
||||
if (newTracks.length) {
|
||||
const updated = await api.listAudio(pdfId);
|
||||
onTracksChange(updated);
|
||||
}
|
||||
const errors = results.filter((r) => !r.success);
|
||||
if (errors.length) {
|
||||
alert(errors.map((r) => `${r.filename}: ${r.error}`).join("\n"));
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Upload failed.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[pdfId, tracks.length, onTracksChange],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (trackId: number) => {
|
||||
if (!confirm("Xóa track này?")) return;
|
||||
setDeleting(trackId);
|
||||
try {
|
||||
await api.deleteAudio(pdfId, trackId);
|
||||
const updated = await api.listAudio(pdfId);
|
||||
onTracksChange(updated);
|
||||
if (currentTrack?.id === trackId) setCurrentIdx(0);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Delete failed.");
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
},
|
||||
[pdfId, currentTrack, onTracksChange],
|
||||
);
|
||||
|
||||
if (!tracks.length && !canManage) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 1000,
|
||||
width: 340,
|
||||
background: "rgba(15, 23, 42, 0.96)",
|
||||
color: "#e2e8f0",
|
||||
borderRadius: 14,
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
|
||||
padding: "12px 14px 10px",
|
||||
backdropFilter: "blur(8px)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{/* Hidden native audio element */}
|
||||
{srcUrl && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={srcUrl}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={handleEnded}
|
||||
preload="metadata"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Track name row */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{currentTrack ? currentTrack.track_name : "Chưa có audio"}
|
||||
</span>
|
||||
{multiTrack && (
|
||||
<button
|
||||
title="Playlist"
|
||||
onClick={() => setShowPlaylist((v) => !v)}
|
||||
style={iconBtn(showPlaylist ? "#3b82f6" : "#64748b")}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
)}
|
||||
{canManage && (
|
||||
<>
|
||||
<button
|
||||
title="Tải lên audio"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
style={iconBtn("#22c55e")}
|
||||
>
|
||||
{uploading ? "…" : "+"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 10, minWidth: 32, textAlign: "right", color: "#94a3b8" }}>{fmt(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
disabled={!srcUrl}
|
||||
style={{ flex: 1, accentColor: "#3b82f6", cursor: srcUrl ? "pointer" : "not-allowed" }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, minWidth: 32, color: "#94a3b8" }}>{fmt(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10 }}>
|
||||
{multiTrack && (
|
||||
<button
|
||||
title="Bài trước"
|
||||
onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}
|
||||
disabled={currentIdx === 0}
|
||||
style={ctrlBtn(currentIdx === 0)}
|
||||
>
|
||||
⏮
|
||||
</button>
|
||||
)}
|
||||
<button title="Tua lại 5 giây" onClick={rewind5} disabled={!srcUrl} style={ctrlBtn(!srcUrl)}>
|
||||
⟲5
|
||||
</button>
|
||||
<button
|
||||
title={isPlaying ? "Tạm dừng" : "Phát"}
|
||||
onClick={togglePlay}
|
||||
disabled={!srcUrl}
|
||||
style={{
|
||||
...ctrlBtn(!srcUrl),
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: srcUrl ? "#3b82f6" : "#475569",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
{multiTrack && (
|
||||
<button
|
||||
title="Bài tiếp"
|
||||
onClick={() => setCurrentIdx((i) => Math.min(tracks.length - 1, i + 1))}
|
||||
disabled={currentIdx === tracks.length - 1}
|
||||
style={ctrlBtn(currentIdx === tracks.length - 1)}
|
||||
>
|
||||
⏭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Playlist drawer */}
|
||||
{showPlaylist && multiTrack && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
maxHeight: 200,
|
||||
overflowY: "auto",
|
||||
borderTop: "1px solid #1e293b",
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
{tracks.map((t, i) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 2px",
|
||||
borderRadius: 6,
|
||||
background: i === currentIdx ? "rgba(59,130,246,0.18)" : "transparent",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
setCurrentIdx(i);
|
||||
setShowPlaylist(false);
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, color: i === currentIdx ? "#93c5fd" : "#94a3b8", minWidth: 16 }}>
|
||||
{i === currentIdx ? "▶" : `${i + 1}.`}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{t.track_name}
|
||||
{t.page_number ? (
|
||||
<span style={{ marginLeft: 4, fontSize: 10, color: "#64748b" }}>tr.{t.page_number}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{canManage && (
|
||||
<button
|
||||
title="Xóa"
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(t.id); }}
|
||||
disabled={deleting === t.id}
|
||||
style={{ background: "none", border: "none", color: "#f87171", cursor: "pointer", fontSize: 13, padding: "0 2px" }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single track delete for admins/teachers */}
|
||||
{!multiTrack && currentTrack && canManage && (
|
||||
<div style={{ marginTop: 8, textAlign: "right" }}>
|
||||
<button
|
||||
onClick={() => handleDelete(currentTrack.id)}
|
||||
disabled={deleting === currentTrack.id}
|
||||
style={{ background: "none", border: "none", color: "#f87171", cursor: "pointer", fontSize: 11 }}
|
||||
>
|
||||
Xóa track
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Style helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function iconBtn(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: "none",
|
||||
border: "none",
|
||||
color,
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
padding: "2px 4px",
|
||||
lineHeight: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function ctrlBtn(disabled: boolean): React.CSSProperties {
|
||||
return {
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: disabled ? "#475569" : "#e2e8f0",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
fontSize: 20,
|
||||
padding: "2px 4px",
|
||||
lineHeight: 1,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user