diff --git a/js/actionCreators/index.ts b/js/actionCreators/index.ts index edad6e15..d9880f89 100644 --- a/js/actionCreators/index.ts +++ b/js/actionCreators/index.ts @@ -94,7 +94,13 @@ export { scrollDownFourTracks, dragSelected } from "./playlist"; -export { initializePresets } from "./milkdrop"; +export { + initializePresets, + requestPresetAtIndex, + selectRandomPreset, + selectNextPreset, + togglePresetOverlay +} from "./milkdrop"; import * as Selectors from "../selectors"; diff --git a/js/actionCreators/milkdrop.ts b/js/actionCreators/milkdrop.ts index 8a59547a..c23746a8 100644 --- a/js/actionCreators/milkdrop.ts +++ b/js/actionCreators/milkdrop.ts @@ -1,6 +1,12 @@ -import { INITIALIZE_PRESETS, GOT_BUTTERCHURN } from "../actionTypes"; -import { Dispatchable } from "../types"; -import Presets from "../components/MilkdropWindow/Presets"; +import { + GOT_BUTTERCHURN_PRESETS, + GOT_BUTTERCHURN, + SELECT_PRESET_AT_INDEX, + RESOLVE_PRESET_AT_INDEX, + TOGGLE_PRESET_OVERLAY +} from "../actionTypes"; +import * as Selectors from "../selectors"; +import { Dispatchable, TransitionType } from "../types"; export function initializePresets(presetOptions: any): Dispatchable { return async dispatch => { @@ -10,8 +16,9 @@ export function initializePresets(presetOptions: any): Dispatchable { presetKeys, minimalPresets } = await loadInitialDependencies(); + dispatch({ type: GOT_BUTTERCHURN, butterchurn }); - const presetDefinitions = presetKeys.map((key: string) => { + const presets = presetKeys.map((key: string) => { if (minimalPresets[key] != null) { return { type: "BUTTERCHURN_JSON", @@ -19,32 +26,66 @@ export function initializePresets(presetOptions: any): Dispatchable { 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] - }; + return { + type: "LAZY_BUTTERCHURN_JSON", + name: key, + getDefinition: async () => { + // TODO: Avoid a race where we try to resolve this promise more than once in parallel. + const nonMinimalPresets = await loadNonMinimalPresets(); + return nonMinimalPresets[key]; + } }; }); - dispatch({ - type: "GOT_BUTTERCHUN_PRESET", - json: minimalPresets[presetKeys[1]] - }); + dispatch({ type: GOT_BUTTERCHURN_PRESETS, presets }); + dispatch(requestPresetAtIndex(0, TransitionType.IMMEDIATE)); + }; +} - setTimeout(() => { - console.log(minimalPresets[presetKeys[0]]); - dispatch({ - type: "GOT_BUTTERCHUN_PRESET", - json: minimalPresets[presetKeys[0]] - }); - }, 8000); +export function selectNextPreset(): Dispatchable { + return (dispatch, getState) => { + const state = getState(); + const currentPresetIndex = Selectors.getCurrentPresetIndex(state); + if (currentPresetIndex == null) { + return; + } + const nextPresetIndex = currentPresetIndex + 1; + dispatch(requestPresetAtIndex(nextPresetIndex, TransitionType.DEFAULT)); + }; +} - dispatch({ type: GOT_BUTTERCHURN, butterchurn }); - // dispatch({ type: INITIALIZE_PRESETS, presets }); +export function selectRandomPreset(): Dispatchable { + return (dispatch, getState) => { + const state = getState(); + const randomIndex = Math.floor( + Math.random() * state.milkdrop.presets.length + ); + dispatch(requestPresetAtIndex(randomIndex, TransitionType.DEFAULT)); + }; +} + +export function requestPresetAtIndex( + index: number, + transitionType: TransitionType +): Dispatchable { + return async (dispatch, getState) => { + const state = getState(); + const preset = state.milkdrop.presets[index]; + if (preset == null) { + // Index might be out of range. + return; + } + switch (preset.type) { + case "BUTTERCHURN_JSON": + dispatch({ type: SELECT_PRESET_AT_INDEX, index, transitionType }); + return; + case "LAZY_BUTTERCHURN_JSON": + const json = await preset.getDefinition(); + // What if the index has changed? + dispatch({ type: RESOLVE_PRESET_AT_INDEX, index, json }); + dispatch({ type: SELECT_PRESET_AT_INDEX, index, transitionType }); + return; + } }; } @@ -88,3 +129,7 @@ async function _fetchPreset( return { [presetName]: preset }; } + +export function togglePresetOverlay(): Dispatchable { + return { type: TOGGLE_PRESET_OVERLAY }; +} diff --git a/js/actionTypes.ts b/js/actionTypes.ts index bfea95a7..47111ab2 100644 --- a/js/actionTypes.ts +++ b/js/actionTypes.ts @@ -77,5 +77,8 @@ export const ENABLE_MEDIA_LIBRARY = "ENABLE_MEDIA_LIBRARY"; export const ENABLE_MILKDROP = "ENABLE_MILKDROP"; export const SET_MILKDROP_DESKTOP = "SET_MILKDROP_DESKTOP"; export const SET_VISUALIZER_STYLE = "SET_VISUALIZER_STYLE"; -export const INITIALIZE_PRESETS = "INITIALIZE_PRESETS"; +export const GOT_BUTTERCHURN_PRESETS = "GOT_BUTTERCHURN_PRESETS"; export const GOT_BUTTERCHURN = "GOT_BUTTERCHURN"; +export const RESOLVE_PRESET_AT_INDEX = "RESOLVE_PRESET_AT_INDEX"; +export const SELECT_PRESET_AT_INDEX = "SELECT_PRESET_AT_INDEX"; +export const TOGGLE_PRESET_OVERLAY = "TOGGLE_PRESET_OVERLAY"; diff --git a/js/components/MilkdropWindow/Background.js b/js/components/MilkdropWindow/Background.tsx similarity index 69% rename from js/components/MilkdropWindow/Background.js rename to js/components/MilkdropWindow/Background.tsx index 9561d892..2b762e18 100644 --- a/js/components/MilkdropWindow/Background.js +++ b/js/components/MilkdropWindow/Background.tsx @@ -1,6 +1,11 @@ import React from "react"; -const Background = props => { +interface Props { + innerRef?: (node: HTMLElement) => void; + children: React.ReactNode; +} + +const Background = (props: Props) => { const { innerRef, ...restProps } = props; return (
{ height: "100%", width: "100%" }} - tabIndex="0" - {...restProps} - /> + tabIndex={0} + > + {props.children} +
); }; diff --git a/js/components/MilkdropWindow/Desktop.js b/js/components/MilkdropWindow/Desktop.tsx similarity index 95% rename from js/components/MilkdropWindow/Desktop.js rename to js/components/MilkdropWindow/Desktop.tsx index ee5a1c71..8ff8ffc6 100644 --- a/js/components/MilkdropWindow/Desktop.js +++ b/js/components/MilkdropWindow/Desktop.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom"; export default class Desktop extends React.Component { + _desktopNode?: HTMLElement; componentWillUnmount() { document.body.removeChild(this._desktopNode); this._desktopNode = null; diff --git a/js/components/MilkdropWindow/MilkdropContextMenu.js b/js/components/MilkdropWindow/MilkdropContextMenu.js deleted file mode 100644 index e16d2c9c..00000000 --- a/js/components/MilkdropWindow/MilkdropContextMenu.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from "react"; -import { Hr, Node } from "../ContextMenu"; - -const MilkdropContextMenu = props => ( - - - -
- -
-); - -export default MilkdropContextMenu; diff --git a/js/components/MilkdropWindow/MilkdropContextMenu.tsx b/js/components/MilkdropWindow/MilkdropContextMenu.tsx new file mode 100644 index 00000000..49fe7d46 --- /dev/null +++ b/js/components/MilkdropWindow/MilkdropContextMenu.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Hr, Node } from "../ContextMenu"; +import { connect } from "react-redux"; +import { WINDOWS } from "../../constants"; +import * as Selectors from "../../selectors"; +import * as Actions from "../../actionCreators"; +import { AppState, Dispatch } from "../../types"; +import ContextMenuWraper from "../ContextMenuWrapper"; + +interface StateProps { + desktop: boolean; +} + +interface DispatchProps { + toggleFullscreen(): void; + closeWindow(): void; + toggleDesktop(): void; +} + +type Props = StateProps & DispatchProps; + +const MilkdropContextMenu = (props: Props) => ( + { + <> + + +
+ + ; + }} + /> +); + +const mapStateToProps = (state: AppState): StateProps => ({ + desktop: Selectors.getMilkdropDesktopEnabled(state) +}); + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + closeWindow: () => dispatch(Actions.closeWindow(WINDOWS.MILKDROP)), + toggleDesktop: () => dispatch(Actions.toggleMilkdropDesktop()), + toggleFullscreen: () => { + throw new Error("Implement fullscreen"); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(MilkdropContextMenu); diff --git a/js/components/MilkdropWindow/PresetOverlay.js b/js/components/MilkdropWindow/PresetOverlay.js deleted file mode 100644 index 6b98b31c..00000000 --- a/js/components/MilkdropWindow/PresetOverlay.js +++ /dev/null @@ -1,184 +0,0 @@ -import React from "react"; -import { promptForFileReferences } from "../../fileUtils"; -import { clamp } from "../../utils"; - -const ENTRY_HEIGHT = 14; -const HEIGHT_PADDING = 15; -const WIDTH_PADDING = 20; - -const LoadingState = () => ( -
- Loading presets -
-); - -const ListWrapper = ({ width, height, children }) => ( -
-
-
    - {children} -
-
-
-); - -class PresetOverlay extends React.Component { - constructor(props) { - super(props); - const listIndex = this._listIndexFromPresetIndex(props.currentPreset); - this.state = { - selectedListIndex: clamp(listIndex, 0, this._maxListIndex()) - }; - } - - componentDidMount() { - this._unsubscribeFocusedKeyDown = this.props.onFocusedKeyDown( - this._handleFocusedKeyboardInput - ); - } - - componentWillUnmount() { - if (this._unsubscribeFocusedKeyDown) { - this._unsubscribeFocusedKeyDown(); - } - } - - _handleFocusedKeyboardInput = e => { - switch (e.keyCode) { - case 38: // up arrow - this.setState({ - selectedListIndex: Math.max(this.state.selectedListIndex - 1, 0) - }); - e.stopPropagation(); - break; - case 40: // down arrow - this.setState({ - selectedListIndex: Math.min( - this.state.selectedListIndex + 1, - this._maxListIndex() - ) - }); - e.stopPropagation(); - break; - case 13: // enter - if (this.state.selectedListIndex === 0) { - this.loadLocalDir(); - } else { - this.props.selectPreset( - this._presetIndexFromListIndex(this.state.selectedListIndex) - ); - } - e.stopPropagation(); - break; - case 27: // escape - this.props.closeOverlay(); - e.stopPropagation(); - break; - } - }; - - _presetIndexFromListIndex(listIndex) { - return listIndex - 1; - } - - _listIndexFromPresetIndex(listIndex) { - return listIndex + 1; - } - - _maxListIndex() { - // Number of presets, plus one for the "Load Local Directory" option, minus - // one to convert a length to an index. - return this.props.presetKeys.length; // - 1 + 1; - } - - async loadLocalDir() { - const fileReferences = await promptForFileReferences({ directory: true }); - // TODO: Technically there is a race condition here, since the component - // could get unmounted before the promise resolves. - this.props.loadPresets(fileReferences); - } - - _renderList() { - const { presetKeys, currentPreset, height, width } = this.props; - const { selectedListIndex } = this.state; - - const maxVisibleRows = Math.floor((height - HEIGHT_PADDING) / ENTRY_HEIGHT); - const rowsToShow = Math.floor(maxVisibleRows * 0.75); // Only fill 3/4 of the screen. - const [startIndex, endIndex] = getRangeCenteredOnIndex( - this._maxListIndex() + 1, // Add one to convert an index to a length - rowsToShow, - selectedListIndex - ); - - const presetElms = []; - for (let i = startIndex; i <= endIndex; i++) { - const presetIndex = this._presetIndexFromListIndex(i); - const isSelected = i === selectedListIndex; - const isCurrent = presetIndex === currentPreset; - let color; - if (isSelected) { - color = isCurrent ? "#FFCC22" : "#FF5050"; - } else { - color = isCurrent ? "#CCFF03" : "#CCCCCC"; - } - presetElms.push( -
  • - {i === 0 ? "Load Local Directory" : presetKeys[presetIndex]} -
  • - ); - } - - return ( - - {presetElms} - - ); - } - - render() { - return this.props.presetKeys != null ? ( - this._renderList() - ) : ( - - ); - } -} - -// Find a tuple `[startIndex, endIndex]` representing start/end indexes into an -// array of length `length`, that descripe a range of size up to `rangeSize` -// where a best effort is made to center `indexToCenter`. -export function getRangeCenteredOnIndex(length, maxRangeSize, indexToCenter) { - const rangeSize = Math.min(length, maxRangeSize); - const halfRangeSize = Math.floor(rangeSize / 2); - const idealStartIndex = indexToCenter - halfRangeSize; - const startIndex = clamp(idealStartIndex, 0, length - rangeSize); - const endIndex = startIndex + rangeSize - 1; - return [startIndex, endIndex]; -} - -export default PresetOverlay; diff --git a/js/components/MilkdropWindow/PresetOverlay.tsx b/js/components/MilkdropWindow/PresetOverlay.tsx new file mode 100644 index 00000000..6ad9ad00 --- /dev/null +++ b/js/components/MilkdropWindow/PresetOverlay.tsx @@ -0,0 +1,210 @@ +import React from "react"; +import { promptForFileReferences } from "../../fileUtils"; +import * as Selectors from "../../selectors"; +import * as Actions from "../../actionCreators"; +import { clamp } from "../../utils"; +import { AppState, Dispatch, TransitionType } from "../../types"; +import { connect } from "react-redux"; + +const ENTRY_HEIGHT = 14; +const HEIGHT_PADDING = 15; +const WIDTH_PADDING = 20; + +const LOADING_STYLE: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + color: "white", + background: "rgba(0.33, 0.33, 0.33, 0.33)" +}; + +const OUTER_WRAPPER_STYLE: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + padding: "15px 10px 0 10px" +}; + +const INNER_WRAPPER_STYLE: React.CSSProperties = { + display: "inline-block", + whiteSpace: "nowrap", + overflow: "hidden", + background: "rgba(0, 0, 0, 0.815)", + fontSize: "12px" +}; + +interface State { + selectedListIndex: number; +} + +interface StateProps { + presetKeys: string[]; + currentPresetIndex: number | null; // Index +} + +interface DispatchProps { + requestPresetAtIndex(i: number): void; + togglePresetOverlay(): void; +} + +interface OwnProps { + height: number; + width: number; +} + +type Props = StateProps & DispatchProps & OwnProps; + +class PresetOverlay extends React.Component { + constructor(props: Props) { + super(props); + this.state = { selectedListIndex: 0 }; + } + + _presetIndexFromListIndex(listIndex: number) { + return listIndex - 1; + } + + _listIndexFromPresetIndex(listIndex: number) { + return listIndex + 1; + } + + _maxListIndex() { + // Number of presets, plus one for the "Load Local Directory" option, minus + // one to convert a length to an index. + return this.props.presetKeys.length; // - 1 + 1; + } + + _renderList() { + const { presetKeys, currentPresetIndex, height } = this.props; + const { selectedListIndex } = this.state; + + const maxVisibleRows = Math.floor((height - HEIGHT_PADDING) / ENTRY_HEIGHT); + const rowsToShow = Math.floor(maxVisibleRows * 0.75); // Only fill 3/4 of the screen. + const [startIndex, endIndex] = getRangeCenteredOnIndex( + this._maxListIndex() + 1, // Add one to convert an index to a length + rowsToShow, + selectedListIndex + ); + + const presetElms = []; + for (let i = startIndex; i <= endIndex; i++) { + const presetIndex = this._presetIndexFromListIndex(i); + const isSelected = i === selectedListIndex; + const isCurrent = presetIndex === currentPresetIndex; + let color: string; + if (isSelected) { + color = isCurrent ? "#FFCC22" : "#FF5050"; + } else { + color = isCurrent ? "#CCFF03" : "#CCCCCC"; + } + presetElms.push( +
  • + {i === 0 ? "Load Local Directory" : presetKeys[presetIndex]} +
  • + ); + } + + return presetElms; + } + + _handleFocusedKeyboardInput = (e: React.KeyboardEvent) => { + switch (e.keyCode) { + case 38: // up arrow + this.setState({ + selectedListIndex: Math.max(this.state.selectedListIndex - 1, 0) + }); + e.stopPropagation(); + break; + case 40: // down arrow + this.setState({ + selectedListIndex: Math.min( + this.state.selectedListIndex + 1, + this._maxListIndex() + ) + }); + e.stopPropagation(); + break; + case 13: // enter + if (this.state.selectedListIndex === 0) { + this.loadLocalDir(); + } else { + this.props.requestPresetAtIndex( + this._presetIndexFromListIndex(this.state.selectedListIndex) + ); + } + e.stopPropagation(); + break; + case 27: // escape + this.props.togglePresetOverlay(); + e.stopPropagation(); + break; + } + }; + + render() { + const { height, width } = this.props; + if (this.props.presetKeys == null) { + return ( +
    + Loading presets +
    + ); + } + return ( +
    +
    +
      + {this._renderList()} +
    +
    +
    + ); + } +} + +// Find a tuple `[startIndex, endIndex]` representing start/end indexes into an +// array of length `length`, that descripe a range of size up to `rangeSize` +// where a best effort is made to center `indexToCenter`. +export function getRangeCenteredOnIndex( + length: number, + maxRangeSize: number, + indexToCenter: number +): [number, number] { + const rangeSize = Math.min(length, maxRangeSize); + const halfRangeSize = Math.floor(rangeSize / 2); + const idealStartIndex = indexToCenter - halfRangeSize; + const startIndex = clamp(idealStartIndex, 0, length - rangeSize); + const endIndex = startIndex + rangeSize - 1; + return [startIndex, endIndex]; +} + +function mapStateToProps(state: AppState): StateProps { + return { + presetKeys: Selectors.getPresetNames(state), + currentPresetIndex: Selectors.getCurrentPresetIndex(state) + }; +} + +function mapDispatchToProps(dispatch: Dispatch): DispatchProps { + return { + requestPresetAtIndex: (i: number) => { + dispatch(Actions.requestPresetAtIndex(i, TransitionType.DEFAULT)); + }, + togglePresetOverlay: () => dispatch(Actions.togglePresetOverlay()) + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(PresetOverlay); diff --git a/js/components/MilkdropWindow/Presets.js b/js/components/MilkdropWindow/Presets.js deleted file mode 100644 index 12a83758..00000000 --- a/js/components/MilkdropWindow/Presets.js +++ /dev/null @@ -1,158 +0,0 @@ -function getRandomIndex(arr) { - return Math.floor(Math.random() * arr.length); -} -function getRandomValue(arr) { - return arr[getRandomIndex(arr)]; -} - -function getLast(arr) { - return arr[arr.length - 1]; -} - -async function readFileAsText(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = function(e) { - resolve(e.target.result); - }; - reader.onerror = function(e) { - reject(e); - }; - - reader.readAsText(file); - }); -} - -/** - * Track a collection of async loaded presets - * - * Presets can be changed via `next`, `previous` or `selectIndex`. In each case, - * a promise is returned. If the promise resolves to `null` it means the - * selection was canceled by a subsequent request before it could be fulfilled. - * If the request is successful, the promise resolves to the selected preset. - * - * We assume a model where some portion of the preset are supplied at initialization - * and the remainder can be loaded async via the function `getRest`. - */ -export default class Presets { - constructor({ - keys, - initialPresets, - getRest, - presetConverterEndpoint, - loadConvertPreset, - randomize = true - }) { - this._keys = keys; // Alphabetical list of preset names - this._presets = initialPresets; // Presets indexed by name - this._getRest = getRest; // An async function to get the rest of the presets - this._presetConverterEndpoint = presetConverterEndpoint; - this._loadConvertPreset = loadConvertPreset; - this._history = []; // Indexes into _keys - - this._randomize = randomize; - - // Initialize with a key that we already have. - const avaliableKeys = Object.keys(initialPresets); - const currentKey = randomize - ? getRandomValue(avaliableKeys) - : avaliableKeys[0]; - this._currentIndex = this._keys.indexOf(currentKey); - this._history.push(this._currentIndex); - } - - toggleRandomize() { - this._randomize = !this._randomize; - } - - setRandomize(val) { - this._randomize = val; - } - - addPresets(presets) { - const startIdx = this._keys.length; - this._keys = this._keys.concat(Object.keys(presets)); - const endIndx = this._keys.length; - - this._presets = Object.assign(this._presets, presets); - - return [startIdx, endIndx]; - } - - loadPresets(presets) { - this._keys = Object.keys(presets); - this._presets = presets; - this._history = []; - - return this._keys.length; - } - - async next() { - let idx; - if (this._randomize || this._history.length === 0) { - idx = getRandomIndex(this._keys); - } else { - idx = (getLast(this._history) + 1) % this._keys.length; - } - this._history.push(idx); - return this._selectIndex(idx); - } - - async previous() { - if (this._history.length > 1) { - this._history.pop(); - return this._selectIndex(getLast(this._history)); - } - // We are at the very beginning. There is no "previous" preset. - return Promise.resolve(); - } - - async selectIndex(idx) { - // The public version of this method must add to the history - this._history.push(idx); - return this._selectIndex(idx); - } - - async _convertPreset(file) { - const convertPreset = await this._loadConvertPreset(); - return convertPreset(file, this._presetConverterEndpoint); - } - - async _selectIndex(idx) { - const preset = this._presets[this._keys[idx]]; - if (!preset) { - const rest = await this._getRest(); - this._presets = Object.assign(this._presets, rest); - if (getLast(this._history) !== idx) { - // This selection must be obsolete. Return null so that - // the caller knows this request got canceled. - return null; - } - } - if (preset && preset.file) { - try { - const fileContents = await readFileAsText(preset.file); - const convertedPreset = await this._convertPreset(fileContents); - this._presets[this._keys[idx]] = convertedPreset; - } catch (e) { - console.error(e); - alert(`Unable to convert MilkDrop preset ${this._keys[idx]}`); - } - } - this._currentIndex = idx; - return this.getCurrent(); - } - - getKeys() { - return this._keys; - } - - getCurrentIndex() { - return this._currentIndex; - } - - getCurrent() { - // #matryoshka - return this._presets[this._keys[this._currentIndex]]; - } -} diff --git a/js/components/MilkdropWindow/Visualizer.tsx b/js/components/MilkdropWindow/Visualizer.tsx index 5d90ed89..6737a7f3 100644 --- a/js/components/MilkdropWindow/Visualizer.tsx +++ b/js/components/MilkdropWindow/Visualizer.tsx @@ -9,7 +9,7 @@ interface StateProps { isEnabledVisualizer: boolean; playing: boolean; butterchurn: any; - trackTitle: string; + trackTitle: string | null; currentPreset: any; transitionType: TransitionType; } @@ -68,6 +68,7 @@ function Visualizer(props: Props) { // Load presets when they change useEffect(() => { + console.log("Preset change", props); if (visualizer == null || props.currentPreset == null) { return; } @@ -92,7 +93,7 @@ function Visualizer(props: Props) { if (!shouldAnimate || visualizer == null) { return; } - let animationFrameRequest = null; + let animationFrameRequest: number | null = null; const loop = () => { visualizer.render(); animationFrameRequest = window.requestAnimationFrame(loop); diff --git a/js/components/MilkdropWindow/index.tsx b/js/components/MilkdropWindow/index.tsx index 042b4761..52c88bdb 100644 --- a/js/components/MilkdropWindow/index.tsx +++ b/js/components/MilkdropWindow/index.tsx @@ -14,20 +14,24 @@ import Visualizer from "./Visualizer"; import "../../../css/milkdrop-window.css"; import Background from "./Background"; +import PresetOverlay from "./PresetOverlay"; + +const MILLISECONDS_BETWEEN_PRESET_TRANSITIONS = 15000; interface StateProps { desktop: boolean; + overlay: boolean; } interface DispatchProps { closeWindow(): void; toggleDesktop(): void; + togglePresetOverlay(): void; + selectRandomPreset(): void; } interface OwnProps { analyser: AnalyserNode; - height: number; - width: number; onFocusedKeyDown(cb: (e: KeyboardEvent) => void): () => void; } @@ -51,7 +55,7 @@ function Milkdrop(props: Props) { // this.props.presets.toggleRandomize(); break; case 76: // L - // this.setState({ presetOverlay: !this.state.presetOverlay }); + props.togglePresetOverlay(); e.stopPropagation(); break; case 84: // T @@ -66,10 +70,20 @@ function Milkdrop(props: Props) { } }); }, [props.onFocusedKeyDown]); + + // Cycle presets + useEffect(() => { + const intervalId = setInterval( + props.selectRandomPreset, + MILLISECONDS_BETWEEN_PRESET_TRANSITIONS + ); + return () => clearImmediate(intervalId); + }, [props.selectRandomPreset]); return ( - {({ height, width }) => ( + {({ height, width }: { width: number; height: number }) => ( + {props.overlay && } )} @@ -78,12 +92,15 @@ function Milkdrop(props: Props) { } const mapStateToProps = (state: AppState): StateProps => ({ - desktop: Selectors.getMilkdropDesktopEnabled(state) + desktop: Selectors.getMilkdropDesktopEnabled(state), + overlay: Selectors.getPresetOverlayOpen(state) }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ closeWindow: () => dispatch(Actions.closeWindow(WINDOWS.MILKDROP)), - toggleDesktop: () => dispatch(Actions.toggleMilkdropDesktop()) + toggleDesktop: () => dispatch(Actions.toggleMilkdropDesktop()), + togglePresetOverlay: () => dispatch(Actions.togglePresetOverlay()), + selectRandomPreset: () => dispatch(Actions.selectRandomPreset()) }); export default connect( diff --git a/js/reducers/milkdrop.ts b/js/reducers/milkdrop.ts index f24950bf..29c05ae4 100644 --- a/js/reducers/milkdrop.ts +++ b/js/reducers/milkdrop.ts @@ -1,45 +1,20 @@ -import { Action } from "../types"; +import { Action, PresetId, Preset } from "../types"; import { SET_MILKDROP_DESKTOP, - INITIALIZE_PRESETS, - GOT_BUTTERCHURN + GOT_BUTTERCHURN_PRESETS, + GOT_BUTTERCHURN, + RESOLVE_PRESET_AT_INDEX, + SELECT_PRESET_AT_INDEX, + TOGGLE_PRESET_OVERLAY } from "../actionTypes"; +import * as Utils from "../utils"; 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; + overlay: boolean; presetOrder: PresetId[]; - presetDefinitions: { - [presetId: string]: PresetDefinition | LazyPresetDefinition; - }; + presets: Preset[]; currentPresetIndex: number | null; butterchurn: any; transitionType: TransitionType; @@ -47,8 +22,9 @@ export interface MilkdropState { const defaultMilkdropState = { desktop: false, + overlay: false, presetOrder: [], - presetDefinitions: {}, + presets: [], currentPresetIndex: null, butterchurn: null, transitionType: TransitionType.DEFAULT @@ -63,16 +39,39 @@ export const milkdrop = ( return { ...state, desktop: action.enabled }; case GOT_BUTTERCHURN: return { ...state, butterchurn: action.butterchurn }; - case "GOT_BUTTERCHUN_PRESET": - const id = "TEMP"; + case GOT_BUTTERCHURN_PRESETS: return { ...state, - presetDefinitions: { - [id]: action.json - }, - currentPresetIndex: 0, - presetOrder: [id] + presets: state.presets.concat( + action.presets.map(preset => { + switch (preset.type) { + case "BUTTERCHURN_JSON": + case "LAZY_BUTTERCHURN_JSON": + return preset; + default: + throw new Error(`Invalid preset type: ${preset.type}`); + } + }) + ) }; + case RESOLVE_PRESET_AT_INDEX: + const preset = state.presets[action.index]; + return { + ...state, + presets: Utils.replaceAtIndex(state.presets, action.index, { + name: preset.name, + type: "BUTTERCHURN_JSON", + definition: action.json + }) + }; + case SELECT_PRESET_AT_INDEX: + return { + ...state, + currentPresetIndex: action.index, + transitionType: action.transitionType + }; + case TOGGLE_PRESET_OVERLAY: + return { ...state, overlay: !state.overlay }; default: return state; } diff --git a/js/selectors.ts b/js/selectors.ts index 609e3230..33d5d327 100644 --- a/js/selectors.ts +++ b/js/selectors.ts @@ -626,10 +626,25 @@ export function getPresetTransitionType(state: AppState): TransitionType { return state.milkdrop.transitionType; } +export function getCurrentPresetIndex(state: AppState): number | null { + return state.milkdrop.currentPresetIndex; +} export function getCurrentPreset(state: AppState): any | null { - const { currentPresetIndex, presetOrder, presetDefinitions } = state.milkdrop; - if (currentPresetIndex == null) { + const index = getCurrentPresetIndex(state); + if (index == null) { return null; } - return presetDefinitions[presetOrder[currentPresetIndex]]; + const preset = state.milkdrop.presets[index]; + if (preset == null || preset.type !== "BUTTERCHURN_JSON") { + return null; + } + return preset.definition; +} + +export function getPresetNames(state: AppState): string[] { + return state.milkdrop.presets.map(preset => preset.name); +} + +export function getPresetOverlayOpen(state: AppState): boolean { + return state.milkdrop.overlay; } diff --git a/js/types.ts b/js/types.ts index 7f8ac4f4..376418d3 100644 --- a/js/types.ts +++ b/js/types.ts @@ -131,6 +131,41 @@ export interface ButterchurnOptions { initialButterchurnPresetUrl?: string | null; } +// This is what we actually pass to butterchurn +type ButterchurnPresetJson = { + type: "BUTTERCHURN_JSON"; + name: string; + definition: Object; +}; + +type LazyButterchurnPresetJson = { + type: "LAZY_BUTTERCHURN_JSON"; + name: string; + getDefinition: () => Promise; +}; + +// 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; +} + +export type PresetDefinition = + | ButterchurnPresetJson + | LazyButterchurnPresetJson + | ButterchurnPresetUrl + | MilkdropPresetUrl; + +export type Preset = ButterchurnPresetJson | LazyButterchurnPresetJson; + +export type PresetId = string; + export interface EqfPreset { name: string; hz60: number; @@ -447,13 +482,24 @@ export type Action = enabled: boolean; } | { - type: "INITIALIZE_PRESETS"; - presets: any; + type: "GOT_BUTTERCHURN_PRESETS"; + presets: PresetDefinition[]; } | { type: "GOT_BUTTERCHURN"; butterchurn: any; - }; + } + | { + type: "RESOLVE_PRESET_AT_INDEX"; + index: number; + json: Object; + } + | { + type: "SELECT_PRESET_AT_INDEX"; + index: number; + transitionType: TransitionType; + } + | { type: "TOGGLE_PRESET_OVERLAY" }; export type MediaTagRequestStatus = | "INITIALIZED" diff --git a/js/utils.test.ts b/js/utils.test.ts index 5168a9a9..d2d49359 100644 --- a/js/utils.test.ts +++ b/js/utils.test.ts @@ -12,7 +12,8 @@ import { moveSelected, spliceIn, getFileExtension, - makeCachingFilterFunction + makeCachingFilterFunction, + replaceAtIndex } from "./utils"; const fixture = (filename: string) => @@ -463,3 +464,9 @@ describe("makeCachingFilterFunction", () => { expect(newComparisons()).toBe(19); }); }); + +describe("replaceAtIndex", () => { + test("can replace", () => { + expect(replaceAtIndex([1, 2, 3, 4], 2, 0)).toEqual([1, 2, 0, 4]); + }); +}); diff --git a/js/utils.ts b/js/utils.ts index 3703f58c..b46f1a55 100644 --- a/js/utils.ts +++ b/js/utils.ts @@ -253,6 +253,10 @@ export function spliceIn(original: T[], start: number, newValues: T[]): T[] { return newArr; } +export function replaceAtIndex(arr: T[], index: number, newValue: T): T[] { + return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)]; +} + export function debounce(func: Function, delay: number): Function { let timeout: number; let callbackArgs: any[] = [];