mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add audio waveform visualization with wavesurfer.js
Replace the basic HTML5 audio element with an interactive waveform player for audio tool pages. Video tools continue using the existing MediaPlayerView component.
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"use-image": "^1",
|
||||
"wavesurfer.js": "^7.12.8",
|
||||
"zundo": "^2",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WaveformPlayerProps {
|
||||
src: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WaveSurfer | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Detect dark mode from the document class (toggled by useTheme)
|
||||
const isDark =
|
||||
typeof document !== "undefined" && document.documentElement.classList.contains("dark");
|
||||
const waveColor = isDark ? "#6B6560" : "#DDD6CC";
|
||||
const progressColor = "#E07832";
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
waveColor,
|
||||
progressColor,
|
||||
cursorColor: progressColor,
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
height: 80,
|
||||
normalize: true,
|
||||
});
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.load(src);
|
||||
|
||||
ws.on("ready", () => {
|
||||
setDuration(ws.getDuration());
|
||||
setIsReady(true);
|
||||
});
|
||||
|
||||
ws.on("audioprocess", () => {
|
||||
setCurrentTime(ws.getCurrentTime());
|
||||
});
|
||||
|
||||
ws.on("seeking", () => {
|
||||
setCurrentTime(ws.getCurrentTime());
|
||||
});
|
||||
|
||||
ws.on("finish", () => {
|
||||
setIsPlaying(false);
|
||||
});
|
||||
|
||||
ws.on("play", () => {
|
||||
setIsPlaying(true);
|
||||
});
|
||||
|
||||
ws.on("pause", () => {
|
||||
setIsPlaying(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ws.destroy();
|
||||
wsRef.current = null;
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsReady(false);
|
||||
};
|
||||
}, [src, waveColor]);
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
wsRef.current?.playPause();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<div className="rounded-lg border border-border bg-background p-4">
|
||||
{/* Waveform container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"w-full cursor-pointer rounded",
|
||||
!isReady && "flex items-center justify-center min-h-[80px]",
|
||||
)}
|
||||
data-testid="waveform-container"
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayPause}
|
||||
disabled={!isReady}
|
||||
className="shrink-0 w-9 h-9 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:opacity-90 transition-opacity disabled:opacity-40"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
data-testid="waveform-play-pause"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ms-0.5" />}
|
||||
</button>
|
||||
<span className="text-sm tabular-nums text-muted-foreground select-none">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,9 @@ import { useSplitStore } from "@/stores/split-store";
|
||||
const MediaPlayerView = lazy(() =>
|
||||
import("@/components/tools/media-player-view").then((m) => ({ default: m.MediaPlayerView })),
|
||||
);
|
||||
const WaveformPlayer = lazy(() =>
|
||||
import("@/components/common/waveform-player").then((m) => ({ default: m.WaveformPlayer })),
|
||||
);
|
||||
const DocumentView = lazy(() =>
|
||||
import("@/components/tools/document-view").then((m) => ({ default: m.DocumentView })),
|
||||
);
|
||||
@@ -626,8 +629,18 @@ export function ToolPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Media player: native <video>/<audio> element
|
||||
// Media player: waveform for audio, native <video> for video
|
||||
if (displayMode === "media-player" && hasFile) {
|
||||
if (tool?.modality === "audio") {
|
||||
const audioSrc = processedUrl ?? originalBlobUrl;
|
||||
if (audioSrc) {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||
<WaveformPlayer src={audioSrc} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||
<MediaPlayerView />
|
||||
|
||||
Generated
+8
@@ -461,6 +461,9 @@ importers:
|
||||
use-image:
|
||||
specifier: ^1
|
||||
version: 1.1.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
wavesurfer.js:
|
||||
specifier: ^7.12.8
|
||||
version: 7.12.8
|
||||
zundo:
|
||||
specifier: ^2
|
||||
version: 2.3.0(zustand@5.0.14(@types/react@19.2.16)(react@19.2.7))
|
||||
@@ -8126,6 +8129,9 @@ packages:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wavesurfer.js@7.12.8:
|
||||
resolution: {integrity: sha512-G3nxzcC4X+ZWrLtcIV17kCWHVq3ysJCS4dS0YkGKILrQ2esAb8cScw965zKNKYxUvpiZsPK93KLWgWTYdIBQiw==}
|
||||
|
||||
weapon-regex@1.3.6:
|
||||
resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==}
|
||||
|
||||
@@ -16696,6 +16702,8 @@ snapshots:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
wavesurfer.js@7.12.8: {}
|
||||
|
||||
weapon-regex@1.3.6: {}
|
||||
|
||||
web-namespaces@2.0.1: {}
|
||||
|
||||
Reference in New Issue
Block a user