/* global document */ const { all, allPass, bind, compose, defaultTo, filter, find, flatten, forEach, keys, map, max, merge, min, omit, path, pick, prop, reduce, repeat, sortBy, values, scan, range, } = require('ramda'); const React = require('react'); const r = require('r-dom'); const { connect, Provider: ReduxProvider, ReactReduxContext: { Consumer: ReduxConsumer }, } = require('react-redux'); const { bindActionCreators } = require('redux'); const { fromRenderProps, } = require('recompose'); const { HotKeys } = require('react-hotkeys'); const { PopupMenu, MenuItem } = require('@futpib/react-electron-menu'); const d = require('../../utils/d'); const memoize = require('../../utils/memoize'); const { forwardRef, unforwardRef, } = require('../../utils/recompose'); const { pulse: pulseActions, icons: iconsActions, } = require('../../actions'); const { getPaiByTypeAndIndex, getPaiByDgoFromInfos, getDerivedMonitorSources, getClientSinkInputs, getModuleSinkInputs, getClientSourceOutputs, getModuleSourceOutputs, getSinkSinkInputs, getDefaultSinkPai, getDefaultSourcePai, } = require('../../selectors'); const { PA_VOLUME_NORM, } = require('../../constants/pulse'); const { size } = require('../../constants/view'); const VolumeSlider = require('../../components/volume-slider'); const { primaryPulseServer } = require('../../reducers/pulse'); const { keyMap } = require('../hot-keys'); const { SatellitesGraphView, } = require('./satellites-graph'); const { Edge, } = require('./base'); const Peaks = require('./peaks'); const LayoutEngine = require('./layout-engine'); const maximum = reduce(max, -Infinity); const clamp = (v, lo, hi) => min(hi, max(lo, v)); const leftOf = (x, xs) => { const i = ((xs.indexOf(x) + xs.length - 1) % xs.length); return xs[i]; }; const rightOf = (x, xs) => { const i = ((xs.indexOf(x) + 1) % xs.length); return xs[i]; }; const selectionObjectTypes = { order: [ 'source', 'sourceOutput', 'client|module', 'sinkInput', 'sink', ], left(type) { return leftOf(type, this.order); }, right(type) { return rightOf(type, this.order); }, fromPulseType(type) { if (type === 'client' || type === 'module') { return 'client|module'; } return type; }, toPulsePredicate(type) { type = this.fromPulseType(type); if (type === 'client|module') { return o => (o.type === 'client' || o.type === 'module'); } return o => o.type === type; }, }; const key = pao => `${pao.type}-${pao.index}`; const sourceKey = pai => { if (pai.type === 'monitorSource') { return `sink-${pai.sinkIndex}`; } if (pai.clientIndex === -1) { return `module-${pai.moduleIndex}`; } return `client-${pai.clientIndex}`; }; const targetKey = pai => { if (pai.type === 'monitorSource') { return `source-${pai.sourceIndex}`; } if (pai.type === 'sinkInput') { return `sink-${pai.sinkIndex}`; } return `source-${pai.sourceIndex}`; }; const paoToNode = memoize(pao => ({ id: key(pao), index: pao.index, type: pao.type, })); const paoToEdge = memoize(pao => ({ id: key(pao), source: sourceKey(pao), target: targetKey(pao), index: pao.index, type: pao.type, })); const getPaiIcon = memoize(pai => { return null || path([ 'properties', 'application', 'icon_name' ], pai) || path([ 'properties', 'device', 'icon_name' ], pai); }); const s2 = size / 2; const Sink = () => r.path({ d: d() .moveTo(-s2, 0) .lineTo(-s2 * 1.3, -s2) .lineTo(s2, -s2) .lineTo(s2, s2) .lineTo(-s2 * 1.3, s2) .close() .toString(), }); const Source = () => r.path({ d: d() .moveTo(s2 * 1.3, 0) .lineTo(s2, s2) .lineTo(-s2, s2) .lineTo(-s2, -s2) .lineTo(s2, -s2) .close() .toString(), }); const Client = () => r.path({ d: d() .moveTo(s2 * 1.3, 0) .lineTo(s2, s2) .lineTo(-s2 * 1.3, s2) .lineTo(-s2, 0) .lineTo(-s2 * 1.3, -s2) .lineTo(s2, -s2) .close() .toString(), }); const Module = Client; const gridDotSize = 2; const gridSpacing = 36; const Marker = ({ id, d }) => r('marker', { id, viewBox: '0 -8 18 16', refX: '16', markerWidth: '16', markerHeight: '16', orient: 'auto', }, r.path({ className: 'arrow', d, })); const sourceArrowPathDescription = 'M 16,-8 L 0,0 L 16,8'; const sinkArrowPathDescription = 'M 2,-8 L 18,0 L 2,8'; const renderDefs = () => r(React.Fragment, [ r.pattern({ id: 'background-pattern', key: 'background-pattern', width: gridSpacing, height: gridSpacing, patternUnits: 'userSpaceOnUse', }, r.circle({ className: 'grid-dot', cx: (gridSpacing || 0) / 2, cy: (gridSpacing || 0) / 2, r: gridDotSize, })), r(Marker, { id: 'my-source-arrow', d: sourceArrowPathDescription, }), r(Marker, { id: 'my-sink-arrow', d: sinkArrowPathDescription, }), // WORKAROUND: `context-fill` did not work r(Marker, { id: 'my-source-arrow-selected', d: sourceArrowPathDescription, }), r(Marker, { id: 'my-sink-arrow-selected', d: sinkArrowPathDescription, }), ]); const renderBackground = ({ gridSize = 40960 / 4, onMouseDown, }) => r.rect({ className: 'background', x: -(gridSize || 0) / 4, y: -(gridSize || 0) / 4, width: gridSize, height: gridSize, fill: 'url(#background-pattern)', onMouseDown, }); const renderNode = (nodeRef, data, key, selected, hovered) => r({ sink: Sink, source: Source, client: Client, module: Module, }[data.type] || Module, { selected, hovered, }); const getVolumesForThumbnail = ({ pai, lockChannelsTogether }) => { let volumes = (pai && pai.channelVolumes) || []; if (lockChannelsTogether) { if (volumes.every(v => v === volumes[0])) { volumes = [ maximum(volumes), ]; } } return volumes; }; 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, lockChannelsTogether }); const muted = !pai || pai.muted; const step = size / 32; const padding = 2; const width = size - 8; const height = ((1 + volumes.length) * step); return r.svg({ classSet: { 'volume-thumbnail': true, 'volume-thumbnail-muted': muted, }, height: (2 * padding) + height, }, [ r.line({ className: 'volume-thumbnail-ruler-line', x1: padding, x2: padding, y1: padding, y2: padding + height, }), baseVolume && r.line({ className: 'volume-thumbnail-ruler-line', x1: padding + ((baseVolume / normVolume) * width), x2: padding + ((baseVolume / normVolume) * width), y1: padding, y2: padding + height, }), r.line({ className: 'volume-thumbnail-ruler-line', x1: padding + width, x2: padding + width, y1: padding, y2: padding + height, }), ...volumes.map((v, i) => { const a = min(v / normVolume, baseVolume / normVolume); const b = min(v / normVolume, 1); const c = v / normVolume; return r(React.Fragment, [ r.line({ className: 'volume-thumbnail-volume-line', x1: padding, x2: padding + (a * width), y1: padding + ((1 + i) * step), y2: padding + ((1 + i) * step), }), r.line({ className: 'volume-thumbnail-volume-line volume-thumbnail-volume-line-warning', x1: padding + (a * width), x2: padding + (b * width), y1: padding + ((1 + i) * step), y2: padding + ((1 + i) * step), }), r.line({ className: 'volume-thumbnail-volume-line volume-thumbnail-volume-line-error', x1: padding + (b * width), x2: padding + (c * width), y1: padding + ((1 + i) * step), y2: padding + ((1 + i) * step), }), ]); }), ]); }); const getVolumes = ({ pai, lockChannelsTogether }) => { let volumes = (pai && pai.channelVolumes) || []; if (lockChannelsTogether) { volumes = [ maximum(volumes), ]; } return volumes; }; 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; return r.div({ className: 'volume-controls', }, [ ...volumes.map((v, channelIndex) => r(VolumeSlider, { muted, baseVolume, normVolume: PA_VOLUME_NORM, maxVolume: PA_VOLUME_NORM * maxVolume, volumeStep, value: v, onChange: v => { if (pai.type === 'sink') { if (lockChannelsTogether) { props.setSinkVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); } else { props.setSinkChannelVolume(pai.index, channelIndex, v); } } else if (pai.type === 'source') { if (lockChannelsTogether) { props.setSourceVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); } else { props.setSourceChannelVolume(pai.index, channelIndex, v); } } else if (pai.type === 'sinkInput') { if (lockChannelsTogether) { props.setSinkInputVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); } else { props.setSinkInputChannelVolume(pai.index, channelIndex, v); } } else if (pai.type === 'sourceOutput') { if (lockChannelsTogether) { props.setSourceOutputVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); } else { props.setSourceOutputChannelVolume(pai.index, channelIndex, v); } } }, })), ]); }); const Icon = connect( state => ({ icons: state.icons, }), )(({ icons, name, title }) => { const src = icons[name]; if (!src) { return r(React.Fragment); } return r.img({ className: 'node-name-icon', src, title, }); }); const RemoteTunnelInfo = ({ pai }) => { const fqdn = path([ 'properties', 'tunnel', 'remote', 'fqdn' ], pai); if (!fqdn) { return r(React.Fragment); } return r.div({ className: 'node-tunnel-info', }, [ fqdn, ]); }; const DebugText = connect( state => ({ showDebugInfo: state.preferences.showDebugInfo, }), )(({ dgo, pai, showDebugInfo }) => { if (!showDebugInfo) { return r(React.Fragment); } return r.div({ style: { fontSize: '50%', }, }, [ JSON.stringify(dgo, null, 2), JSON.stringify(pai, null, 2), ]); }); const SinkText = connect( state => ({ defaultSinkName: state.pulse[primaryPulseServer].serverInfo.defaultSinkName, }), )(({ dgo, pai, selected, defaultSinkName }) => r(React.Fragment, [ r.div({ className: 'node-name', }, [ defaultSinkName === pai.name && r(React.Fragment, [ r(Icon, { name: 'starred', title: 'Default sink', }), ' ', ]), r.span({ title: pai.name, }, pai.description), ]), r.div({ className: 'node-main', }, [ r(selected ? VolumeControls : VolumeThumbnail, { pai }), ]), r(RemoteTunnelInfo, { pai }), r(DebugText, { dgo, pai }), ])); const SourceText = connect( state => ({ defaultSourceName: state.pulse[primaryPulseServer].serverInfo.defaultSourceName, }), )(({ dgo, pai, selected, defaultSourceName }) => r(React.Fragment, [ r.div({ className: 'node-name', }, [ defaultSourceName === pai.name && r(React.Fragment, [ r(Icon, { name: 'starred', title: 'Default source', }), ' ', ]), r.span({ title: pai.name, }, pai.description), ]), r.div({ className: 'node-main', }, [ r(selected ? VolumeControls : VolumeThumbnail, { pai }), ]), r(RemoteTunnelInfo, { pai }), r(DebugText, { dgo, pai }), ])); const ClientText = connect( state => ({ modules: state.pulse[primaryPulseServer].infos.modules, }), )(({ dgo, pai, modules }) => { let title = path('properties.application.process.binary'.split('.'), pai); const module = modules[pai.moduleIndex]; if (module && module.name === 'module-native-protocol-tcp') { title = path([ 'properties', 'native-protocol', 'peer' ], pai) || title; } return r(React.Fragment, [ r.div({ className: 'node-name', title, }, pai.name), r(DebugText, { dgo, pai }), ]); }); const ModuleText = ({ dgo, pai }) => r(React.Fragment, [ r.div({ className: 'node-name', title: path([ 'properties', 'module', 'description' ], pai) || pai.name, }, pai.name), r(DebugText, { dgo, pai }), ]); 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); } return r('foreignObject', { x: -s2, y: -s2, }, r.div({ className: 'node-text', style: { width: size, height: size, backgroundImage: (icon => icon && `url(${icon})`)(icons[getPaiIcon(pai)]), }, }, r({ sink: SinkText, source: SourceText, client: ClientText, module: ModuleText, }[dgo.type] || ModuleText, { dgo, pai, 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: { [props.data.type]: true, }, ...props, }); 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 }), ]))); const renderEdgeText = withStorePassthrough(({ data: dgo, transform, selected }) => { return r(EdgeText, { dgo, transform, selected }); }); const layoutEngine = new LayoutEngine(); class BackgroundContextMenu extends React.PureComponent { render() { return r(PopupMenu, { onClose: this.props.onClose, }, [ r(MenuItem, { label: 'Create', }, [ r(MenuItem, { label: 'Loopback', onClick: this.props.onLoadModuleLoopback, }), r(MenuItem, { label: 'Simultaneous output', onClick: this.props.onLoadModuleCombineSink, }), r(MenuItem, { label: 'Null output', onClick: this.props.onLoadModuleNullSink, }), ]), r(MenuItem, { label: 'Load a module...', onClick: this.props.onLoadModule, }), ]); } } class GraphObjectContextMenu extends React.PureComponent { render() { return r(PopupMenu, { onClose: this.props.onClose, }, [ this.props.canSetAsDefault() && r(React.Fragment, [ r(MenuItem, { label: 'Set as default', onClick: this.props.onSetAsDefault, }), r(MenuItem.Separator), ]), this.props.canDelete() && r(MenuItem, { label: 'Delete', onClick: this.props.onDelete, }), ]); } } const backgroundSymbol = Symbol('graph.backgroundSymbol'); class Graph extends React.PureComponent { constructor(props) { super(props); this.satellitesGraphViewRef = React.createRef(); this.state = { selected: null, moved: null, contexted: null, isDraggingNode: false, isZooming: false, }; this._requestedIcons = new Set(); Object.assign(this, { renderBackground: this.renderBackground.bind(this), onBackgroundMouseDown: this.onBackgroundMouseDown.bind(this), onZoomStart: this.onZoomStart.bind(this), onZoomEnd: this.onZoomEnd.bind(this), onSelectNode: this.onSelectNode.bind(this), onCreateNode: this.onCreateNode.bind(this), onUpdateNode: this.onUpdateNode.bind(this), onDeleteNode: this.onDeleteNode.bind(this), onNodeMouseDown: this.onNodeMouseDown.bind(this), onNodeDragStart: this.onNodeDragStart.bind(this), onNodeDragEnd: this.onNodeDragEnd.bind(this), onSelectEdge: this.onSelectEdge.bind(this), canCreateEdge: this.canCreateEdge.bind(this), onCreateEdge: this.onCreateEdge.bind(this), onSwapEdge: this.onSwapEdge.bind(this), onDeleteEdge: this.onDeleteEdge.bind(this), onEdgeMouseDown: this.onEdgeMouseDown.bind(this), onContextMenuClose: this.onContextMenuClose.bind(this), canContextMenuSetAsDefault: this.canContextMenuSetAsDefault.bind(this), onContextMenuSetAsDefault: this.onContextMenuSetAsDefault.bind(this), canContextMenuDelete: this.canContextMenuDelete.bind(this), onContextMenuDelete: this.onContextMenuDelete.bind(this), onLoadModuleLoopback: this.onLoadModuleLoopback.bind(this), onLoadModuleCombineSink: this.onLoadModuleCombineSink.bind(this), onLoadModuleNullSink: this.onLoadModuleNullSink.bind(this), }); } static getDerivedStateFromProps(props, state) { let edges = map(paoToEdge, flatten(map(values, [ props.objects.sinkInputs, props.objects.sourceOutputs, props.derivations.monitorSources, ]))); const connectedNodeKeys = new Set(); edges.forEach(edge => { if (edge.type === 'monitorSource') { return; } connectedNodeKeys.add(edge.source); connectedNodeKeys.add(edge.target); }); const filteredNodeKeys = new Set(); const nodes = filter(node => { if ((props.preferences.hideDisconnectedClients && node.type === 'client') || (props.preferences.hideDisconnectedModules && node.type === 'module') || (props.preferences.hideDisconnectedSources && node.type === 'source') || (props.preferences.hideDisconnectedSinks && node.type === 'sink') ) { if (!connectedNodeKeys.has(node.id)) { return false; } } const pai = getPaiByDgoFromInfos(node)(props.infos); if (pai) { if (props.preferences.hideMonitors && pai.properties.device && pai.properties.device.class === 'monitor' ) { return false; } if (props.preferences.hidePulseaudioApps) { const binary = path([ 'properties', 'application', 'process', 'binary' ], pai) || ''; const name = path([ 'properties', 'application', 'name' ], pai) || ''; if (binary.startsWith('pavucontrol') || binary.startsWith('kmix') || binary === 'pulseaudio' || name === 'papeaks' || name === 'paclient.js' ) { return false; } } } filteredNodeKeys.add(node.id); return true; }, map(paoToNode, flatten(map(values, [ props.objects.sinks, props.objects.sources, props.objects.clients, props.objects.modules, ])))); edges = filter(edge => { if (props.preferences.hideMonitorSourceEdges && edge.type === 'monitorSource') { return false; } return filteredNodeKeys.has(edge.source) && filteredNodeKeys.has(edge.target); }, edges); let { selected, moved, contexted } = state; if (contexted && contexted !== backgroundSymbol && selected !== contexted) { contexted = null; } if (selected) { selected = find(x => x.id === selected.id, nodes) || find(x => x.id === selected.id, edges); } if (moved) { moved = find(x => x.id === moved.id, nodes) || find(x => x.id === moved.id, edges); } if (contexted && contexted !== backgroundSymbol) { contexted = find(x => x.id === contexted.id, nodes) || find(x => x.id === contexted.id, edges); } return { nodes, edges, selected, moved, contexted, }; } componentDidMount() { this.getIconPath('starred'); this.graphViewElement = document.querySelector('#graph .view-wrapper'); this.graphViewElement.setAttribute('tabindex', '-1'); this.props.connect(); } componentDidUpdate() { forEach(pai => { const icon = getPaiIcon(pai); if (icon) { this.getIconPath(icon); } }, flatten(map(values, [ this.props.infos.sinks, this.props.infos.sources, this.props.infos.clients, this.props.infos.modules, ]))); } getIconPath(icon) { if (!this._requestedIcons.has(icon) && !this.props.icons[icon]) { this.props.getIconPath(icon, 128); } this._requestedIcons.add(icon); } onBackgroundMouseDown(event) { if (event.button === 1) { this.toggleAllMute(this.props.infos.sinks); } else if (event.button === 2) { this.setState({ contexted: backgroundSymbol, }); } } onSelectNode(selected) { this.setState({ selected }); } onCreateNode() { } onUpdateNode() { } onDeleteNode(selected) { this.onDelete(selected); } onNodeMouseDown(event, data) { const pai = getPaiByDgoFromInfos(data)(this.props.infos); if (pai && event.button === 1) { if (pai.type === 'sink' || pai.type === 'source' || pai.type === 'client' || pai.type === 'module' ) { this.toggleMute(pai); } } else if (pai && event.button === 2) { this.setState({ selected: data, contexted: data, }); } } onNodeDragStart() { this.setState({ isDraggingNode: true, }); } onNodeDragEnd() { this.setState({ isDraggingNode: false, }); } onSelectEdge(selected) { this.setState({ selected }); } canCreateEdge(source, target) { if (!target) { return true; } if (source.type === 'source' && target.type === 'sink') { return true; } return false; } onCreateEdge(source, target) { const sourcePai = getPaiByDgoFromInfos(source)(this.props.infos); const targetPai = getPaiByDgoFromInfos(target)(this.props.infos); if (sourcePai && targetPai && source.type === 'source' && target.type === 'sink' ) { this.props.loadModule('module-loopback', `source=${sourcePai.name} sink=${targetPai.name}`); } else { this.forceUpdate(); } } onSwapEdge(sourceNode, targetNode, edge) { if (edge.type === 'sinkInput') { this.props.moveSinkInput(edge.index, targetNode.index); } else if (edge.type === 'sourceOutput') { this.props.moveSourceOutput(edge.index, targetNode.index); } } onDeleteEdge(selected) { this.onDelete(selected); } onEdgeMouseDown(event, data) { const pai = getPaiByDgoFromInfos(data)(this.props.infos); if (pai && event.button === 1) { if (pai.type === 'sinkInput' || pai.type === 'sourceOutput' ) { this.toggleMute(pai); } } else if (pai && event.button === 2) { this.setState({ selected: data, contexted: data, }); } } toggleAllMute(pais) { pais = values(pais); const allMuted = all(prop('muted'), pais); pais.forEach(pai => this.toggleMute(pai, !allMuted)); } toggleMute(pai, muted = !pai.muted, sourceBiased = false) { if (pai.muted === muted) { return; } if (pai.type === 'sinkInput') { this.props.setSinkInputMuteByIndex(pai.index, muted); } else if (pai.type === 'sourceOutput') { this.props.setSourceOutputMuteByIndex(pai.index, muted); } else if (pai.type === 'sink') { if (sourceBiased) { const sinkInputs = getSinkSinkInputs(pai)(this.props.store.getState()); this.toggleAllMute(sinkInputs); } else { this.props.setSinkMute(pai.index, muted); } } else if (pai.type === 'source') { this.props.setSourceMute(pai.index, muted); } else if (pai.type === 'client') { if (sourceBiased) { const sourceOutputs = getClientSourceOutputs(pai)(this.props.store.getState()); this.toggleAllMute(sourceOutputs); } else { const sinkInputs = getClientSinkInputs(pai)(this.props.store.getState()); this.toggleAllMute(sinkInputs); } } else if (pai.type === 'module') { if (sourceBiased) { const sourceOutputs = getModuleSourceOutputs(pai)(this.props.store.getState()); this.toggleAllMute(sourceOutputs); } else { const sinkInputs = getModuleSinkInputs(pai)(this.props.store.getState()); this.toggleAllMute(sinkInputs); } } } onDelete(selected) { const pai = getPaiByDgoFromInfos(selected)(this.props.infos); if (selected.type === 'client') { this.props.killClientByIndex(selected.index); } else if (selected.type === 'module') { this.props.unloadModuleByIndex(selected.index); } else if (selected.type === 'sinkInput') { this.props.killSinkInputByIndex(selected.index); } else if (selected.type === 'sourceOutput') { this.props.killSourceOutputByIndex(selected.index); } else if ( (selected.type === 'sink' || selected.type === 'source') && pai && pai.moduleIndex >= 0 ) { this.props.unloadModuleByIndex(pai.moduleIndex); } } canContextMenuDelete() { return this.state.contexted !== backgroundSymbol; } onContextMenuDelete() { this.onDelete(this.state.contexted); } onContextMenuClose() { this.setState({ contexted: null, }); } canContextMenuSetAsDefault() { const pai = getPaiByDgoFromInfos(this.state.contexted)(this.props.infos); if (pai && pai.type === 'sink' && pai.name !== this.props.serverInfo.defaultSinkName) { return true; } if (pai && pai.type === 'source' && pai.name !== this.props.serverInfo.defaultSourceName) { return true; } return false; } setAsDefault(data) { const pai = getPaiByDgoFromInfos(data)(this.props.infos); if (pai.type === 'sink') { this.props.setDefaultSinkByName(pai.name); } if (pai.type === 'source') { this.props.setDefaultSourceByName(pai.name); } } onContextMenuSetAsDefault() { this.setAsDefault(this.state.contexted); } hotKeySetAsDefault() { this.setAsDefault(this.state.selected); } focus() { this.graphViewElement.focus(); } onZoomStart() { this.setState({ isZooming: true, }); } onZoomEnd() { this.setState({ isZooming: false, }); } hotKeyEscape() { const { moved } = this.state; if (moved) { this.setState({ selected: moved, moved: null, }); return; } this.setState({ selected: null, }); } hotKeyMute({ shiftKey: sourceBiased, ctrlKey: all }) { if (!this.state.selected) { if (sourceBiased) { if (all) { this.toggleAllMute(this.props.infos.sources); } else { const defaultSource = getDefaultSourcePai(this.props.store.getState()); this.toggleMute(defaultSource); } } else { if (all) { // eslint-disable-line no-lonely-if this.toggleAllMute(this.props.infos.sinks); } else { const defaultSink = getDefaultSinkPai(this.props.store.getState()); this.toggleMute(defaultSink); } } return; } const pai = getPaiByDgoFromInfos(this.state.selected)(this.props.infos); if (!pai) { return; } this.toggleMute(pai, undefined, sourceBiased); } _volume(pai, direction) { const { lockChannelsTogether, maxVolume, volumeStep } = this.props.preferences; const d = direction === 'up' ? 1 : -1; let newVolumes = map( v => clamp(v + (d * (volumeStep * PA_VOLUME_NORM)), 0, maxVolume * PA_VOLUME_NORM), pai.channelVolumes, ); if (lockChannelsTogether) { const max = maximum(newVolumes); newVolumes = map(() => max, newVolumes); } if (pai.type === 'sink') { this.props.setSinkVolumes(pai.index, newVolumes); } else if (pai.type === 'source') { this.props.setSourceVolumes(pai.index, newVolumes); } else if (pai.type === 'sinkInput') { this.props.setSinkInputVolumes(pai.index, newVolumes); } else if (pai.type === 'sourceOutput') { this.props.setSourceOutputVolumes(pai.index, newVolumes); } } _volumeAll(pais, direction) { forEach(pai => this._volume(pai, direction), values(pais)); } _hotKeyVolume(direction) { let pai; if (this.state.selected) { pai = getPaiByDgoFromInfos(this.state.selected)(this.props.infos); } else { pai = getDefaultSinkPai(this.props.store.getState()); } if (!pai) { return; } if (pai.type === 'client') { const sinkInputs = getClientSinkInputs(pai)(this.props.store.getState()); this._volumeAll(sinkInputs, direction); return; } if (pai.type === 'module') { const sinkInputs = getModuleSinkInputs(pai)(this.props.store.getState()); this._volumeAll(sinkInputs, direction); return; } if (![ 'sink', 'source', 'sinkInput', 'sourceOutput' ].includes(pai.type)) { return; } this._volume(pai, direction); } hotKeyVolumeDown() { this._hotKeyVolume('down'); } hotKeyVolumeUp() { this._hotKeyVolume('up'); } _findNextObjectForSelection(object, direction) { const { type } = object || { type: 'client' }; const predicate = selectionObjectTypes.toPulsePredicate(type); const candidates = compose( sortBy(prop('index')), filter(predicate), )(this.state.nodes.concat(this.state.edges)); return (direction === 'up' ? leftOf : rightOf)(object, candidates); } hotKeyFocusDown() { if (this._hotKeyMovePosition('down')) { return; } const selected = this._findNextObjectForSelection(this.state.selected, 'down'); this.setState({ selected }); } hotKeyFocusUp() { if (this._hotKeyMovePosition('up')) { return; } const selected = this._findNextObjectForSelection(this.state.selected, 'up'); this.setState({ selected }); } _findAnyObjectForSelection(types, isBest) { let node = null; for (const type of types) { const predicate = selectionObjectTypes.toPulsePredicate(type); node = (isBest && find(allPass([ predicate, isBest ]), this.state.nodes)) || (isBest && find(allPass([ predicate, isBest ]), this.state.edges)) || find(predicate, this.state.nodes) || find(predicate, this.state.edges); if (node) { break; } } return node; } _focusHorizontal(direction) { const { selected } = this.state; if (!selected) { this.setState({ selected: this._findAnyObjectForSelection(direction === 'left' ? [ 'sourceOutput', 'source', ] : [ 'sinkInput', 'sink', ]), }); return; } const next = t => selectionObjectTypes[direction](t); const types = scan( next, next(selectionObjectTypes.fromPulseType(selected.type)), range(0, 3), ); const bestSelectionPredicate = x => null || x.source === selected.id || x.target === selected.id || selected.source === x.id || selected.target === x.id; this.setState({ selected: this._findAnyObjectForSelection(types, bestSelectionPredicate), }); } hotKeyFocusLeft() { if (this._hotKeyMovePosition('left')) { return; } this._focusHorizontal('left'); } hotKeyFocusRight() { if (this._hotKeyMovePosition('right')) { return; } this._focusHorizontal('right'); } _hotKeyMovePosition(direction) { const { selected, moved } = this.state; if (!selected || selected !== moved || ![ 'sink', 'source', 'client', 'module' ].includes(moved.type) ) { return false; } const x = direction === 'right' ? 1 : (direction === 'left' ? -1 : 0); const y = direction === 'down' ? 1 : (direction === 'up' ? -1 : 0); moved.x += x * (size + (size / 12)); moved.y += y * (size + (size / 12)); this.forceUpdate(); return true; } hotKeyMove() { let { selected, moved } = this.state; if (!selected) { return; } if (moved) { this.onSwapEdge(null, selected, moved); this.setState({ selected: moved, moved: null, }); return; } moved = selected; if (moved.type === 'sinkInput') { selected = find( node => node.id !== moved.target && node.type === 'sink', this.state.nodes, ); } else if (moved.type === 'sourceOutput') { selected = find( node => node.id !== moved.target && node.type === 'source', this.state.nodes, ); } this.setState({ selected, moved, }); } hotKeyAdd() { this.props.openNewGraphObjectModal(); } onLoadModuleLoopback() { this.props.loadModule('module-loopback', ''); } onLoadModuleCombineSink() { this.props.loadModule('module-combine-sink', ''); } onLoadModuleNullSink() { this.props.loadModule('module-null-sink', ''); } renderBackground() { return renderBackground({ onMouseDown: this.onBackgroundMouseDown, }); } render() { const { nodes, edges } = this.state; const satellitesGraphViewState = path( [ 'current', 'state' ], this.satellitesGraphViewRef, ); return r(HotKeys, { handlers: map(f => bind(f, this), pick(keys(keyMap), this)), }, r.div({ id: 'graph', }, [ !this.props.preferences.hideLiveVolumePeaks && r(Peaks, { key: 'peaks', nodes: defaultTo([], prop('satelliteNodes', satellitesGraphViewState)), edges: defaultTo([], prop('satelliteEdges', satellitesGraphViewState)), accommodateGraphAnimation: this.state.isDraggingNode || this.state.isZooming, peaks: this.props.peaks, }), r(SatellitesGraphView, { key: 'graph', ref: this.satellitesGraphViewRef, nodeKey: 'id', edgeKey: 'id', nodes, edges, selected: this.state.selected, moved: this.state.moved, nodeTypes: {}, nodeSubtypes: {}, edgeTypes: {}, onZoomStart: this.onZoomStart, onZoomEnd: this.onZoomEnd, onSelectNode: this.onSelectNode, onCreateNode: this.onCreateNode, onUpdateNode: this.onUpdateNode, onDeleteNode: this.onDeleteNode, onNodeMouseDown: this.onNodeMouseDown, onNodeDragStart: this.onNodeDragStart, onNodeDragEnd: this.onNodeDragEnd, onSelectEdge: this.onSelectEdge, canCreateEdge: this.canCreateEdge, onCreateEdge: this.onCreateEdge, onSwapEdge: this.onSwapEdge, onDeleteEdge: this.onDeleteEdge, onEdgeMouseDown: this.onEdgeMouseDown, showGraphControls: false, edgeArrowSize: 64, layoutEngine, renderBackground: this.renderBackground, renderDefs, renderNode, renderNodeText: renderNodeText(this.props.store), renderEdge, renderEdgeText: renderEdgeText(this.props.store), }), this.state.contexted && ( this.state.contexted === backgroundSymbol ? r(BackgroundContextMenu, { key: 'background-context-menu', onClose: this.onContextMenuClose, onLoadModule: this.props.openLoadModuleModal, onLoadModuleLoopback: this.onLoadModuleLoopback, onLoadModuleCombineSink: this.onLoadModuleCombineSink, onLoadModuleNullSink: this.onLoadModuleNullSink, }) : r(GraphObjectContextMenu, { key: 'graph-object-context-menu', onClose: this.onContextMenuClose, canSetAsDefault: this.canContextMenuSetAsDefault, onSetAsDefault: this.onContextMenuSetAsDefault, canDelete: this.canContextMenuDelete, onDelete: this.onContextMenuDelete, }) ), ])); } } module.exports = compose( forwardRef(), connect( state => ({ serverInfo: state.pulse[primaryPulseServer].serverInfo, objects: state.pulse[primaryPulseServer].objects, infos: state.pulse[primaryPulseServer].infos, derivations: { monitorSources: getDerivedMonitorSources(state), }, icons: state.icons, preferences: state.preferences, }), dispatch => bindActionCreators(omit([ 'serverInfo', 'unloadModuleByIndex', ], merge(pulseActions, iconsActions)), dispatch), ), fromRenderProps( ReduxConsumer, ({ store }) => ({ store }), ), unforwardRef(), )(Graph);