Rewrite visualizer to use hooks

This commit is contained in:
Jordan Eldredge 2019-01-23 14:25:39 -08:00
parent 59666b8699
commit 5a4fcf47a9
8 changed files with 317 additions and 456 deletions

View file

@ -4,30 +4,47 @@ import Presets from "../components/MilkdropWindow/Presets";
export function initializePresets(presetOptions: any): Dispatchable {
return async dispatch => {
const [
{ butterchurn, presetKeys, minimalPresets },
initialPreset
] = await Promise.all([
presetOptions.loadInitialDependencies(),
_loadInitialPreset(presetOptions)
]);
const { loadInitialDependencies, loadNonMinimalPresets } = presetOptions;
const {
butterchurn,
presetKeys,
minimalPresets
} = await loadInitialDependencies();
const presets = new Presets({
keys: presetKeys,
initialPresets: minimalPresets,
getRest: presetOptions.loadNonMinimalPresets,
presetConverterEndpoint: presetOptions.presetConverterEndpoint,
loadConvertPreset: presetOptions.loadConvertPreset
const presetDefinitions = presetKeys.map((key: string) => {
if (minimalPresets[key] != null) {
return {
type: "BUTTERCHURN_JSON",
name: key,
definition: minimalPresets[key]
};
}
return async () => {
// TODO: Avoid a race where we try to resolve this promise more than once in parallel.
const nonMinimalPresets = await loadNonMinimalPresets();
return {
type: "BUTTERCHURN_JSON",
name: key,
definition: nonMinimalPresets[key]
};
};
});
if (initialPreset) {
const presetIndices = presets.addPresets(initialPreset);
const presetIndex = presetIndices[0];
await presets.selectIndex(presetIndex);
}
dispatch({
type: "GOT_BUTTERCHUN_PRESET",
json: minimalPresets[presetKeys[1]]
});
setTimeout(() => {
console.log(minimalPresets[presetKeys[0]]);
dispatch({
type: "GOT_BUTTERCHUN_PRESET",
json: minimalPresets[presetKeys[0]]
});
}, 8000);
dispatch({ type: GOT_BUTTERCHURN, butterchurn });
dispatch({ type: INITIALIZE_PRESETS, presets });
// dispatch({ type: INITIALIZE_PRESETS, presets });
};
}
@ -44,25 +61,6 @@ function _presetNameFromURL(url: string): string {
}
}
async function _loadInitialPreset({
initialButterchurnPresetUrl,
initialMilkdropPresetUrl
}: {
initialButterchurnPresetUrl: string;
initialMilkdropPresetUrl: string;
}) {
if (initialButterchurnPresetUrl && initialMilkdropPresetUrl) {
alert(
"Unable to handle both milkdropPresetUrl and butterchurnPresetUrl. Please specify one or the other."
);
} else if (initialButterchurnPresetUrl) {
return _fetchPreset(initialButterchurnPresetUrl, { isButterchurn: true });
} else if (initialMilkdropPresetUrl) {
return _fetchPreset(initialMilkdropPresetUrl, { isButterchurn: false });
}
return null;
}
async function _fetchPreset(
presetUrl: string,
{ isButterchurn }: { isButterchurn: boolean }

View file

@ -1,283 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import * as Selectors from "../../selectors";
import DropTarget from "../DropTarget";
import PresetOverlay from "./PresetOverlay";
const USER_PRESET_TRANSITION_SECONDS = 5.7;
const PRESET_TRANSITION_SECONDS = 2.7;
const MILLISECONDS_BETWEEN_PRESET_TRANSITIONS = 15000;
function transitionTimeFromTransitionType(transitionType) {
switch (transitionType) {
case "DEFAULT":
return PRESET_TRANSITION_SECONDS;
case "IMMEDIATE":
return 0;
case "USER_PRESET":
return USER_PRESET_TRANSITION_SECONDS;
}
}
class Milkdrop extends React.Component {
constructor(props) {
super(props);
this.__debugState = "CONSTRUCTED";
this.__updates = 0;
this.state = {
isFullscreen: false,
presetOverlay: false
};
}
async componentDidMount() {
this._initializeIfNeeded();
}
async _initializeIfNeeded() {
if (this.visualizer || !this.props.butterchurn || !this.props.presets) {
return;
}
this.visualizer = this.props.butterchurn.createVisualizer(
this.props.analyser.context,
this._canvasNode,
{
width: this.props.width,
height: this.props.height,
meshWidth: 32,
meshHeight: 24,
pixelRatio: window.devicePixelRatio || 1
}
);
if (this.visualizer == null) {
// https://github.com/captbaritone/webamp/issues/731
throw new Error("Visualizer not initialized. WAT.");
}
this.__debugState = "VISUALIZER_CREATED";
this.visualizer.connectAudio(this.props.analyser);
this.presetCycle = true;
this.selectPreset(this.props.presets.getCurrent(), 0);
// Kick off the animation loop
const loop = () => {
if (this.props.playing && this.props.isEnabledVisualizer) {
this.visualizer.render();
}
this._animationFrameRequest = window.requestAnimationFrame(loop);
};
loop();
this._unsubscribeFocusedKeyDown = this.props.onFocusedKeyDown(
this._handleFocusedKeyboardInput
);
if (this.visualizer == null) {
// https://github.com/captbaritone/webamp/issues/731
throw new Error("Visualizer not initialized after await. WAT.");
}
this.__debugState = "MOUNT_ENDED";
}
componentWillUnmount() {
this.__debugState = "WILL_UNMOUNT";
this._pauseViz();
this._stopCycling();
if (this._unsubscribeFocusedKeyDown) {
this._unsubscribeFocusedKeyDown();
}
}
componentDidUpdate(prevProps) {
this._initializeIfNeeded();
if (
this.props.width !== prevProps.width ||
this.props.height !== prevProps.height
) {
this.visualizer.setRendererSize(this.props.width, this.props.height);
}
if (this.props.trackTitle !== prevProps.trackTitle) {
this.visualizer.launchSongTitleAnim(this.props.trackTitle);
}
}
_pauseViz() {
if (this._animationFrameRequest) {
window.cancelAnimationFrame(this._animationFrameRequest);
this._animationFrameRequest = null;
}
}
_stopCycling() {
if (this.cycleInterval) {
clearInterval(this.cycleInterval);
this.cycleInterval = null;
}
}
_restartCycling() {
this._stopCycling();
if (this.presetCycle) {
this.cycleInterval = setInterval(() => {
this._nextPreset(PRESET_TRANSITION_SECONDS);
}, MILLISECONDS_BETWEEN_PRESET_TRANSITIONS);
}
}
_handleFocusedKeyboardInput = e => {
switch (e.keyCode) {
case 32: // spacebar
this._nextPreset(USER_PRESET_TRANSITION_SECONDS);
break;
case 8: // backspace
this._prevPreset(0);
break;
case 72: // H
this._nextPreset(0);
break;
case 82: // R
this.props.presets.toggleRandomize();
break;
case 76: // L
this.setState({ presetOverlay: !this.state.presetOverlay });
e.stopPropagation();
break;
case 84: // T
this.visualizer.launchSongTitleAnim(this.props.trackTitle);
e.stopPropagation();
break;
case 145: // scroll lock
case 125: // F14 (scroll lock for OS X)
this.presetCycle = !this.presetCycle;
this._restartCycling();
break;
}
};
_addNewPresets(files) {
const presetKeys = this.props.presets.getKeys();
const presets = {};
const presetIndices = [];
files.forEach(file => {
const fileName = file.name;
const presetName = fileName.substring(0, fileName.length - 5); // remove .milk
const presetIdx = presetKeys.indexOf(presetName);
if (presetIdx >= 0) {
presetIndices.push(presetIdx);
} else {
presets[presetName] = { file };
}
});
if (Object.keys(presets).length > 0) {
const filePresetIndices = this.props.presets.addPresets(presets);
for (let j = filePresetIndices[0]; j < filePresetIndices[1]; j++) {
presetIndices.push(j);
}
}
return presetIndices;
}
async _handleDrop(e) {
const files = e.dataTransfer.files;
if (files.length > 0) {
const milkFiles = Array.from(files).filter(file =>
file.name.endsWith(".milk")
);
if (milkFiles.length === 0) {
alert("Visualizer only supports .milk files");
return;
}
const presetIndices = this._addNewPresets(milkFiles);
this.selectPreset(
await this.props.presets.selectIndex(presetIndices[0]),
PRESET_TRANSITION_SECONDS
);
}
}
async _nextPreset(blendTime) {
this.selectPreset(await this.props.presets.next(), blendTime);
}
async _prevPreset(blendTime) {
this.selectPreset(await this.props.presets.previous(), blendTime);
}
selectPreset(preset, blendTime) {
if (preset != null) {
this.visualizer.loadPreset(preset, blendTime);
this._restartCycling();
// TODO: Kinda weird that we use the passed preset for the visualizer,
// but set state to the current. Maybe we should just always use the curent..
this.setState({ currentPreset: this.props.presets.getCurrentIndex() });
}
}
async loadPresets(presetFiles) {
const presets = {};
const milkFiles = Array.from(presetFiles).filter(file =>
file.name.endsWith(".milk")
);
for (let i = 0; i < milkFiles.length; i++) {
const file = milkFiles[i];
presets[file.name] = { file };
}
const numPresets = this.props.presets.loadPresets(presets);
this.selectPreset(
await this.props.presets.selectIndex(
Math.floor(Math.random() * numPresets)
),
PRESET_TRANSITION_SECONDS
);
}
closePresetOverlay() {
this.setState({ presetOverlay: false });
}
render() {
if (this.props.presets == null) {
return null;
}
return (
<DropTarget id="milkdrop-window" handleDrop={e => this._handleDrop(e)}>
{this.state.presetOverlay && (
<PresetOverlay
width={this.props.width}
height={this.props.height}
presetKeys={this.props.presets.getKeys()}
currentPreset={this.state.currentPreset}
onFocusedKeyDown={listener => this.props.onFocusedKeyDown(listener)}
selectPreset={async idx => {
this.selectPreset(await this.props.presets.selectIndex(idx), 0);
}}
loadPresets={async presetFiles => this.loadPresets(presetFiles)}
closeOverlay={() => this.closePresetOverlay()}
/>
)}
<canvas
height={this.props.height}
width={this.props.width}
style={{
height: "100%",
width: "100%",
display: this.props.isEnabledVisualizer ? "block" : "none"
}}
ref={node => (this._canvasNode = node)}
/>
</DropTarget>
);
}
}
const mapStateToProps = state => ({
trackTitle: Selectors.getCurrentTrackDisplayName(state),
presets: Selectors.getPresets(state),
butterchurn: Selectors.getButterchurn(state),
transitionType: Selectors.getPresetTransitionType(state)
});
export default connect(mapStateToProps)(Milkdrop);

View file

@ -0,0 +1,132 @@
// @ts-ignore #hook-types
import React, { useEffect, useState, useRef } from "react";
import { connect } from "react-redux";
import { VISUALIZERS } from "../../constants";
import * as Selectors from "../../selectors";
import { AppState, TransitionType } from "../../types";
interface StateProps {
isEnabledVisualizer: boolean;
playing: boolean;
butterchurn: any;
trackTitle: string;
currentPreset: any;
transitionType: TransitionType;
}
interface OwnProps {
analyser: AnalyserNode;
height: number;
width: number;
}
type Props = StateProps & OwnProps;
const TRANSITION_TYPE_DURATIONS = {
[TransitionType.DEFAULT]: 2.7,
[TransitionType.IMMEDIATE]: 0,
[TransitionType.USER_PRESET]: 5.7
};
function Visualizer(props: Props) {
const canvasRef = useRef(null);
const [visualizer, setVisualizer] = useState(null);
// Initialize the visualizer
useEffect(() => {
if (canvasRef.current == null || props.butterchurn == null) {
return;
}
if (visualizer != null) {
// Note: The visualizer does not offer anyway to clean itself up. So, we
// don't offer any way to recreate it. So, if you swap out the analyser
// node, or the canvas, that change won't be respected.
return;
}
const _visualizer = props.butterchurn.createVisualizer(
props.analyser.context,
canvasRef.current,
{
width: props.width,
height: props.height,
meshWidth: 32,
meshHeight: 24,
pixelRatio: window.devicePixelRatio || 1
}
);
_visualizer.connectAudio(props.analyser);
setVisualizer(_visualizer);
}, [canvasRef.current, props.butterchurn, props.analyser]);
// Ensure render size stays up to date
useEffect(() => {
if (visualizer == null) {
return;
}
visualizer.setRendererSize(props.width, props.height);
}, [visualizer, props.width, props.height]);
// Load presets when they change
useEffect(() => {
if (visualizer == null || props.currentPreset == null) {
return;
}
visualizer.loadPreset(
props.currentPreset,
TRANSITION_TYPE_DURATIONS[props.transitionType]
);
}, [visualizer, props.currentPreset]);
// Handle title animations
useEffect(() => {
if (visualizer == null || !props.trackTitle) {
return;
}
visualizer.launchSongTitleAnim(props.trackTitle);
}, [visualizer, props.trackTitle]);
const shouldAnimate = props.playing && props.isEnabledVisualizer;
// Kick off the animation loop
useEffect(() => {
if (!shouldAnimate || visualizer == null) {
return;
}
let animationFrameRequest = null;
const loop = () => {
visualizer.render();
animationFrameRequest = window.requestAnimationFrame(loop);
};
loop();
return () => {
if (animationFrameRequest != null) {
window.cancelAnimationFrame(animationFrameRequest);
}
};
}, [visualizer, shouldAnimate]);
return (
<canvas
height={props.height}
width={props.width}
style={{
height: "100%",
width: "100%",
display: props.isEnabledVisualizer ? "block" : "none"
}}
ref={canvasRef}
/>
);
}
const mapStateToProps = (state: AppState): StateProps => ({
isEnabledVisualizer:
Selectors.getVisualizerStyle(state) === VISUALIZERS.MILKDROP,
playing: Selectors.getMediaIsPlaying(state),
butterchurn: Selectors.getButterchurn(state),
trackTitle: Selectors.getCurrentTrackDisplayName(state),
currentPreset: Selectors.getCurrentPreset(state),
transitionType: Selectors.getPresetTransitionType(state)
});
export default connect(mapStateToProps)(Visualizer);

View file

@ -1,131 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import screenfull from "screenfull";
import ContextMenuWrapper from "../ContextMenuWrapper";
import GenWindow from "../GenWindow";
import { WINDOWS, VISUALIZERS } from "../../constants";
import * as Selectors from "../../selectors";
import * as Actions from "../../actionCreators";
import MilkdropContextMenu from "./MilkdropContextMenu";
import Desktop from "./Desktop";
import Milkdrop from "./Milkdrop";
import Background from "./Background";
import "../../../css/milkdrop-window.css";
// This component is just responsible for loading dependencies.
// This simplifies the inner <Milkdrop /> component, by allowing
// it to always assume that it has its dependencies.
class PresetsLoader extends React.Component {
constructor(props) {
super(props);
this.state = { isFullscreen: false };
}
isHidden() {
return this.props.desktop;
}
async componentDidMount() {
this.props.initializePresets(this.props.options);
screenfull.onchange(this._handleFullscreenChange);
}
componentWillUnmount() {
screenfull.off("change", this._handleFullscreenChange);
}
_handleFullscreenChange = () => {
this.setState({ isFullscreen: screenfull.isFullscreen });
};
_handleRequestFullsceen = () => {
if (screenfull.enabled) {
if (!screenfull.isFullscreen) {
screenfull.request(this._wrapperNode);
} else {
screenfull.exit();
}
}
};
_renderMilkdrop(size) {
const { width, height } = this.state.isFullscreen
? { width: screen.width, height: screen.height }
: size;
// Note: This _wrapperNode must not be removed from the DOM while
// in/entering full screen mode. Ensure `this.setState({isFullscreen})`
// does not cause this node to change identity.
return (
<Background innerRef={node => (this._wrapperNode = node)}>
<Milkdrop
{...this.props}
width={width}
height={height}
isFullscreen={this.state.isFullscreen}
/>
</Background>
);
}
render() {
if (this.props.desktop) {
const size = { width: window.innerWidth, height: window.innerHeight };
return (
<ContextMenuWrapper
onDoubleClick={this._handleRequestFullsceen}
renderContents={() => (
<MilkdropContextMenu
close={this.props.closeWindow}
toggleFullscreen={this._handleRequestFullsceen}
desktopMode={this.props.desktop}
toggleDesktop={this.props.toggleDesktop}
/>
)}
>
<Desktop>{this._renderMilkdrop(size)}</Desktop>
</ContextMenuWrapper>
);
}
return (
<GenWindow title={"Milkdrop"} windowId={WINDOWS.MILKDROP}>
{({ height, width }) => (
<ContextMenuWrapper
onDoubleClick={this._handleRequestFullsceen}
renderContents={() => (
<MilkdropContextMenu
close={this.props.closeWindow}
toggleFullscreen={this._handleRequestFullsceen}
desktopMode={this.props.desktop}
toggleDesktop={this.props.toggleDesktop}
/>
)}
>
{this._renderMilkdrop({ width, height })}
</ContextMenuWrapper>
)}
</GenWindow>
);
}
}
const mapStateToProps = state => ({
isEnabledVisualizer:
Selectors.getVisualizerStyle(state) === VISUALIZERS.MILKDROP,
playing: Selectors.getMediaIsPlaying(state),
desktop: Selectors.getMilkdropDesktopEnabled(state)
});
const mapDispatchToProps = dispatch => ({
closeWindow: () => dispatch(Actions.closeWindow(WINDOWS.MILKDROP)),
toggleDesktop: () => dispatch(Actions.toggleMilkdropDesktop()),
initializePresets: presets => dispatch(Actions.initializePresets(presets))
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(PresetsLoader);

View file

@ -0,0 +1,92 @@
// @ts-ignore #hook-types
import React, { useEffect, useState, useRef } from "react";
import { connect } from "react-redux";
import screenfull from "screenfull";
import ContextMenuWrapper from "../ContextMenuWrapper";
import GenWindow from "../GenWindow";
import { WINDOWS, VISUALIZERS } from "../../constants";
import * as Selectors from "../../selectors";
import * as Actions from "../../actionCreators";
import MilkdropContextMenu from "./MilkdropContextMenu";
import Desktop from "./Desktop";
import { AppState, Dispatch } from "../../types";
import Visualizer from "./Visualizer";
import "../../../css/milkdrop-window.css";
import Background from "./Background";
interface StateProps {
desktop: boolean;
}
interface DispatchProps {
closeWindow(): void;
toggleDesktop(): void;
}
interface OwnProps {
analyser: AnalyserNode;
height: number;
width: number;
onFocusedKeyDown(cb: (e: KeyboardEvent) => void): () => void;
}
type Props = StateProps & DispatchProps & OwnProps;
function Milkdrop(props: Props) {
// Handle keyboard events
useEffect(() => {
return props.onFocusedKeyDown(e => {
switch (e.keyCode) {
case 32: // spacebar
// this._nextPreset(USER_PRESET_TRANSITION_SECONDS);
break;
case 8: // backspace
// this._prevPreset(0);
break;
case 72: // H
// this._nextPreset(0);
break;
case 82: // R
// this.props.presets.toggleRandomize();
break;
case 76: // L
// this.setState({ presetOverlay: !this.state.presetOverlay });
e.stopPropagation();
break;
case 84: // T
// this.visualizer.launchSongTitleAnim(this.props.trackTitle);
e.stopPropagation();
break;
case 145: // scroll lock
case 125: // F14 (scroll lock for OS X)
// this.presetCycle = !this.presetCycle;
// this._restartCycling();
break;
}
});
}, [props.onFocusedKeyDown]);
return (
<GenWindow title={"Milkdrop"} windowId={WINDOWS.MILKDROP}>
{({ height, width }) => (
<Background>
<Visualizer width={width} height={height} analyser={props.analyser} />
</Background>
)}
</GenWindow>
);
}
const mapStateToProps = (state: AppState): StateProps => ({
desktop: Selectors.getMilkdropDesktopEnabled(state)
});
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
closeWindow: () => dispatch(Actions.closeWindow(WINDOWS.MILKDROP)),
toggleDesktop: () => dispatch(Actions.toggleMilkdropDesktop())
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(Milkdrop);

View file

@ -6,16 +6,50 @@ import {
} from "../actionTypes";
import { TransitionType } from "../types";
// This is what we actually pass to butterchurn
type ButterchurnPresetJson = {
type: "BUTTERCHURN_JSON";
name: string;
definition: any;
};
// A URL that points to a Butterchurn preset
interface ButterchurnPresetUrl {
type: "BUTTERCHURN_URL";
url: string;
}
// A URL that points to a .milk preset
interface MilkdropPresetUrl {
type: "MILKDROP_URL";
url: string;
}
type PresetDefinition =
| ButterchurnPresetJson
| ButterchurnPresetUrl
| MilkdropPresetUrl;
type LazyPresetDefinition = () => Promise<PresetDefinition>;
type PresetId = string;
export interface MilkdropState {
desktop: boolean;
presets: any;
presetOrder: PresetId[];
presetDefinitions: {
[presetId: string]: PresetDefinition | LazyPresetDefinition;
};
currentPresetIndex: number | null;
butterchurn: any;
transitionType: TransitionType;
}
const defaultMilkdropState = {
desktop: false,
presets: null,
presetOrder: [],
presetDefinitions: {},
currentPresetIndex: null,
butterchurn: null,
transitionType: TransitionType.DEFAULT
};
@ -27,10 +61,18 @@ export const milkdrop = (
switch (action.type) {
case SET_MILKDROP_DESKTOP:
return { ...state, desktop: action.enabled };
case INITIALIZE_PRESETS:
return { ...state, presets: action.presets };
case GOT_BUTTERCHURN:
return { ...state, butterchurn: action.butterchurn };
case "GOT_BUTTERCHUN_PRESET":
const id = "TEMP";
return {
...state,
presetDefinitions: {
[id]: action.json
},
currentPresetIndex: 0,
presetOrder: [id]
};
default:
return state;
}

View file

@ -625,3 +625,11 @@ export function getButterchurn(state: AppState): any {
export function getPresetTransitionType(state: AppState): TransitionType {
return state.milkdrop.transitionType;
}
export function getCurrentPreset(state: AppState): any | null {
const { currentPresetIndex, presetOrder, presetDefinitions } = state.milkdrop;
if (currentPresetIndex == null) {
return null;
}
return presetDefinitions[presetOrder[currentPresetIndex]];
}

View file

@ -199,6 +199,9 @@ class Winamp {
type: ENABLE_MILKDROP,
open: options.__butterchurnOptions.butterchurnOpen
});
this.store.dispatch(
Actions.initializePresets(options.__butterchurnOptions)
);
}
if (options.__enableMediaLibrary) {