158 lines
4.2 KiB
TypeScript
158 lines
4.2 KiB
TypeScript
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
|
import { RootState, AppDispatch } from "../../store";
|
|
|
|
//************************
|
|
// Interface definitions *
|
|
//************************
|
|
|
|
interface MusicPlayerState {
|
|
muted: boolean;
|
|
playing: PlayState;
|
|
title: MusicPlayerTitle;
|
|
}
|
|
|
|
interface MusicPlayerTitle {
|
|
source: string;
|
|
artist: string;
|
|
name: string;
|
|
album: string;
|
|
length: number;
|
|
}
|
|
|
|
enum PlayState {
|
|
Playing = "Playing",
|
|
Paused = "Paused",
|
|
Loading = "Loading",
|
|
}
|
|
|
|
//*********************
|
|
// Music player logic *
|
|
//*********************
|
|
|
|
class MusicPlayer {
|
|
private context?: AudioContext;
|
|
private source: HTMLAudioElement;
|
|
private sourceNode?: MediaElementAudioSourceNode;
|
|
private volume?: GainNode;
|
|
private analyser?: AnalyserNode;
|
|
|
|
constructor() {
|
|
this.source = new Audio();
|
|
}
|
|
|
|
get audioAnalyser() {
|
|
return this.analyser;
|
|
}
|
|
|
|
set src(source: string) {
|
|
this.source.src = source;
|
|
}
|
|
|
|
togglePlay = async (
|
|
_: null,
|
|
{ getState }: { getState: () => RootState }
|
|
): Promise<PlayState> => {
|
|
if (this.context === undefined) {
|
|
this.context = new AudioContext();
|
|
this.sourceNode = this.context.createMediaElementSource(
|
|
this.source
|
|
);
|
|
this.volume = this.context.createGain();
|
|
this.analyser = this.context.createAnalyser();
|
|
|
|
this.analyser.fftSize = 2048;
|
|
this.analyser.smoothingTimeConstant = 0.8;
|
|
|
|
this.sourceNode.connect(this.analyser);
|
|
this.sourceNode.connect(this.volume);
|
|
this.volume.connect(this.context.destination);
|
|
}
|
|
|
|
const playing = getState().musicPlayer.playing;
|
|
|
|
switch (playing) {
|
|
case PlayState.Playing:
|
|
this.source.pause();
|
|
return PlayState.Paused;
|
|
case PlayState.Paused:
|
|
case PlayState.Loading:
|
|
// Chrome's extra cookie, it refuses to play if we
|
|
// don't resume after the first user interaction.
|
|
await this.context.resume();
|
|
return this.source.play().then(() => PlayState.Playing);
|
|
}
|
|
};
|
|
}
|
|
|
|
const player = new MusicPlayer();
|
|
|
|
//*************************
|
|
// Redux state management *
|
|
//*************************
|
|
|
|
const initialState: MusicPlayerState = {
|
|
muted: false,
|
|
playing: PlayState.Paused,
|
|
title: {
|
|
source: "",
|
|
artist: "",
|
|
name: "",
|
|
album: "",
|
|
length: 0,
|
|
},
|
|
};
|
|
|
|
export const musicPlayerSlice = createSlice({
|
|
name: "musicPlayer",
|
|
initialState,
|
|
|
|
reducers: {
|
|
setSource: (state, action: PayloadAction<MusicPlayerTitle>) => {
|
|
state.title = action.payload;
|
|
player.src = state.title.source;
|
|
},
|
|
},
|
|
|
|
extraReducers: (builder) => {
|
|
builder
|
|
.addCase(togglePlay.pending, (state) => {
|
|
// If we are currently paused or loading, then this is
|
|
// actually an async call, otherwise we just
|
|
// synchronously pause the music.
|
|
if (state.playing !== PlayState.Playing) {
|
|
state.playing = PlayState.Loading;
|
|
}
|
|
})
|
|
.addCase(togglePlay.fulfilled, (state, { payload }) => {
|
|
state.playing = payload;
|
|
})
|
|
.addCase(togglePlay.rejected, (state, { error }) => {
|
|
if (error.message !== undefined) {
|
|
console.error(`Could not play music: ${error.message}`);
|
|
}
|
|
|
|
state.playing = PlayState.Paused;
|
|
});
|
|
},
|
|
});
|
|
|
|
export const togglePlay = createAsyncThunk<
|
|
PlayState,
|
|
null,
|
|
{ dispatch: AppDispatch; state: RootState }
|
|
>("musicPlayer/togglePlay", player.togglePlay, {
|
|
condition: (_, { getState }) => {
|
|
const playing = getState().musicPlayer.playing;
|
|
|
|
if (playing == PlayState.Loading) {
|
|
// Block updates when we're loading
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
|
|
export const { setSource } = musicPlayerSlice.actions;
|
|
export { PlayState, player as musicPlayer };
|
|
export type { MusicPlayerState };
|
|
export default musicPlayerSlice.reducer;
|