Add volumes

This commit is contained in:
futpib 2018-11-12 21:06:14 +03:00
parent fa26db923c
commit f756bc9626
18 changed files with 683 additions and 76 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
const r = require('r-dom');
module.exports = props => r.input({
className: 'input',
...props,
}, props.children);

View File

@ -0,0 +1,6 @@
const r = require('r-dom');
module.exports = props => r.label({
className: 'label',
}, props.children);

View File

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

View File

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

View File

@ -0,0 +1,4 @@
module.exports = class Slider {
};

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,9 @@ const initialState = {
hidePulseaudioApps: true,
hideVolumeThumbnails: false,
lockChannelsTogether: true,
maxVolume: 1.5,
showDebugInfo: false,
};

View File

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

View File

@ -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 => {

16
utils/memoize.js Normal file
View File

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

View File

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