diff --git a/package-lock.json b/package-lock.json
index 11dfdd5..8a61914 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3125,6 +3125,11 @@
         }
       }
     },
+    "classnames": {
+      "version": "2.2.6",
+      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
+      "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
+    },
     "clean-css": {
       "version": "4.2.3",
       "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz",
diff --git a/package.json b/package.json
index 5ce1291..2c0f846 100644
--- a/package.json
+++ b/package.json
@@ -16,9 +16,18 @@
   },
   "dependencies": {
     "@fortawesome/fontawesome-free": "^5.12.1",
+    "@types/react": "^16.9.23",
+    "@types/react-redux": "^7.1.7",
     "bootstrap": "^4.0.0",
+    "classnames": "^2.2.6",
+    "immutability-helper": "^3.0.1",
     "jquery": "^3.3.1",
     "popper.js": "^1.16.1",
+    "react": "^16.13.0",
+    "react-dom": "^16.13.1",
+    "react-redux": "^7.2.0",
+    "redux": "^4.0.5",
+    "redux-act": "^1.7.7",
     "three": "^0.101.1"
   },
   "scripts": {
diff --git a/src/music/MusicPlayer.tsx b/src/music/MusicPlayer.tsx
new file mode 100644
index 0000000..22cb10d
--- /dev/null
+++ b/src/music/MusicPlayer.tsx
@@ -0,0 +1,94 @@
+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<MusicPlayerProps, {}> {
+  private audioState: AudioState;
+
+  constructor(props: MusicPlayerProps) {
+    super(props);
+
+    let context = new AudioContext();
+    let source = new Audio();
+    let sourceNode = context.createMediaElementSource(source);
+    let volume = context.createGain();
+
+    sourceNode.connect(volume);
+    volume.connect(context.destination);
+
+    this.audioState = {
+      audioContext: context,
+      audioSourceNode: sourceNode,
+      audioSource: source,
+      audioVolume: volume,
+    };
+  }
+
+  render() {
+    return (
+      <div id="player" style={{ height: "100%", width: "100%" }}>
+        <Visualizer
+          audioContext={this.audioState.audioContext}
+          audioSource={this.audioState.audioSourceNode}
+        />
+        <Controls />
+      </div>
+    );
+  }
+
+  componentDidUpdate() {
+    let context = this.audioState.audioContext;
+    let source = this.audioState.audioSource;
+    let 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) {
+      source
+        .play()
+        .then(() => {
+          console.info("Started playing audio");
+        })
+        .catch((error) => {
+          console.error(`Could not play audio: ${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/components/controls.tsx b/src/music/components/controls.tsx
new file mode 100644
index 0000000..1ff9ac2
--- /dev/null
+++ b/src/music/components/controls.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+import Redux from "redux";
+import { connect } from "react-redux";
+
+import { State } from "../store";
+import { Title, togglePlay } from "../store/music/types";
+import Indicator from "./indicator";
+
+type ControlProps = {
+  title: Title;
+};
+
+class Controls extends React.Component<ControlProps, {}> {
+  render() {
+    return (
+      <div id="playerControls" className="container-fluid fixed-bottom">
+        <div className="row">
+          <Indicator></Indicator>
+          <div
+            id="playerText"
+            className="text-justify text-truncate col-6 playerControlsContent"
+          >
+            {this.props.title.name} - {this.props.title.album}
+          </div>
+
+          {this.props.title.name === "Journey" &&
+          this.props.title.artist === "Mseq" ? (
+            <div id="copyrightNotice" className="col text-center">
+              <a href="http://dig.ccmixter.org/files/Mseq/54702">Journey</a>
+              &nbsp;by Mseq (c) copyright 2016 Licensed under a Creative
+              Commons&nbsp;
+              <a href="http://creativecommons.org/licenses/by-nc/3.0/">
+                Attribution Noncommercial (3.0)
+              </a>
+              &nbsp; license. Ft: Admiral Bob,Texas Radio Fish
+            </div>
+          ) : null}
+        </div>
+      </div>
+    );
+  }
+}
+
+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
new file mode 100644
index 0000000..8324bbc
--- /dev/null
+++ b/src/music/components/indicator.tsx
@@ -0,0 +1,59 @@
+import React from "react";
+import { connect } from "react-redux";
+import classNames from "classnames";
+
+import { State } from "../store";
+import { MusicState, togglePlay } from "../store/music/types";
+
+type IndicatorProps = {
+  muted: boolean;
+  playing: boolean;
+};
+
+type IndicatorDispatch = {
+  play: () => void;
+};
+
+type Props = IndicatorProps & IndicatorDispatch;
+
+class Indicator extends React.Component<Props, {}> {
+  click() {
+    this.props.play();
+  }
+
+  render() {
+    let classes = classNames({
+      "col-auto": true,
+      "text-center": true,
+      fas: true,
+      "fa-muted": this.props.muted,
+      "fa-play": this.props.playing,
+      "fa-pause": !this.props.playing,
+    });
+
+    return (
+      <div
+        id="playerIndicator"
+        onClick={this.click.bind(this)}
+        className={classes}
+      ></div>
+    );
+  }
+}
+
+function mapStateToProps(state: State): IndicatorProps {
+  return {
+    muted: state.musicState.muted,
+    playing: state.musicState.playing,
+  };
+}
+
+function mapDispatchToProps(dispatch: Redux.Dispatch<any>): 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
new file mode 100644
index 0000000..ebb7bfe
--- /dev/null
+++ b/src/music/components/visualizer.tsx
@@ -0,0 +1,218 @@
+import React from "react";
+import * as three from "three";
+
+type VisualizerProps = {
+  audioContext: AudioContext;
+  audioSource: AudioNode;
+};
+
+class CanvasDrawer {
+  private analyser: AnalyserNode;
+  private canvas: HTMLCanvasElement;
+
+  private analyserData: Float32Array;
+
+  private boxes: Array<three.Mesh>;
+  private camera: three.Camera;
+  private renderer: three.Renderer;
+  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 = Array(analyser.frequencyBinCount);
+    let width = 2 / analyser.frequencyBinCount;
+    for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
+      let geometry = new three.BoxGeometry(1, 1, 1);
+      let material = new three.MeshLambertMaterial({
+        color: new three.Color(0x99d1ce),
+      });
+      let 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
+    let ambientLight = new three.AmbientLight(0xffffff, 0.4);
+    this.scene.add(ambientLight);
+
+    let 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);
+
+    // Set up canvas resizing
+    window.addEventListener("resize", this.resize.bind(this));
+
+    // Run the first, set the first animation frame time and start requesting
+    // animation frames
+    this.resize();
+    this.lastTime = 0;
+    this.animationFrame = requestAnimationFrame(this.render.bind(this));
+  }
+
+  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
+    let elapsed = time - this.lastTime;
+    this.lastTime = time;
+
+    let camera = this.camera;
+    let renderer = this.renderer;
+    let scene = this.scene;
+
+    this.scaleBoxes();
+    this.rotateCamera(elapsed);
+
+    renderer.render(scene, camera);
+    this.animationFrame = requestAnimationFrame(this.render.bind(this));
+  }
+
+  scaleBoxes() {
+    let 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);
+    }
+
+    let camera = this.camera;
+    let 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);
+  }
+
+  resize() {
+    let canvas = this.canvas;
+    if (canvas.parentElement === null) {
+      throw Error("Could not access canvas parent for size calculation");
+    }
+
+    // Compute the height of all our siblings
+    let combinedHeight = 0;
+    for (let i = 0; i < canvas.parentElement.children.length; i++) {
+      const child = canvas.parentElement.children[i];
+
+      if (child != canvas) {
+        combinedHeight += child.clientHeight;
+      }
+    }
+
+    // The remaining space we want to fill
+    let remainingHeight = canvas.parentElement.clientHeight - combinedHeight;
+    canvas.height = remainingHeight;
+    canvas.width = canvas.parentElement.clientWidth;
+
+    this.camera.aspect = canvas.width / remainingHeight;
+    this.camera.updateProjectionMatrix();
+    this.renderer.setSize(canvas.width, remainingHeight);
+  }
+
+  stop() {
+    if (this.animationFrame != 0) {
+      cancelAnimationFrame(this.animationFrame);
+    }
+  }
+}
+
+class Visualizer extends React.Component<VisualizerProps, {}> {
+  private analyser: AnalyserNode;
+  private canvas: React.RefObject<HTMLCanvasElement>;
+  private drawer: CanvasDrawer;
+
+  constructor(props: VisualizerProps) {
+    super(props);
+    this.canvas = React.createRef();
+  }
+
+  render() {
+    return (
+      <canvas
+        id="visualizer"
+        ref={this.canvas}
+        style={{ width: "100%", height: "100%" }}
+      ></canvas>
+    );
+  }
+
+  componentDidMount() {
+    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() {
+    this.drawer.stop();
+    this.props.audioSource.disconnect(this.analyser);
+  }
+}
+
+export default Visualizer;
diff --git a/src/music/index.tsx b/src/music/index.tsx
new file mode 100644
index 0000000..a5c2917
--- /dev/null
+++ b/src/music/index.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import ReactDOM from "react-dom";
+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";
+
+const rootElement = document.getElementById("playerUI");
+
+ReactDOM.render(
+  <Provider store={store}>
+    <MusicPlayer />
+  </Provider>,
+  rootElement
+);
+
+store.dispatch(setSource(mseq));
+store.dispatch(
+  setTitle({
+    name: "Journey",
+    artist: "Mseq",
+    album: "Unknown album",
+    length: 192052244,
+  })
+);
diff --git a/src/music/music.js b/src/music/music.js
deleted file mode 100644
index fb6e764..0000000
--- a/src/music/music.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import music from "./Mseq_-_Journey.mp3";
-import Player from "./player/player.js";
-
-window.player = new Player(music);
diff --git a/src/music/music.scss b/src/music/music.scss
index adf4336..6f79835 100644
--- a/src/music/music.scss
+++ b/src/music/music.scss
@@ -2,16 +2,19 @@
 // @import "@fortawesome/fontawesome-free/scss/fontawesome.scss";
 // @import "@fortawesome/fontawesome-free/scss/solid.scss";
 
-@import "~/src/lib/scss/main";
+/* @import "~/src/lib/scss/main"; */
+@import url("~/node_modules/@fortawesome/fontawesome-free/scss/fontawesome");
+@import url("~/node_modules/@fortawesome/fontawesome-free/scss/solid");
 
 #playerControls {
-  background-color: $dark;
+  background-color: #11151c;
   max-width: 100%;
   padding: 0.5rem 1rem;
 }
 
-.playerControlsContent {
+.playerControlsContent, #playerIndicator {
   padding: 0.5rem 1rem;
+  line-height: 1.5;
 }
 
 #playerButton {
diff --git a/src/music/player/audio_manager.js b/src/music/player/audio_manager.js
deleted file mode 100644
index ca20973..0000000
--- a/src/music/player/audio_manager.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * AudioManager()
- *
- * A class to manage the audio stream.
- *
- * @param {string} src - The URL of the audio to play.
- */
-class AudioManager {
-    constructor(src) {
-        this._muted = false;
-
-        // Create audio graph
-        this._context = new AudioContext();
-        this.audio = new Audio(src);
-        this._volume = this._context.createGain();
-        this._source = this._context.createMediaElementSource(this.audio);
-
-        let context = this._context;
-        let volume = this._volume;
-        let source = this._source;
-
-        source.connect(volume);
-        volume.connect(context.destination);
-    }
-
-    get context() {
-        return this._context;
-    }
-
-    get source() {
-        return this._source;
-    }
-
-    get muted() {
-        return this._muted;
-    }
-
-    addEventListener(...args) {
-        this.audio.addEventListener(...args);
-    }
-
-    mute() {
-        let context = this._context;
-        let volume = this._volume;
-
-        if (this._muted) {
-            volume.gain.setValueAtTime(1, context.currentTime);
-        } else {
-            volume.gain.setValueAtTime(0, context.currentTime);
-        }
-
-        this._muted = !this._muted;
-    }
-}
-
-export default AudioManager;
diff --git a/src/music/player/background.js b/src/music/player/background.js
deleted file mode 100644
index 031dfd2..0000000
--- a/src/music/player/background.js
+++ /dev/null
@@ -1,12 +0,0 @@
-class Background {
-    constructor(display, audioManager) {
-        if (this.constructor === Background)
-            throw new Error("Cannot instantiate abstract class!");
-    }
-
-    start() {}
-
-    stop() {}
-}
-
-export default Background;
diff --git a/src/music/player/backgrounds/Spectrum.js b/src/music/player/backgrounds/Spectrum.js
deleted file mode 100644
index eec5efd..0000000
--- a/src/music/player/backgrounds/Spectrum.js
+++ /dev/null
@@ -1,135 +0,0 @@
-import $ from "jquery";
-import * as three from "three";
-
-import Background from "../background";
-
-class Spectrum extends Background {
-    constructor(display, audioManager) {
-        super(audioManager, display);
-
-        this._audioManager = audioManager;
-        this._display = display;
-
-        this._init_analyser();
-        this._init_scene();
-        this._init_objects();
-        this._resize();
-    }
-
-    _get_canvas_height() {
-        return (
-            this._display.parent().height() -
-            this._display
-                .siblings()
-                .toArray()
-                .reduce((a, b) => {
-                    return a + b.clientHeight;
-                }, 0)
-        );
-    }
-
-    _resize() {
-        this._camera.aspect = this._display.width() / this._get_canvas_height();
-        this._camera.updateProjectionMatrix();
-        this._renderer.setSize(
-            this._display.width(),
-            this._get_canvas_height()
-        );
-    }
-
-    _init_analyser() {
-        let audioManager = this._audioManager;
-
-        let analyser = audioManager.context.createAnalyser();
-        analyser.fftSize = 2048;
-        analyser.smoothingTimeConstant = 0.8;
-
-        let analyser_data = new Float32Array(analyser.frequencyBinCount);
-
-        audioManager.source.connect(analyser);
-        analyser.getFloatFrequencyData(analyser_data);
-
-        this._analyser = analyser;
-        this._analyser_data = analyser_data;
-    }
-
-    _init_scene() {
-        let scene = new three.Scene();
-
-        let camera = new three.PerspectiveCamera(
-            70,
-            this._display.width() / this._get_canvas_height(),
-            0.01,
-            10
-        );
-        camera.position.z = 1;
-        scene.add(camera);
-
-        let renderer = new three.WebGLRenderer({
-            antialias: true,
-            powerPreference: "low-power"
-        });
-
-        renderer.setSize(this._display.width(), this._display.height());
-
-        this._display.append(renderer.domElement);
-
-        this._scene = scene;
-        this._camera = camera;
-        this._renderer = renderer;
-
-        // Set the resize handler
-        $(window).resize(() => this._resize());
-    }
-
-    _init_objects() {
-        let analyser = this._analyser;
-        let scene = this._scene;
-
-        let boxes = Array(analyser.frequencyBinCount);
-        let width = 2 / analyser.frequencyBinCount;
-
-        for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
-            let geometry = new three.BoxGeometry(1, 1, 1);
-            let material = new three.MeshBasicMaterial({ color: 0x99d1ce });
-            let cube = new three.Mesh(geometry, material);
-
-            cube.scale.set(width, 1e-6, width);
-            cube.position.set(-1 + freq * width, 0, 0);
-
-            scene.add(cube);
-            boxes[freq] = cube;
-        }
-
-        this._boxes = boxes;
-    }
-
-    start() {
-        requestAnimationFrame(this._render.bind(this));
-    }
-
-    _render() {
-        let analyser = this._analyser;
-        let camera = this._camera;
-        let renderer = this._renderer;
-        let scene = this._scene;
-
-        for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
-            let height = analyser.maxDecibels / this._analyser_data[freq];
-
-            if (height > 0.3) {
-                height -= 0.3;
-            } else {
-                height = 1e-6;
-            }
-
-            this._boxes[freq].scale.y = height;
-        }
-
-        renderer.render(scene, camera);
-        analyser.getFloatFrequencyData(this._analyser_data);
-        requestAnimationFrame(this._render.bind(this));
-    }
-}
-
-export default Spectrum;
diff --git a/src/music/player/controls.js b/src/music/player/controls.js
deleted file mode 100644
index 4603df2..0000000
--- a/src/music/player/controls.js
+++ /dev/null
@@ -1,195 +0,0 @@
-import $ from "jquery";
-
-/**
- * _Text()
- *
- * A helper class to control the text area for audio controls.
- *
- * @private
- * @param {HTMLElement} div - The div of the text area.
- */
-class _Text {
-    constructor(div) {
-        this._element = div;
-
-        this._text = "";
-        this._flashing = false;
-
-        this._flash_timeout = null;
-    }
-
-    flashText(text) {
-        let element = this._element;
-        let flash_timeout = this._flash_timeout;
-
-        element.html(text);
-        this._flashing = true;
-
-        clearTimeout(flash_timeout);
-        this._flash_timeout = setTimeout(() => {
-            this._flashing = false;
-        }, 1000);
-    }
-
-    setText(text) {
-        let element = this._element;
-        let flashing = this._flashing;
-
-        if (!flashing) {
-            element.html(text);
-        }
-
-        this._text = text;
-    }
-}
-
-/**
- * _Button()
- *
- * A helper class to control the indicator button for audio controls.
- *
- * @private
- * @param {HTMLElement} button - The div of the button.
- */
-class _Button {
-    constructor(button) {
-        this._element = button;
-
-        this._classes = "";
-        this._flash_classes = "";
-        this._flashing = false;
-
-        this._flash_timeout = null;
-    }
-
-    click(callback) {
-        this._element.click(callback);
-    }
-
-    flashIcon(icon) {
-        let classes = this._classes;
-        let element = this._element;
-        let flash_classes = this._flash_classes;
-        let flash_timeout = this._flash_timeout;
-
-        // We remove any potentially current classes, and add the
-        // requested icon class.
-        element.removeClass(flash_classes);
-        element.removeClass(classes);
-        element.addClass(icon);
-
-        // We keep track of the classes we are flashing and the fact
-        // that we are flashing, so that we can remove the correct
-        // classes later.
-        this._flash_classes = icon;
-        this._flashing = true;
-
-        // If we previously started a timeout, we'll want to cancel it
-        // now, since otherwise *it* will clear out our classes.
-        clearTimeout(flash_timeout);
-
-        // We start a timeout to reset to the original classes.
-        this._flash_timeout = setTimeout(() => {
-            element.removeClass(this._flash_classes);
-            element.addClass(this._classes);
-
-            this._flash_classes = "";
-            this._flashing = false;
-        }, 1000);
-    }
-
-    setIcon(icon) {
-        let classes = this._classes;
-        let element = this._element;
-        let flashing = this._flashing;
-
-        // If we are currently flashing an icon, we'll hold off on
-        // changing the icon.
-        if (!flashing) {
-            element.removeClass(classes);
-            element.addClass(icon);
-        }
-
-        this._classes = icon;
-    }
-}
-
-/**
- * Controls()
- *
- * A class to manage music player controls.
- *
- * @param {AudioManager} audioManager - The audio manager to interact with.
- */
-class Controls {
-    constructor(audioManager) {
-        this._element = $("#playerControls");
-        this._button = new _Button($("#playerControls #playerButton"));
-        this._text = new _Text($("#playerControls #playerText"));
-        this._audioManager = audioManager;
-
-        this._setAudioEvents({
-            canplay: () => this._audioManager.audio.play(),
-            error: () => this.setError(this._audioManager.audio.error.message),
-            loadstart: () => this.setLoading(),
-            playing: () => this.setPlaying(),
-            stalled: () => this.setLoading("Stalling")
-        });
-
-        this._button.click(() => this.mute());
-    }
-
-    setPlaying() {
-        let button = this._button;
-        let text = this._text;
-
-        button.setIcon("fa-play");
-        text.setText("");
-    }
-
-    setLoading(reason = "Loading") {
-        let button = this._button;
-        let text = this._text;
-
-        button.setIcon("fa-spin fa-spinner");
-        text.setText(reason);
-    }
-
-    setError(reason = "") {
-        let button = this._button;
-        let text = this._text;
-
-        if (reason == "") {
-            reason = "Error";
-        } else {
-            reason = `Error: ${reason}`;
-        }
-
-        button.setIcon("fa-stop-circle");
-        text.setText(reason);
-    }
-
-    mute() {
-        let audioManager = this._audioManager;
-        let button = this._button;
-        let text = this._text;
-
-        audioManager.mute();
-
-        if (audioManager.muted) {
-            button.flashIcon("fa-volume-off");
-            text.flashText("Muted");
-        } else {
-            button.flashIcon("fa-volume-up");
-            text.flashText("Unmuted");
-        }
-    }
-
-    _setAudioEvents(events) {
-        for (let [event, handler] of Object.entries(events)) {
-            this._audioManager.addEventListener(event, handler);
-        }
-    }
-}
-
-export default Controls;
diff --git a/src/music/player/display.js b/src/music/player/display.js
deleted file mode 100644
index 2828909..0000000
--- a/src/music/player/display.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import $ from "jquery";
-import Spectrum from "./backgrounds/Spectrum.js";
-
-let backgrounds = {
-    Spectrum: Spectrum
-};
-
-class Display {
-    constructor(audioManager) {
-        this._audioManager = audioManager;
-        this._element = $("#playerContent");
-
-        this._background = new backgrounds["Spectrum"](
-            this._element,
-            audioManager
-        );
-        this._background.start();
-    }
-
-    set background(name) {}
-}
-
-export default Display;
diff --git a/src/music/player/player.js b/src/music/player/player.js
deleted file mode 100644
index 749c8e1..0000000
--- a/src/music/player/player.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import $ from "jquery";
-import AudioManager from "./audio_manager";
-import Controls from "./controls";
-import Display from "./display";
-
-class Player {
-    constructor(src = "https://tlater.net/assets/Mseq_-_Journey.mp3") {
-        this._ui = $("#playerUI");
-        this._audioManager = new AudioManager(src);
-        this._controls = new Controls(this._audioManager);
-        this._display = new Display(this._audioManager);
-    }
-}
-
-export default Player;
diff --git a/src/music/store/index.ts b/src/music/store/index.ts
new file mode 100644
index 0000000..f523735
--- /dev/null
+++ b/src/music/store/index.ts
@@ -0,0 +1,17 @@
+import { createStore, combineReducers } from "redux";
+
+import { MusicState } from "./music/types";
+import { musicStateReducer } from "./music/reducers";
+
+export interface State {
+  musicState: MusicState;
+}
+
+const rootReducer = combineReducers<State>({
+  musicState: musicStateReducer,
+});
+
+export const store = createStore(
+  rootReducer,
+  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
+);
diff --git a/src/music/store/music/reducers.ts b/src/music/store/music/reducers.ts
new file mode 100644
index 0000000..83883d1
--- /dev/null
+++ b/src/music/store/music/reducers.ts
@@ -0,0 +1,45 @@
+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<MusicState>(
+  {
+    [setTitle]: (state: MusicState, title: Title): MusicState => {
+      return update(state, {
+        title: { $set: title },
+      });
+    },
+    [togglePlay]: (state: MusicState): MusicState => {
+      return update(state, { $toggle: ["playing"] });
+    },
+    [toggleMute]: (state: MusicState): MusicState => {
+      return update(state, { $toggle: ["muted"] });
+    },
+    [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
new file mode 100644
index 0000000..39555fc
--- /dev/null
+++ b/src/music/store/music/types.ts
@@ -0,0 +1,35 @@
+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<null, null> = createAction(
+  "set currently playing title"
+);
+
+export const setPlayTime: (time: number) => Action<null, null> = createAction(
+  "set the play time"
+);
+
+export const toggleMute: () => Action<null, null> = createAction("toggle mute");
+
+export const togglePlay: () => Action<null, null> = createAction("toggle play");
+
+export const setSource: (source: string) => Action<null, null> = createAction(
+  "set the title"
+);
diff --git a/src/music/store/ui/types.ts b/src/music/store/ui/types.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/music_sample.pug b/src/music_sample.pug
index 66235ce..78dcdb2 100644
--- a/src/music_sample.pug
+++ b/src/music_sample.pug
@@ -5,10 +5,4 @@ block stylesheets
 
 block footer
   #playerUI.container-fluid.flex-grow-1
-    .row.d-flex.flex-column.h-100
-      #playerContent.container-fluid.flex-grow-1
-      #playerControls.container-fluid.fixed-bottom
-        .row
-          span#playerButton.col-1.playerControlsContent.fa.fa-fw.fa-spin.fa-spinner
-          #playerText.col-11.playerControlsContent Some text for now
-  script(type="text/javascript" src="./music/music.js")
+  script(type="text/javascript" src="./music/")
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..9efa87c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "strictNullChecks": true,
+    "esModuleInterop": true,
+    "jsx": "react"
+  }
+}
+