diff --git a/src/music/MusicPlayer.tsx b/src/music/MusicPlayer.tsx deleted file mode 100644 index 1ba8c60..0000000 --- a/src/music/MusicPlayer.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React from "react"; -import { connect } from "react-redux"; - -import Controls from "./components/controls"; -import Visualizer from "./components/visualizer"; -import { State } from "./store"; - -type AudioState = { - audioContext: AudioContext; - audioSource: HTMLAudioElement; - audioSourceNode: MediaElementAudioSourceNode; - audioVolume: GainNode; -}; - -type MusicPlayerProps = { - playing: boolean; - muted: boolean; - source?: string; -}; - -class MusicPlayer extends React.Component { - private audioState: AudioState; - - constructor(props: MusicPlayerProps) { - super(props); - - const context = new AudioContext(); - const source = new Audio(); - const sourceNode = context.createMediaElementSource(source); - const volume = context.createGain(); - - sourceNode.connect(volume); - volume.connect(context.destination); - - this.audioState = { - audioContext: context, - audioSourceNode: sourceNode, - audioSource: source, - audioVolume: volume, - }; - } - - render() { - return ( -
-
- -
-
- -
-
- ); - } - - async componentDidUpdate() { - const context = this.audioState.audioContext; - const source = this.audioState.audioSource; - const volume = this.audioState.audioVolume; - - // First, set the audio source (if it changed) - if (this.props.source && source.src != this.props.source) { - source.src = this.props.source; - } - - if (this.props.playing) { - // Chrome is super awkward about AudioContext, and won't - // even allow creating one without complaining about - // wanting user input, so we need to resume the context - // before we can actually play. - // - // Luckily, this has no adverse effects on Firefox. - try { - await context.resume(); - await source.play(); - } catch (error) { - if (error instanceof DOMException) { - console.error(`Could not play audio: ${error.message}`); - } else { - throw error; - } - } - } else { - source.pause(); - } - - if (!this.props.muted) { - volume.gain.setValueAtTime(1, context.currentTime); - } else { - volume.gain.setValueAtTime(0, context.currentTime); - } - } -} - -function mapStateToProps(state: State): MusicPlayerProps { - return { - playing: state.musicState.playing, - muted: state.musicState.muted, - source: state.musicState.source, - }; -} - -export default connect(mapStateToProps)(MusicPlayer); diff --git a/src/music/Mseq_-_Journey.mp3 b/src/music/assets/Mseq_-_Journey.mp3 similarity index 100% rename from src/music/Mseq_-_Journey.mp3 rename to src/music/assets/Mseq_-_Journey.mp3 diff --git a/src/music/Mseq_-_Journey.mp3.d.ts b/src/music/assets/Mseq_-_Journey.mp3.d.ts similarity index 100% rename from src/music/Mseq_-_Journey.mp3.d.ts rename to src/music/assets/Mseq_-_Journey.mp3.d.ts diff --git a/src/music/components/controls.tsx b/src/music/components/controls.tsx deleted file mode 100644 index 380e666..0000000 --- a/src/music/components/controls.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react"; -import { connect } from "react-redux"; - -import { State } from "../store"; -import { Title } from "../store/music/types"; -import Indicator from "./indicator"; - -type ControlProps = { - title: Title; -}; - -class Controls extends React.Component { - render() { - let title = ( -
-
{this.props.title.name}
-
- ); - - if ( - this.props.title.name == "Journey" && - this.props.title.artist == "Mseq" - ) { - title = ( -
-
- - Journey - -  by Mseq (c) copyright 2016 Licensed under a - Creative Commons  - - Attribution Noncommercial (3.0) - -   license. Ft: Admiral Bob,Texas Radio Fish -
-
- ); - } - - return ( -
-
-
- - {title} -
-
-
- {this.props.title.artist} -
-
-
-
- ); - } -} - -function mapStateToProps(state: State): ControlProps { - return { - title: state.musicState.title, - }; -} - -export default connect(mapStateToProps)(Controls); diff --git a/src/music/components/indicator.tsx b/src/music/components/indicator.tsx deleted file mode 100644 index e112d1e..0000000 --- a/src/music/components/indicator.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from "react"; -import { connect } from "react-redux"; -import classNames from "classnames"; - -import { Dispatch, State } from "../store"; -import { togglePlay } from "../store/music/types"; - -type IndicatorProps = { - muted: boolean; - playing: boolean; -}; - -type IndicatorDispatch = { - play: () => void; -}; - -type Props = IndicatorProps & IndicatorDispatch; - -class Indicator extends React.Component { - render() { - const button_classes = classNames({ - button: true, - "is-primary": true, - "level-item": true, - // TODO(tlater): Add loading logic here - }); - - const icon_classes = classNames({ - fas: true, - "fa-2x": true, - "fa-muted": this.props.muted, - "fa-play": !this.props.playing, - "fa-pause": this.props.playing, - }); - - return ( - - ); - } - - click = () => { - this.props.play(); - }; -} - -function mapStateToProps(state: State): IndicatorProps { - return { - muted: state.musicState.muted, - playing: state.musicState.playing, - }; -} - -function mapDispatchToProps(dispatch: Dispatch): IndicatorDispatch { - return { - play: () => { - dispatch(togglePlay()); - }, - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(Indicator); diff --git a/src/music/components/visualizer.tsx b/src/music/components/visualizer.tsx deleted file mode 100644 index a02070e..0000000 --- a/src/music/components/visualizer.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React from "react"; -import * as three from "three"; - -import { State } from "../store"; - -type VisualizerProps = { - audioContext: AudioContext; - audioSource: AudioNode; -}; - -class CanvasDrawer { - private analyser: AnalyserNode; - private canvas: HTMLCanvasElement; - - private analyserData: Float32Array; - - private boxes: Array; - private camera: three.PerspectiveCamera; - private renderer: three.WebGLRenderer; - private scene: three.Scene; - - private angle: number; - - private animationFrame: number; - private lastTime: number; - - constructor(analyser: AnalyserNode, canvas: HTMLCanvasElement) { - this.analyser = analyser; - this.canvas = canvas; - - // Set up analyser data storage - this.analyserData = new Float32Array(analyser.frequencyBinCount); - - // Initialize the scene - this.scene = new three.Scene(); - - // Make a bunch of boxes to represent the bars - this.boxes = new Array(analyser.frequencyBinCount) as Array; - const width = 2 / analyser.frequencyBinCount; - for (let freq = 0; freq < analyser.frequencyBinCount; freq++) { - const geometry = new three.BoxGeometry(1, 1, 1); - const material = new three.MeshLambertMaterial({ - color: new three.Color(0x99d1ce), - }); - const cube = new three.Mesh(geometry, material); - - cube.scale.set(width, 1e-6, width); - cube.position.set(-1 + freq * width, 0, 0); - - this.scene.add(cube); - this.boxes[freq] = cube; - } - - // Add lights for shadowing - const ambientLight = new three.AmbientLight(0xffffff, 0.4); - this.scene.add(ambientLight); - - const directionalLight = new three.DirectionalLight(0xffffff, 1); - directionalLight.position.set(-1, 0.3, -1); - directionalLight.castShadow = true; - this.scene.add(directionalLight); - - // Add a camera - this.angle = 3; - this.camera = new three.PerspectiveCamera( - 70, - canvas.width / canvas.height, - 0.01, - 10 - ); - this.camera.lookAt(0, 0, 0); - this.scene.add(this.camera); - this.rotateCamera(1); - - // Add a renderer - this.renderer = new three.WebGLRenderer({ - antialias: true, - canvas: canvas, - powerPreference: "low-power", - }); - - this.renderer.setClearColor(new three.Color(0x0f0f0f)); - this.renderer.setSize(canvas.width, canvas.height, false); - - // Set up canvas resizing - window.addEventListener("resize", this.resize); - - // Run the first, set the first animation frame time and start requesting - // animation frames - this.resize(); - this.lastTime = 0; - this.animationFrame = requestAnimationFrame(this.render); - } - - scaleBoxes() { - const analyser = this.analyser; - - analyser.getFloatFrequencyData(this.analyserData); - - for (let freq = 0; freq < analyser.frequencyBinCount; freq++) { - let height = analyser.maxDecibels / this.analyserData[freq]; - - if (height > 0.3) { - height -= 0.3; - } else { - height = 1e-6; - } - - this.boxes[freq].scale.y = height; - } - } - - rotateCamera(elapsed: number) { - if (this.angle >= Math.PI * 2) { - this.angle = 0; - } else { - this.angle += 0.1 * (elapsed / 1000); - } - - const camera = this.camera; - const angle = this.angle; - - camera.position.x = 1.01 * Math.sin(angle); - camera.position.z = 1.01 * Math.cos(angle); - - /* camera.position.y = (1 - Math.abs(angle - 0.5) / 0.5); */ - camera.lookAt(0, 0, 0); - } - - stop() { - if (this.animationFrame != 0) { - cancelAnimationFrame(this.animationFrame); - } - } - - render = (time: number) => { - // Set our animation frame to 0, so that if we stop, we don't try to cancel a past animation frame - this.animationFrame = 0; - // Update elapsed time - const elapsed = time - this.lastTime; - this.lastTime = time; - - const camera = this.camera; - const renderer = this.renderer; - const scene = this.scene; - - this.scaleBoxes(); - this.rotateCamera(elapsed); - - renderer.render(scene, camera); - this.animationFrame = requestAnimationFrame(this.render); - }; - - resize = () => { - const canvas = this.canvas; - - if (canvas.parentElement === null) { - throw Error("Could not access canvas parent for size calculation"); - } - - // This is stupid, but by setting the canvas proportions to 0 - // for a split second the browser can actually figure out the - // height of the parentElement. - // - // If the canvas is allowed to keep its height, it will - // prevent the parentElement from being able to shrink because - // its contents are filling it completely. - // - // I've seen others use `overflow: hidden` to achieve this - // (and in fact seen it render in my browser on jsfiddle), but - // that doesn't work here somehow. - canvas.height = 0; - canvas.width = 0; - - const height = canvas.parentElement.clientHeight; - const width = canvas.parentElement.clientWidth; - - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(width, height, false); - }; -} - -class Visualizer extends React.Component { - private analyser?: AnalyserNode; - private canvas: React.RefObject; - private drawer?: CanvasDrawer; - - constructor(props: VisualizerProps) { - super(props); - this.canvas = React.createRef(); - } - - render(): React.ReactNode { - return ( - - ); - } - - componentDidMount(): void { - if (this.canvas.current === null) { - throw Error("Failed to create canvas; aborting"); - } - - this.analyser = this.props.audioContext.createAnalyser(); - this.analyser.fftSize = 2048; - this.analyser.smoothingTimeConstant = 0.8; - this.props.audioSource.connect(this.analyser); - this.drawer = new CanvasDrawer(this.analyser, this.canvas.current); - } - - componentWillUnmount(): void { - if (!this.drawer || !this.analyser) { - return; - } - - this.drawer.stop(); - this.props.audioSource.disconnect(this.analyser); - } -} - -export default Visualizer; diff --git a/src/music/features/controls/Controls.tsx b/src/music/features/controls/Controls.tsx new file mode 100644 index 0000000..31a682f --- /dev/null +++ b/src/music/features/controls/Controls.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +import Indicator from "../indicator/Indicator"; +import { useAppSelector } from "../../hooks"; + +function Controls() { + const title = useAppSelector((state) => state.musicPlayer.title); + + let titleLine =
{title.name}
; + + if (title.name === "Journey" && title.artist === "Mseq") { + titleLine = ( +
+
+ + Journey + +  by Mseq (c) copyright 2016 Licensed under a Creative + Commons  + + Attribution Noncommercial (3.0) + +   license. Ft: Admiral Bob,Texas Radio Fish +
+
+ ); + } + + return ( +
+
+
+ + {titleLine} +
+
+
{title.artist}
+
+
+
+ ); +} + +export default Controls; diff --git a/src/music/features/indicator/Indicator.tsx b/src/music/features/indicator/Indicator.tsx new file mode 100644 index 0000000..dd1a899 --- /dev/null +++ b/src/music/features/indicator/Indicator.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import classNames from "classnames"; + +import { useAppSelector, useAppDispatch } from "../../hooks"; +import { togglePlay, PlayState } from "../musicplayer/musicPlayerSlice"; + +function Indicator() { + const playing = useAppSelector((state) => state.musicPlayer.playing); + const muted = useAppSelector((state) => state.musicPlayer.muted); + const dispatch = useAppDispatch(); + + const buttonClass = classNames({ + button: true, + "is-primary": true, + "level-item": true, + "is-loading": playing === PlayState.Loading, + }); + + const iconClass = classNames({ + fas: true, + "fa-2x": true, + "fa-muted": muted, + "fa-play": playing === PlayState.Paused, + "fa-pause": playing === PlayState.Playing, + }); + + return ( + + ); +} + +export default Indicator; diff --git a/src/music/features/musicplayer/MusicPlayer.tsx b/src/music/features/musicplayer/MusicPlayer.tsx new file mode 100644 index 0000000..e9f6e32 --- /dev/null +++ b/src/music/features/musicplayer/MusicPlayer.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import Controls from "../controls/Controls"; +import { musicPlayer } from "./musicPlayerSlice"; + +function MusicPlayer() { + return ( +
+
+
+
+
+ +
+
+ ); +} + +export default MusicPlayer; diff --git a/src/music/features/musicplayer/musicPlayerSlice.ts b/src/music/features/musicplayer/musicPlayerSlice.ts new file mode 100644 index 0000000..14e4a7c --- /dev/null +++ b/src/music/features/musicplayer/musicPlayerSlice.ts @@ -0,0 +1,150 @@ +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; + + constructor() { + this.context = new AudioContext(); + this.source = new Audio(); + this.sourceNode = this.context.createMediaElementSource(this.source); + this.volume = this.context.createGain(); + + this.sourceNode.connect(this.volume); + this.volume.connect(this.context.destination); + } + + get audioContext() { + return this.context; + } + + get audioNode() { + return this.sourceNode; + } + + set src(source: string) { + this.source.src = source; + } + + togglePlay = async ( + _: null, + { getState }: { getState: () => RootState } + ): Promise => { + 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; diff --git a/src/music/hooks.ts b/src/music/hooks.ts new file mode 100644 index 0000000..8768ccf --- /dev/null +++ b/src/music/hooks.ts @@ -0,0 +1,5 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; +import type { RootState, AppDispatch } from "./store"; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/music/index.tsx b/src/music/index.tsx index 43e4d50..a350690 100644 --- a/src/music/index.tsx +++ b/src/music/index.tsx @@ -2,10 +2,10 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { Provider } from "react-redux"; -import { store } from "./store"; -import MusicPlayer from "./MusicPlayer"; -import { setSource, setTitle } from "./store/music/types"; -import mseq from "./Mseq_-_Journey.mp3"; +import store from "./store"; +import MusicPlayer from "./features/musicplayer/MusicPlayer"; +import { setSource } from "./features/musicplayer/musicPlayerSlice"; +import mseq from "./assets/Mseq_-_Journey.mp3"; const rootElement = document.getElementById("playerUI"); @@ -20,11 +20,11 @@ root.render( ); -store.dispatch(setSource(mseq)); store.dispatch( - setTitle({ - name: "Journey", + setSource({ + source: mseq, artist: "Mseq", + name: "Journey", album: "Unknown album", length: 192052244, }) diff --git a/src/music/player.ts b/src/music/player.ts new file mode 100644 index 0000000..7b1afc5 --- /dev/null +++ b/src/music/player.ts @@ -0,0 +1,15 @@ +class Player { + constructor() { + console.info("Test"); + } +} + +let player: Player | null = null; + +export default () => { + if (player === null) { + player = new Player(); + } + + return player; +}; diff --git a/src/music/store.ts b/src/music/store.ts new file mode 100644 index 0000000..b6ad9fe --- /dev/null +++ b/src/music/store.ts @@ -0,0 +1,13 @@ +import { configureStore } from "@reduxjs/toolkit"; +import musicPlayerReducer from "./features/musicplayer/musicPlayerSlice"; + +const store = configureStore({ + reducer: { + musicPlayer: musicPlayerReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +export default store; diff --git a/src/music/store/index.ts b/src/music/store/index.ts deleted file mode 100644 index 7a429eb..0000000 --- a/src/music/store/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createStore, combineReducers } from "redux"; - -import { MusicState } from "./music/types"; -import { musicStateReducer } from "./music/reducers"; - -export interface State { - musicState: MusicState; -} - -const rootReducer = combineReducers({ - musicState: musicStateReducer, -}); - -export const store = createStore( - rootReducer, - // @ts-expect-error - These properties are set by the devtools extension - window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() -); - -export type Dispatch = typeof store.dispatch; diff --git a/src/music/store/music/reducers.ts b/src/music/store/music/reducers.ts deleted file mode 100644 index f79cd22..0000000 --- a/src/music/store/music/reducers.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createReducer } from "redux-act"; -import update from "immutability-helper"; - -import { - Title, - MusicState, - setTitle, - toggleMute, - togglePlay, - setSource, -} from "./types"; - -const defaultTitle: Title = { - name: "Untitled", - artist: "Unknown Artist", - album: "Unknown Album", - length: 0, -}; - -const initialState: MusicState = { - muted: false, - playing: false, - title: defaultTitle, - playTime: 0, -}; - -export const musicStateReducer = createReducer( - { - // @ts-expect-error - These appear to be working, even if functions - // are technically prohibited, and were recommended upstream - [setTitle]: (state: MusicState, title: Title): MusicState => { - return update(state, { - title: { $set: title }, - }); - }, - // @ts-expect-error - These appear to be working, even if functions - // are technically prohibited, and were recommended upstream - [togglePlay]: (state: MusicState): MusicState => { - return update(state, { $toggle: ["playing"] }); - }, - // @ts-expect-error - These appear to be working, even if functions - // are technically prohibited, and were recommended upstream - [toggleMute]: (state: MusicState): MusicState => { - return update(state, { $toggle: ["muted"] }); - }, - // @ts-expect-error - These appear to be working, even if functions - // are technically prohibited, and were recommended upstream - [setSource]: (state: MusicState, source: string): MusicState => { - return update(state, { source: { $set: source } }); - }, - }, - initialState -); diff --git a/src/music/store/music/types.ts b/src/music/store/music/types.ts deleted file mode 100644 index c5bacfc..0000000 --- a/src/music/store/music/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Action, createAction } from "redux-act"; - -export interface Title { - name: string; - artist: string; - album: string; - /** - * The length of the title in nanoseconds. - */ - length: number; -} - -export interface MusicState { - muted: boolean; - playing: boolean; - title: Title; - playTime: number; - source?: string; -} - -export const setTitle: (_title: Title) => Action = createAction( - "set currently playing title" -); - -export const setPlayTime: (_time: number) => Action = - createAction("set the play time"); - -export const toggleMute: () => Action = createAction("toggle mute"); - -export const togglePlay: () => Action = createAction("toggle play"); - -export const setSource: (_source: string) => Action = - createAction("set the title"); diff --git a/tsconfig.json b/tsconfig.json index 1185b0b..1a93d76 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "esModuleInterop": true, "jsx": "react", "isolatedModules": true, + "target": "es5", "plugins": [ { "name": "typescript-eslint-language-service"