hoàn thành tính năng mp3 player

This commit is contained in:
2026-04-03 07:41:10 +07:00
parent 6246481192
commit 34ff7d72df
12 changed files with 731 additions and 28 deletions
+8
View File
@@ -9,6 +9,14 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
</head>
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
</html>
);
@@ -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,
};
}
+45 -24
View File
@@ -5,6 +5,8 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration";
import FloatingAudioPlayer from "./FloatingAudioPlayer";
import type { AudioTrack } from "@/types";
// ─────────────────────────────────────────────────────────────────────────────
// Preset color palette (30 hues, 3 brightness levels × 10 hue families)
@@ -63,13 +65,13 @@ function ToolBtn({
current,
onClick,
title,
children,
iconClass,
}: {
id: Tool;
current: Tool;
onClick: (t: Tool) => void;
title: string;
children: React.ReactNode;
iconClass: string;
}) {
return (
<button
@@ -81,9 +83,7 @@ function ToolBtn({
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800"
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{children}
</svg>
<i className={`${iconClass} w-4 text-center`} style={{ fontSize: 15 }} />
</button>
);
}
@@ -130,6 +130,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
const [saveNotice, setSaveNotice] = useState(false);
const [exporting, setExporting] = useState(false);
const [isReady, setIsReady] = useState(false);
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [showAudio, setShowAudio] = useState(false);
const [pdfTitle, setPdfTitle] = useState("PDF");
const [loadError, setLoadError] = useState("");
const [rendering, setRendering] = useState(false);
@@ -246,6 +248,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
}).catch(() => {});
}, []);
// Fetch audio tracks for this PDF
useEffect(() => {
api.listAudio(pdfId).then(setAudioTracks).catch(() => {});
}, [pdfId]);
// Close color picker when clicking outside
useEffect(() => {
if (!colorPickerOpen) return;
@@ -1469,25 +1476,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
<div className="flex items-center gap-0.5 flex-shrink-0">
<ToolBtn id="select" current={tool} onClick={setTool} title="Select / Move">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</ToolBtn>
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</ToolBtn>
<ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</ToolBtn>
<ToolBtn id="text" current={tool} onClick={setTool} title="Text">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12h6m-3-3v6M7 20h10a2 2 0 002-2V6a2 2 0 00-2-2H7a2 2 0 00-2 2v12a2 2 0 002 2z" />
</ToolBtn>
<ToolBtn id="eraser" current={tool} onClick={setTool} title="Eraser">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-1.5 1.5M4.5 19.5l9-9m0 0L9 6l-4.5 4.5 4.5 4.5m4.5-4.5L18 6" />
</ToolBtn>
<ToolBtn id="select" current={tool} onClick={setTool} title="Select / Move" iconClass="fa-solid fa-arrow-pointer" />
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen" iconClass="fa-solid fa-pen" />
<ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter" iconClass="fa-solid fa-highlighter" />
<ToolBtn id="text" current={tool} onClick={setTool} title="Text" iconClass="fa-solid fa-font" />
<ToolBtn id="eraser" current={tool} onClick={setTool} title="Eraser" iconClass="fa-solid fa-eraser" />
</div>
{/* Color picker button */}
@@ -1609,6 +1602,25 @@ export default function WorkbookViewer({ pdfId }: Props) {
{saveNotice && (
<span className="text-xs text-green-600 font-medium animate-pulse"> Saved</span>
)}
{/* Audio toggle button */}
{(audioTracks.length > 0 || userRole === "admin" || userRole === "teacher") && (
<button
onClick={() => setShowAudio(v => !v)}
title={showAudio ? "Ẩn trình phát audio" : "Hiện trình phát audio"}
className={`flex items-center gap-1 text-xs font-semibold px-3 py-1.5 rounded-lg transition ${
showAudio
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-100 hover:bg-gray-200 text-gray-700"
} disabled:opacity-60`}
>
<i className="fa-solid fa-music" style={{ fontSize: 13 }} />
{audioTracks.length > 0 && (
<span className={`text-[10px] font-bold px-1 py-0 rounded-full ${
showAudio ? "bg-white text-blue-600" : "bg-blue-600 text-white"
}`}>{audioTracks.length}</span>
)}
</button>
)}
{/* Export PDF with annotations baked in */}
<button
onClick={handleExportPDF}
@@ -1735,6 +1747,15 @@ export default function WorkbookViewer({ pdfId }: Props) {
</div>
)}
{showAudio && typeof document !== "undefined" && (
<FloatingAudioPlayer
pdfId={pdfId}
tracks={audioTracks}
onTracksChange={setAudioTracks}
userRole={userRole}
/>
)}
{colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
<div
ref={colorPickerRef}
+32
View File
@@ -101,4 +101,36 @@ export const api = {
}
return res.json();
},
// Audio
listAudio: (pdfId: number) =>
request<import("@/types").AudioTrack[]>(`/api/pdfs/${pdfId}/audio`),
uploadAudio: async (
pdfId: number,
files: File[],
meta: { trackName?: string; pageNumber?: number; sortOrder?: number }[] = [],
): Promise<import("@/types").AudioUploadResult[]> => {
const form = new FormData();
files.forEach((f) => form.append("files", f));
meta.forEach((m) => form.append("track_names", m.trackName ?? ""));
meta.forEach((m) => form.append("page_numbers", String(m.pageNumber ?? 0)));
meta.forEach((m) => form.append("sort_orders", String(m.sortOrder ?? 0)));
const res = await fetch(`/api/pdfs/${pdfId}/audio`, {
method: "POST",
credentials: "include",
body: form,
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? "Upload failed");
}
return res.json();
},
deleteAudio: (pdfId: number, audioId: number) =>
request<void>(`/api/pdfs/${pdfId}/audio/${audioId}`, { method: "DELETE" }),
audioFileUrl: (pdfId: number, audioId: number) =>
`/api/pdfs/${pdfId}/audio/${audioId}/file`,
};
+16
View File
@@ -34,3 +34,19 @@ export interface AnnotationData {
canvas_data: Record<string, any>;
updated_at: string;
}
export interface AudioTrack {
id: number;
pdf_id: number;
track_name: string;
page_number: number | null;
sort_order: number;
created_at: string;
}
export interface AudioUploadResult {
filename: string;
success: boolean;
audio: AudioTrack | null;
error: string | null;
}