diff --git a/actions/pulse.js b/actions/pulse.js index fe17b34..8d55d52 100644 --- a/actions/pulse.js +++ b/actions/pulse.js @@ -21,5 +21,15 @@ module.exports = createActionCreators({ KILL_SOURCE_OUTPUT_BY_INDEX: sourceOutputIndex => ({ sourceOutputIndex }), UNLOAD_MODULE_BY_INDEX: moduleIndex => ({ moduleIndex }), + + SET_SINK_VOLUMES: (index, channelVolumes) => ({ index, channelVolumes }), + SET_SOURCE_VOLUMES: (index, channelVolumes) => ({ index, channelVolumes }), + SET_SINK_INPUT_VOLUMES: (index, channelVolumes) => ({ index, channelVolumes }), + SET_SOURCE_OUTPUT_VOLUMES: (index, channelVolumes) => ({ index, channelVolumes }), + + SET_SINK_CHANNEL_VOLUME: (index, channelIndex, volume) => ({ index, channelIndex, volume }), + SET_SOURCE_CHANNEL_VOLUME: (index, channelIndex, volume) => ({ index, channelIndex, volume }), + SET_SINK_INPUT_CHANNEL_VOLUME: (index, channelIndex, volume) => ({ index, channelIndex, volume }), + SET_SOURCE_OUTPUT_CHANNEL_VOLUME: (index, channelIndex, volume) => ({ index, channelIndex, volume }), }, }); diff --git a/components/graph/base.js b/components/graph/base.js index 7745fa7..7f33588 100644 --- a/components/graph/base.js +++ b/components/graph/base.js @@ -4,6 +4,7 @@ const r = require('r-dom'); const { GraphView: GraphViewBase, + Node: NodeBase, Edge: EdgeBase, GraphUtils, } = require('react-digraph'); @@ -18,8 +19,11 @@ class GraphView extends GraphViewBase { _super_handleNodeMove: this.handleNodeMove, handleNodeMove: this.constructor.prototype.handleNodeMove.bind(this), - _super_getEdgeComponent: this.handleNodeMove, + _super_getEdgeComponent: this.getEdgeComponent, getEdgeComponent: this.constructor.prototype.getEdgeComponent.bind(this), + + _super_getNodeComponent: this.getNodeComponent, + getNodeComponent: this.constructor.prototype.getNodeComponent.bind(this), }); } @@ -45,6 +49,29 @@ class GraphView extends GraphViewBase { super.componentDidUpdate(prevProps, prevState); } + getNodeComponent(id, node) { + const { nodeTypes, nodeSubtypes, nodeSize, renderNode, renderNodeText, nodeKey } = this.props; + return r(Node, { + key: id, + id, + data: node, + nodeTypes, + nodeSize, + nodeKey, + nodeSubtypes, + onNodeMouseEnter: this.handleNodeMouseEnter, + onNodeMouseLeave: this.handleNodeMouseLeave, + onNodeMove: this.handleNodeMove, + onNodeUpdate: this.handleNodeUpdate, + onNodeSelected: this.handleNodeSelected, + renderNode, + renderNodeText, + isSelected: this.state.selectedNodeObj.node === node, + layoutEngine: this.layoutEngine, + viewWrapperElem: this.viewWrapper.current, + }); + } + handleNodeMove(position, nodeId, shiftKey) { this._super_handleNodeMove(position, nodeId, shiftKey); if (this.props.onNodeMove) { @@ -52,7 +79,7 @@ class GraphView extends GraphViewBase { } } - getEdgeComponent(edge) { + getEdgeComponent(edge, nodeMoving) { if (!this.props.renderEdge) { return this._super_getEdgeComponent(edge); } @@ -74,13 +101,46 @@ class GraphView extends GraphViewBase { targetNode: targetNode || targetPosition, nodeKey, isSelected: selected, + nodeMoving, renderEdgeText, }); } + + syncRenderEdge(edge, nodeMoving = false) { + if (!edge.source) { + return; + } + + const idVar = edge.target ? `${edge.source}-${edge.target}` : 'custom'; + const id = `edge-${idVar}`; + const element = this.getEdgeComponent(edge, nodeMoving); + this.renderEdge(id, element, edge, nodeMoving); + + if (this.isEdgeSelected(edge)) { + const container = document.getElementById(`${id}-container`); + container.parentNode.appendChild(container); + } + } } const size = 120; +class Node extends NodeBase { + constructor(props) { + super(props); + + Object.assign(this, { + _super_handleDragEnd: this.handleDragEnd, + handleDragEnd: this.constructor.prototype.handleDragEnd.bind(this), + }); + } + + handleDragEnd(...args) { + this.oldSibling = null; + return this._super_handleDragEnd(...args); + } +} + EdgeBase.calculateOffset = function (nodeSize, source, target) { const arrowVector = math.matrix([ target.x - source.x, target.y - source.y ]); const offsetLength = Math.max(0, Math.min((0.75 * size), (math.norm(arrowVector) / 2) - 40)); @@ -112,10 +172,6 @@ class Edge extends EdgeBase { className: 'edge-path', d: this.getPathDescription(data) || undefined, }), - this.props.renderEdgeText && r(this.props.renderEdgeText, { - data, - transform: this.getEdgeHandleTranslation(), - }), ]), r.g({ className: 'edge-mouse-handler', @@ -128,6 +184,11 @@ class Edge extends EdgeBase { 'data-target': data.target, d: this.getPathDescription(data) || undefined, }), + this.props.renderEdgeText && !this.props.nodeMoving && r(this.props.renderEdgeText, { + data, + transform: this.getEdgeHandleTranslation(), + selected: this.props.isSelected, + }), ]), ]); } diff --git a/components/graph/index.js b/components/graph/index.js index b3b3a70..405c878 100644 --- a/components/graph/index.js +++ b/components/graph/index.js @@ -3,11 +3,11 @@ const { map, values, flatten, - memoizeWith, path, filter, forEach, merge, + repeat, } = require('ramda'); const React = require('react'); @@ -18,6 +18,7 @@ const { connect } = require('react-redux'); const { bindActionCreators } = require('redux'); const d = require('../../utils/d'); +const memoize = require('../../utils/memoize'); const { pulse: pulseActions, @@ -30,6 +31,8 @@ const { PA_VOLUME_NORM, } = require('../../constants/pulse'); +const VolumeSlider = require('../../components/volume-slider'); + const { GraphView, } = require('./satellites-graph'); @@ -38,18 +41,8 @@ const { Edge, } = require('./base'); -const weakmapId_ = new WeakMap(); -const weakmapId = o => { - if (!weakmapId_.has(o)) { - weakmapId_.set(o, String(Math.random())); - } - return weakmapId_.get(o); -}; - const dgoToPai = new WeakMap(); -const memoize = memoizeWith(weakmapId); - const key = pao => `${pao.type}-${pao.index}`; const sourceKey = pai => { @@ -72,12 +65,12 @@ const paoToNode = memoize(pao => ({ type: pao.type, })); -const paiToEdge = memoize(pai => ({ - id: key(pai), - source: sourceKey(pai), - target: targetKey(pai), - index: pai.index, - type: pai.type, +const paoToEdge = memoize(pao => ({ + id: key(pao), + source: sourceKey(pao), + target: targetKey(pao), + index: pao.index, + type: pao.type, })); const getPaiIcon = memoize(pai => { @@ -211,12 +204,26 @@ const renderNode = (nodeRef, data, key, selected, hovered) => r({ hovered, }); +const getVolumesForThumbnail = ({ pai, state }) => { + const { lockChannelsTogether } = state.preferences; + let volumes = (pai && pai.channelVolumes) || []; + if (lockChannelsTogether) { + if (volumes.every(v => v === volumes[0])) { + volumes = [ + volumes.reduce((a, b) => Math.max(a, b)), + ]; + } + } + return volumes; +}; + const VolumeThumbnail = ({ pai, state }) => { if (state.preferences.hideVolumeThumbnails) { return r(React.Fragment); } + const { baseVolume } = pai; - const volumes = (pai && pai.channelVolumes) || []; + const volumes = getVolumesForThumbnail({ pai, state }); const muted = !pai || pai.muted; const step = size / 32; @@ -229,6 +236,7 @@ const VolumeThumbnail = ({ pai, state }) => { 'volume-thumbnail': true, 'volume-thumbnail-muted': muted, }, + height: (2 * padding) + height, }, [ r.line({ className: 'volume-thumbnail-ruler-line', @@ -238,6 +246,14 @@ const VolumeThumbnail = ({ pai, state }) => { y2: padding + height, }), + baseVolume && r.line({ + className: 'volume-thumbnail-ruler-line', + x1: padding + ((baseVolume / PA_VOLUME_NORM) * width), + x2: padding + ((baseVolume / PA_VOLUME_NORM) * width), + y1: padding, + y2: padding + height, + }), + r.line({ className: 'volume-thumbnail-ruler-line', x1: padding + width, @@ -256,6 +272,63 @@ const VolumeThumbnail = ({ pai, state }) => { ]); }; +const getVolumes = ({ pai, state }) => { + const { lockChannelsTogether } = state.preferences; + let volumes = (pai && pai.channelVolumes) || []; + if (lockChannelsTogether) { + volumes = [ + volumes.reduce((a, b) => Math.max(a, b)), + ]; + } + return { volumes, lockChannelsTogether }; +}; + +const VolumeControls = ({ pai, state }) => { + const { maxVolume } = state.preferences; + const { volumes, lockChannelsTogether } = getVolumes({ pai, state }); + 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, + value: v, + onChange: v => { + if (pai.type === 'sink') { + if (lockChannelsTogether) { + state.setSinkVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); + } else { + state.setSinkChannelVolume(pai.index, channelIndex, v); + } + } else if (pai.type === 'source') { + if (lockChannelsTogether) { + state.setSourceVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); + } else { + state.setSourceChannelVolume(pai.index, channelIndex, v); + } + } else if (pai.type === 'sinkInput') { + if (lockChannelsTogether) { + state.setSinkInputVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); + } else { + state.setSinkInputChannelVolume(pai.index, channelIndex, v); + } + } else if (pai.type === 'sourceOutput') { + if (lockChannelsTogether) { + state.setSourceOutputVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); + } else { + state.setSourceOutputChannelVolume(pai.index, channelIndex, v); + } + } + }, + })), + ]); +}; + const DebugText = ({ dgo, pai, state }) => r.div({ style: { fontSize: '50%', @@ -265,21 +338,23 @@ const DebugText = ({ dgo, pai, state }) => r.div({ JSON.stringify(pai, null, 2), ] : []); -const SinkText = ({ dgo, pai, state }) => r.div([ +const SinkText = ({ dgo, pai, state, selected }) => r.div([ r.div({ className: 'node-name', title: pai.name, }, pai.description), - r(VolumeThumbnail, { pai, state }), + !selected && r(VolumeThumbnail, { pai, state }), + selected && r(VolumeControls, { pai, state }), r(DebugText, { dgo, pai, state }), ]); -const SourceText = ({ dgo, pai, state }) => r.div([ +const SourceText = ({ dgo, pai, state, selected }) => r.div([ r.div({ className: 'node-name', title: pai.name, }, pai.description), - r(VolumeThumbnail, { pai, state }), + !selected && r(VolumeThumbnail, { pai, state }), + selected && r(VolumeControls, { pai, state }), r(DebugText, { dgo, pai, state }), ]); @@ -299,7 +374,7 @@ const ModuleText = ({ dgo, pai, state }) => r.div([ r(DebugText, { dgo, pai, state }), ]); -const renderNodeText = state => dgo => r('foreignObject', { +const renderNodeText = state => (dgo, i, selected) => r('foreignObject', { x: -s2, y: -s2, }, r.div({ @@ -319,6 +394,7 @@ const renderNodeText = state => dgo => r('foreignObject', { dgo, pai: dgoToPai.get(dgo), state, + selected, }))); const renderEdge = props => r(Edge, { @@ -328,7 +404,7 @@ const renderEdge = props => r(Edge, { ...props, }); -const renderEdgeText = state => ({ data: dgo, transform }) => { +const renderEdgeText = state => ({ data: dgo, transform, selected }) => { const pai = dgo.type && getPaiByTypeAndIndex(dgo.type, dgo.index)({ pulse: state }); return r('foreignObject', { @@ -340,7 +416,8 @@ const renderEdgeText = state => ({ data: dgo, transform }) => { height: size, }, }, [ - pai && r(VolumeThumbnail, { pai, state }), + pai && (!selected) && r(VolumeThumbnail, { pai, state }), + pai && selected && r(VolumeControls, { pai, state }), r(DebugText, { dgo, pai, state }), ])); }; @@ -444,9 +521,9 @@ class Graph extends React.Component { } render() { - let edges = map(paiToEdge, flatten(map(values, [ - this.props.infos.sinkInputs, - this.props.infos.sourceOutputs, + let edges = map(paoToEdge, flatten(map(values, [ + this.props.objects.sinkInputs, + this.props.objects.sourceOutputs, ]))); const connectedNodeKeys = new Set(); diff --git a/components/graph/satellites-graph.js b/components/graph/satellites-graph.js index 6a31ed6..f3e94cd 100644 --- a/components/graph/satellites-graph.js +++ b/components/graph/satellites-graph.js @@ -5,9 +5,6 @@ const { prop, groupBy, flatten, - addIndex, - mapObjIndexed, - values, } = require('ramda'); const React = require('react'); @@ -16,11 +13,39 @@ const r = require('r-dom'); const plusMinus = require('../../utils/plus-minus'); +const memoize = require('../../utils/memoize'); + const { GraphView: GraphViewBase, } = require('./base'); -const mapIndexed = addIndex(map); +const originalEdgeToSatelliteNode = edge => ({ + id: `${edge.target}__satellite__${edge.id}`, + edge: edge.id, + source: edge.source, + target: edge.target, + type: 'satellite', +}); + +const originalEdgeAndSatelliteNodeToSatelliteEdge = (edge, satelliteNode) => { + const satelliteEdge = { + id: edge.id, + source: edge.source, + target: satelliteNode.id, + originalTarget: edge.target, + index: edge.index, + type: edge.type, + }; + + satelliteEdgeToOriginalEdge.set(satelliteEdge, edge); + return satelliteEdge; +}; + +const originalEdgeToSatellites = memoize(edge => { + const satelliteNode = originalEdgeToSatelliteNode(edge); + const satelliteEdge = originalEdgeAndSatelliteNodeToSatelliteEdge(edge, satelliteNode); + return { satelliteEdge, satelliteNode }; +}); const Satellite = () => r(React.Fragment); @@ -33,8 +58,10 @@ class GraphView extends React.Component { super(props); this.state = { - edgesByTargetNodeKey: {}, + originalEdgesByTargetNodeKey: {}, satelliteNodesByTargetNodeKey: {}, + satelliteEdges: [], + selected: null, }; this.graph = React.createRef(); @@ -48,23 +75,41 @@ class GraphView extends React.Component { renderNode: this.renderNode.bind(this), renderNodeText: this.renderNodeText.bind(this), + renderEdge: this.renderEdge.bind(this), + renderEdgeText: this.renderEdgeText.bind(this), + afterRenderEdge: this.afterRenderEdge.bind(this), }); } static getDerivedStateFromProps(props) { - const { nodeKey, edgeKey } = props; + const originalEdgesByTargetNodeKey = groupBy(prop('target'), props.edges); - const edgesByTargetNodeKey = groupBy(prop('target'), props.edges); - const satelliteNodesByTargetNodeKey = map(map(edge => ({ - [nodeKey]: `${edge.target}__satellite__${edge[edgeKey]}`, - edge: edge[edgeKey], - source: edge.source, - target: edge.target, - type: 'satellite', - })), edgesByTargetNodeKey); + let { selected } = props; - return { edgesByTargetNodeKey, satelliteNodesByTargetNodeKey }; + const satelliteEdges = []; + + const satelliteNodesByTargetNodeKey = map(edges => map(edge => { + const { + satelliteNode, + satelliteEdge, + } = originalEdgeToSatellites(edge); + + if (edge === selected) { + selected = satelliteEdge; + } + + satelliteEdges.push(satelliteEdge); + + return satelliteNode; + }, edges), originalEdgesByTargetNodeKey); + + return { + originalEdgesByTargetNodeKey, + satelliteNodesByTargetNodeKey, + satelliteEdges, + selected, + }; } static repositionSatellites(position, satelliteNodes) { @@ -119,6 +164,14 @@ class GraphView extends React.Component { return r(React.Fragment); } + renderEdge(...args) { + return this.props.renderEdge(...args); + } + + renderEdgeText(...args) { + return this.props.renderEdgeText(...args); + } + afterRenderEdge(id, element, edge, edgeContainer) { const originalEdge = satelliteEdgeToOriginalEdge.get(edge); this.props.afterRenderEdge(id, element, originalEdge || edge, edgeContainer); @@ -126,7 +179,11 @@ class GraphView extends React.Component { render() { const { nodeKey } = this.props; - const { edgesByTargetNodeKey, satelliteNodesByTargetNodeKey } = this.state; + const { + satelliteNodesByTargetNodeKey, + satelliteEdges: edges, + selected, + } = this.state; const nodes = flatten(map(node => { const satelliteNodes = satelliteNodesByTargetNodeKey[node[nodeKey]] || []; @@ -134,26 +191,6 @@ class GraphView extends React.Component { return satelliteNodes.concat(node); }, this.props.nodes)); - let { selected } = this.props; - - const edges = flatten(values(mapObjIndexed((edges, target) => mapIndexed((edge, i) => { - const satelliteEdge = { - id: edge.id, - source: edge.source, - target: satelliteNodesByTargetNodeKey[target][i][nodeKey], - originalTarget: edge.target, - index: edge.index, - type: edge.type, - }; - - if (edge === selected) { - selected = satelliteEdge; - } - - satelliteEdgeToOriginalEdge.set(satelliteEdge, edge); - return satelliteEdge; - }, edges), edgesByTargetNodeKey))); - return r(GraphViewBase, { ...this.props, @@ -172,6 +209,9 @@ class GraphView extends React.Component { renderNode: this.renderNode, renderNodeText: this.renderNodeText, + renderEdge: this.renderEdge, + renderEdgeText: this.renderEdgeText, + afterRenderEdge: this.props.afterRenderEdge && this.afterRenderEdge, }); } diff --git a/components/input/index.js b/components/input/index.js new file mode 100644 index 0000000..be80383 --- /dev/null +++ b/components/input/index.js @@ -0,0 +1,7 @@ + +const r = require('r-dom'); + +module.exports = props => r.input({ + className: 'input', + ...props, +}, props.children); diff --git a/components/label/index.js b/components/label/index.js new file mode 100644 index 0000000..3e85b33 --- /dev/null +++ b/components/label/index.js @@ -0,0 +1,6 @@ + +const r = require('r-dom'); + +module.exports = props => r.label({ + className: 'label', +}, props.children); diff --git a/components/number-input/index.js b/components/number-input/index.js new file mode 100644 index 0000000..f451d67 --- /dev/null +++ b/components/number-input/index.js @@ -0,0 +1,10 @@ + +const r = require('r-dom'); + +const Label = require('../label'); +const Input = require('../input'); + +module.exports = props => r(Label, [ + ...[].concat(props.children), + r(Input, props), +]); diff --git a/components/preferences/index.js b/components/preferences/index.js index 40915bd..bc19a4f 100644 --- a/components/preferences/index.js +++ b/components/preferences/index.js @@ -1,6 +1,7 @@ const { pick, + defaultTo, } = require('ramda'); const r = require('r-dom'); @@ -14,6 +15,7 @@ const { preferences: preferencesActions } = require('../../actions'); const Button = require('../button'); const Checkbox = require('../checkbox'); +const NumberInput = require('../number-input'); const Preferences = withStateHandlers( { @@ -84,6 +86,24 @@ const Preferences = withStateHandlers( }, 'Hide volume thumbnails'), ]), + r.div([ + r(Checkbox, { + checked: props.preferences.lockChannelsTogether, + onChange: () => props.actions.toggle('lockChannelsTogether'), + }, 'Lock channels together'), + ]), + + r.div([ + r(NumberInput, { + type: 'number', + value: defaultTo(150, Math.round(props.preferences.maxVolume * 100)), + onChange: e => { + const v = defaultTo(150, Math.max(0, parseInt(e.target.value, 10))); + props.actions.set({ maxVolume: v / 100 }); + }, + }, 'Maximum volume: '), + ]), + r.div([ r(Checkbox, { checked: props.preferences.showDebugInfo, diff --git a/components/slider/index.js b/components/slider/index.js new file mode 100644 index 0000000..483f8ef --- /dev/null +++ b/components/slider/index.js @@ -0,0 +1,4 @@ + +module.exports = class Slider { + +}; diff --git a/components/volume-slider/index.js b/components/volume-slider/index.js new file mode 100644 index 0000000..0f391eb --- /dev/null +++ b/components/volume-slider/index.js @@ -0,0 +1,167 @@ +/* global document */ + +const React = require('react'); + +const r = require('r-dom'); + +const width = 300; +const height = 18; + +const clamp = x => Math.min( + width - (height / 2), + Math.max( + (height / 2), + x, + ), +); + +const vol2pix = (v, maxVolume) => (v / maxVolume) * (width - height); +const pix2vol = (x, maxVolume) => (x * maxVolume) / (width - height); + +module.exports = class VolumeSlider extends React.Component { + constructor(props) { + super(props); + + this.svg = React.createRef(); + + this.state = { + draggingX: null, + }; + + Object.assign(this, { + handlePointerDown: this.handlePointerDown.bind(this), + }); + } + + componentDidMount() { + this.svg.current.addEventListener('pointerdown', this.handlePointerDown); + } + + componentWillUnmount() { + this.svg.current.removeEventListener('pointerdown', this.handlePointerDown); + } + + handlePointerDown(e) { + e.preventDefault(); + e.stopPropagation(); + + const originX = e.clientX - e.offsetX; + + const move = e => { + if (this.state.draggingX !== null) { + this.setState({ + draggingX: clamp(e.clientX - originX), + }); + } + }; + + const up = e => { + this.setState({ + draggingX: null, + }); + + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + + e.preventDefault(); + e.stopPropagation(); + }; + + const click = e => { + e.preventDefault(); + e.stopPropagation(); + document.removeEventListener('click', click, true); + }; + + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + document.addEventListener('click', click, true); + + this.setState({ + draggingX: clamp(e.offsetX), + }); + } + + componentDidUpdate() { + const { draggingX } = this.state; + const { maxVolume } = this.props; + + if (draggingX === null) { + return; + } + + const targetValue = Math.floor(pix2vol(draggingX - (height / 2), maxVolume)); + + this.props.onChange(targetValue); + } + + render() { + const { + muted, + baseVolume, + normVolume, + maxVolume, + value, + } = this.props; + + const { + draggingX, + } = this.state; + + const x = draggingX === null ? + ((height / 2) + vol2pix(value, maxVolume)) : + draggingX; + + const baseX = (height / 2) + vol2pix(baseVolume, maxVolume); + const normX = (height / 2) + vol2pix(normVolume, maxVolume); + + return r.svg({ + ref: this.svg, + classSet: { + 'volume-slider': true, + 'volume-slider-muted': muted, + }, + width, + height, + }, [ + baseVolume && r.line({ + className: 'volume-slider-base-mark', + x1: baseX, + x2: baseX, + y1: 0, + y2: height, + }), + + r.line({ + className: 'volume-slider-norm-mark', + x1: normX, + x2: normX, + y1: 0, + y2: height, + }), + + r.line({ + className: 'volume-slider-bg', + x1: height / 2, + x2: width - (height / 2), + y1: height / 2, + y2: height / 2, + }), + + r.line({ + className: 'volume-slider-fill', + x1: height / 2, + x2: x, + y1: height / 2, + y2: height / 2, + }), + + r.circle({ + className: 'volume-slider-handle', + cx: x, + cy: height / 2, + r: (height - 2) / 2, + }), + ]); + } +}; diff --git a/index.css b/index.css index 36d693e..9d57166 100644 --- a/index.css +++ b/index.css @@ -33,12 +33,30 @@ button:active { top: 1px; } +.label { + user-select: none; +} + .checkbox { user-select: none; padding: 8px; } +.input { + background: var(--themeUnfocusedBaseColor); + color: var(--themeUnfocusedFgColor); + border: 1px solid var(--borders); + padding: 4px; +} +.input:focus { + outline: none; + border-color: var(--themeSelectedBgColor); +} +.input[type="number"] { + width: 64px; +} + .view-wrapper .graph { background: var(--themeBaseColor); } @@ -63,6 +81,10 @@ button:active { color: var(--themeSelectedFgColor); } +.view-wrapper .edge-container:hover .edge { + stroke: var(--themeSelectedBgColor); +} + .view-wrapper .graph .edge.selected { stroke: var(--themeSelectedBgColor); } @@ -90,6 +112,7 @@ button:active { #edge-custom-container .edge-path { marker-end: none; + stroke: var(--themeSelectedBgColor); } .view-wrapper .graph .edge { @@ -100,6 +123,16 @@ button:active { fill: var(--borders); } +.view-wrapper .edge-mouse-handler.edge-mouse-handler { + opacity: 1; +} +.view-wrapper .edge-mouse-handler .edge-overlay-path { + opacity: 0; +} +.view-wrapper .edge-mouse-handler .edge-text { + opacity: 1; +} + .preferences { position: absolute; right: 0; @@ -172,3 +205,46 @@ button:active { .edge-text { pointer-events: none; } + +.volume-controls { + background: var(--themeBgColor); + border: 1px solid var(--borders); + + pointer-events: initial; + padding: 2px; + + width: 308px; + + margin-left: -50%; +} + +.volume-controls:hover { + border-color: var(--themeSelectedBgColor); +} + +.volume-slider-norm-mark, .volume-slider-base-mark { + stroke: var(--borders); + stroke-width: 1px; +} + +.volume-slider-bg { + stroke: var(--borders); + stroke-width: 6px; + stroke-linecap: round; +} + +.volume-slider-fill { + stroke: var(--successColor); + stroke-width: 6px; + stroke-linecap: round; +} + +.volume-slider-handle { + fill: var(--themeBgColor); + stroke: var(--borders); + stroke-width: 1px; +} + +.volume-slider-handle:hover { + stroke: var(--themeSelectedBgColor); +} diff --git a/index.js b/index.js index c380248..f89e0af 100644 --- a/index.js +++ b/index.js @@ -10,4 +10,12 @@ app.on('ready', () => { win.setAutoHideMenuBar(true); win.setMenuBarVisibility(false); win.loadFile('index.html'); + + if (process.env.NODE_ENV !== 'production') { + const { default: installExtension, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer'); + + installExtension(REACT_DEVELOPER_TOOLS) + .then(name => console.log(`Added Extension: ${name}`)) + .catch(error => console.error(error)); + } }); diff --git a/package.json b/package.json index 33b90ee..fd05250 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "devDependencies": { "ava": "^0.25.0", "electron": "^3.0.8", + "electron-devtools-installer": "^2.2.4", "eslint-config-xo-overrides": "^1.1.2", "remotedev-server": "^0.2.6", "uws": "^99.0.0", @@ -20,7 +21,7 @@ ] }, "dependencies": { - "@futpib/paclient": "^0.0.3", + "@futpib/paclient": "^0.0.5", "bluebird": "^3.5.3", "camelcase": "^5.0.0", "electron-store": "^2.0.0", diff --git a/reducers/preferences.js b/reducers/preferences.js index 15d9698..553889d 100644 --- a/reducers/preferences.js +++ b/reducers/preferences.js @@ -17,6 +17,9 @@ const initialState = { hidePulseaudioApps: true, hideVolumeThumbnails: false, + lockChannelsTogether: true, + + maxVolume: 1.5, showDebugInfo: false, }; diff --git a/reducers/pulse.js b/reducers/pulse.js index 325720b..4261057 100644 --- a/reducers/pulse.js +++ b/reducers/pulse.js @@ -5,6 +5,8 @@ const { omit, fromPairs, map, + pick, + equals, } = require('ramda'); const { combineReducers } = require('redux'); @@ -33,6 +35,9 @@ const reducer = combineReducers({ if (payload.type !== type) { return state; } + if (payload.type === 'sinkInput' || payload.type === 'sourceOutput') { + return state; + } return merge(state, { [payload.index]: payload, }); @@ -43,6 +48,32 @@ const reducer = combineReducers({ } return omit([ payload.index ], state); }, + [pulse.info]: (state, { payload }) => { + if (payload.type !== type) { + return state; + } + if (payload.type === 'sinkInput' || payload.type === 'sourceOutput') { + const newPao = pick([ + 'type', + 'index', + 'moduleIndex', + 'clientIndex', + 'sinkIndex', + 'sourceIndex', + ], payload); + + const oldPao = state[payload.index]; + + if (equals(newPao, oldPao)) { + return state; + } + + return merge(state, { + [newPao.index]: newPao, + }); + } + return state; + }, [pulse.close]: () => initialState.objects[key], }, initialState.objects[key]) ], things))), diff --git a/store/pulse-middleware.js b/store/pulse-middleware.js index cc0a586..69f68b0 100644 --- a/store/pulse-middleware.js +++ b/store/pulse-middleware.js @@ -9,6 +9,8 @@ const { pulse: pulseActions } = require('../actions'); const { things } = require('../constants/pulse'); +const { getPaiByTypeAndIndex } = require('../selectors'); + function getFnFromType(type) { let fn; switch (type) { @@ -29,6 +31,23 @@ function getFnFromType(type) { return 'get' + fn[0].toUpperCase() + fn.slice(1); } +function setSinkChannelVolume(pa, store, index, channelIndex, volume, cb) { + const pai = getPaiByTypeAndIndex('sink', index)(store.getState()); + pa.setSinkVolumes(index, pai.channelVolumes.map((v, i) => i === channelIndex ? volume : v), cb); +} +function setSourceChannelVolume(pa, store, index, channelIndex, volume, cb) { + const pai = getPaiByTypeAndIndex('source', index)(store.getState()); + pa.setSourceVolumes(index, pai.channelVolumes.map((v, i) => i === channelIndex ? volume : v), cb); +} +function setSinkInputChannelVolume(pa, store, index, channelIndex, volume, cb) { + const pai = getPaiByTypeAndIndex('sinkInput', index)(store.getState()); + pa.setSinkInputVolumesByIndex(index, pai.channelVolumes.map((v, i) => i === channelIndex ? volume : v), cb); +} +function setSourceOutputChannelVolume(pa, store, index, channelIndex, volume, cb) { + const pai = getPaiByTypeAndIndex('sourceOutput', index)(store.getState()); + pa.setSourceOutputVolumesByIndex(index, pai.channelVolumes.map((v, i) => i === channelIndex ? volume : v), cb); +} + module.exports = store => { const pa = new PAClient(); @@ -128,6 +147,37 @@ module.exports = store => { pa.unloadModuleByIndex(moduleIndex, rethrow); return state; }, + + [pulseActions.setSinkVolumes]: (state, { payload: { index, channelVolumes } }) => { + pa.setSinkVolumes(index, channelVolumes, rethrow); + return state; + }, + [pulseActions.setSourceVolumes]: (state, { payload: { index, channelVolumes } }) => { + pa.setSourceVolumes(index, channelVolumes, rethrow); + return state; + }, + [pulseActions.setSinkInputVolumes]: (state, { payload: { index, channelVolumes } }) => { + pa.setSinkInputVolumesByIndex(index, channelVolumes, rethrow); + return state; + }, + [pulseActions.setSourceOutputVolumes]: (state, { payload: { index, channelVolumes } }) => { + pa.setSourceOutputVolumesByIndex(index, channelVolumes, rethrow); + return state; + }, + + [pulseActions.setSinkChannelVolume]: (state, { payload: { index, channelIndex, volume } }) => { + return setSinkChannelVolume(pa, store, index, channelIndex, volume, rethrow); + }, + [pulseActions.setSourceChannelVolume]: (state, { payload: { index, channelIndex, volume } }) => { + return setSourceChannelVolume(pa, store, index, channelIndex, volume, rethrow); + }, + [pulseActions.setSinkInputChannelVolume]: (state, { payload: { index, channelIndex, volume } }) => { + return setSinkInputChannelVolume(pa, store, index, channelIndex, volume, rethrow); + }, + [pulseActions.setSourceOutputChannelVolume]: (state, { payload: { index, channelIndex, volume } }) => { + return setSourceOutputChannelVolume(pa, store, index, channelIndex, volume, rethrow); + }, + }, null); return next => action => { diff --git a/utils/memoize.js b/utils/memoize.js new file mode 100644 index 0000000..a9e920c --- /dev/null +++ b/utils/memoize.js @@ -0,0 +1,16 @@ + +const { + memoizeWith, +} = require('ramda'); + +const weakmapId_ = new WeakMap(); +const weakmapId = o => { + if (!weakmapId_.has(o)) { + weakmapId_.set(o, String(Math.random())); + } + return weakmapId_.get(o); +}; + +const memoize = memoizeWith(weakmapId); + +module.exports = memoize; diff --git a/yarn.lock b/yarn.lock index c15bd6c..4f272c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"7zip@0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/7zip/-/7zip-0.0.6.tgz#9cafb171af82329490353b4816f03347aa150a30" + integrity sha1-nK+xca+CMpSQNTtIFvAzR6oVCjA= + "@ava/babel-plugin-throws-helper@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@ava/babel-plugin-throws-helper/-/babel-plugin-throws-helper-2.0.0.tgz#2fc1fe3c211a71071a4eca7b8f7af5842cd1ae7c" @@ -79,10 +84,10 @@ dependencies: arrify "^1.0.1" -"@futpib/paclient@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@futpib/paclient/-/paclient-0.0.3.tgz#54c10ac6d811c5104b66a9a4985809955c8ffee6" - integrity sha512-9OuBDQRb9U55y3Xu89tZV+oiwt2ghgTsSAqM+SAySoLCBt0yG5LaB9PCV+bxkBy6aY6bvvPuNrYd88hL57gaxQ== +"@futpib/paclient@^0.0.5": + version "0.0.5" + resolved "https://registry.yarnpkg.com/@futpib/paclient/-/paclient-0.0.5.tgz#0de89ee7175e3de994bc298ddb1e461aa3007543" + integrity sha512-49jeRSEOXto3MntDj2Dzm4t7U9M0X41seqF+T/xwFRYk/pBUKdiXHuofNFJvn4rwM7P4BVjxU6fRpCOEAxH/VA== "@ladjs/time-require@^0.1.4": version "0.1.4" @@ -1409,6 +1414,11 @@ cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" +cross-unzip@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/cross-unzip/-/cross-unzip-0.0.2.tgz#5183bc47a09559befcf98cc4657964999359372f" + integrity sha1-UYO8R6CVWb78+YzEZXlkmZNZNy8= + crypto-random-string@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" @@ -1899,6 +1909,16 @@ ejs@^2.4.1: resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.1.tgz#498ec0d495655abc6f23cd61868d926464071aa0" integrity sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ== +electron-devtools-installer@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-2.2.4.tgz#261a50337e37121d338b966f07922eb4939a8763" + integrity sha512-b5kcM3hmUqn64+RUcHjjr8ZMpHS2WJ5YO0pnG9+P/RTdx46of/JrEjuciHWux6pE+On6ynWhHJF53j/EDJN0PA== + dependencies: + "7zip" "0.0.6" + cross-unzip "0.0.2" + rimraf "^2.5.2" + semver "^5.3.0" + electron-download@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/electron-download/-/electron-download-4.1.1.tgz#02e69556705cc456e520f9e035556ed5a015ebe8" @@ -5485,7 +5505,7 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== -rimraf@^2.2.8, rimraf@^2.6.1: +rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.6.1: version "2.6.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" integrity sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==