mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 09:03:11 +07:00
hoàn thành tính năng mp3 player
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""add pdf_audios table
|
||||
|
||||
Revision ID: 0003_add_pdf_audios
|
||||
Revises: 0002_add_role_status
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0003_add_pdf_audios"
|
||||
down_revision = "0002_add_role_status"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"pdf_audios",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("pdf_id", sa.Integer(), sa.ForeignKey("pdfs.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("file_path", sa.Text(), nullable=False),
|
||||
sa.Column("track_name", sa.String(255), nullable=False),
|
||||
sa.Column("page_number", sa.Integer(), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("pdf_audios")
|
||||
+2
-1
@@ -4,7 +4,7 @@ import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .routers import admin, annotations, auth, backup, pdfs, ws
|
||||
from .routers import admin, annotations, audio, auth, backup, pdfs, ws
|
||||
from .database import SessionLocal
|
||||
from .models import User, UserRole, UserStatus
|
||||
from .security import hash_password
|
||||
@@ -57,5 +57,6 @@ app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(backup.router, prefix="/api")
|
||||
app.include_router(pdfs.router, prefix="/api")
|
||||
app.include_router(audio.router, prefix="/api")
|
||||
app.include_router(annotations.router, prefix="/api")
|
||||
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
||||
ForeignKey,
|
||||
DateTime,
|
||||
UniqueConstraint,
|
||||
Boolean,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
@@ -57,6 +58,7 @@ class PDF(Base):
|
||||
# Relationships
|
||||
owner = relationship("User", back_populates="pdfs")
|
||||
annotations = relationship("Annotation", back_populates="pdf", cascade="all, delete-orphan")
|
||||
audio_tracks = relationship("PDFAudio", back_populates="pdf", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Annotation(Base):
|
||||
@@ -81,3 +83,18 @@ class Annotation(Base):
|
||||
# Relationships
|
||||
pdf = relationship("PDF", back_populates="annotations")
|
||||
owner = relationship("User", back_populates="annotations")
|
||||
|
||||
|
||||
class PDFAudio(Base):
|
||||
__tablename__ = "pdf_audios"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
pdf_id = Column(Integer, ForeignKey("pdfs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
file_path = Column(Text, nullable=False)
|
||||
track_name = Column(String(255), nullable=False)
|
||||
page_number = Column(Integer, nullable=True) # optional: link to a specific page
|
||||
sort_order = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
# Relationships
|
||||
pdf = relationship("PDF", back_populates="audio_tracks")
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Audio management endpoints.
|
||||
|
||||
POST /api/pdfs/{pdf_id}/audio — upload one or more MP3 files (admin/teacher only)
|
||||
GET /api/pdfs/{pdf_id}/audio — list all audio tracks for a PDF (any authenticated user)
|
||||
DELETE /api/pdfs/{pdf_id}/audio/{audio_id} — delete a single track (admin/teacher only)
|
||||
GET /api/pdfs/{pdf_id}/audio/{audio_id}/file — stream the audio file
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..dependencies import get_current_user, require_role
|
||||
from ..models import PDF, PDFAudio, UserRole
|
||||
from ..schemas import AudioOut, AudioUploadResult
|
||||
|
||||
router = APIRouter(prefix="/pdfs", tags=["audio"])
|
||||
|
||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads"))
|
||||
MAX_AUDIO_SIZE = 200 * 1024 * 1024 # 200 MB per file
|
||||
ALLOWED_AUDIO_TYPES = {"audio/mpeg", "audio/mp3", "audio/mp4", "audio/x-m4a", "audio/ogg", "application/octet-stream"}
|
||||
|
||||
|
||||
def _audio_dir(pdf_id: int) -> Path:
|
||||
d = UPLOAD_DIR / "audio" / str(pdf_id)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _safe_track_name(raw: str) -> str:
|
||||
"""Strip control characters; truncate to 255 chars."""
|
||||
cleaned = re.sub(r"[\x00-\x1f\x7f]", "", raw).strip()
|
||||
return cleaned[:255] or "Track"
|
||||
|
||||
|
||||
# ── GET /api/pdfs/{pdf_id}/audio ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/{pdf_id}/audio", response_model=list[AudioOut])
|
||||
def list_audio(
|
||||
pdf_id: int,
|
||||
current_user=Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
pdf = db.get(PDF, pdf_id)
|
||||
if not pdf:
|
||||
raise HTTPException(status_code=404, detail="PDF not found.")
|
||||
tracks = (
|
||||
db.query(PDFAudio)
|
||||
.filter(PDFAudio.pdf_id == pdf_id)
|
||||
.order_by(PDFAudio.sort_order, PDFAudio.id)
|
||||
.all()
|
||||
)
|
||||
return tracks
|
||||
|
||||
|
||||
# ── POST /api/pdfs/{pdf_id}/audio ────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/{pdf_id}/audio",
|
||||
response_model=list[AudioUploadResult],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def upload_audio(
|
||||
pdf_id: int,
|
||||
files: list[UploadFile],
|
||||
track_names: list[str] = Form(default=[]),
|
||||
page_numbers: list[int] = Form(default=[]),
|
||||
sort_orders: list[int] = Form(default=[]),
|
||||
current_user=Depends(require_role(UserRole.admin, UserRole.teacher)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Upload one or more audio files for a PDF.
|
||||
|
||||
Optional form fields (parallel arrays, indexed per file):
|
||||
- track_names: display name for each track
|
||||
- page_numbers: page the track belongs to (0 = whole PDF / unset)
|
||||
- sort_orders: ordering integer (default 1)
|
||||
"""
|
||||
pdf = db.get(PDF, pdf_id)
|
||||
if not pdf:
|
||||
raise HTTPException(status_code=404, detail="PDF not found.")
|
||||
|
||||
results: list[AudioUploadResult] = []
|
||||
|
||||
for idx, file in enumerate(files):
|
||||
fname = file.filename or f"audio_{idx + 1}"
|
||||
|
||||
content_type = (file.content_type or "").lower()
|
||||
if content_type not in ALLOWED_AUDIO_TYPES:
|
||||
# Accept based on extension if browser sends wrong MIME
|
||||
ext = Path(fname).suffix.lower()
|
||||
if ext not in {".mp3", ".m4a", ".ogg", ".wav", ".aac"}:
|
||||
results.append(AudioUploadResult(filename=fname, success=False, error="Unsupported audio format."))
|
||||
continue
|
||||
|
||||
body = await file.read()
|
||||
if len(body) > MAX_AUDIO_SIZE:
|
||||
results.append(AudioUploadResult(filename=fname, success=False, error="File exceeds 200 MB limit."))
|
||||
continue
|
||||
|
||||
safe_name = f"{uuid.uuid4()}{Path(fname).suffix or '.mp3'}"
|
||||
dest = _audio_dir(pdf_id) / safe_name
|
||||
dest.write_bytes(body)
|
||||
|
||||
# Derive metadata from parallel arrays (fall back to sensible defaults)
|
||||
raw_track_name = track_names[idx] if idx < len(track_names) else Path(fname).stem
|
||||
track_name = _safe_track_name(raw_track_name) or Path(fname).stem or f"Track {idx + 1}"
|
||||
page_num = page_numbers[idx] if idx < len(page_numbers) else None
|
||||
if page_num == 0:
|
||||
page_num = None
|
||||
sort_ord = sort_orders[idx] if idx < len(sort_orders) else (idx + 1)
|
||||
|
||||
# Determine next sort order if not supplied
|
||||
if not sort_orders:
|
||||
existing_count = db.query(PDFAudio).filter(PDFAudio.pdf_id == pdf_id).count()
|
||||
sort_ord = existing_count + idx + 1
|
||||
|
||||
audio = PDFAudio(
|
||||
pdf_id=pdf_id,
|
||||
file_path=str(dest),
|
||||
track_name=track_name,
|
||||
page_number=page_num,
|
||||
sort_order=sort_ord,
|
||||
)
|
||||
db.add(audio)
|
||||
db.commit()
|
||||
db.refresh(audio)
|
||||
|
||||
results.append(AudioUploadResult(filename=fname, success=True, audio=AudioOut.model_validate(audio)))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── DELETE /api/pdfs/{pdf_id}/audio/{audio_id} ───────────────────────────────
|
||||
|
||||
@router.delete("/{pdf_id}/audio/{audio_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_audio(
|
||||
pdf_id: int,
|
||||
audio_id: int,
|
||||
current_user=Depends(require_role(UserRole.admin, UserRole.teacher)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
track = db.query(PDFAudio).filter(PDFAudio.id == audio_id, PDFAudio.pdf_id == pdf_id).first()
|
||||
if not track:
|
||||
raise HTTPException(status_code=404, detail="Audio track not found.")
|
||||
file_path = Path(track.file_path)
|
||||
db.delete(track)
|
||||
db.commit()
|
||||
if file_path.exists():
|
||||
file_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ── GET /api/pdfs/{pdf_id}/audio/{audio_id}/file ─────────────────────────────
|
||||
|
||||
@router.get("/{pdf_id}/audio/{audio_id}/file")
|
||||
def serve_audio(
|
||||
pdf_id: int,
|
||||
audio_id: int,
|
||||
current_user=Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
track = db.query(PDFAudio).filter(PDFAudio.id == audio_id, PDFAudio.pdf_id == pdf_id).first()
|
||||
if not track:
|
||||
raise HTTPException(status_code=404, detail="Audio track not found.")
|
||||
file_path = Path(track.file_path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk.")
|
||||
|
||||
# Determine media type from extension
|
||||
ext = file_path.suffix.lower()
|
||||
media_type_map = {".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".ogg": "audio/ogg", ".wav": "audio/wav", ".aac": "audio/aac"}
|
||||
media_type = media_type_map.get(ext, "audio/mpeg")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=media_type,
|
||||
filename=f"{track.track_name}{ext}",
|
||||
)
|
||||
@@ -98,6 +98,26 @@ class AnnotationIn(BaseModel):
|
||||
canvas_data: dict
|
||||
|
||||
|
||||
# ── Audio ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AudioOut(BaseModel):
|
||||
id: int
|
||||
pdf_id: int
|
||||
track_name: str
|
||||
page_number: int | None
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AudioUploadResult(BaseModel):
|
||||
filename: str
|
||||
success: bool
|
||||
audio: AudioOut | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class AnnotationOut(BaseModel):
|
||||
id: int
|
||||
pdf_id: int
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lms_dev_password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms_user} -d ${POSTGRES_DB:-lms_db}"]
|
||||
interval: 5s
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+3
-3
@@ -24,10 +24,10 @@ POSTGRES_DB="${POSTGRES_DB:-lms_db}"
|
||||
POSTGRES_USER="${POSTGRES_USER:-lms_user}"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-lms_test_password_123}"
|
||||
JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set in .env}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-3011}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-8001}"
|
||||
|
||||
DATABASE_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}"
|
||||
DATABASE_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5433/${POSTGRES_DB}"
|
||||
UPLOAD_DIR="./backend/uploads"
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user