webamp/js/components/Visualizer.js
2018-06-11 22:44:22 -07:00

261 lines
7.8 KiB
JavaScript

import React from "react";
import { connect } from "react-redux";
import { toggleVisualizerStyle } from "../actionCreators";
import { getWindowShade, getVisualizerStyle } from "../selectors";
import { VISUALIZERS } from "../constants";
const PIXEL_DENSITY = 2;
const BAR_WIDTH = 6;
const GRADIENT_COLOR_COUNT = 16;
const PEAK_COLOR_INDEX = 23;
// Return the average value in a slice of dataArray
function sliceAverage(dataArray, sliceWidth, sliceNumber) {
const start = sliceWidth * sliceNumber;
const end = start + sliceWidth;
let sum = 0;
for (let i = start; i < end; i++) {
sum += dataArray[i];
}
return sum / sliceWidth;
}
class Visualizer extends React.Component {
componentDidMount() {
this.canvasCtx = this.canvas.getContext("2d");
this.canvasCtx.imageSmoothingEnabled = false;
this.setStyle();
// Kick off the animation loop
const loop = () => {
if (this.props.status === "PLAYING") {
this.paintFrame();
}
this._animationRequest = window.requestAnimationFrame(loop);
};
loop();
}
componentWillUnmount() {
if (this._animationRequest) {
window.cancelAnimationFrame(this._animationRequest);
}
}
componentDidUpdate() {
this.setStyle();
// Redraw the current frame, since the skin may have changed.
this.paintFrame();
}
_renderWidth() {
return this.props.width;
}
_renderHeight() {
return this.props.height;
}
_height() {
return this.props.height * PIXEL_DENSITY;
}
_width() {
return this.props.width * PIXEL_DENSITY;
}
setStyle() {
if (!this.props.colors) {
return;
}
// TODO: Split this into to methods. One for skin update, one for style
// update.
this.preRenderBg();
this.preRenderBar();
if (this.props.style === VISUALIZERS.OSCILLOSCOPE) {
this.props.analyser.fftSize = 2048;
this.bufferLength = this.props.analyser.fftSize;
this.dataArray = new Uint8Array(this.bufferLength);
} else if (this.props.style === VISUALIZERS.BAR) {
this.props.analyser.fftSize = 64; // Must be a power of two
// Number of bins/bars we get
this.bufferLength = this.props.analyser.frequencyBinCount;
this.dataArray = new Uint8Array(this.bufferLength);
}
}
clear() {
this.canvasCtx.drawImage(this.bgCanvas, 0, 0);
}
// Pre-render the background grid
preRenderBg() {
// Off-screen canvas for pre-rendering the background
this.bgCanvas = document.createElement("canvas");
this.bgCanvas.width = this._width();
this.bgCanvas.height = this._height();
const bgCanvasCtx = this.bgCanvas.getContext("2d");
bgCanvasCtx.fillStyle = this.props.colors[0];
bgCanvasCtx.fillRect(0, 0, this._width(), this._height());
if (this.props.spekles) {
bgCanvasCtx.fillStyle = this.props.colors[1];
for (let x = 0; x < this._width(); x += 4) {
for (let y = PIXEL_DENSITY; y < this._height(); y += 4) {
bgCanvasCtx.fillRect(x, y, PIXEL_DENSITY, PIXEL_DENSITY);
}
}
}
}
// Pre-render the bar gradient
preRenderBar() {
/**
* The order of the colours is commented in the file: the fist two colours
* define the background and dots (check it to see what are the dots), the
* next 16 colours are the analyzer's colours from top to bottom, the next
* 5 colours are the oscilloscope's ones, from center to top/bottom, the
* last colour is for the analyzer's peak markers.
*/
// Off-screen canvas for pre-rendering a single bar gradient
this.barCanvas = document.createElement("canvas");
this.barCanvas.width = BAR_WIDTH;
this.barCanvas.height = this._height();
const offset = 2; // The first two colors are for the background;
const gradientColors = this.props.colors.slice(
offset,
offset + GRADIENT_COLOR_COUNT
);
const barCanvasCtx = this.barCanvas.getContext("2d");
const height = this._renderHeight();
const multiplier = GRADIENT_COLOR_COUNT / height;
// In shade mode, the five colors are, from top to bottom:
// 214, 102, 0 -- 3
// 222, 165, 24 -- 6
// 148, 222, 33 -- 9
// 57, 181, 16 -- 12
// 24, 132, 8 -- 15
// TODO: This could probably be improved by iterating backwards
for (let i = 0; i < height; i++) {
const colorIndex = GRADIENT_COLOR_COUNT - 1 - Math.floor(i * multiplier);
barCanvasCtx.fillStyle = gradientColors[colorIndex];
const y = this._height() - i * PIXEL_DENSITY;
barCanvasCtx.fillRect(0, y, BAR_WIDTH, PIXEL_DENSITY);
}
}
paintFrame() {
this.clear();
if (this.props.style === VISUALIZERS.OSCILLOSCOPE) {
this._paintOscilloscopeFrame();
} else if (this.props.style === VISUALIZERS.BAR) {
this._paintBarFrame();
}
}
_paintOscilloscopeFrame() {
this.props.analyser.getByteTimeDomainData(this.dataArray);
this.canvasCtx.lineWidth = PIXEL_DENSITY;
// Just use one of the viscolors for now
this.canvasCtx.strokeStyle = this.props.colors[18];
// Since dataArray has more values than we have pixels to display, we
// have to average several dataArray values per pixel. We call these
// groups slices.
//
// We use the 2x scale here since we only want to plot values for
// "real" pixels.
const sliceWidth =
Math.floor(this.bufferLength / this._width()) * PIXEL_DENSITY;
// The max amplitude is half the height
const h = this._height() / 2;
this.canvasCtx.beginPath();
// Iterate over the width of the canvas in "real" pixels.
for (let j = 0; j <= this._renderWidth(); j++) {
const amplitude = sliceAverage(this.dataArray, sliceWidth, j);
const percentAmplitude = amplitude / 128; // dataArray gives us bytes
const y = percentAmplitude * h;
const x = j * PIXEL_DENSITY;
// Canvas coordinates are in the middle of the pixel by default.
// When we want to draw pixel perfect lines, we will need to
// account for that here
if (x === 0) {
this.canvasCtx.moveTo(x, y);
} else {
this.canvasCtx.lineTo(x, y);
}
}
this.canvasCtx.stroke();
}
_printBar(x, height) {
height = Math.ceil(height) * PIXEL_DENSITY;
if (height > 0) {
const y = this._height() - height;
const ctx = this.canvasCtx;
// Draw the gradient
const b = BAR_WIDTH;
ctx.drawImage(this.barCanvas, 0, y, b, height, x, y, b, height);
// Draw the gray peak line
// TODO: Rather than sitting on top of the bar, these
// are expected to be behind the top pixel, and fall more slowly.
// Currently these overwrite the top pixel.
ctx.fillStyle = this.props.colors[PEAK_COLOR_INDEX];
ctx.fillRect(x, y, BAR_WIDTH, PIXEL_DENSITY);
}
}
_paintBarFrame() {
this.props.analyser.getByteFrequencyData(this.dataArray);
// We are printing bars off the right of the canvas :(
const xOffset = BAR_WIDTH + PIXEL_DENSITY; // Bar width, plus a pixel of spacing to the right.
const heightMultiplier = this._renderHeight() / 256;
for (let j = 0; j < this.bufferLength; j++) {
this._printBar(j * xOffset, this.dataArray[j] * heightMultiplier);
}
}
render() {
const { width, height } = this.props;
return (
<canvas
id="visualizer"
ref={node => (this.canvas = node)}
style={{ width, height }}
width={width * PIXEL_DENSITY}
height={height * PIXEL_DENSITY}
onClick={this.props.toggleVisualizerStyle}
/>
);
}
}
const mapStateToProps = state => ({
colors: state.display.skinColors,
style: getVisualizerStyle(state),
width: getWindowShade(state, "main") ? 38 : 76,
height: getWindowShade(state, "main") ? 5 : 16,
status: state.media.status,
spekles: !getWindowShade(state, "main")
});
const mapDispatchToProps = {
toggleVisualizerStyle
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Visualizer);