Skip to content

Commit

Permalink
refactor: Drop enhancer API in favor of separate middlewares+reducer
Browse files Browse the repository at this point in the history
BREAKING CHANGE: makeMidiEnhancer has been removed. It became increasingly difficult to reason about its correctness with respect to other enhancers and particularly user-specified middleware. The new API is a little more verbose but allows the user to place MIDI I/O explicitly in the middleware chain as well as explicitly mount the required reducer into their state tree.
Tests, documentation and the bare-input example are up-to-date.
  • Loading branch information
motiz88 committed Jun 12, 2016
1 parent 1b34ff1 commit afe9a1b
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 202 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

[API documentation][doc-url] (up-to-date with `master`)

This module provides a store enhancer and a set of action creators wrapping the Web MIDI API for use in Redux apps.
This module provides middleware functions, a reducer and a set of action creators wrapping the Web MIDI API for use in Redux apps.

* The list of MIDI devices is kept up-to-date in the state tree for your own reducers to use; updates are sent via the `RECEIVE_DEVICE_LIST` action.
* Dispatch a `SEND_MIDI_MESSAGE` action with a device ID, MIDI data and optional timestamp, and it will be sent.
Expand Down
35 changes: 16 additions & 19 deletions examples/bare-input/configureStore.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
import { createStore, applyMiddleware, compose } from 'redux';
import createLogger from 'redux-logger';
import { makeMidiEnhancer } from '../../src';
import setup, {reducer as midiReducer, RECEIVE_MIDI_MESSAGE} from '../../src';
import {createStore, applyMiddleware, compose, combineReducers} from 'redux';

const logger = createLogger();
export default function configureStore () {
const rootReducer = combineReducers({
midi: midiReducer,
midiMessages: (state, action) => {
if (!state) state = [];
if (action.type === RECEIVE_MIDI_MESSAGE) {
state = [action.payload, ...state];
}
return state;
}
});

export default function configureStore (initialState, reducer) {
let actions = [];
const {inputMiddleware, outputMiddleware} = setup({midiOptions: {sysex: true}});

function collector ({getState}) {
return (next) => (action) => {
actions.push(action);
return next(action);
};
}
const middleware = [collector, logger];

const store = createStore(reducer || (state => state), initialState, compose(
makeMidiEnhancer({midiOptions: {sysex: true}}),
applyMiddleware(...middleware),
return createStore(rootReducer, compose(
applyMiddleware(inputMiddleware, outputMiddleware),
global.devToolsExtension ? global.devToolsExtension() : f => f
));

return store;
}
13 changes: 2 additions & 11 deletions examples/bare-input/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import configureStore from './configureStore';
import {setListeningDevices, RECEIVE_MIDI_MESSAGE} from '../../src';

const store = configureStore({}, (state, action) => {
if (!state.midiMessages) {
state = {...state, midiMessages: []};
}
if (action.type === RECEIVE_MIDI_MESSAGE) {
state = {...state, midiMessages: [action.payload, ...state.midiMessages]};
}
return state;
});
import { setListeningDevices } from '../../src';
const store = configureStore();

const deviceList = document.createElement('pre');
const messageLog = document.createElement('pre');
Expand Down
207 changes: 103 additions & 104 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,16 @@ const initialState = {
devices: [],
listeningDevices: []
};

const reducer = myDuck.createReducer({
/**
* Reduces MIDI I/O and device discovery actions to state changes.
* Maintains state in two keys: devices (an array of device objects straight from the underlying Web MIDI API) and `listeningDevices` (an array of device IDs being listened to).
* @example
* import { createStore, applyMiddleware, combineReducers } from 'redux';
* import setup, { reducer } from 'redux-midi';
* const {inputMiddleware, outputMiddleware} = setup();
* const store = createStore(combineReducers({midi: reducer}), initialState, applyMiddleware(inputMiddleware, outputMiddleware));
*/
export const reducer = myDuck.createReducer({
[RECEIVE_DEVICE_LIST]: (state, action) => ({
...state,
devices: action.payload
Expand All @@ -54,11 +62,6 @@ const reducer = myDuck.createReducer({
})
}, initialState);

export { reducer };
export default reducer;

import observeStore from './observeStore';

import sortBy from 'lodash.sortby';
import deepEqual from 'deep-equal';

Expand All @@ -67,113 +70,109 @@ const defaultRequestMIDIAccess = (global && global.navigator && global.navigator
);

/**
* Create a Redux {@link https://github.com/reactjs/redux/blob/master/docs/Glossary.md#store-enhancer|store enhancer} wrapping MIDI I/O and device discovery.
* Create a pair of Redux {@link https://github.com/reactjs/redux/blob/master/docs/Glossary.md#middleware|middleware} functions wrapping MIDI I/O and device discovery.
* The input middleware dispatches RECEIVE_DEVICE_LIST whenever the list of MIDI devices changes and RECEIVE_MIDI_MESSAGE when MIDI messages are received on devices that are being listened to.
* The output middleware sends MIDI messages to output devices as a side effect of SEND_MIDI_MESSAGE actions.
* @param {MIDIOptions} [$0.midiOptions] - Options with which to invoke `requestMIDIAccess`.
* @param {string} [$0.stateKey='midi'] - The key under which the enhancer will store MIDI device information in the state.
* @param {string} [$0.stateKey='midi'] - The state key at which redux-midi's reducer is mounted.
* @param {function(MIDIOptions): Promise<MIDIAccess>} [$0.requestMIDIAccess=navigator.requestMIDIAccess] - Web MIDI API entry point.
* @example
* // Basic usage
* import { createStore } from 'redux';
* import { makeMidiEnhancer } from 'redux-midi';
* const store = createStore(reducer, initialState, makeMidiEnhancer());
* @example
* // With middleware
* import { createStore, applyMiddleware, compose } from 'redux';
* import { makeMidiEnhancer } from 'redux-midi';
* // assuming middleware is an array of Redux middleware functions
* const store = createStore(reducer, initialState, compose(
* makeMidiEnhancer({midiOptions: {sysex: true}}),
* applyMiddleware(...middleware)
* ));
* import { createStore, applyMiddleware, combineReducers } from 'redux';
* import setup, { reducer } from 'redux-midi';
* const {inputMiddleware, outputMiddleware} = setup();
* const store = createStore(combineReducers({midi: reducer}), initialState, applyMiddleware(inputMiddleware, outputMiddleware));
*/
export const makeMidiEnhancer = ({midiOptions, stateKey = 'midi', requestMIDIAccess = defaultRequestMIDIAccess} = {}) => next => (userReducer, preloadedState) => {
let midiAccess = null;

const enhancedReducer = (state = {}, action) => {
const midiState = state[stateKey];
const nextMidiState = reducer(midiState, action);

const nextState = userReducer(state, action) || {};
if (nextMidiState !== nextState[stateKey]) {
return {...nextState, [stateKey]: nextMidiState};
}
return nextState;
export default function setup ({midiOptions, stateKey = 'midi', requestMIDIAccess = defaultRequestMIDIAccess}) {
let midiAccess;
const requestMIDIAccessOnce = () => {
if (midiAccess) return Promise.resolve(midiAccess);

midiAccess = requestMIDIAccess(midiOptions)
.then(receivedMidiAccess => {
midiAccess = receivedMidiAccess;
return midiAccess;
});
return midiAccess;
};

const store = next(enhancedReducer, preloadedState);

const enhancedStoreMethods = {
dispatch (action) {
action = store.dispatch(action);
if (action.type === SEND_MIDI_MESSAGE) {
const {payload} = action;
const {timestamp, data, device} = payload;
if (midiAccess) {
const {outputs} = midiAccess;
if (outputs.has(device)) {
outputs.get(device).send(data, timestamp);
const inputMiddleware = ({dispatch, getState}) => {
return next => {
requestMIDIAccessOnce().then(() => {
const sendDeviceList = () => {
const devices = sortBy([...midiAccess.inputs.values(), ...midiAccess.outputs.values()].map(device => ({
id: device.id,
manufacturer: device.manufacturer,
name: device.name,
type: device.type,
version: device.version,
state: device.state,
connection: device.connection
})), 'id');
if (!deepEqual(devices, getState()[stateKey].devices)) {
dispatch(receiveDeviceList(devices));
}
};

midiAccess.onstatechange = () => sendDeviceList();
Promise.resolve()
.then(sendDeviceList);
});

return action => {
const toListen = [];
const toUnlisten = [];
const prevState = getState()[stateKey] || initialState;
action = next(action);
const state = getState()[stateKey];

if (state.listeningDevices !== prevState.listeningDevices) {
let prev = new Set(prevState ? prevState.listeningDevices : []);
let next = new Set(state.listeningDevices);
toUnlisten.push(...prevState.listeningDevices.filter(dev => !next.has(dev)));
toListen.push(...state.listeningDevices.filter(dev => !prev.has(dev)));
}
}
return action;
}
};

requestMIDIAccess(midiOptions).then(receivedMidiAccess => {
midiAccess = receivedMidiAccess;

const sendDeviceList = () => {
const devices = sortBy([...midiAccess.inputs.values(), ...midiAccess.outputs.values()].map(device => ({
id: device.id,
manufacturer: device.manufacturer,
name: device.name,
type: device.type,
version: device.version,
state: device.state,
connection: device.connection
})), 'id');
if (!deepEqual(devices, store.getState()[stateKey].devices)) {
store.dispatch(receiveDeviceList(devices));
}
};

midiAccess.onstatechange = () => sendDeviceList();

observeStore(store, state => state[stateKey], (state, prevState) => {
let toUnlisten = [];
let toListen = [];

if (!prevState) prevState = initialState;

if (state.listeningDevices !== prevState.listeningDevices) {
let prev = new Set(prevState ? prevState.listeningDevices : []);
let next = new Set(state.listeningDevices);
toUnlisten.push(...prevState.listeningDevices.filter(dev => !next.has(dev)));
toListen.push(...state.listeningDevices.filter(dev => !prev.has(dev)));
}
if (state.devices !== prevState.devices) {
let prev = new Set(prevState.devices.map(device => device.id));
let next = new Set(state.devices.map(device => device.id));
toListen.push(...state.listeningDevices.filter(device => midiAccess.inputs.has(device) && next.has(device) && !prev.has(device)));
}

if (state.devices !== prevState.devices) {
let prev = new Set(prevState.devices.map(device => device.id));
let next = new Set(state.devices.map(device => device.id));
toListen.push(...state.listeningDevices.filter(device => midiAccess.inputs.has(device) && next.has(device) && !prev.has(device)));
}
for (let device of toUnlisten) {
if (midiAccess.inputs.has(device)) {
midiAccess.inputs.get(device).onmidimessage = null;
}
}

for (let device of toUnlisten) {
if (midiAccess.inputs.has(device)) {
midiAccess.inputs.get(device).onmidimessage = null;
for (let device of toListen) {
if (midiAccess.inputs.has(device)) {
midiAccess.inputs.get(device).onmidimessage = ({receivedTime, timeStamp, timestamp, data}) => {
timestamp = [receivedTime, timeStamp, timestamp].filter(x => x !== undefined)[0];
dispatch(receiveMidiMessage({ timestamp, data, device }));
};
}
}
}

for (let device of toListen) {
if (midiAccess.inputs.has(device)) {
midiAccess.inputs.get(device).onmidimessage = ({receivedTime, timeStamp, timestamp, data}) => {
timestamp = [receivedTime, timeStamp, timestamp].filter(x => x !== undefined)[0];
store.dispatch(receiveMidiMessage({ timestamp, data, device }));
};
return action;
};
};
};

const outputMiddleware = () => next => action => {
action = next(action);
if (action.type === SEND_MIDI_MESSAGE) {
const {payload} = action;
const {timestamp, data, device} = payload;
const send = () => {
const {outputs} = midiAccess;
if (outputs.has(device)) {
outputs.get(device).send(data, timestamp);
}
}
});
sendDeviceList();
});
return {...store, ...enhancedStoreMethods};
};
};
if (midiAccess && !midiAccess.then) send();
else requestMIDIAccessOnce().then(send);
}
return action;
};

return { inputMiddleware, outputMiddleware };
}
6 changes: 3 additions & 3 deletions src/observeStore.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
export default function observeStore (store, select, onChange) {
export default function observeStore ({getState, subscribe}, select, onChange) {
let currentState;

function handleChange () {
const nextState = select(store.getState());
const nextState = select(getState());
if (nextState !== currentState) {
const prevState = currentState;
currentState = nextState;
onChange(currentState, prevState);
}
}

let unsubscribe = store.subscribe(handleChange);
let unsubscribe = subscribe(handleChange);
handleChange();
return unsubscribe;
}
16 changes: 0 additions & 16 deletions test/specs/makeMidiEnhancer.js

This file was deleted.

Loading

0 comments on commit afe9a1b

Please sign in to comment.