Reputation: 9948
I am making an audio player. It has pause, rewind and time seek features. How and who should handle the audio element?
So what is the most redux way to handle audio with progress display?
Upvotes: 18
Views: 19690
Reputation: 1277
In order to keep audio player in sync with rest of the app you can use classic React props down events up data flow. That means you keep your state in parent component and pass it to children components as props, along with event handlers that modify the state. More specifically, in your state you can have:
seekTime
: this will be used to force time update on your playerappTime
: this will be broadcasted by your player and passed to other components in order to keep them in sync with player.An example implementation might look like this:
import { useRef, useEffect, useState } from "react";
function App() {
// we'll define state in parent component:
const [playing, setPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [seekTime, setSeekTime] = useState(0); // forces player to update its time
const [appTime, setAppTime] = useState(0); // dictated by player, broadcasted to other components
// state will be passed as props and setter function will allow child components to change state:
return (
<div>
<button onClick={() => setPlaying(true)}>PLAY</button>
<button onClick={() => setPlaying(false)}>PAUSE</button>
<button onClick={() => setSeekTime(appTime - 5)}>-5 SEC</button>
<button onClick={() => setSeekTime(appTime + 5)}>+5 SEC</button>
<Seekbar
value={appTime}
min="0"
max={duration}
onInput={(event) => setSeekTime(event.target.value)}
/>
<Player
playing={playing}
seekTime={seekTime}
onTimeUpdate={(event) => setAppTime(event.target.currentTime)}
onLoadedData={(event) => setDuration(event.target.duration)}
/>
</div>
);
}
function Seekbar({ value, min, max, onInput }) {
return (
<input
type="range"
step="any"
value={value}
min={min}
max={max}
onInput={onInput}
/>
);
}
function Player({ playing, seekTime, onTimeUpdate, onLoadedData }) {
const ref = useRef(null);
if (ref.current) playing ? ref.current.play() : ref.current.pause();
//updates audio element only on seekTime change (and not on each rerender):
useEffect(() => (ref.current.currentTime = seekTime), [seekTime]);
return (
<audio
src="./your.file"
ref={ref}
onTimeUpdate={onTimeUpdate}
onLoadedData={onLoadedData}
/>
);
}
export default App;
If you prefer a redux-like solution, you can move your app's state to a reducer function and rewrite the parent component like this:
import { useRef, useEffect, useReducer } from "react";
// define reducer and initial state outside of component
const initialState = { playing: false, duration: 0, seekTime: 0, appTime: 0 };
function reducer(state, action) {
switch (action.type) {
case "play":
return { ...state, playing: true };
case "pause":
return { ...state, playing: false };
case "set-duration":
return { ...state, duration: action.value };
case "set-time":
return { ...state, appTime: action.value };
case "seek":
return { ...state, seekTime: action.value };
default:
throw new Error("Unhandled action " + action.type);
}
}
function App() {
// use reducer and dispatch instead of state
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<button onClick={() => dispatch({ type: "play" })}>PLAY</button>
<button onClick={() => dispatch({ type: "pause" })}>PAUSE</button>
<button
onClick={() => dispatch({ type: "seek", value: state.appTime + 5 })}
>
-5 SEC
</button>
<button
onClick={() => dispatch({ type: "seek", value: state.appTime + 5 })}
>
+5 SEC
</button>
<Seekbar
value={state.appTime}
min="0"
max={state.duration}
onInput={(event) =>
dispatch({ type: "seek", value: event.target.value })
}
/>
<Player
playing={state.playing}
seekTime={state.seekTime}
onTimeUpdate={(event) =>
dispatch({ type: "set-time", value: event.target.currentTime })
}
onLoadedData={(event) =>
dispatch({ type: "set-duration", value: event.target.duration })
}
/>
</div>
);
}
// The rest of app doesn't need any change compared to previous example.
// That's due to decoupled architecture!
function Seekbar({ value, min, max, onInput }) {
return (
<input
type="range"
step="any"
value={value}
min={min}
max={max}
onInput={onInput}
/>
);
}
function Player({ playing, seekTime, onTimeUpdate, onLoadedData }) {
const ref = useRef(null);
if (ref.current) playing ? ref.current.play() : ref.current.pause();
useEffect(() => (ref.current.currentTime = seekTime), [seekTime]);
return (
<audio
src="./your.file"
ref={ref}
onTimeUpdate={onTimeUpdate}
onLoadedData={onLoadedData}
/>
);
}
export default App;
It might be tempting to simply pass a ref to DOM audio element around the app instead of implementing proper state management. However this solution would couple your components together and thus make your app harder to maintain. So unless you really really need the 3 miliseconds taken by React's virtual DOM (and in most cases you don't), I would advise against doing it.
Upvotes: 2
Reputation: 18674
Redux - it's all about the state and consistency.
Your goal is to keep in sync the song time and the progress bar.
I see two possible aproaches:
So you have to keep the song's current time (in seconds for instance) in the Store, because of there are a few dependend components and its hard to sync them without the Store.
You have few events those change the current time:
On a time change you will dispatch an action and will update the Store with the new time. Thereby keeping current song's time all components will be in sync.
Managing the state in unidirectional data flow with actions dispatching, reducers and stores is the Redux way of implementing any component.
Here is a pseudo code of the #1 aproach:
class AudioPlayer extends React.Component {
onPlay(second) {
// Store song current time in the Store on each one second
store.dispatch({ type: 'SET_CURRENT_SECOND', second });
}
onRewind(seconds) {
// Rewind song current time
store.dispatch({ type: 'REWIND_CURRENT_SECOND', seconds });
}
onSeek(seconds) {
// Seek song current time
store.dispatch({ type: 'SEEK_CURRENT_SECOND', seconds });
}
render() {
const { currentTime, songLength } = this.state;
return <div>
<audio onPlay={this.onPlay} onRewind={this.onRewind} onSeek={this.onSeek} />
<AudioProgressBar currentTime songLength />
</div>
}
}
If the above aproach doesn't fit your needs, for example you may have a lot of Audio players on a same screen - there may be a performance gap.
In that case you can access your HTML5 audio tag and components via refs in the componentDidMount lifecycle method.
The HTML5 audio tag has DOM events and you can keep the both components in sync without touching the Store. If there is a need to save something in the Store - you can do it anytime.
Please take a look at react-audio-player source code and check how it handles the refs and what API the plugin exposes. For sure you can take inspiration from there. Also you can reuse it for your use case.
Here are some of the API methods those are related to your questions:
It depends to your use case specifics. However generally speaking in the both aproaches it's a good idea to implement a presentational component with the necessary API methods and it's up to you to decide how much data to manage in the Store.
So I created a starting component for you to illustrate how to handle the refs to the audio and slider. Start / Stop / Seeking features included. For sure it has drawbacks, but as I already mentioned it's a good starting point.
You can evolve it to a presentational component with good API methods, that suits your needs.
class Audio extends React.Component {
constructor(props) {
super(props);
this.state = {
duration: null
}
};
handlePlay() {
this.audio.play();
}
handleStop() {
this.audio.currentTime = 0;
this.slider.value = 0;
this.audio.pause();
}
componentDidMount() {
this.slider.value = 0;
this.currentTimeInterval = null;
// Get duration of the song and set it as max slider value
this.audio.onloadedmetadata = function() {
this.setState({duration: this.audio.duration});
}.bind(this);
// Sync slider position with song current time
this.audio.onplay = () => {
this.currentTimeInterval = setInterval( () => {
this.slider.value = this.audio.currentTime;
}, 500);
};
this.audio.onpause = () => {
clearInterval(this.currentTimeInterval);
};
// Seek functionality
this.slider.onchange = (e) => {
clearInterval(this.currentTimeInterval);
this.audio.currentTime = e.target.value;
};
}
render() {
const src = "https://mp3.gisher.org/download/1000/preview/true";
return <div>
<audio ref={(audio) => { this.audio = audio }} src={src} />
<input type="button" value="Play"
onClick={ this.handlePlay.bind(this) } />
<input type="button"
value="Stop"
onClick={ this.handleStop.bind(this) } />
<p><input ref={(slider) => { this.slider = slider }}
type="range"
name="points"
min="0" max={this.state.duration} /> </p>
</div>
}
}
ReactDOM.render(<Audio />, document.getElementById('container'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="container">
<!-- This element's contents will be replaced with your component. -->
</div>
If you have any questions feel free to comment below! :)
Upvotes: 31