diff --git a/src/app/admin/datasets/[id]/entries/[entryId]/page.tsx b/src/app/admin/datasets/[id]/entries/[entryId]/page.tsx index f680b10..cbd0276 100644 --- a/src/app/admin/datasets/[id]/entries/[entryId]/page.tsx +++ b/src/app/admin/datasets/[id]/entries/[entryId]/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import db from '@/lib/db'; import { dataset_entry } from '@/lib/model/dataset_entry'; import { eq } from 'drizzle-orm'; -import AudioPlayer from '@/components/AudioPlayer'; +import WaveformPlayer from '@/components/WaveformPlayer'; import { requireAdmin } from '@/lib/auth'; import { redirect } from 'next/navigation'; @@ -114,7 +114,11 @@ export default async function DatasetEntryPage({ params }: DatasetEntryPageProps

Audio Player

- + {(() => { + const fileExtension = entry.fileName.substring(entry.fileName.lastIndexOf('.')); + const src = `/public/datasets/${datasetIdNum}/${entry.externalId}${fileExtension}`; + return ; + })()}
diff --git a/src/components/AnnotationViews/SingleChoiceEntryView.tsx b/src/components/AnnotationViews/SingleChoiceEntryView.tsx index 92b60ba..34ee079 100644 --- a/src/components/AnnotationViews/SingleChoiceEntryView.tsx +++ b/src/components/AnnotationViews/SingleChoiceEntryView.tsx @@ -59,7 +59,6 @@ export default function SingleChoiceEntryView({ {/* Waveform player */} setFullyPlayed(true)} /> diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx deleted file mode 100644 index 2302819..0000000 --- a/src/components/AudioPlayer.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client'; - -import { useState, useRef, useEffect } from 'react'; -import { useAudio } from './AudioProvider'; - -interface AudioPlayerProps { - fileName: string; - datasetId: number; - externalId?: string; -} - -export default function AudioPlayer({ fileName, datasetId, externalId }: AudioPlayerProps) { - const [isPlaying, setIsPlaying] = useState(false); - const audioRef = useRef(null); - const { currentAudioId, setCurrentAudio } = useAudio(); - const audioId = `${datasetId}-${externalId}`; - - // Extract file extension from original fileName and construct URL using externalId - const fileExtension = fileName.substring(fileName.lastIndexOf('.')); - const audioPath = `/public/datasets/${datasetId}/${externalId}${fileExtension}`; - - // Stop playing if another audio started - useEffect(() => { - if (currentAudioId !== audioId && audioRef.current && isPlaying) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - setIsPlaying(false); - } - }, [currentAudioId, audioId, isPlaying]); - - const handlePlay = () => { - if (audioRef.current) { - setCurrentAudio(audioId); - audioRef.current.currentTime = 0; - audioRef.current.play(); - setIsPlaying(true); - } - }; - - const handleStop = () => { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - setCurrentAudio(null); - setIsPlaying(false); - } - }; - - const handlePlaybackEnd = () => { - setIsPlaying(false); - setCurrentAudio(null); - }; - - return ( -
-
- ); -} diff --git a/src/components/CalibrationViews/CalibrationEntryView.tsx b/src/components/CalibrationViews/CalibrationEntryView.tsx index 3e8a48b..f787366 100644 --- a/src/components/CalibrationViews/CalibrationEntryView.tsx +++ b/src/components/CalibrationViews/CalibrationEntryView.tsx @@ -82,7 +82,6 @@ export default function CalibrationEntryView({ {/* Waveform player */} setFullyPlayed(true)} /> diff --git a/src/components/DatasetEntriesList.tsx b/src/components/DatasetEntriesList.tsx index fa33d7c..fe30a6a 100644 --- a/src/components/DatasetEntriesList.tsx +++ b/src/components/DatasetEntriesList.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import AudioPlayer from './AudioPlayer'; +import WaveformPlayer from './WaveformPlayer'; import { getDatasetEntries } from '@/app/actions/get-dataset-entries'; import { getFilterOptions } from '@/app/actions/get-filter-options'; import { downloadFilteredEntriesAsZip, downloadFilteredEntriesAsCsv } from '@/lib/download-utils'; @@ -514,7 +514,11 @@ export default function DatasetEntriesList({ {entries.map((entry) => ( - + {(() => { + const fileExtension = entry.fileName.substring(entry.fileName.lastIndexOf('.')); + const src = `/public/datasets/${datasetId}/${entry.externalId}${fileExtension}`; + return ; + })()}
Audio
- + {(() => { + const fileExtension = selectedAnnotation.fileName.substring(selectedAnnotation.fileName.lastIndexOf('.')); + const src = `/public/datasets/${selectedAnnotation.datasetId}/${selectedAnnotation.externalId}${fileExtension}`; + return ; + })()}
diff --git a/src/components/WaveformPlayer.tsx b/src/components/WaveformPlayer.tsx index 34b43e5..9cc42c7 100644 --- a/src/components/WaveformPlayer.tsx +++ b/src/components/WaveformPlayer.tsx @@ -1,38 +1,43 @@ 'use client'; -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useAudio } from './AudioProvider'; interface WaveformPlayerProps { + // Audio source URL (required) src: string; - durationMs?: number | null; + + // Audio playback options onPlay?: () => void; - onFullyPlayed: () => void; + onFullyPlayed?: () => void; + + // Display options + showWaveform?: boolean; // defaults to true; if false, shows simple player button } const BAR_COUNT = 120; export default function WaveformPlayer({ src, - durationMs, onPlay, onFullyPlayed, + showWaveform = true, }: WaveformPlayerProps) { const { currentAudioId, setCurrentAudio } = useAudio(); // Generate unique ID for this player instance - const playerId = useMemo(() => Math.random().toString(36).slice(2), []); + const playerId = useRef(Math.random().toString(36).slice(2)).current; const isActive = currentAudioId === playerId; const audioRef = useRef(null); const canvasRef = useRef(null); const rafRef = useRef(0); + const fullyPlayedRef = useRef(false); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0); + const [duration, setDuration] = useState(0); const [peaks, setPeaks] = useState([]); - const fullyPlayedRef = useRef(false); // Stop if another player became active useEffect(() => { @@ -43,12 +48,11 @@ export default function WaveformPlayer({ } }, [isActive, isPlaying]); - // Reset state when src changes and cancel playback - + // Reset state when src changes useEffect(() => { setPeaks([]); setCurrentTime(0); - setDuration(durationMs ? durationMs / 1000 : 0); + setDuration(0); fullyPlayedRef.current = false; if (audioRef.current) { audioRef.current.pause(); @@ -56,10 +60,61 @@ export default function WaveformPlayer({ } cancelAnimationFrame(rafRef.current); setIsPlaying(false); - }, [src, durationMs]); + }, [src]); - // Decode audio and generate waveform peaks + const tick = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + setCurrentTime(audio.currentTime); + rafRef.current = requestAnimationFrame(tick); + }, []); + + const handlePlay = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + setCurrentAudio(playerId); + onPlay?.(); + audio.currentTime = 0; + audio.play(); + setIsPlaying(true); + rafRef.current = requestAnimationFrame(tick); + }, [playerId, onPlay, tick]); + + const handlePause = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + audio.pause(); + setIsPlaying(false); + cancelAnimationFrame(rafRef.current); + }, []); + + const handleStop = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + audio.pause(); + audio.currentTime = 0; + setCurrentAudio(null); + setIsPlaying(false); + cancelAnimationFrame(rafRef.current); + }, []); + + const handleEnded = useCallback(() => { + setIsPlaying(false); + cancelAnimationFrame(rafRef.current); + if (!fullyPlayedRef.current) { + fullyPlayedRef.current = true; + onFullyPlayed?.(); + } + }, [onFullyPlayed]); + + const handleLoadedMetadata = useCallback(() => { + if (audioRef.current) setDuration(audioRef.current.duration); + }, []); + + // Decode audio and generate waveform peaks (only if showWaveform is true) useEffect(() => { + if (!showWaveform) return; + let cancelled = false; (async () => { try { @@ -87,10 +142,17 @@ export default function WaveformPlayer({ } })(); return () => { cancelled = true; }; + }, [src, showWaveform]); + + // Reset peaks when src changes + useEffect(() => { + setPeaks([]); }, [src]); // Draw waveform on canvas whenever peaks or playback position change useEffect(() => { + if (!showWaveform) return; + const canvas = canvasRef.current; if (!canvas || peaks.length === 0) return; const ctx = canvas.getContext('2d'); @@ -110,45 +172,7 @@ export default function WaveformPlayer({ ctx.roundRect(x, y, barW, barH, 2); ctx.fill(); }); - }, [peaks, currentTime, duration]); - - const tick = useCallback(() => { - const audio = audioRef.current; - if (!audio) return; - setCurrentTime(audio.currentTime); - rafRef.current = requestAnimationFrame(tick); - }, []); - - const handlePlay = () => { - const audio = audioRef.current; - if (!audio) return; - setCurrentAudio(playerId); - onPlay?.(); - audio.play(); - setIsPlaying(true); - rafRef.current = requestAnimationFrame(tick); - }; - - const handlePause = () => { - const audio = audioRef.current; - if (!audio) return; - audio.pause(); - setIsPlaying(false); - cancelAnimationFrame(rafRef.current); - }; - - const handleEnded = () => { - setIsPlaying(false); - cancelAnimationFrame(rafRef.current); - if (!fullyPlayedRef.current) { - fullyPlayedRef.current = true; - onFullyPlayed?.(); - } - }; - - const handleLoadedMetadata = () => { - if (audioRef.current) setDuration(audioRef.current.duration); - }; + }, [peaks, currentTime, duration, showWaveform]); const fmt = (s: number) => { const m = Math.floor(s / 60); @@ -156,6 +180,40 @@ export default function WaveformPlayer({ return `${m}:${sec.toString().padStart(2, '0')}`; }; + // Simple player button (no waveform) + if (!showWaveform) { + return ( +
+
+ ); + } + + // Waveform player with visualization return (