[WIP] Milkdrop rewrite

This commit is contained in:
Jordan Eldredge 2019-01-24 20:48:41 -08:00
parent 5a4fcf47a9
commit d1cfc707ff
17 changed files with 508 additions and 452 deletions

View file

@ -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";

View file

@ -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 };
}

View file

@ -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";

View file

@ -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 (
<div
@ -17,9 +22,10 @@ const Background = props => {
height: "100%",
width: "100%"
}}
tabIndex="0"
{...restProps}
/>
tabIndex={0}
>
{props.children}
</div>
);
};

View file

@ -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;

View file

@ -1,22 +0,0 @@
import React from "react";
import { Hr, Node } from "../ContextMenu";
const MilkdropContextMenu = props => (
<React.Fragment>
<Node
onClick={props.toggleFullscreen}
label="Fullscreen"
hotkey="Alt+Enter"
/>
<Node
onClick={props.toggleDesktop}
checked={props.desktopMode}
label="Desktop Mode"
hotkey="Alt+D"
/>
<Hr />
<Node onClick={props.close} label="Quit" />
</React.Fragment>
);
export default MilkdropContextMenu;

View file

@ -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) => (
<ContextMenuWraper
onDoubleClick={props.toggleFullscreen}
renderContents={() => {
<>
<Node
onClick={props.toggleFullscreen}
label="Fullscreen"
hotkey="Alt+Enter"
/>
<Node
onClick={props.toggleDesktop}
checked={props.desktop}
label="Desktop Mode"
hotkey="Alt+D"
/>
<Hr />
<Node onClick={props.closeWindow} label="Quit" />
</>;
}}
/>
);
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);

View file

@ -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 = () => (
<div
style={{
position: "absolute",
top: 0,
left: 0,
color: "white",
background: "rgba(0.33, 0.33, 0.33, 0.33)"
}}
>
<span>Loading presets</span>
</div>
);
const ListWrapper = ({ width, height, children }) => (
<div
style={{
position: "absolute",
top: 0,
left: 0,
padding: "15px 10px 0 10px"
}}
>
<div
style={{
display: "inline-block",
width: `${width - WIDTH_PADDING}px`,
maxHeight: `${height - HEIGHT_PADDING}px`,
whiteSpace: "nowrap",
overflow: "hidden",
background: "rgba(0, 0, 0, 0.815)",
fontSize: "12px"
}}
>
<ul style={{ listStyleType: "none", padding: 0, margin: 0 }}>
{children}
</ul>
</div>
</div>
);
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(
<li key={i} style={{ color, lineHeight: `${ENTRY_HEIGHT}px` }}>
{i === 0 ? "Load Local Directory" : presetKeys[presetIndex]}
</li>
);
}
return (
<ListWrapper width={width - 20} height={height}>
{presetElms}
</ListWrapper>
);
}
render() {
return this.props.presetKeys != null ? (
this._renderList()
) : (
<LoadingState />
);
}
}
// 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;

View file

@ -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<Props, State> {
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(
<li key={i} style={{ color, lineHeight: `${ENTRY_HEIGHT}px` }}>
{i === 0 ? "Load Local Directory" : presetKeys[presetIndex]}
</li>
);
}
return presetElms;
}
_handleFocusedKeyboardInput = (e: React.KeyboardEvent<HTMLDivElement>) => {
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 (
<div style={LOADING_STYLE}>
<span>Loading presets</span>
</div>
);
}
return (
<div
style={OUTER_WRAPPER_STYLE}
onKeyDown={this._handleFocusedKeyboardInput}
tabIndex={-1}
>
<div
style={{
...INNER_WRAPPER_STYLE,
width: width - 20 - WIDTH_PADDING,
maxHeight: height - HEIGHT_PADDING
}}
>
<ul style={{ listStyleType: "none", padding: 0, margin: 0 }}>
{this._renderList()}
</ul>
</div>
</div>
);
}
}
// 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);

View file

@ -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]];
}
}

View file

@ -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);

View file

@ -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 (
<GenWindow title={"Milkdrop"} windowId={WINDOWS.MILKDROP}>
{({ height, width }) => (
{({ height, width }: { width: number; height: number }) => (
<Background>
{props.overlay && <PresetOverlay width={width} height={height} />}
<Visualizer width={width} height={height} analyser={props.analyser} />
</Background>
)}
@ -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(

View file

@ -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<PresetDefinition>;
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;
}

View file

@ -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;
}

View file

@ -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<Object>;
};
// 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"

View file

@ -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]);
});
});

View file

@ -253,6 +253,10 @@ export function spliceIn<T>(original: T[], start: number, newValues: T[]): T[] {
return newArr;
}
export function replaceAtIndex<T>(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[] = [];