diff --git a/js/actionCreators/milkdrop.ts b/js/actionCreators/milkdrop.ts index f026284f..8a59547a 100644 --- a/js/actionCreators/milkdrop.ts +++ b/js/actionCreators/milkdrop.ts @@ -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 } diff --git a/js/components/MilkdropWindow/Milkdrop.js b/js/components/MilkdropWindow/Milkdrop.js deleted file mode 100644 index d1f21875..00000000 --- a/js/components/MilkdropWindow/Milkdrop.js +++ /dev/null @@ -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 ( - this._handleDrop(e)}> - {this.state.presetOverlay && ( - 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()} - /> - )} - (this._canvasNode = node)} - /> - - ); - } -} - -const mapStateToProps = state => ({ - trackTitle: Selectors.getCurrentTrackDisplayName(state), - presets: Selectors.getPresets(state), - butterchurn: Selectors.getButterchurn(state), - transitionType: Selectors.getPresetTransitionType(state) -}); - -export default connect(mapStateToProps)(Milkdrop); diff --git a/js/components/MilkdropWindow/Visualizer.tsx b/js/components/MilkdropWindow/Visualizer.tsx new file mode 100644 index 00000000..5d90ed89 --- /dev/null +++ b/js/components/MilkdropWindow/Visualizer.tsx @@ -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 ( + + ); +} + +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); diff --git a/js/components/MilkdropWindow/index.js b/js/components/MilkdropWindow/index.js deleted file mode 100644 index 62c056f7..00000000 --- a/js/components/MilkdropWindow/index.js +++ /dev/null @@ -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 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 ( - (this._wrapperNode = node)}> - - - ); - } - - render() { - if (this.props.desktop) { - const size = { width: window.innerWidth, height: window.innerHeight }; - return ( - ( - - )} - > - {this._renderMilkdrop(size)} - - ); - } - - return ( - - {({ height, width }) => ( - ( - - )} - > - {this._renderMilkdrop({ width, height })} - - )} - - ); - } -} - -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); diff --git a/js/components/MilkdropWindow/index.tsx b/js/components/MilkdropWindow/index.tsx new file mode 100644 index 00000000..042b4761 --- /dev/null +++ b/js/components/MilkdropWindow/index.tsx @@ -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 ( + + {({ height, width }) => ( + + + + )} + + ); +} + +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); diff --git a/js/reducers/milkdrop.ts b/js/reducers/milkdrop.ts index 6a694466..f24950bf 100644 --- a/js/reducers/milkdrop.ts +++ b/js/reducers/milkdrop.ts @@ -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; + +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; } diff --git a/js/selectors.ts b/js/selectors.ts index c8b6ed0c..609e3230 100644 --- a/js/selectors.ts +++ b/js/selectors.ts @@ -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]]; +} diff --git a/js/webampLazy.tsx b/js/webampLazy.tsx index 75d97dc6..5d63b28d 100644 --- a/js/webampLazy.tsx +++ b/js/webampLazy.tsx @@ -199,6 +199,9 @@ class Winamp { type: ENABLE_MILKDROP, open: options.__butterchurnOptions.butterchurnOpen }); + this.store.dispatch( + Actions.initializePresets(options.__butterchurnOptions) + ); } if (options.__enableMediaLibrary) {