diff --git a/backend/alembic/versions/0003_add_pdf_audios.py b/backend/alembic/versions/0003_add_pdf_audios.py new file mode 100644 index 0000000..dda33eb --- /dev/null +++ b/backend/alembic/versions/0003_add_pdf_audios.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 2134b1f..ac1ba73 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 5a174ad..feb5e95 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routers/audio.py b/backend/app/routers/audio.py new file mode 100644 index 0000000..27866fc --- /dev/null +++ b/backend/app/routers/audio.py @@ -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}", + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b5e77cf..7718860 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 99ef852..82a94dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 3a61991..27cfe68 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -9,6 +9,14 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( + + + {children} ); diff --git a/frontend/src/components/FloatingAudioPlayer.tsx b/frontend/src/components/FloatingAudioPlayer.tsx new file mode 100644 index 0000000..2e0a894 --- /dev/null +++ b/frontend/src/components/FloatingAudioPlayer.tsx @@ -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(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(null); + const fileInputRef = useRef(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) => { + 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) => { + 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 ( +
+ {/* Hidden native audio element */} + {srcUrl && ( +
+ ); +} + +// ── 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, + }; +} diff --git a/frontend/src/components/WorkbookViewer.tsx b/frontend/src/components/WorkbookViewer.tsx index 36c772a..e951204 100644 --- a/frontend/src/components/WorkbookViewer.tsx +++ b/frontend/src/components/WorkbookViewer.tsx @@ -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 ( ); } @@ -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([]); + 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) {
- - - - - - - - - - - - - - - + + + + +
{/* Color picker button */} @@ -1609,6 +1602,25 @@ export default function WorkbookViewer({ pdfId }: Props) { {saveNotice && ( ✓ Saved )} + {/* Audio toggle button */} + {(audioTracks.length > 0 || userRole === "admin" || userRole === "teacher") && ( + + )} {/* Export PDF with annotations baked in */}