Multiple Wavesurfer instances and bottom player in React / Next.js (Soundcloud like implementation) #3190
-
Hello I hope someone could help with the following: I am doing a project using next.js 12 and wavesurfer.js I need to have multiple tracks with waveforms and Play/pause showing on the same page. When pressing play, I need to have a bottom player also showing wave form of the track being played + controls. Very much like it is implemented on Soundcloud. The important point is to have the bottom audio player still playing whilst browsing other pages throughout the website so obviously via context. Could someone give me some pointers about how to go to implement this or even some resources ? It is quite a common implementation and yet I don't seem to see much code examples out there and I did try multiple searches on here. So far I managed to generate multiple instances of wave surfer and manage the instances via react context to ensure only one is playing at a time but implementing the bottom player proves much more difficult. Below is my WaveSurferPlayer component any help would be greatly appreciated 🙏🏻
|
Beta Was this translation helpful? Give feedback.
Replies: 4 comments 15 replies
-
You can sync the bottom player with the currently playing track by making them share the same E.g. const topPlayers = topTracks.map((url) => WaveSurfer.create({ container, url }))
const currentPlayer = topPlayers[0]
const bottomPlayer = WaveSurfer.create({ container, media: currentPlayer.getMediaElement() }) The bottom player will then play, pause and seek whenever the current player is played/paused/seeking. |
Beta Was this translation helpful? Give feedback.
-
If we need the bottom player to persist across screen navigations we run into a problem because the Unfortunately, there is no setter method to update Since JavaScript doesn't truly have protected/constant implementation one hack would be to manually mutate the protected media element. // This creates the <audio> element we want to share between players
const bottomPlayer = WaveSurfer.create({ container })
// Only render waverform with pre-generated waveform peaks data
const topPlayers = topTracks.map((peaks) => WaveSurfer.create({ container, peaks }))
// On a top track play we attach to bottom player's media element
let currentPlayer = topPlayers[0]
let origMediaEl = currentPlayer.getMediaElement()
// This mutates a protected property of wavesurfer object
currentPlayer.media = bottomPlayer.getMediaElement()
bottomPlayer.load(url)
bottomPlayer.play()
// On a track change
// 1. detach previous top player from bottom player's media
// 2. attach new top player to bottom player's media element
currentPlayer.media = origMediaEl
currentPlayer = topPlayers[1]
origMediaEl = currentPlayer.getMediaElement()
currentPlayer.media = bottomPlayer.getMediaElement()
bottomPlayer.load(url)
bottomPlayer.play() Note: The above example is not intended for real use but just to demonstrate a workaround. Would be nice if we had a public setter method to update |
Beta Was this translation helpful? Give feedback.
-
Hey guys, I'm trying to implement something similar as well. There's one weird that I've noticed with this: if you go to the example and start playing the 2nd track, you'll see that global waveform is lagging, but the row one is not. Upon pausing and resuming the track, global waveform performance is improved and there are no signs of lag. Any ideas on what is going on? EDIT: I've had a quick look at what could be wrong and I've noticed that on the first click, progress is rendered on line 192 in |
Beta Was this translation helpful? Give feedback.
-
Sorry for the spam @katspaugh . I´ll add some context here. I have this AudioProvider "use client";
import React, {
createContext,
PropsWithChildren,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import WaveSurfer from "wavesurfer.js";
import { Song } from "@/types/song/song";
import { useWaveOptions } from "@/lib/waveform/useWaveOptions";
interface AudioPlayerContextType {
currentTrack: Song | null;
isPlaying: boolean;
playTrack: (song: Song) => void;
pause: () => void;
wavesurfer: WaveSurfer | null;
globalAudioRef: HTMLAudioElement | null;
}
// Create the context
const AudioPlayerContext = createContext<AudioPlayerContextType>({
currentTrack: null,
isPlaying: false,
playTrack: () => {},
pause: () => {},
wavesurfer: null,
globalAudioRef: null,
});
export function AudioPlayerProvider({ children }: PropsWithChildren<object>) {
const [currentTrack, setCurrentTrack] = useState<Song | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// 1) We keep a single <audio> element
// Using a ref so it never changes/gets recreated.
const globalAudioRef = useRef<HTMLAudioElement | null>(null);
// 2) Wavesurfer instance for the global bottom player
const wavesurferRef = useRef<WaveSurfer | null>(null);
const waveOptions = useWaveOptions();
// On mount, create the <audio> and the Wavesurfer
useEffect(() => {
if (!globalAudioRef.current) {
// We create a single HTMLAudioElement
globalAudioRef.current = new Audio();
// We do *not* give it a src at first. That comes only on playTrack().
}
// Create the wavesurfer instance for the "global" bottom player.
wavesurferRef.current = WaveSurfer.create({
...waveOptions,
container: "#global-waveform",
media: globalAudioRef.current, // Link the global audio element
});
// Cleanup on unmount
return () => {
wavesurferRef.current?.destroy();
};
}, []);
// Wavesurfer event handlers
useEffect(() => {
const ws = wavesurferRef.current;
if (!ws) return;
const onPlay = () => {
console.log("onPlay");
setIsPlaying(true);
};
const onPause = () => {
console.log("onPause");
setIsPlaying(false);
};
const onFinish = () => setIsPlaying(false);
ws.on("play", onPlay);
ws.on("pause", onPause);
ws.on("finish", onFinish);
return () => {
ws.un("play", onPlay);
ws.un("pause", onPause);
ws.un("finish", onFinish);
};
}, []);
/**
* Load & play a track globally.
* - We only load the track’s URL now (so no big pre‐downloads).
* - We can also pass track.peaks if we have them, so we
* avoid re‐analyzing the audio each time.
*/
const playTrack = (song: Song) => {
if (currentTrack?.id === song.id) {
// If same track, just toggle play/pause
if (isPlaying) {
console.log("pause");
wavesurferRef.current?.pause();
} else {
console.log("play");
wavesurferRef.current?.play();
}
return;
}
setCurrentTrack(song);
if (wavesurferRef.current) {
wavesurferRef.current.load(
song.audio.original, // or lowQuality, or whichever URL
song.peaks || undefined, // optional peaks
song.metadata.duration || undefined, // optional duration
);
// Optionally auto‐play once loaded:
wavesurferRef.current.once("ready", () => {
wavesurferRef.current?.play();
});
}
};
const pause = useCallback(() => {
wavesurferRef.current?.pause();
}, []);
const play = useCallback(() => {
wavesurferRef.current?.pause();
}, []);
return (
<AudioPlayerContext
value={{
currentTrack,
isPlaying,
playTrack,
pause,
wavesurfer: wavesurferRef.current,
globalAudioRef: globalAudioRef.current,
}}
>
{children}
</AudioPlayerContext>
);
}
// Hook to use the context
export function useAudioPlayer() {
return useContext(AudioPlayerContext);
} and then we also have a Global Bottom Player: "use client";
import { useEffect, useRef } from "react";
import { useAudioPlayer } from "@/lib/Audio/global_test_2/AudioContext";
export default function GlobalPlayer() {
const { currentTrack, isPlaying, wavesurfer, pause } = useAudioPlayer();
// We'll create a div ref that we attach Wavesurfer’s waveform into.
const containerRef = useRef<HTMLDivElement | null>(null);
// On mount, let Wavesurfer know about our container
useEffect(() => {
if (!wavesurfer) return;
if (!containerRef.current) return;
wavesurfer.empty(); // in case something was in it
// This forcibly re‐renders the waveform in the containerRef
wavesurfer.setOptions({ container: containerRef.current });
}, [wavesurfer]);
return (
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
background: "#333",
color: "#fff",
padding: "1rem",
}}
>
{/* The Wavesurfer waveform */}
<div
id={"global-waveform"}
ref={containerRef}
style={{ width: "100%", height: "80px" }}
/>
{currentTrack ? (
<div>
<strong>Now playing:</strong> {currentTrack.title}
{isPlaying ? (
<button onClick={pause}>Pause</button>
) : (
<button
onClick={
() => wavesurfer?.play() // or re‐call the waveSurfer’s .play()
}
>
Play
</button>
)}
</div>
) : (
<div>No track playing.</div>
)}
</div>
);
} and then our TrackCard : "use client";
import React, { useEffect } from "react";
import { useAudioPlayer } from "@/lib/Audio/global_test_2/AudioContext";
import { Song } from "@/types/song/song";
import { useWaveOptions } from "@/lib/waveform/useWaveOptions";
import WaveSurfer from "wavesurfer.js"; // or you can do the raw wavesurfer.js
type TrackCardProps = {
song: Song;
};
/**
* Renders just the waveform + a "Play" button.
* This DOESN'T load the audio; it only draws the waveform from `peaks`.
*/
export default function TrackCard({ song }: TrackCardProps) {
const { playTrack, currentTrack, isPlaying, wavesurfer } = useAudioPlayer();
const waveOptions = useWaveOptions();
// We only want to show the waveform from peaks, not fetch audio.
// So we pass in peaks & the global <audio> element.
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isThisTrackPlaying = currentTrack?.id === song.id && isPlaying;
useEffect(() => {
if (!containerRef.current) return;
const ws = WaveSurfer.create({
...waveOptions,
container: containerRef.current,
normalize: false,
backend: "MediaElement",
media:
currentTrack?.id === song.id
? wavesurfer?.getMediaElement()
: undefined, // Attach the global audio if this is the playing track.
// Provide the peaks to draw the wave:
peaks: song.peaks || undefined,
duration: song.metadata.duration || undefined,
// We do NOT pass a `url` or `media` here, so no audio is downloaded.
});
return () => {
ws.destroy();
};
}, [currentTrack?.id]);
return (
<div style={{ border: "1px solid #555", marginBottom: "1rem" }}>
<h3>{song.title}</h3>
<div ref={containerRef} style={{ width: "100%", height: "80px" }} />
{/* Show a simple button that triggers the global player to load & play. */}
<button onClick={() => playTrack(song)}>
{isThisTrackPlaying ? "Pause" : "Play"}
</button>
</div>
);
} from those files, i have this problem:The audio "pauses" for both the Global Bottom Player and the TrackCard since they share the same HTML Audio Element, and the TrackCard runs Since the ws is destroyted (only the one in the TrackCard), teh shared audio element in the Provider / Global player is autmatically paused. Why does it pause? |
Beta Was this translation helpful? Give feedback.
You can sync the bottom player with the currently playing track by making them share the same
media
element.E.g.
The bottom player will then play, pause and seek whenever the current player is played/paused/seeking.