Add remote server sink/source tunneling

This commit is contained in:
futpib 2018-11-23 02:45:12 +03:00
parent 558f0b37e1
commit ac4b6d42b7
16 changed files with 479 additions and 221 deletions

View File

@ -1,20 +1,39 @@
const { map } = require('ramda');
const { createActions: createActionCreators } = require('redux-actions');
const withMetaPulseServerId = payloadCreator => {
const metaCreator = (...args) => ({
pulseServerId: args[payloadCreator.length],
});
return [
payloadCreator,
metaCreator,
];
};
const noop = () => null;
const identity = x => x;
module.exports = createActionCreators({
PULSE: {
READY: null,
CLOSE: null,
PULSE: map(withMetaPulseServerId, {
READY: noop,
CLOSE: noop,
ERROR: null,
CONNECT: noop,
DISCONNECT: noop,
NEW: null,
CHANGE: null,
REMOVE: null,
ERROR: identity,
INFO: null,
NEW: identity,
CHANGE: identity,
REMOVE: identity,
SERVER_INFO: null,
INFO: identity,
SERVER_INFO: identity,
MOVE_SINK_INPUT: (sinkInputIndex, destSinkIndex) => ({ sinkInputIndex, destSinkIndex }),
MOVE_SOURCE_OUTPUT: (sourceOutputIndex, destSourceIndex) => ({ sourceOutputIndex, destSourceIndex }),
@ -46,8 +65,5 @@ module.exports = createActionCreators({
SET_DEFAULT_SINK_BY_NAME: name => ({ name }),
SET_DEFAULT_SOURCE_BY_NAME: name => ({ name }),
REMOTE_SERVER_CONNECT: null,
REMOTE_SERVER_DISCONNECT: null,
},
}),
});

View File

@ -15,6 +15,8 @@ const { bindActionCreators } = require('redux');
const { pulse: pulseActions } = require('../../actions');
const { primaryPulseServer } = require('../../reducers/pulse');
const Button = require('../button');
const Label = require('../label');
const Select = require('../select');
@ -97,7 +99,7 @@ class Cards extends React.Component {
module.exports = connect(
state => ({
cards: state.pulse.infos.cards,
cards: state.pulse[primaryPulseServer].infos.cards,
preferences: state.preferences,
}),
dispatch => ({

View File

@ -28,10 +28,11 @@ const {
} = require('ramda');
const React = require('react');
const PropTypes = require('prop-types');
const r = require('r-dom');
const { connect } = require('react-redux');
const { connect, Provider: ReduxProvider } = require('react-redux');
const { bindActionCreators } = require('redux');
const { HotKeys } = require('react-hotkeys');
@ -48,6 +49,8 @@ const {
const {
getPaiByTypeAndIndex,
getPaiByDgoFromInfos,
getDerivedMonitorSources,
getClientSinkInputs,
@ -70,6 +73,8 @@ const { size } = require('../../constants/view');
const VolumeSlider = require('../../components/volume-slider');
const { primaryPulseServer } = require('../../reducers/pulse');
const { keyMap } = require('../hot-keys');
const {
@ -128,8 +133,6 @@ const selectionObjectTypes = {
},
};
const dgoToPai = new WeakMap();
const key = pao => `${pao.type}-${pao.index}`;
const sourceKey = pai => {
@ -287,8 +290,7 @@ const renderNode = (nodeRef, data, key, selected, hovered) => r({
hovered,
});
const getVolumesForThumbnail = ({ pai, state }) => {
const { lockChannelsTogether } = state.preferences;
const getVolumesForThumbnail = ({ pai, lockChannelsTogether }) => {
let volumes = (pai && pai.channelVolumes) || [];
if (lockChannelsTogether) {
if (volumes.every(v => v === volumes[0])) {
@ -300,14 +302,20 @@ const getVolumesForThumbnail = ({ pai, state }) => {
return volumes;
};
const VolumeThumbnail = ({ pai, state }) => {
if (state.preferences.hideVolumeThumbnails) {
const VolumeThumbnail = connect(
state => ({
hideVolumeThumbnails: state.preferences.hideVolumeThumbnails,
lockChannelsTogether: state.preferences.lockChannelsTogether,
}),
)(({ pai, hideVolumeThumbnails, lockChannelsTogether }) => {
if (hideVolumeThumbnails) {
return r(React.Fragment);
}
const normVolume = PA_VOLUME_NORM;
const baseVolume = defaultTo(normVolume, pai && pai.baseVolume);
const volumes = getVolumesForThumbnail({ pai, state });
const volumes = getVolumesForThumbnail({ pai, lockChannelsTogether });
const muted = !pai || pai.muted;
const step = size / 32;
@ -378,22 +386,27 @@ const VolumeThumbnail = ({ pai, state }) => {
]);
}),
]);
};
});
const getVolumes = ({ pai, state }) => {
const { lockChannelsTogether } = state.preferences;
const getVolumes = ({ pai, lockChannelsTogether }) => {
let volumes = (pai && pai.channelVolumes) || [];
if (lockChannelsTogether) {
volumes = [
maximum(volumes),
];
}
return { volumes, lockChannelsTogether };
return volumes;
};
const VolumeControls = ({ pai, state }) => {
const { maxVolume, volumeStep } = state.preferences;
const { volumes, lockChannelsTogether } = getVolumes({ pai, state });
const VolumeControls = connect(
state => pick([
'maxVolume',
'volumeStep',
'lockChannelsTogether',
], state.preferences),
dispatch => bindActionCreators(pulseActions, dispatch),
)(({ pai, maxVolume, volumeStep, lockChannelsTogether, ...props }) => {
const volumes = getVolumes({ pai, lockChannelsTogether });
const baseVolume = pai && pai.baseVolume;
const muted = !pai || pai.muted;
@ -410,36 +423,40 @@ const VolumeControls = ({ pai, state }) => {
onChange: v => {
if (pai.type === 'sink') {
if (lockChannelsTogether) {
state.setSinkVolumes(pai.index, repeat(v, pai.sampleSpec.channels));
props.setSinkVolumes(pai.index, repeat(v, pai.sampleSpec.channels));
} else {
state.setSinkChannelVolume(pai.index, channelIndex, v);
props.setSinkChannelVolume(pai.index, channelIndex, v);
}
} else if (pai.type === 'source') {
if (lockChannelsTogether) {
state.setSourceVolumes(pai.index, repeat(v, pai.sampleSpec.channels));
props.setSourceVolumes(pai.index, repeat(v, pai.sampleSpec.channels));
} else {
state.setSourceChannelVolume(pai.index, channelIndex, v);
props.setSourceChannelVolume(pai.index, channelIndex, v);
}
} else if (pai.type === 'sinkInput') {
if (lockChannelsTogether) {
state.setSinkInputVolumes(pai.index, repeat(v, pai.sampleSpec.channels));
props.setSinkInputVolumes(pai.index, repeat(v, pai.sampleSpec.channels));
} else {
state.setSinkInputChannelVolume(pai.index, channelIndex, v);
props.setSinkInputChannelVolume(pai.index, channelIndex, v);
}
} else if (pai.type === 'sourceOutput') {
if (lockChannelsTogether) {
state.setSourceOutputVolumes(pai.index, repeat(v, pai.sampleSpec.channels));
props.setSourceOutputVolumes(pai.index, repeat(v, pai.sampleSpec.channels));
} else {
state.setSourceOutputChannelVolume(pai.index, channelIndex, v);
props.setSourceOutputChannelVolume(pai.index, channelIndex, v);
}
}
},
})),
]);
};
});
const Icon = ({ state, name, ...props }) => {
const src = state.icons[name];
const Icon = connect(
state => ({
icons: state.icons,
}),
)(({ icons, name, title }) => {
const src = icons[name];
if (!src) {
return r(React.Fragment);
@ -448,9 +465,9 @@ const Icon = ({ state, name, ...props }) => {
return r.img({
className: 'node-name-icon',
src,
...props,
title,
});
};
});
const RemoteTunnelInfo = ({ pai }) => {
const fqdn = path([ 'properties', 'tunnel', 'remote', 'fqdn' ], pai);
@ -466,8 +483,12 @@ const RemoteTunnelInfo = ({ pai }) => {
]);
};
const DebugText = ({ dgo, pai, state }) => {
if (!state.preferences.showDebugInfo) {
const DebugText = connect(
state => ({
showDebugInfo: state.preferences.showDebugInfo,
}),
)(({ dgo, pai, showDebugInfo }) => {
if (!showDebugInfo) {
return r(React.Fragment);
}
@ -479,15 +500,18 @@ const DebugText = ({ dgo, pai, state }) => {
JSON.stringify(dgo, null, 2),
JSON.stringify(pai, null, 2),
]);
};
});
const SinkText = ({ dgo, pai, state, selected }) => r(React.Fragment, [
const SinkText = connect(
state => ({
defaultSinkName: state.pulse[primaryPulseServer].serverInfo.defaultSinkName,
}),
)(({ dgo, pai, selected, defaultSinkName }) => r(React.Fragment, [
r.div({
className: 'node-name',
}, [
state.serverInfo.defaultSinkName === pai.name && r(React.Fragment, [
defaultSinkName === pai.name && r(React.Fragment, [
r(Icon, {
state,
name: 'starred',
title: 'Default sink',
}),
@ -501,20 +525,23 @@ const SinkText = ({ dgo, pai, state, selected }) => r(React.Fragment, [
r.div({
className: 'node-main',
}, [
r(selected ? VolumeControls : VolumeThumbnail, { pai, state }),
r(selected ? VolumeControls : VolumeThumbnail, { pai }),
]),
r(RemoteTunnelInfo, { pai }),
r(DebugText, { dgo, pai, state }),
]);
r(DebugText, { dgo, pai }),
]));
const SourceText = ({ dgo, pai, state, selected }) => r(React.Fragment, [
const SourceText = connect(
state => ({
defaultSourceName: state.pulse[primaryPulseServer].serverInfo.defaultSourceName,
}),
)(({ dgo, pai, selected, defaultSourceName }) => r(React.Fragment, [
r.div({
className: 'node-name',
}, [
state.serverInfo.defaultSourceName === pai.name && r(React.Fragment, [
defaultSourceName === pai.name && r(React.Fragment, [
r(Icon, {
state,
name: 'starred',
title: 'Default source',
}),
@ -528,17 +555,21 @@ const SourceText = ({ dgo, pai, state, selected }) => r(React.Fragment, [
r.div({
className: 'node-main',
}, [
r(selected ? VolumeControls : VolumeThumbnail, { pai, state }),
r(selected ? VolumeControls : VolumeThumbnail, { pai }),
]),
r(RemoteTunnelInfo, { pai }),
r(DebugText, { dgo, pai, state }),
]);
r(DebugText, { dgo, pai }),
]));
const ClientText = ({ dgo, pai, state }) => {
const ClientText = connect(
state => ({
modules: state.pulse[primaryPulseServer].infos.modules,
}),
)(({ dgo, pai, modules }) => {
let title = path('properties.application.process.binary'.split('.'), pai);
const module = state.infos.modules[pai.moduleIndex];
const module = modules[pai.moduleIndex];
if (module && module.name === 'module-native-protocol-tcp') {
title = path([ 'properties', 'native-protocol', 'peer' ], pai) || title;
}
@ -548,21 +579,24 @@ const ClientText = ({ dgo, pai, state }) => {
className: 'node-name',
title,
}, pai.name),
r(DebugText, { dgo, pai, state }),
r(DebugText, { dgo, pai }),
]);
};
});
const ModuleText = ({ dgo, pai, state }) => r(React.Fragment, [
const ModuleText = ({ dgo, pai }) => r(React.Fragment, [
r.div({
className: 'node-name',
title: pai.properties.module.description,
}, pai.name),
r(DebugText, { dgo, pai, state }),
r(DebugText, { dgo, pai }),
]);
const renderNodeText = state => (dgo, i, selected) => {
const pai = dgoToPai.get(dgo);
const NodeText = connect(
(state, { dgo }) => ({
icons: state.icons,
pai: dgo.type && getPaiByTypeAndIndex(dgo.type, dgo.index)(state),
}),
)(({ dgo, pai, selected, icons }) => {
if (!pai) {
return r(React.Fragment);
}
@ -576,7 +610,7 @@ const renderNodeText = state => (dgo, i, selected) => {
width: size,
height: size,
backgroundImage: (icon => icon && `url(${icon})`)(state.icons[getPaiIcon(pai)]),
backgroundImage: (icon => icon && `url(${icon})`)(icons[getPaiIcon(pai)]),
},
}, r({
sink: SinkText,
@ -586,10 +620,16 @@ const renderNodeText = state => (dgo, i, selected) => {
}[dgo.type] || ModuleText, {
dgo,
pai,
state,
selected,
})));
};
});
const withStorePassthrough = component => store =>
(...args) => r(ReduxProvider, { store }, component(...args));
const renderNodeText = withStorePassthrough((dgo, i, selected) => {
return r(NodeText, { dgo, selected });
});
const renderEdge = props => r(Edge, {
classSet: {
@ -598,23 +638,27 @@ const renderEdge = props => r(Edge, {
...props,
});
const renderEdgeText = state => ({ data: dgo, transform, selected }) => {
const pai = dgo.type && getPaiByTypeAndIndex(dgo.type, dgo.index)({ pulse: state });
const EdgeText = connect(
(state, { dgo }) => ({
pai: dgo.type && getPaiByTypeAndIndex(dgo.type, dgo.index)(state),
}),
)(({ dgo, pai, transform, selected }) => r('foreignObject', {
transform,
}, r.div({
className: 'edge-text',
style: {
width: size,
height: size,
},
}, [
pai && (!selected) && r(VolumeThumbnail, { pai }),
pai && selected && r(VolumeControls, { pai }),
r(DebugText, { dgo, pai }),
])));
return r('foreignObject', {
transform,
}, r.div({
className: 'edge-text',
style: {
width: size,
height: size,
},
}, [
pai && (!selected) && r(VolumeThumbnail, { pai, state }),
pai && selected && r(VolumeControls, { pai, state }),
r(DebugText, { dgo, pai, state }),
]));
};
const renderEdgeText = withStorePassthrough(({ data: dgo, transform, selected }) => {
return r(EdgeText, { dgo, transform, selected });
});
const layoutEngine = new LayoutEngine();
@ -744,7 +788,7 @@ class Graph extends React.Component {
}
}
const pai = dgoToPai.get(node);
const pai = getPaiByDgoFromInfos(node)(props.infos);
if (pai) {
if (props.preferences.hideMonitors &&
pai.properties.device &&
@ -782,16 +826,6 @@ class Graph extends React.Component {
return filteredNodeKeys.has(edge.source) && filteredNodeKeys.has(edge.target);
}, edges);
nodes.forEach(node => {
const pai = getPaiByTypeAndIndex(node.type, node.index)({ pulse: props });
dgoToPai.set(node, pai);
});
edges.forEach(edge => {
const pai = getPaiByTypeAndIndex(edge.type, edge.index)({ pulse: props });
dgoToPai.set(edge, pai);
});
let { selected, moved, contexted } = state;
if (contexted && contexted !== backgroundSymbol && selected !== contexted) {
@ -841,6 +875,8 @@ class Graph extends React.Component {
this.graphViewElement = document.querySelector('#graph .view-wrapper');
this.graphViewElement.setAttribute('tabindex', '-1');
this.props.connect();
}
componentDidUpdate() {
@ -889,7 +925,7 @@ class Graph extends React.Component {
}
onNodeMouseDown(event, data) {
const pai = dgoToPai.get(data);
const pai = getPaiByDgoFromInfos(data)(this.props.infos);
if (pai && event.button === 1) {
if (pai.type === 'sink' ||
pai.type === 'source' ||
@ -923,8 +959,8 @@ class Graph extends React.Component {
}
onCreateEdge(source, target) {
const sourcePai = dgoToPai.get(source);
const targetPai = dgoToPai.get(target);
const sourcePai = getPaiByDgoFromInfos(source)(this.props.infos);
const targetPai = getPaiByDgoFromInfos(target)(this.props.infos);
if (sourcePai && targetPai &&
source.type === 'source' && target.type === 'sink'
) {
@ -947,7 +983,7 @@ class Graph extends React.Component {
}
onEdgeMouseDown(event, data) {
const pai = dgoToPai.get(data);
const pai = getPaiByDgoFromInfos(data)(this.props.infos);
if (pai && event.button === 1) {
if (pai.type === 'sinkInput' ||
pai.type === 'sourceOutput'
@ -979,7 +1015,7 @@ class Graph extends React.Component {
this.props.setSourceOutputMuteByIndex(pai.index, muted);
} else if (pai.type === 'sink') {
if (sourceBiased) {
const sinkInputs = getSinkSinkInputs(pai)({ pulse: this.props });
const sinkInputs = getSinkSinkInputs(pai)(this.context.store.getState());
this.toggleAllMute(sinkInputs);
} else {
this.props.setSinkMute(pai.index, muted);
@ -988,25 +1024,25 @@ class Graph extends React.Component {
this.props.setSourceMute(pai.index, muted);
} else if (pai.type === 'client') {
if (sourceBiased) {
const sourceOutputs = getClientSourceOutputs(pai)({ pulse: this.props });
const sourceOutputs = getClientSourceOutputs(pai)(this.context.store.getState());
this.toggleAllMute(sourceOutputs);
} else {
const sinkInputs = getClientSinkInputs(pai)({ pulse: this.props });
const sinkInputs = getClientSinkInputs(pai)(this.context.store.getState());
this.toggleAllMute(sinkInputs);
}
} else if (pai.type === 'module') {
if (sourceBiased) {
const sourceOutputs = getModuleSourceOutputs(pai)({ pulse: this.props });
const sourceOutputs = getModuleSourceOutputs(pai)(this.context.store.getState());
this.toggleAllMute(sourceOutputs);
} else {
const sinkInputs = getModuleSinkInputs(pai)({ pulse: this.props });
const sinkInputs = getModuleSinkInputs(pai)(this.context.store.getState());
this.toggleAllMute(sinkInputs);
}
}
}
onDelete(selected) {
const pai = dgoToPai.get(selected);
const pai = getPaiByDgoFromInfos(selected)(this.props.infos);
if (selected.type === 'client') {
this.props.killClientByIndex(selected.index);
@ -1040,8 +1076,7 @@ class Graph extends React.Component {
}
canContextMenuSetAsDefault() {
const { contexted } = this.state;
const pai = dgoToPai.get(contexted);
const pai = getPaiByDgoFromInfos(this.state.contexted)(this.props.infos);
if (pai && pai.type === 'sink' && pai.name !== this.props.serverInfo.defaultSinkName) {
return true;
@ -1055,7 +1090,7 @@ class Graph extends React.Component {
}
setAsDefault(data) {
const pai = dgoToPai.get(data);
const pai = getPaiByDgoFromInfos(data)(this.props.infos);
if (pai.type === 'sink') {
this.props.setDefaultSinkByName(pai.name);
@ -1100,21 +1135,21 @@ class Graph extends React.Component {
if (all) {
this.toggleAllMute(this.props.infos.sources);
} else {
const defaultSource = getDefaultSourcePai({ pulse: this.props });
const defaultSource = getDefaultSourcePai(this.context.store.getState());
this.toggleMute(defaultSource);
}
} else {
if (all) { // eslint-disable-line no-lonely-if
this.toggleAllMute(this.props.infos.sinks);
} else {
const defaultSink = getDefaultSinkPai({ pulse: this.props });
const defaultSink = getDefaultSinkPai(this.context.store.getState());
this.toggleMute(defaultSink);
}
}
return;
}
const pai = dgoToPai.get(this.state.selected);
const pai = getPaiByDgoFromInfos(this.state.selected)(this.props.infos);
if (!pai) {
return;
@ -1157,9 +1192,9 @@ class Graph extends React.Component {
let pai;
if (this.state.selected) {
pai = dgoToPai.get(this.state.selected);
pai = getPaiByDgoFromInfos(this.state.selected)(this.props.infos);
} else {
pai = getDefaultSinkPai({ pulse: this.props });
pai = getDefaultSinkPai(this.context.store.getState());
}
if (!pai) {
@ -1167,12 +1202,12 @@ class Graph extends React.Component {
}
if (pai.type === 'client') {
const sinkInputs = getClientSinkInputs(pai)({ pulse: this.props });
const sinkInputs = getClientSinkInputs(pai)(this.context.store.getState());
this._volumeAll(sinkInputs, direction);
return;
}
if (pai.type === 'module') {
const sinkInputs = getModuleSinkInputs(pai)({ pulse: this.props });
const sinkInputs = getModuleSinkInputs(pai)(this.context.store.getState());
this._volumeAll(sinkInputs, direction);
return;
}
@ -1407,10 +1442,10 @@ class Graph extends React.Component {
renderDefs,
renderNode,
renderNodeText: renderNodeText(this.props),
renderNodeText: renderNodeText(this.context.store),
renderEdge,
renderEdgeText: renderEdgeText(this.props),
renderEdgeText: renderEdgeText(this.context.store),
}),
this.state.contexted && (
@ -1438,12 +1473,16 @@ class Graph extends React.Component {
}
}
Graph.contextTypes = {
store: PropTypes.any,
};
module.exports = connect(
state => ({
serverInfo: state.pulse.serverInfo,
serverInfo: state.pulse[primaryPulseServer].serverInfo,
objects: state.pulse.objects,
infos: state.pulse.infos,
objects: state.pulse[primaryPulseServer].objects,
infos: state.pulse[primaryPulseServer].infos,
derivations: {
monitorSources: getDerivedMonitorSources(state),

View File

@ -19,6 +19,8 @@ const weakmapId = require('../../utils/weakmap-id');
const { pulse: pulseActions } = require('../../actions');
const { primaryPulseServer } = require('../../reducers/pulse');
const actionTypeText = {
[pulseActions.ready]: 'Connected to PulseAudio',
[pulseActions.close]: 'Disconnected from PulseAudio',
@ -84,6 +86,6 @@ Log.defaultProps = {
module.exports = connect(
state => ({
log: state.pulse.log,
log: state.pulse[primaryPulseServer].log,
}),
)(Log);

View File

@ -21,7 +21,7 @@ const WindowMenu = props => r(WindowMenuBase, [
label: 'File',
}, [
r(MenuItem, {
label: 'Connect to server...',
label: 'Open a server...',
accelerator: 'CommandOrControl+N',
onClick: props.openConnectToServerModal,
}),

View File

@ -20,7 +20,7 @@ class ConnectToServerModal extends React.PureComponent {
super(props);
this.state = {
value: 'tcp:remote-computer.lan',
address: props.defaults.address,
};
this.handleSubmit = this.handleSubmit.bind(this);
@ -33,7 +33,7 @@ class ConnectToServerModal extends React.PureComponent {
detached: true,
stdio: 'ignore',
env: merge(process.env, {
PULSE_SERVER: this.state.value,
PULSE_SERVER: this.state.address,
}),
});
@ -62,8 +62,8 @@ class ConnectToServerModal extends React.PureComponent {
r(Input, {
style: { width: '100%' },
autoFocus: true,
value: this.state.value,
onChange: e => this.setState({ value: e.target.value }),
value: this.state.address,
onChange: e => this.setState({ address: e.target.value }),
}),
]),
]),
@ -84,4 +84,10 @@ class ConnectToServerModal extends React.PureComponent {
}
}
ConnectToServerModal.defaultProps = {
defaults: {
address: 'tcp:remote-computer.lan',
},
};
module.exports = ConnectToServerModal;

View File

@ -9,6 +9,7 @@ const {
const r = require('r-dom');
const React = require('react');
const PropTypes = require('prop-types');
const Modal = require('react-modal');
@ -26,6 +27,8 @@ const {
const { modules } = require('../../constants/pulse');
const { primaryPulseServer } = require('../../reducers/pulse');
const ConnectToServerModal = require('./connect-to-server');
const ConfirmationModal = require('./confirmation');
const NewGraphObjectModal = require('./new-graph-object');
@ -80,7 +83,7 @@ class Modals extends React.PureComponent {
return continuation();
}
const target = f(...args);
const target = f.apply(this, args);
if (!target) {
return continuation();
@ -93,7 +96,7 @@ class Modals extends React.PureComponent {
});
}, {
unloadModuleByIndex(index) {
const pai = getPaiByTypeAndIndex('module', index)({ pulse: props });
const pai = getPaiByTypeAndIndex('module', index)(this.context.store.getState());
if (pai && path([ pai.name, 'confirmUnload' ], modules)) {
return pai;
@ -105,8 +108,11 @@ class Modals extends React.PureComponent {
};
}
openConnectToServerModal() {
this.setState({ connectToServerModalOpen: true });
openConnectToServerModal(modalDefaults) {
this.setState({
connectToServerModalOpen: true,
modalDefaults,
});
}
openNewGraphObjectModal() {
@ -145,9 +151,11 @@ class Modals extends React.PureComponent {
toggle,
}),
r(ConnectToServerModal, {
isOpen: this.state.connectToServerModalOpen,
this.state.connectToServerModalOpen && r(ConnectToServerModal, {
isOpen: true,
onRequestClose: this.handleCancel,
defaults: this.state.modalDefaults,
}),
r(NewGraphObjectModal, {
@ -172,9 +180,13 @@ class Modals extends React.PureComponent {
}
}
Modals.contextTypes = {
store: PropTypes.any,
};
module.exports = connect(
state => ({
infos: state.pulse.infos,
infos: state.pulse[primaryPulseServer].infos,
preferences: state.preferences,
}),
dispatch => bindActionCreators(merge(pulseActions, preferencesActions), dispatch),

View File

@ -24,19 +24,19 @@ const {
} = require('../../actions');
const { formatModuleArgs } = require('../../utils/module-args');
const { getRemoteServerByAddress } = require('../../selectors');
const { primaryPulseServer } = require('../../reducers/pulse');
const Button = require('../button');
const Label = require('../label');
const RemoteServer = connect(
(state, props) => ({
remoteServer: getRemoteServerByAddress(props.address)(state),
remoteServer: state.pulse[props.address],
}),
dispatch => ({
actions: bindActionCreators(merge(pulseActions, preferencesActions), dispatch),
}),
)(({ address, remoteServer = {}, actions }) => {
)(({ address, remoteServer = {}, actions, ...props }) => {
const { targetState, state } = remoteServer;
const hostname = path([ 'serverInfo', 'hostname' ], remoteServer);
@ -44,35 +44,53 @@ const RemoteServer = connect(
r.div({
style: { display: 'flex', justifyContent: 'space-between' },
}, [
r(Label, {
userSelect: true,
}, [
hostname || address,
r.div([
r.div([ hostname ]),
r.code(address),
]),
targetState === 'ready' ? r(Button, {
onClick: () => {
actions.remoteServerDisconnect(address);
},
}, 'Disconnect') : r(React.Fragment, [
r.div([
r(Button, {
onClick: () => {
actions.remoteServerDisconnect(address);
actions.setDelete('remoteServerAddresses', address);
props.openConnectToServerModal({ address });
},
}, 'Forget'),
}, 'Open'),
r(Button, {
' ',
targetState === 'ready' ? r(Button, {
onClick: () => {
actions.remoteServerConnect(address);
actions.disconnect(address);
},
}, 'Connect'),
}, 'Disconnect') : r(React.Fragment, [
r(Button, {
onClick: () => {
actions.disconnect(address);
actions.setDelete('remoteServerAddresses', address);
},
}, 'Forget'),
' ',
r(Button, {
onClick: () => {
actions.connect(address);
},
}, 'Connect'),
]),
]),
]),
state === 'ready' ? r(React.Fragment, [
// TODO
]) : targetState === 'ready' ? r(Label, [
state === 'ready' ? r(Label, {
passive: true,
}, [
keys(remoteServer.objects.sinks).length,
' sinks and ',
keys(remoteServer.objects.sources).length,
' sources.',
]) : targetState === 'ready' ? r(Label, {
passive: true,
}, [
'Connecting...',
]) : null,
]);
@ -188,7 +206,10 @@ class Cards extends React.Component {
'Remote servers:',
]),
...map(address => r(RemoteServer, { address }), remoteServerAddresses),
...map(address => r(RemoteServer, {
address,
openConnectToServerModal: this.props.openConnectToServerModal,
}), remoteServerAddresses),
]) : r(Label, [
'No known servers',
]),
@ -212,7 +233,7 @@ class Cards extends React.Component {
module.exports = connect(
state => ({
modules: state.pulse.infos.modules,
modules: state.pulse[primaryPulseServer].infos.modules,
preferences: state.preferences,
}),
dispatch => ({

View File

@ -7,6 +7,8 @@ const r = require('r-dom');
const { connect } = require('react-redux');
const { primaryPulseServer } = require('../../reducers/pulse');
const localHostname = os.hostname();
const { username: localUsername } = os.userInfo();
@ -27,6 +29,6 @@ class ServerInfo extends React.Component {
module.exports = connect(
state => ({
serverInfo: state.pulse.serverInfo,
serverInfo: state.pulse[primaryPulseServer].serverInfo,
}),
)(ServerInfo);

View File

@ -1,8 +1,4 @@
const {
map,
} = require('ramda');
const r = require('r-dom');
const { connect } = require('react-redux');

View File

@ -26,7 +26,7 @@
]
},
"dependencies": {
"@futpib/paclient": "^0.0.8",
"@futpib/paclient": "^0.0.9",
"@futpib/react-electron-menu": "^0.3.1",
"bluebird": "^3.5.3",
"camelcase": "^5.0.0",
@ -35,6 +35,7 @@
"freedesktop-icons": "^0.1.0",
"ini": "^1.3.5",
"mathjs": "^5.2.3",
"prop-types": "^15.6.2",
"r-dom": "^2.4.0",
"ramda": "^0.25.0",
"react": "^16.6.0",

View File

@ -9,7 +9,7 @@ const {
equals,
takeLast,
over,
lensPath,
lensProp,
} = require('ramda');
const { combineReducers } = require('redux');
@ -20,8 +20,11 @@ const { pulse } = require('../actions');
const { things } = require('../constants/pulse');
const initialState = {
const primaryPulseServer = '__PRIMARY_PULSE_SERVER__';
const serverInitialState = {
state: 'closed',
targetState: 'closed',
serverInfo: {},
@ -29,17 +32,22 @@ const initialState = {
infos: fromPairs(map(({ key }) => [ key, {} ], things)),
log: { items: [] },
remoteServers: {},
};
const initialState = {};
const logMaxItems = 3;
const reducer = combineReducers({
const serverReducer = combineReducers({
state: handleActions({
[pulse.ready]: always('ready'),
[pulse.close]: always('closed'),
}, initialState.state),
}, serverInitialState.state),
targetState: handleActions({
[pulse.connect]: always('ready'),
[pulse.disconnect]: always('closed'),
}, serverInitialState.targetState),
serverInfo: handleActions({
[pulse.serverInfo]: (state, { payload }) => {
@ -47,8 +55,8 @@ const reducer = combineReducers({
state :
payload;
},
[pulse.close]: always(initialState.serverInfo),
}, initialState.serverInfo),
[pulse.close]: always(serverInitialState.serverInfo),
}, serverInitialState.serverInfo),
objects: combineReducers(fromPairs(map(({ key, type }) => [ key, handleActions({
[pulse.new]: (state, { payload }) => {
@ -94,8 +102,8 @@ const reducer = combineReducers({
}
return state;
},
[pulse.close]: () => initialState.objects[key],
}, initialState.objects[key]) ], things))),
[pulse.close]: () => serverInitialState.objects[key],
}, serverInitialState.objects[key]) ], things))),
infos: combineReducers(fromPairs(map(({ key, type }) => [ key, handleActions({
[pulse.remove]: (state, { payload }) => {
@ -112,8 +120,8 @@ const reducer = combineReducers({
[payload.index]: payload,
});
},
[pulse.close]: () => initialState.objects[key],
}, initialState.infos[key]) ], things))),
[pulse.close]: () => serverInitialState.objects[key],
}, serverInitialState.infos[key]) ], things))),
log: combineReducers({
items: handleActions({
@ -129,16 +137,18 @@ const reducer = combineReducers({
type: 'info',
action: type,
})),
}, initialState.log.items),
}, serverInitialState.log.items),
}),
remoteServers: handleActions({
[pulse.remoteServerConnect]: (state, { payload }) => over(lensPath([ payload, 'targetState' ]), always('ready'), state),
[pulse.remoteServerDisconnect]: (state, { payload }) => over(lensPath([ payload, 'targetState' ]), always('closed'), state),
}, initialState.remoteServers),
});
const reducer = (state = initialState, action) => {
const { pulseServerId = primaryPulseServer } = action.meta || {};
return over(lensProp(pulseServerId), s => serverReducer(s, action), state);
};
module.exports = {
initialState,
reducer,
primaryPulseServer,
};

View File

@ -15,37 +15,43 @@ const { createSelector } = require('reselect');
const { things } = require('../constants/pulse');
const { primaryPulseServer } = require('../reducers/pulse');
const storeKeyByType = map(prop('key'), indexBy(prop('type'), things));
const getPaiByTypeAndIndex = (type, index) => state => path([ storeKeyByType[type], index ], state.pulse.infos);
const getPaiByTypeAndIndex = (type, index, pulseServerId = primaryPulseServer) =>
state => path([ pulseServerId, 'infos', storeKeyByType[type], index ], state.pulse);
const getClientSinkInputs = client => state => pickBy(
const getPaiByTypeAndIndexFromInfos = (type, index) => infos => path([ storeKeyByType[type], index ], infos);
const getPaiByDgoFromInfos = ({ type, index }) => infos => path([ storeKeyByType[type], index ], infos);
const getClientSinkInputs = (client, pulseServerId = primaryPulseServer) => state => pickBy(
si => si.clientIndex === client.index,
state.pulse.infos.sinkInputs,
state.pulse[pulseServerId].infos.sinkInputs,
);
const getModuleSinkInputs = module => state => pickBy(
const getModuleSinkInputs = (module, pulseServerId = primaryPulseServer) => state => pickBy(
si => si.moduleIndex === module.index,
state.pulse.infos.sinkInputs,
state.pulse[pulseServerId].infos.sinkInputs,
);
const getClientSourceOutputs = client => state => pickBy(
const getClientSourceOutputs = (client, pulseServerId = primaryPulseServer) => state => pickBy(
so => so.clientIndex === client.index,
state.pulse.infos.sourceOutputs,
state.pulse[pulseServerId].infos.sourceOutputs,
);
const getModuleSourceOutputs = module => state => pickBy(
const getModuleSourceOutputs = (module, pulseServerId = primaryPulseServer) => state => pickBy(
so => so.moduleIndex === module.index,
state.pulse.infos.sourceOutputs,
state.pulse[pulseServerId].infos.sourceOutputs,
);
const getSinkSinkInputs = sink => state => pickBy(
const getSinkSinkInputs = (sink, pulseServerId = primaryPulseServer) => state => pickBy(
si => si.sinkIndex === sink.index,
state.pulse.infos.sinkInputs,
state.pulse[pulseServerId].infos.sinkInputs,
);
const getDerivedMonitorSources = createSelector(
state => state.pulse.infos.sources,
state => state.pulse[primaryPulseServer].infos.sources,
sources => map(source => ({
index: source.index,
type: 'monitorSource',
@ -55,21 +61,22 @@ const getDerivedMonitorSources = createSelector(
);
const getDefaultSourcePai = createSelector(
state => state.pulse.infos.sources,
state => state.pulse.serverInfo.defaultSourceName,
state => state.pulse[primaryPulseServer].infos.sources,
state => state.pulse[primaryPulseServer].serverInfo.defaultSourceName,
(sources, defaultSourceName) => find(propEq('name', defaultSourceName), values(sources)),
);
const getDefaultSinkPai = createSelector(
state => state.pulse.infos.sinks,
state => state.pulse.serverInfo.defaultSinkName,
state => state.pulse[primaryPulseServer].infos.sinks,
state => state.pulse[primaryPulseServer].serverInfo.defaultSinkName,
(sinks, defaultSinkName) => find(propEq('name', defaultSinkName), values(sinks)),
);
const getRemoteServerByAddress = address => state => state.pulse.remoteServers[address];
module.exports = {
getPaiByTypeAndIndex,
getPaiByTypeAndIndexFromInfos,
getPaiByDgoFromInfos,
getDerivedMonitorSources,
getClientSinkInputs,
@ -82,6 +89,4 @@ module.exports = {
getDefaultSinkPai,
getDefaultSourcePai,
getRemoteServerByAddress,
};

View File

@ -1,4 +1,14 @@
const {
difference,
keys,
filter,
values,
propEq,
compose,
indexBy,
} = require('ramda');
const Bluebird = require('bluebird');
const PAClient = require('@futpib/paclient');
@ -11,6 +21,10 @@ const { things } = require('../constants/pulse');
const { getPaiByTypeAndIndex } = require('../selectors');
const { primaryPulseServer } = require('../reducers/pulse');
const { parseModuleArgs, formatModuleArgs } = require('../utils/module-args');
function getFnFromType(type) {
let fn;
switch (type) {
@ -48,7 +62,11 @@ function setSourceOutputChannelVolume(pa, store, index, channelIndex, volume, cb
pa.setSourceOutputVolumesByIndex(index, pai.channelVolumes.map((v, i) => i === channelIndex ? volume : v), cb);
}
module.exports = store => {
const createPulseClient = (store, pulseServerId = primaryPulseServer) => {
let state = store.getState();
const getPulseServerState = (s = state) => s.pulse[pulseServerId] || {};
const pa = new PAClient();
const getInfo = (type, index) => {
@ -72,13 +90,13 @@ module.exports = store => {
throw err;
}
info.type = info.type || type;
store.dispatch(pulseActions.info(info));
store.dispatch(pulseActions.info(info, pulseServerId));
});
};
pa
.on('ready', () => {
store.dispatch(pulseActions.ready());
store.dispatch(pulseActions.ready(pulseServerId));
pa.subscribe('all');
getServerInfo();
@ -89,14 +107,14 @@ module.exports = store => {
infos.forEach(info => {
const { index } = info;
info.type = info.type || type;
store.dispatch(pulseActions.new({ type, index }));
store.dispatch(pulseActions.info(info));
store.dispatch(pulseActions.new({ type, index }, pulseServerId));
store.dispatch(pulseActions.info(info, pulseServerId));
});
});
});
})
.on('close', () => {
store.dispatch(pulseActions.close());
store.dispatch(pulseActions.close(pulseServerId));
reconnect();
})
.on('new', (type, index) => {
@ -104,7 +122,7 @@ module.exports = store => {
getServerInfo();
return;
}
store.dispatch(pulseActions.new({ type, index }));
store.dispatch(pulseActions.new({ type, index }, pulseServerId));
getInfo(type, index);
})
.on('change', (type, index) => {
@ -112,20 +130,33 @@ module.exports = store => {
getServerInfo();
return;
}
store.dispatch(pulseActions.change({ type, index }));
store.dispatch(pulseActions.change({ type, index }, pulseServerId));
getInfo(type, index);
})
.on('remove', (type, index) => {
store.dispatch(pulseActions.remove({ type, index }));
store.dispatch(pulseActions.remove({ type, index }, pulseServerId));
})
.on('error', error => {
handleError(error);
});
const reconnect = () => new Bluebird((resolve, reject) => {
const server = getPulseServerState();
if (server.targetState !== 'ready') {
resolve();
return;
}
pa.once('ready', resolve);
pa.once('error', reject);
pa.connect();
if (pulseServerId === primaryPulseServer) {
pa.connect();
} else {
pa.connect({
serverString: pulseServerId,
});
}
}).catch(error => {
if (error.message === 'Unable to connect to PulseAudio server') {
return Bluebird.delay(5000).then(reconnect);
@ -133,14 +164,12 @@ module.exports = store => {
throw error;
});
reconnect();
const getServerInfo = () => {
pa.getServerInfo((err, info) => {
if (err) {
handleError(err);
} else {
store.dispatch(pulseActions.serverInfo(info));
store.dispatch(pulseActions.serverInfo(info, pulseServerId));
}
});
};
@ -152,7 +181,7 @@ module.exports = store => {
console.error(error);
store.dispatch(pulseActions.error(error));
store.dispatch(pulseActions.error(error, pulseServerId));
};
const handlePulseActions = handleActions({
@ -250,10 +279,120 @@ module.exports = store => {
},
}, null);
return {
handleAction: action => handlePulseActions(null, action),
storeWillUpdate(prevState, nextState) {
state = nextState;
const prev = getPulseServerState(prevState);
const next = getPulseServerState(nextState);
if (prev === next) {
return;
}
if (prev.targetState !== next.targetState) {
if (next.targetState === 'ready') {
reconnect();
} else if (next.targetState === 'closed') {
pa.end();
}
}
},
};
};
const tunnelAttempts = {};
const tunnelAttemptTimeout = 15000;
const isNotMonitor = s => s.monitorSourceIndex < 0;
const updateTunnels = (dispatch, primaryState, remoteServerId, remoteState) => {
const sourceTunnels = compose(
indexBy(m => parseModuleArgs(m.args).source),
filter(propEq('name', 'module-tunnel-source')),
values,
)(primaryState.infos.modules);
const sinkTunnels = compose(
indexBy(m => parseModuleArgs(m.args).sink),
filter(propEq('name', 'module-tunnel-sink')),
values,
)(primaryState.infos.modules);
const remoteSources = filter(isNotMonitor, values(remoteState.infos.sources));
const remoteSinks = values(remoteState.infos.sinks);
// FIXME: BUG: sounce/sink name collisions are possible, should also check server id
remoteSinks.forEach(sink => {
if ((tunnelAttempts[sink.name] || 0) + tunnelAttemptTimeout > Date.now()) {
return;
}
if (!sinkTunnels[sink.name]) {
tunnelAttempts[sink.name] = Date.now();
dispatch(pulseActions.loadModule('module-tunnel-sink', formatModuleArgs({
server: remoteServerId,
sink: sink.name,
})));
}
});
remoteSources.forEach(source => {
if ((tunnelAttempts[source.name] || 0) + tunnelAttemptTimeout > Date.now()) {
return;
}
if (!sourceTunnels[source.name]) {
tunnelAttempts[source.name] = Date.now();
dispatch(pulseActions.loadModule('module-tunnel-source', formatModuleArgs({
server: remoteServerId,
source: source.name,
})));
}
});
};
module.exports = store => {
const clients = {
[primaryPulseServer]: createPulseClient(store, primaryPulseServer),
};
return next => action => {
const { pulseServerId = primaryPulseServer } = action.meta || {};
const prevState = store.getState();
const ret = next(action);
handlePulseActions(null, action);
const nextState = store.getState();
const newPulseServerIds = difference(keys(nextState.pulse), keys(clients));
newPulseServerIds.forEach(pulseServerId => {
clients[pulseServerId] = createPulseClient(store, pulseServerId);
});
const client = clients[pulseServerId];
if (client) {
client.handleAction(action);
if (prevState !== nextState) {
client.storeWillUpdate(prevState, nextState);
}
}
const primaryState = nextState.pulse[primaryPulseServer];
keys(nextState.pulse).forEach(pulseServerId => {
if (pulseServerId === primaryPulseServer) {
return;
}
const remoteState = nextState.pulse[pulseServerId];
if (primaryState.state === 'ready' &&
remoteState.state === 'ready' &&
primaryState.targetState === 'ready' &&
primaryState.targetState === 'ready'
) {
updateTunnels(store.dispatch, primaryState, pulseServerId, remoteState);
}
});
return ret;
};

View File

@ -2,6 +2,7 @@
const {
map,
toPairs,
fromPairs,
} = require('ramda');
const separators = {
@ -18,4 +19,10 @@ const formatModuleArgs = object => map(([ k, v ]) => {
return `${k}=${v}`;
}, toPairs(object)).join(' ');
module.exports = { formatModuleArgs };
const parseModuleArgs = (args = '') => fromPairs(args.split(' ').map(arg => {
const [ key, ...value ] = arg.split('=');
// TODO: `separators`
return [ key, value.join('=') ];
}));
module.exports = { formatModuleArgs, parseModuleArgs };

View File

@ -156,10 +156,10 @@
dependencies:
arrify "^1.0.1"
"@futpib/paclient@^0.0.8":
version "0.0.8"
resolved "https://registry.yarnpkg.com/@futpib/paclient/-/paclient-0.0.8.tgz#c7530d2175798aba9ca21e3d0312bbfa3fd18a44"
integrity sha512-Uaup+EdAWKtfuos4wBlDuUWeZfj/OtTtllGpniFTElEiD+MDvryzq64t/Ibokt3a5TkVY3M2O69YZaGH2J6Gqw==
"@futpib/paclient@^0.0.9":
version "0.0.9"
resolved "https://registry.yarnpkg.com/@futpib/paclient/-/paclient-0.0.9.tgz#406949cea4543725ab4d25267dad8e4cf8a8a423"
integrity sha512-uNMUcd4XXJy1HrT7TP/dxCx6KUquBZyF0UQH7dWDT0VH/tmYmkpOHnBUcVjGVSG3CWWtAlqtw+vsk+N+1aBRMw==
"@futpib/react-electron-menu@^0.3.1":
version "0.3.1"