display waveform
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -93,6 +93,7 @@
|
|||||||
"type-fest": "^5.7.0",
|
"type-fest": "^5.7.0",
|
||||||
"uploadthing": "^7.7.4",
|
"uploadthing": "^7.7.4",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"wavesurfer.js": "^7.12.8",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2434,6 +2435,8 @@
|
|||||||
|
|
||||||
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
|
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
|
||||||
|
|
||||||
|
"wavesurfer.js": ["wavesurfer.js@7.12.8", "", {}, "sha512-G3nxzcC4X+ZWrLtcIV17kCWHVq3ysJCS4dS0YkGKILrQ2esAb8cScw965zKNKYxUvpiZsPK93KLWgWTYdIBQiw=="],
|
||||||
|
|
||||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||||
|
|
||||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
"type-fest": "^5.7.0",
|
"type-fest": "^5.7.0",
|
||||||
"uploadthing": "^7.7.4",
|
"uploadthing": "^7.7.4",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"wavesurfer.js": "^7.12.8",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import type WaveSurfer from "wavesurfer.js";
|
||||||
import { Download, Loader2, Pause, Play } from "lucide-react";
|
import { Download, Loader2, Pause, Play } from "lucide-react";
|
||||||
import { Slider } from "~/components/ui/slider";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -13,6 +14,20 @@ function formatTime(seconds: number) {
|
|||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string, fallback: string) {
|
||||||
|
if (typeof window === "undefined") return fallback;
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return v || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waveColors() {
|
||||||
|
return {
|
||||||
|
waveColor: cssVar("--muted-foreground", "#9ca3af"),
|
||||||
|
progressColor: cssVar("--primary", "#e2761b"),
|
||||||
|
cursorColor: cssVar("--foreground", "#111827"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function AudioPlayer(props: {
|
export default function AudioPlayer(props: {
|
||||||
/** Streaming-friendly source the player actually plays. */
|
/** Streaming-friendly source the player actually plays. */
|
||||||
src: string;
|
src: string;
|
||||||
@@ -20,21 +35,60 @@ export default function AudioPlayer(props: {
|
|||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
downloadName: string;
|
downloadName: string;
|
||||||
}) {
|
}) {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wsRef = useRef<WaveSurfer | null>(null);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [seeking, setSeeking] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ws: WaveSurfer | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
setReady(false);
|
||||||
|
setCurrent(0);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const WaveSurferClass = (await import("wavesurfer.js")).default;
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
ws = WaveSurferClass.create({
|
||||||
|
container: containerRef.current,
|
||||||
|
url: props.src,
|
||||||
|
height: 44,
|
||||||
|
barWidth: 2,
|
||||||
|
barGap: 1,
|
||||||
|
barRadius: 2,
|
||||||
|
normalize: true,
|
||||||
|
cursorWidth: 1,
|
||||||
|
...waveColors(),
|
||||||
|
});
|
||||||
|
wsRef.current = ws;
|
||||||
|
ws.on("ready", () => {
|
||||||
|
setReady(true);
|
||||||
|
setDuration(ws?.getDuration() ?? 0);
|
||||||
|
});
|
||||||
|
ws.on("play", () => setPlaying(true));
|
||||||
|
ws.on("pause", () => setPlaying(false));
|
||||||
|
ws.on("finish", () => setPlaying(false));
|
||||||
|
ws.on("timeupdate", (t) => setCurrent(t));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
ws?.destroy();
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [props.src]);
|
||||||
|
|
||||||
|
// Re-tint the waveform when the user toggles light/dark.
|
||||||
|
useEffect(() => {
|
||||||
|
wsRef.current?.setOptions(waveColors());
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
const audio = audioRef.current;
|
wsRef.current?.playPause();
|
||||||
if (!audio) return;
|
|
||||||
if (audio.paused) {
|
|
||||||
audio.play();
|
|
||||||
} else {
|
|
||||||
audio.pause();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload() {
|
async function handleDownload() {
|
||||||
@@ -62,24 +116,12 @@ export default function AudioPlayer(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
|
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={props.src}
|
|
||||||
preload="metadata"
|
|
||||||
onPlay={() => setPlaying(true)}
|
|
||||||
onPause={() => setPlaying(false)}
|
|
||||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
|
||||||
onTimeUpdate={(e) => {
|
|
||||||
if (!seeking) setCurrent(e.currentTarget.currentTime);
|
|
||||||
}}
|
|
||||||
onEnded={() => setPlaying(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label={playing ? "Pause" : "Play"}
|
aria-label={playing ? "Pause" : "Play"}
|
||||||
|
disabled={!ready}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
>
|
>
|
||||||
{playing ? <Pause /> : <Play />}
|
{playing ? <Pause /> : <Play />}
|
||||||
@@ -89,21 +131,14 @@ export default function AudioPlayer(props: {
|
|||||||
{formatTime(current)}
|
{formatTime(current)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Slider
|
<div className="relative flex-1">
|
||||||
className="flex-1"
|
<div ref={containerRef} className="w-full" />
|
||||||
min={0}
|
{!ready && (
|
||||||
max={duration || 1}
|
<div className="absolute inset-0 flex items-center">
|
||||||
step={0.1}
|
<div className="h-7 w-full animate-pulse rounded bg-muted-foreground/15" />
|
||||||
value={[current]}
|
</div>
|
||||||
onValueChange={([v]) => {
|
)}
|
||||||
setSeeking(true);
|
</div>
|
||||||
setCurrent(v ?? 0);
|
|
||||||
}}
|
|
||||||
onValueCommit={([v]) => {
|
|
||||||
if (audioRef.current) audioRef.current.currentTime = v ?? 0;
|
|
||||||
setSeeking(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
|
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
|
|||||||
Reference in New Issue
Block a user