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 => { 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) => { 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;