├── .travis.yml ├── components ├── slider │ └── index.js ├── input │ └── index.js ├── select │ └── index.js ├── number-input │ └── index.js ├── label │ └── index.js ├── checkbox │ └── index.js ├── button │ └── index.js ├── top-left-on-screen-button-group │ └── index.js ├── modals │ ├── new-graph-object.js │ ├── confirmation.js │ ├── add-remote-server-modal.js │ ├── connect-to-server.js │ ├── load-module.js │ └── index.js ├── graph │ ├── layout-engine.test.js │ ├── layout-engine.js │ ├── peaks.js │ ├── satellites-graph.js │ ├── base.js │ └── index.js ├── server-info │ └── index.js ├── menu │ └── index.js ├── log │ └── index.js ├── hot-keys │ └── index.js ├── volume-peaks-provider │ └── index.js ├── volume-slider │ └── index.js ├── cards │ └── index.js ├── preferences │ └── index.js └── network │ └── index.js ├── constants ├── view.js └── pulse.js ├── assets └── trail.png ├── index.html ├── utils ├── plus-minus.js ├── memoize.js ├── weakmap-id.js ├── plus-minus.test.js ├── recompose │ └── index.js ├── d │ └── index.js ├── module-args │ └── index.js ├── theme │ ├── index.js │ └── default-colors.json └── gtk-theme │ └── index.js ├── actions ├── index.js ├── preferences.js ├── icons.js └── pulse.js ├── .editorconfig ├── resources └── pagraphcontrol.desktop ├── reducers ├── icons.js ├── index.js ├── preferences.js └── pulse.js ├── .yarnclean ├── index.js ├── store ├── index.js └── pulse-middleware.js ├── .gitignore ├── package.json ├── renderer.js ├── readme.md ├── todo.md ├── selectors └── index.js └── index.css /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | -------------------------------------------------------------------------------- /components/slider/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class Slider { 3 | 4 | }; 5 | -------------------------------------------------------------------------------- /constants/view.js: -------------------------------------------------------------------------------- 1 | 2 | const size = 120; 3 | 4 | module.exports = { size }; 5 | -------------------------------------------------------------------------------- /assets/trail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/futpib/pagraphcontrol/HEAD/assets/trail.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | -------------------------------------------------------------------------------- /utils/plus-minus.js: -------------------------------------------------------------------------------- 1 | 2 | const plusMinus = i => Math.ceil(i / 2) * ((2 * ((i + 1) % 2)) - 1); 3 | 4 | module.exports = plusMinus; 5 | -------------------------------------------------------------------------------- /actions/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = Object.assign( 3 | {}, 4 | require('./pulse'), 5 | require('./preferences'), 6 | require('./icons'), 7 | ); 8 | -------------------------------------------------------------------------------- /components/input/index.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | module.exports = props => r.input({ 5 | className: 'input', 6 | ...props, 7 | }, props.children); 8 | -------------------------------------------------------------------------------- /utils/memoize.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | memoizeWith, 4 | } = require('ramda'); 5 | 6 | const weakmapId = require('./weakmap-id'); 7 | 8 | const memoize = memoizeWith(weakmapId); 9 | 10 | module.exports = memoize; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,*.yml}] 11 | indent_style = space 12 | indent_size = 2 -------------------------------------------------------------------------------- /components/select/index.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | module.exports = ({ options, optionValue, optionText, ...props }) => r.select({ 5 | className: 'select', 6 | ...props, 7 | }, options.map(o => r.option({ value: optionValue(o) }, optionText(o)))); 8 | -------------------------------------------------------------------------------- /components/number-input/index.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | const Label = require('../label'); 5 | const Input = require('../input'); 6 | 7 | module.exports = props => r(Label, [ 8 | ...[].concat(props.children), 9 | r(Input, props), 10 | ]); 11 | -------------------------------------------------------------------------------- /utils/weakmap-id.js: -------------------------------------------------------------------------------- 1 | 2 | let counter = 0; 3 | const weakmap = new WeakMap(); 4 | const weakmapId = o => { 5 | if (!weakmap.has(o)) { 6 | weakmap.set(o, String(counter++)); 7 | } 8 | 9 | return weakmap.get(o); 10 | }; 11 | 12 | module.exports = weakmapId; 13 | -------------------------------------------------------------------------------- /components/label/index.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | module.exports = ({ userSelect, passive, ...props }) => r.label({ 5 | classSet: { 6 | label: true, 7 | 'label-user-select': userSelect, 8 | 'label-passive': passive, 9 | }, 10 | ...props, 11 | }, props.children); 12 | -------------------------------------------------------------------------------- /utils/plus-minus.test.js: -------------------------------------------------------------------------------- 1 | 2 | const test = require('ava'); 3 | 4 | const { map, range } = require('ramda'); 5 | 6 | const plusMinus = require('./plus-minus'); 7 | 8 | test('plusMinus', t => { 9 | t.deepEqual( 10 | map(plusMinus, range(0, 7)), 11 | [ 0, -1, 1, -2, 2, -3, 3 ], 12 | ); 13 | }); 14 | -------------------------------------------------------------------------------- /resources/pagraphcontrol.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.0 3 | Name=PulseAudio Graph Control 4 | GenericName=Volume Control 5 | Comment=Adjust the volume level 6 | Exec=pagraphcontrol 7 | Icon=multimedia-volume-control 8 | StartupNotify=true 9 | Type=Application 10 | Categories=AudioVideo;Audio;Mixer; 11 | -------------------------------------------------------------------------------- /components/checkbox/index.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | const Label = require('../label'); 5 | 6 | const Checkbox = ({ title, ...props }) => r(Label, { 7 | title, 8 | }, [ 9 | r.input({ 10 | ...props, 11 | type: 'checkbox', 12 | }), 13 | 14 | ...[].concat(props.children), 15 | ]); 16 | 17 | module.exports = Checkbox; 18 | -------------------------------------------------------------------------------- /actions/preferences.js: -------------------------------------------------------------------------------- 1 | 2 | const { createActions: createActionCreators } = require('redux-actions'); 3 | 4 | module.exports = createActionCreators({ 5 | PREFERENCES: { 6 | SET: null, 7 | TOGGLE: null, 8 | RESET_DEFAULTS: null, 9 | SET_ADD: (key, value) => ({ key, value }), 10 | SET_DELETE: (key, value) => ({ key, value }), 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /reducers/icons.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | merge, 4 | } = require('ramda'); 5 | 6 | const { handleActions } = require('redux-actions'); 7 | 8 | const { icons } = require('../actions'); 9 | 10 | const initialState = {}; 11 | 12 | const reducer = handleActions({ 13 | [icons.getIconPath + '_FULFILLED']: (state, { payload, meta }) => merge(state, { [meta]: payload }), 14 | }, initialState); 15 | 16 | module.exports = { 17 | initialState, 18 | reducer, 19 | }; 20 | -------------------------------------------------------------------------------- /components/button/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | memoizeWith, 4 | } = require('ramda'); 5 | 6 | const r = require('r-dom'); 7 | 8 | const ref = memoizeWith(autoFocus => String(Boolean(autoFocus)), autoFocus => input => { 9 | if (input && autoFocus) { 10 | input.focus(); 11 | } 12 | }); 13 | 14 | const Button = props => r.button({ 15 | ref, 16 | className: 'button', 17 | type: 'button', 18 | ...props, 19 | }, props.children); 20 | 21 | module.exports = Button; 22 | -------------------------------------------------------------------------------- /utils/recompose/index.js: -------------------------------------------------------------------------------- 1 | 2 | const React = require('react'); 3 | 4 | const r = require('r-dom'); 5 | 6 | const forwardRef = () => Component => React.forwardRef((props, ref) => r(Component, { 7 | ...props, 8 | __forwardedRef: ref, 9 | })); 10 | 11 | const unforwardRef = () => Component => ({ __forwardedRef, ...props }) => r(Component, { 12 | ...props, 13 | ref: __forwardedRef, 14 | }); 15 | 16 | module.exports = { 17 | forwardRef, 18 | unforwardRef, 19 | }; 20 | -------------------------------------------------------------------------------- /utils/d/index.js: -------------------------------------------------------------------------------- 1 | 2 | class D { 3 | constructor(s = '') { 4 | this._s = s; 5 | } 6 | 7 | _next(...args) { 8 | return new this.constructor([ this._s, ...args ].join(' ')); 9 | } 10 | 11 | moveTo(x, y) { 12 | return this._next('M', x, y); 13 | } 14 | 15 | lineTo(x, y) { 16 | return this._next('L', x, y); 17 | } 18 | 19 | close() { 20 | return this._next('z'); 21 | } 22 | 23 | toString() { 24 | return this._s; 25 | } 26 | } 27 | 28 | const d = () => new D(); 29 | 30 | module.exports = d; 31 | -------------------------------------------------------------------------------- /reducers/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { combineReducers } = require('redux'); 3 | 4 | const { reducer: pulse, initialState: pulseInitialState } = require('./pulse'); 5 | const { reducer: preferences, initialState: preferencesInitialState } = require('./preferences'); 6 | const { reducer: icons, initialState: iconsInitialState } = require('./icons'); 7 | 8 | const initialState = { 9 | pulse: pulseInitialState, 10 | preferences: preferencesInitialState, 11 | icons: iconsInitialState, 12 | }; 13 | 14 | const reducer = combineReducers({ 15 | pulse, 16 | preferences, 17 | icons, 18 | }); 19 | 20 | module.exports = { 21 | initialState, 22 | reducer, 23 | }; 24 | -------------------------------------------------------------------------------- /.yarnclean: -------------------------------------------------------------------------------- 1 | # test directories 2 | __tests__ 3 | test 4 | tests 5 | powered-test 6 | 7 | # asset directories 8 | docs 9 | doc 10 | website 11 | images 12 | assets 13 | 14 | # examples 15 | example 16 | examples 17 | 18 | # code coverage directories 19 | coverage 20 | .nyc_output 21 | 22 | # build scripts 23 | Makefile 24 | Gulpfile.js 25 | Gruntfile.js 26 | 27 | # configs 28 | appveyor.yml 29 | circle.yml 30 | codeship-services.yml 31 | codeship-steps.yml 32 | wercker.yml 33 | .tern-project 34 | .gitattributes 35 | .editorconfig 36 | .*ignore 37 | .eslintrc 38 | .jshintrc 39 | .flowconfig 40 | .documentup.json 41 | .yarn-metadata.json 42 | .travis.yml 43 | 44 | # misc 45 | *.md 46 | -------------------------------------------------------------------------------- /utils/module-args/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | map, 4 | toPairs, 5 | fromPairs, 6 | } = require('ramda'); 7 | 8 | const separators = { 9 | 'auth-ip-acl': ';', 10 | }; 11 | 12 | const formatModuleArgs = object => map(([ k, v ]) => { 13 | v = [].concat(v); 14 | if (k in separators) { 15 | v = v.join(separators[k]); 16 | } else { 17 | v = v.join(','); 18 | } 19 | 20 | return `${k}=${v}`; 21 | }, toPairs(object)).join(' '); 22 | 23 | const parseModuleArgs = (args = '') => fromPairs(args.split(' ').map(arg => { 24 | const [ key, ...value ] = arg.split('='); 25 | // TODO: `separators` 26 | return [ key, value.join('=') ]; 27 | })); 28 | 29 | module.exports = { formatModuleArgs, parseModuleArgs }; 30 | -------------------------------------------------------------------------------- /components/top-left-on-screen-button-group/index.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | const { connect } = require('react-redux'); 5 | 6 | const Button = require('../button'); 7 | 8 | const TopLeftOnScreenButtonGroup = props => r.div({ 9 | classSet: { 10 | panel: true, 11 | 'top-left-on-screen-button-group': true, 12 | }, 13 | }, props.preferences.hideOnScreenButtons ? [] : [ 14 | r(Button, { 15 | autoFocus: true, 16 | onClick: props.focusCards, 17 | }, 'Cards'), 18 | 19 | r(Button, { 20 | autoFocus: true, 21 | onClick: props.focusNetwork, 22 | }, 'Network'), 23 | ]); 24 | 25 | module.exports = connect( 26 | state => ({ 27 | preferences: state.preferences, 28 | }), 29 | )(TopLeftOnScreenButtonGroup); 30 | -------------------------------------------------------------------------------- /utils/theme/index.js: -------------------------------------------------------------------------------- 1 | 2 | const camelCase = require('camelcase'); 3 | 4 | const { 5 | map, 6 | } = require('ramda'); 7 | 8 | const theme = require('../gtk-theme'); 9 | 10 | const colors = require('./default-colors.json'); 11 | 12 | theme.css.replace(/@define-color\s+([\w_]+?)\s+(.+?);/g, (_, name, value) => { 13 | colors[camelCase(name)] = value; 14 | }); 15 | 16 | const resolveColor = (value, depth = 0) => { 17 | if (depth > 3) { 18 | return value; 19 | } 20 | 21 | if (value && value.startsWith('@')) { 22 | return resolveColor(colors[camelCase(value.slice(1))], depth + 1); 23 | } 24 | 25 | return value; 26 | }; 27 | 28 | module.exports = { 29 | iconThemeNames: theme.iconThemeNames, 30 | colors: map(resolveColor, colors), 31 | }; 32 | -------------------------------------------------------------------------------- /components/modals/new-graph-object.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | const React = require('react'); 5 | 6 | const Modal = require('react-modal'); 7 | 8 | const Button = require('../button'); 9 | 10 | class NewGraphObjectModal extends React.PureComponent { 11 | constructor(props) { 12 | super(props); 13 | 14 | this.state = { 15 | name: '', 16 | args: '', 17 | }; 18 | } 19 | 20 | render() { 21 | const { isOpen, onRequestClose, openLoadModuleModal } = this.props; 22 | 23 | return r(Modal, { 24 | isOpen, 25 | onRequestClose, 26 | }, [ 27 | r.h3('Add something'), 28 | 29 | r(Button, { 30 | style: { width: '100%' }, 31 | onClick: openLoadModuleModal, 32 | autoFocus: true, 33 | }, 'Load a module...'), 34 | ]); 35 | } 36 | } 37 | 38 | module.exports = NewGraphObjectModal; 39 | -------------------------------------------------------------------------------- /components/graph/layout-engine.test.js: -------------------------------------------------------------------------------- 1 | 2 | const test = require('ava'); 3 | 4 | const LayoutEngine = require('./layout-engine'); 5 | 6 | const n = (x, y) => ({ x, y }); 7 | 8 | test('nodesIntersect', t => { 9 | const l = new LayoutEngine(); 10 | const { size, margin } = l; 11 | 12 | const true_ = (x1, y1, x2, y2) => t.true(l.nodesIntersect(n(x1, y1), n(x2, y2))); 13 | const false_ = (x1, y1, x2, y2) => t.false(l.nodesIntersect(n(x1, y1), n(x2, y2))); 14 | 15 | [ 16 | [ 0, 0 ], 17 | [ 500, 500 ], 18 | [ -500, -500 ], 19 | ].forEach(([ x0, y0 ]) => { 20 | true_(x0, y0, x0, y0); 21 | 22 | false_(x0, y0, x0 + size + margin, y0); 23 | false_(x0, y0, x0, y0 + size + margin); 24 | 25 | true_(x0, y0, x0 + size + margin - 1, y0); 26 | true_(x0, y0, x0, y0 + size + margin - 1); 27 | 28 | true_(x0, y0, x0 + size + margin - 1, y0 + size + margin - 1); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /components/server-info/index.js: -------------------------------------------------------------------------------- 1 | 2 | const os = require('os'); 3 | 4 | const React = require('react'); 5 | 6 | const r = require('r-dom'); 7 | 8 | const { connect } = require('react-redux'); 9 | 10 | const { primaryPulseServer } = require('../../reducers/pulse'); 11 | 12 | const localHostname = os.hostname(); 13 | const { username: localUsername } = os.userInfo(); 14 | 15 | class ServerInfo extends React.Component { 16 | render() { 17 | const { username, hostname } = this.props.serverInfo; 18 | 19 | const server = `${username}@${hostname}`; 20 | const local = `${localUsername}@${localHostname}`; 21 | 22 | return r.div({ 23 | className: 'server-info', 24 | }, [ 25 | hostname && (server !== local) && r.span(server), 26 | ]); 27 | } 28 | } 29 | 30 | module.exports = connect( 31 | state => ({ 32 | serverInfo: state.pulse[primaryPulseServer].serverInfo, 33 | }), 34 | )(ServerInfo); 35 | -------------------------------------------------------------------------------- /utils/theme/default-colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "themeFgColor": "#eff0f1", 3 | "themeTextColor": "#eff0f1", 4 | "themeBgColor": "#31363b", 5 | "themeBaseColor": "#232629", 6 | "themeSelectedBgColor": "#3daee9", 7 | "themeSelectedFgColor": "#eff0f1", 8 | "insensitiveBgColor": "#2d3136", 9 | "insensitiveFgColor": "rgba(216, 218, 221, 0.35)", 10 | "insensitiveBaseColor": "rgba(216, 218, 221, 0.35)", 11 | "themeUnfocusedFgColor": "#eff0f1", 12 | "themeUnfocusedTextColor": "#eff0f1", 13 | "themeUnfocusedBgColor": "#31363b", 14 | "themeUnfocusedBaseColor": "#232629", 15 | "themeUnfocusedSelectedBgColor": "rgba(61, 174, 233, 0.5)", 16 | "themeUnfocusedSelectedFgColor": "#eff0f1", 17 | "borders": "#616569", 18 | "unfocusedBorders": "#616569", 19 | "insensitiveBorders": "rgba(88, 92, 95, 0.35)", 20 | "warningColor": "#f67400", 21 | "errorColor": "#da4453", 22 | "successColor": "#27ae60", 23 | "contentViewBg": "#232629" 24 | } 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | process.env.NODE_ENV = process.env.NODE_ENV || 'production'; 3 | 4 | const { app, BrowserWindow } = require('electron'); 5 | 6 | const theme = require('./utils/theme'); 7 | 8 | app.on('ready', () => { 9 | const win = new BrowserWindow({ 10 | backgroundColor: theme.colors.themeBaseColor, 11 | webPreferences: { 12 | nodeIntegration: true, 13 | enableRemoteModule: true, 14 | }, 15 | }); 16 | win.setAutoHideMenuBar(true); 17 | win.setMenuBarVisibility(false); 18 | win.loadFile('index.html'); 19 | 20 | win.on('closed', () => { 21 | app.quit(); 22 | }); 23 | 24 | if (process.env.NODE_ENV !== 'production') { 25 | const { 26 | default: installExtension, 27 | REACT_DEVELOPER_TOOLS, 28 | REDUX_DEVTOOLS, 29 | } = require('electron-devtools-installer'); 30 | 31 | installExtension(REACT_DEVELOPER_TOOLS) 32 | .then(name => console.log(`Added Extension: ${name}`)) 33 | .catch(error => console.error(error)); 34 | 35 | installExtension(REDUX_DEVTOOLS) 36 | .then(name => console.log(`Added Extension: ${name}`)) 37 | .catch(error => console.error(error)); 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /actions/icons.js: -------------------------------------------------------------------------------- 1 | 2 | const { createActions: createActionCreators } = require('redux-actions'); 3 | 4 | const freedesktopIcons = require('freedesktop-icons'); 5 | 6 | const { iconThemeNames } = require('../utils/theme'); 7 | 8 | const fallbacks = new Map(Object.entries({ 9 | 'audio-card-pci': 'audio-card', 10 | 'audio-card-usb': 'audio-card', 11 | starred: 'starred-symbolic', 12 | })); 13 | 14 | const cache = new Map(); 15 | 16 | const getIconWithFallback = async name => { 17 | if (cache.has(name)) { 18 | return cache.get(name); 19 | } 20 | 21 | let result = await freedesktopIcons({ 22 | name, 23 | type: 'scalable', 24 | }, iconThemeNames); 25 | 26 | if (!result) { 27 | result = await freedesktopIcons({ 28 | name, 29 | size: 128, 30 | }, iconThemeNames); 31 | } 32 | 33 | if (!result && fallbacks.has(name)) { 34 | return getIconWithFallback(fallbacks.get(name)); 35 | } 36 | 37 | if (!result) { 38 | console.warn('icon missing', name); 39 | } 40 | 41 | cache.set(name, result); 42 | 43 | return result; 44 | }; 45 | 46 | module.exports = createActionCreators({ 47 | ICONS: { 48 | GET_ICON_PATH: [ 49 | icon => getIconWithFallback(icon), 50 | icon => icon, 51 | ], 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /store/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { createStore, applyMiddleware } = require('redux'); 3 | 4 | const { composeWithDevTools } = require('redux-devtools-extension'); 5 | 6 | const { default: thunkMiddleware } = require('redux-thunk'); 7 | const { default: promiseMiddleware } = require('redux-promise-middleware'); 8 | 9 | const { persistStore, persistReducer } = require('redux-persist'); 10 | const createElectronStorage = require('redux-persist-electron-storage'); 11 | 12 | const { reducer, initialState } = require('../reducers'); 13 | 14 | const pulseMiddleware = require('./pulse-middleware'); 15 | 16 | const persistConfig = { 17 | key: 'redux-persist', 18 | whitelist: [ 'preferences' ], 19 | storage: createElectronStorage(), 20 | }; 21 | 22 | const dev = process.env.NODE_ENV !== 'production'; 23 | 24 | module.exports = (state = initialState) => { 25 | const middlewares = [ 26 | thunkMiddleware, 27 | promiseMiddleware, 28 | pulseMiddleware, 29 | ].filter(Boolean); 30 | 31 | const reducer_ = persistReducer(persistConfig, reducer); 32 | 33 | const store = createStore( 34 | reducer_, 35 | state, 36 | composeWithDevTools({ 37 | realtime: dev, 38 | })(applyMiddleware(...middlewares)), 39 | ); 40 | 41 | persistStore(store); 42 | 43 | return store; 44 | }; 45 | -------------------------------------------------------------------------------- /components/modals/confirmation.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | const React = require('react'); 5 | 6 | const Modal = require('react-modal'); 7 | 8 | const Checkbox = require('../checkbox'); 9 | const Button = require('../button'); 10 | 11 | class ConfirmationModal extends React.PureComponent { 12 | render() { 13 | const { target, confirmation, onConfirm, onCancel } = this.props; 14 | 15 | return r(Modal, { 16 | isOpen: Boolean(confirmation), 17 | onRequestClose: onCancel, 18 | }, [ 19 | confirmation === 'unloadModuleByIndex' && r(React.Fragment, [ 20 | r.h3('Module unload confirmation'), 21 | 22 | target && r.p([ 23 | 'You are about to unload ', 24 | r.code(target.name), 25 | '.', 26 | 'This may not be easily undoable and may impair sound playback on your system.', 27 | ]), 28 | ]), 29 | 30 | r(Checkbox, { 31 | checked: this.props.preferences.doNotAskForConfirmations, 32 | onChange: () => this.props.toggle('doNotAskForConfirmations'), 33 | }, 'Do not ask for confirmations'), 34 | 35 | r.div({ 36 | className: 'button-group', 37 | }, [ 38 | r(Button, { 39 | onClick: onCancel, 40 | }, 'Cancel'), 41 | 42 | r(Button, { 43 | onClick: onConfirm, 44 | autoFocus: true, 45 | }, 'Confirm'), 46 | ]), 47 | ]); 48 | } 49 | } 50 | 51 | module.exports = ConfirmationModal; 52 | -------------------------------------------------------------------------------- /reducers/preferences.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | merge, 4 | over, 5 | lensProp, 6 | not, 7 | omit, 8 | } = require('ramda'); 9 | 10 | const { handleActions } = require('redux-actions'); 11 | 12 | const { preferences } = require('../actions'); 13 | 14 | const initialState = { 15 | hideOnScreenButtons: false, 16 | 17 | hideDisconnectedClients: true, 18 | hideDisconnectedModules: true, 19 | hideDisconnectedSources: false, 20 | hideDisconnectedSinks: false, 21 | 22 | hideMonitorSourceEdges: false, 23 | hideMonitors: false, 24 | hidePulseaudioApps: true, 25 | 26 | hideVolumeThumbnails: false, 27 | lockChannelsTogether: true, 28 | 29 | maxVolume: 1.5, 30 | volumeStep: 1 / 20, 31 | 32 | hideLiveVolumePeaks: false, 33 | 34 | doNotAskForConfirmations: false, 35 | showDebugInfo: false, 36 | 37 | remoteServerAddresses: {}, 38 | }; 39 | 40 | const reducer = handleActions({ 41 | [preferences.set]: (state, { payload }) => merge(state, payload), 42 | [preferences.toggle]: (state, { payload }) => over(lensProp(payload), not, state), 43 | 44 | [preferences.setAdd]: (state, { payload: { key, value } }) => over(lensProp(key), merge({ [value]: true }), state), 45 | [preferences.setDelete]: (state, { payload: { key, value } }) => over(lensProp(key), omit([ value ]), state), 46 | 47 | [preferences.resetDefaults]: () => initialState, 48 | }, initialState); 49 | 50 | module.exports = { 51 | initialState, 52 | reducer, 53 | }; 54 | -------------------------------------------------------------------------------- /constants/pulse.js: -------------------------------------------------------------------------------- 1 | 2 | const PA_VOLUME_NORM = 0x10000; 3 | 4 | const things = [ { 5 | method: 'getModules', 6 | type: 'module', 7 | key: 'modules', 8 | }, { 9 | method: 'getCards', 10 | type: 'card', 11 | key: 'cards', 12 | }, { 13 | method: 'getClients', 14 | type: 'client', 15 | key: 'clients', 16 | }, { 17 | method: 'getSinks', 18 | type: 'sink', 19 | key: 'sinks', 20 | }, { 21 | method: 'getSources', 22 | type: 'source', 23 | key: 'sources', 24 | }, { 25 | method: 'getSinkInputs', 26 | type: 'sinkInput', 27 | key: 'sinkInputs', 28 | }, { 29 | method: 'getSourceOutputs', 30 | type: 'sourceOutput', 31 | key: 'sourceOutputs', 32 | } ]; 33 | 34 | const modules = { 35 | 'module-alsa-sink': { 36 | confirmUnload: true, 37 | }, 38 | 'module-alsa-source': { 39 | confirmUnload: true, 40 | }, 41 | 'module-alsa-card': { 42 | confirmUnload: true, 43 | }, 44 | 'module-oss': { 45 | confirmUnload: true, 46 | }, 47 | 'module-solaris': { 48 | confirmUnload: true, 49 | }, 50 | 51 | 'module-cli': { 52 | confirmUnload: true, 53 | }, 54 | 'module-cli-protocol-unix': { 55 | confirmUnload: true, 56 | }, 57 | 'module-simple-protocol-unix': { 58 | confirmUnload: true, 59 | }, 60 | 'module-esound-protocol-unix': { 61 | confirmUnload: true, 62 | }, 63 | 'module-native-protocol-unix': { 64 | confirmUnload: true, 65 | }, 66 | }; 67 | 68 | module.exports = { 69 | PA_VOLUME_NORM, 70 | 71 | things, 72 | modules, 73 | }; 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | dist 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pagraphcontrol", 3 | "description": "PulseAudio Graph Control", 4 | "version": "1.0.13", 5 | "private": true, 6 | "main": "index.js", 7 | "license": "GPL-3.0", 8 | "devDependencies": { 9 | "ava": "^3.9.0", 10 | "electron": "^11.1.0", 11 | "electron-devtools-installer": "^3.1.1", 12 | "electron-packager": "^15.2.0", 13 | "eslint-config-xo-overrides": "^1.4.0", 14 | "nyc": "^15.1.0", 15 | "uws": "^100.0.1", 16 | "xo": "^0.32.0" 17 | }, 18 | "scripts": { 19 | "start": "NODE_ENV=development electron .", 20 | "test": "xo && nyc ava", 21 | "build": "electron-packager --overwrite . pagraphcontrol --out dist --verbose" 22 | }, 23 | "xo": { 24 | "extends": [ 25 | "eslint-config-xo-overrides" 26 | ] 27 | }, 28 | "dependencies": { 29 | "@futpib/paclient": "^0.0.10", 30 | "@futpib/react-electron-menu": "^0.3.1", 31 | "bluebird": "^3.7.2", 32 | "camelcase": "^6.0.0", 33 | "d3": "^5.16.0", 34 | "electron-store": "^6.0.1", 35 | "freedesktop-icons": "^1.0.0", 36 | "ini": "^1.3.5", 37 | "mathjs": "^7.0.1", 38 | "pixi.js": "^5.2.4", 39 | "r-dom": "^2.4.0", 40 | "ramda": "^0.27.0", 41 | "react": "^16.13.1", 42 | "react-digraph": "^6.7.1", 43 | "react-dom": "^16.13.1", 44 | "react-hotkeys": "^1", 45 | "react-modal": "^3.11.2", 46 | "react-redux": "^7.2.0", 47 | "react-transition-group": "^4.4.1", 48 | "recompose": "^0.30.0", 49 | "redux": "^4.0.5", 50 | "redux-actions": "^2.6.5", 51 | "redux-devtools-extension": "^2.13.8", 52 | "redux-persist": "^6.0.0", 53 | "redux-persist-electron-storage": "^2.1.0", 54 | "redux-promise-middleware": "^6.1.2", 55 | "redux-thunk": "^2.3.0", 56 | "reselect": "^4.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /renderer.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const r = require('r-dom'); 4 | 5 | const { render } = require('react-dom'); 6 | 7 | const { Provider: ReduxProvider } = require('react-redux'); 8 | 9 | const createStore = require('./store'); 10 | 11 | const Graph = require('./components/graph'); 12 | const TopLeftOnScreenButtonGroup = require('./components/top-left-on-screen-button-group'); 13 | const Cards = require('./components/cards'); 14 | const Network = require('./components/network'); 15 | const Preferences = require('./components/preferences'); 16 | const Log = require('./components/log'); 17 | const ServerInfo = require('./components/server-info'); 18 | const { HotKeys } = require('./components/hot-keys'); 19 | const { MenuProvider } = require('./components/menu'); 20 | const Modals = require('./components/modals'); 21 | const { VolumePeaksProvider, VolumePeaksConsumer } = require('./components/volume-peaks-provider'); 22 | 23 | const theme = require('./utils/theme'); 24 | 25 | const Root = () => r(ReduxProvider, { 26 | store: createStore(), 27 | }, r(VolumePeaksProvider, { 28 | }, r(VolumePeaksConsumer, { 29 | }, peaks => r(HotKeys, { 30 | }, ({ 31 | graphRef, 32 | cardsRef, 33 | networkRef, 34 | preferencesRef, 35 | actions: hotKeysActions, 36 | }) => r(Modals, { 37 | }, ({ actions: modalsActions }) => r(MenuProvider, { 38 | ...modalsActions, 39 | ...hotKeysActions, 40 | }, [ 41 | r(Graph, { ref: graphRef, peaks, ...modalsActions }), 42 | r(TopLeftOnScreenButtonGroup, hotKeysActions), 43 | r(Cards, { ref: cardsRef }), 44 | r(Network, { ref: networkRef, ...modalsActions }), 45 | r(Preferences, { ref: preferencesRef }), 46 | r(ServerInfo), 47 | r(Log), 48 | ])))))); 49 | 50 | Object.entries(theme.colors).forEach(([ key, value ]) => { 51 | document.body.style.setProperty('--' + key, value); 52 | }); 53 | 54 | render(r(Root), document.querySelector('#root')); 55 | -------------------------------------------------------------------------------- /components/menu/index.js: -------------------------------------------------------------------------------- 1 | 2 | const electron = require('electron'); 3 | 4 | const React = require('react'); 5 | 6 | const r = require('r-dom'); 7 | 8 | const { 9 | WindowMenu: WindowMenuBase, 10 | MenuItem, 11 | Provider, 12 | } = require('@futpib/react-electron-menu'); 13 | 14 | const MenuProvider = ({ children, ...props }) => r(Provider, { electron }, r(React.Fragment, {}, [ 15 | r(WindowMenu, props), 16 | ...[].concat(children), 17 | ])); 18 | 19 | const WindowMenu = props => r(WindowMenuBase, [ 20 | r(MenuItem, { 21 | label: '&File', 22 | }, [ 23 | r(MenuItem, { 24 | label: 'Open a server...', 25 | accelerator: 'CommandOrControl+N', 26 | onClick: props.openConnectToServerModal, 27 | }), 28 | 29 | r(MenuItem.Separator), 30 | 31 | r(MenuItem, { 32 | label: 'Quit', 33 | role: 'quit', 34 | }), 35 | ]), 36 | 37 | r(MenuItem, { 38 | label: '&View', 39 | }, [ 40 | r(MenuItem, { 41 | label: 'Cards', 42 | onClick: props.focusCards, 43 | }), 44 | r(MenuItem, { 45 | label: 'Network', 46 | onClick: props.focusNetwork, 47 | }), 48 | r(MenuItem, { 49 | label: 'Preferences', 50 | onClick: props.focusPreferences, 51 | }), 52 | 53 | r(MenuItem.Separator), 54 | 55 | r(MenuItem, { 56 | label: 'Reload', 57 | role: 'reload', 58 | }), 59 | r(MenuItem, { 60 | label: 'Force Reload', 61 | role: 'forcereload', 62 | }), 63 | r(MenuItem, { 64 | label: 'Toggle Developer Tools', 65 | role: 'toggledevtools', 66 | }), 67 | 68 | r(MenuItem.Separator), 69 | 70 | r(MenuItem, { 71 | label: 'Toggle Full Screen', 72 | role: 'togglefullscreen', 73 | }), 74 | ]), 75 | 76 | r(MenuItem, { 77 | label: '&Help', 78 | }, [ 79 | r(MenuItem, { 80 | label: 'Documentation', 81 | onClick: () => electron.shell.openExternal('https://github.com/futpib/pagraphcontrol#readme'), 82 | }), 83 | ]), 84 | ]); 85 | 86 | module.exports = { MenuProvider }; 87 | -------------------------------------------------------------------------------- /components/modals/add-remote-server-modal.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | const React = require('react'); 5 | 6 | const { connect } = require('react-redux'); 7 | const { bindActionCreators } = require('redux'); 8 | 9 | const Modal = require('react-modal'); 10 | 11 | const Button = require('../button'); 12 | const Label = require('../label'); 13 | const Input = require('../input'); 14 | 15 | const { 16 | preferences: preferencesActions, 17 | } = require('../../actions'); 18 | 19 | class AddRemoteServerModal extends React.PureComponent { 20 | constructor(props) { 21 | super(props); 22 | 23 | this.state = { 24 | address: 'tcp:remote-computer.lan', 25 | }; 26 | 27 | this.handleSubmit = this.handleSubmit.bind(this); 28 | } 29 | 30 | handleSubmit(event) { 31 | event.preventDefault(); 32 | 33 | const { address } = this.state; 34 | this.props.setAdd('remoteServerAddresses', address); 35 | this.props.onRequestClose(); 36 | } 37 | 38 | render() { 39 | const { isOpen, onRequestClose } = this.props; 40 | 41 | return r(Modal, { 42 | isOpen, 43 | onRequestClose, 44 | }, [ 45 | r.h3('Add remote server'), 46 | 47 | r.form({ 48 | onSubmit: this.handleSubmit, 49 | }, [ 50 | r(Label, { 51 | title: 'PULSE_SERVER syntax', 52 | }, [ 53 | r.div('Server address:'), 54 | r.p([ 55 | r(Input, { 56 | style: { width: '100%' }, 57 | autoFocus: true, 58 | value: this.state.address, 59 | onChange: ({ target: { value } }) => this.setState({ address: value }), 60 | }), 61 | ]), 62 | ]), 63 | 64 | r.div({ 65 | className: 'button-group', 66 | }, [ 67 | r(Button, { 68 | onClick: onRequestClose, 69 | }, 'Cancel'), 70 | 71 | r(Button, { 72 | type: 'submit', 73 | }, 'Confirm'), 74 | ]), 75 | ]), 76 | ]); 77 | } 78 | } 79 | 80 | module.exports = connect( 81 | null, 82 | dispatch => bindActionCreators(preferencesActions, dispatch), 83 | )(AddRemoteServerModal); 84 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # PulseAudio Graph Control 2 | [ 3 | ](https://travis-ci.org/futpib/pagraphcontrol) 4 | 5 | ## Screenshot 6 | 7 |  8 | 9 | ## Keyboard Shortcuts 10 | 11 | | Key | Mnemonic | Description | 12 | | ------------- | -------------- | --------------------- | 13 | | hjkl ←↓↑→ | vim-like | Selecting objects | 14 | | space | | Toggle mute (also try with control and/or shift) | 15 | | 90 /* | mpv-like | Volume up/down | 16 | | cnpg | Cards, Network, Preferences, Graph | Switch between panels | 17 | | f | Pay respects | Set sink/source as default | 18 | | m | Move | Move selected object | 19 | | a | Add | Load a module | 20 | 21 | ## Install 22 | 23 | ### Arch 24 | 25 | [pagraphcontrol-git on AUR](https://aur.archlinux.org/packages/pagraphcontrol-git) 26 | 27 | ```bash 28 | yay -S pagraphcontrol-git 29 | ``` 30 | 31 | ### Ubuntu (manual build) 32 | 33 | ```bash 34 | sudo apt install npm python 35 | sudo npm install -g yarn 36 | 37 | git clone https://github.com/futpib/pagraphcontrol.git 38 | cd pagraphcontrol 39 | 40 | yarn install 41 | yarn build 42 | ``` 43 | 44 | #### PulseAudio volume peaks (optional) 45 | To see audio peaks build [papeaks](https://github.com/futpib/papeaks) and put it on your `PATH`. 46 | 47 | ## See Also 48 | 49 | ### Other PulseAudio Superpowers 50 | 51 | * [pulseaudio-dlna](https://github.com/masmu/pulseaudio-dlna) - DLNA / UPNP / Chromecast - streams to most TVs and stuff 52 | * [PulseEffects](https://github.com/wwmm/pulseeffects) - Equalizer and other audio effects 53 | 54 | ### PulseAudio Documentation 55 | 56 | * [Modules](https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/Modules/) 57 | * [Server (Connection) String Format](https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/ServerStrings/) 58 | -------------------------------------------------------------------------------- /components/modals/connect-to-server.js: -------------------------------------------------------------------------------- 1 | 2 | const { spawn } = require('child_process'); 3 | 4 | const { 5 | merge, 6 | } = require('ramda'); 7 | 8 | const r = require('r-dom'); 9 | 10 | const React = require('react'); 11 | 12 | const Modal = require('react-modal'); 13 | 14 | const Button = require('../button'); 15 | const Label = require('../label'); 16 | const Input = require('../input'); 17 | 18 | class ConnectToServerModal extends React.PureComponent { 19 | constructor(props) { 20 | super(props); 21 | 22 | this.state = { 23 | address: props.defaults.address, 24 | }; 25 | 26 | this.handleSubmit = this.handleSubmit.bind(this); 27 | } 28 | 29 | handleSubmit(event) { 30 | event.preventDefault(); 31 | 32 | const subprocess = spawn('pagraphcontrol', [], { 33 | detached: true, 34 | stdio: 'ignore', 35 | env: merge(process.env, { 36 | PULSE_SERVER: this.state.address, 37 | }), 38 | }); 39 | 40 | subprocess.unref(); 41 | 42 | this.props.onRequestClose(); 43 | } 44 | 45 | render() { 46 | const { isOpen, onRequestClose } = this.props; 47 | 48 | return r(Modal, { 49 | isOpen, 50 | onRequestClose, 51 | }, [ 52 | r.h3('Connect to PulseAudio server'), 53 | 54 | r.form({ 55 | onSubmit: this.handleSubmit, 56 | }, [ 57 | r(Label, [ 58 | r.div({ 59 | title: 'Same format as PULSE_SERVER', 60 | }, 'Specify the server to connect to:'), 61 | r.p([ 62 | r(Input, { 63 | style: { width: '100%' }, 64 | autoFocus: true, 65 | value: this.state.address, 66 | onChange: ({ target: { value } }) => this.setState({ address: value }), 67 | }), 68 | ]), 69 | ]), 70 | 71 | r.div({ 72 | className: 'button-group', 73 | }, [ 74 | r(Button, { 75 | onClick: onRequestClose, 76 | }, 'Cancel'), 77 | 78 | r(Button, { 79 | type: 'submit', 80 | }, 'Connect'), 81 | ]), 82 | ]), 83 | ]); 84 | } 85 | } 86 | 87 | ConnectToServerModal.defaultProps = { 88 | defaults: { 89 | address: 'tcp:remote-computer.lan', 90 | }, 91 | }; 92 | 93 | module.exports = ConnectToServerModal; 94 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | 1 exit Terminate the daemon 2 | 1 load-module Load a module (args: name, arguments) 3 | 2 set-sink-port Change the port of a sink (args: index|name, port-name) 4 | 2 set-source-port Change the port of a source (args: index|name, port-name) 5 | 3 describe-module Describe a module (arg: name) 6 | 3 help Show this help 7 | 4 set-port-latency-offset Change the latency of a port (args: card-index|card-name, port-name, latency-offset) 8 | 5 suspend Suspend all sinks and all sources (args: bool) 9 | 5 suspend-sink Suspend sink (args: index|name, bool) 10 | 5 suspend-source Suspend source (args: index|name, bool) 11 | 8 list-samples List all entries in the sample cache 12 | 8 load-sample Load a sound file into the sample cache (args: name, filename) 13 | 8 load-sample-dir-lazy Lazily load all files in a directory into the sample cache (args: pathname) 14 | 8 load-sample-lazy Lazily load a sound file into the sample cache (args: name, filename) 15 | 8 play-file Play a sound file (args: filename, sink|index) 16 | 8 play-sample Play a sample from the sample cache (args: name, sink|index) 17 | 8 remove-sample Remove a sample from the sample cache (args: name) 18 | 9 dump Dump daemon configuration 19 | 9 info Show comprehensive status 20 | 9 set-log-backtrace Show backtrace in log messages (args: frames) 21 | 9 set-log-level Change the log level (args: numeric level) 22 | 9 set-log-meta Show source code location in log messages (args: bool) 23 | 9 set-log-target Change the log target (args: null|auto|syslog|stderr|file:PATH|newfile:PATH) 24 | 9 set-log-time Show timestamps in log messages (args: bool) 25 | 9 stat Show memory block statistics 26 | 9 update-sink-input-proplist Update the properties of a sink input (args: index, properties) 27 | 9 update-sink-proplist Update the properties of a sink (args: index|name, properties) 28 | 9 update-source-output-proplist Update the properties of a source output (args: index, properties) 29 | 9 update-source-proplist Update the properties of a source (args: index|name, properties) 30 | -------------------------------------------------------------------------------- /components/log/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | compose, 4 | map, 5 | filter, 6 | differenceWith, 7 | takeLast, 8 | } = require('ramda'); 9 | 10 | const React = require('react'); 11 | 12 | const { TransitionGroup, CSSTransition } = require('react-transition-group'); 13 | 14 | const r = require('r-dom'); 15 | 16 | const { connect } = require('react-redux'); 17 | 18 | const weakmapId = require('../../utils/weakmap-id'); 19 | 20 | const { pulse: pulseActions } = require('../../actions'); 21 | 22 | const { primaryPulseServer } = require('../../reducers/pulse'); 23 | 24 | const actionTypeText = { 25 | [pulseActions.ready]: 'Connected to PulseAudio', 26 | [pulseActions.close]: 'Disconnected from PulseAudio', 27 | }; 28 | 29 | class Log extends React.Component { 30 | constructor(props) { 31 | super(props); 32 | 33 | this.state = { 34 | removedItems: [], 35 | }; 36 | } 37 | 38 | removeItem(item) { 39 | this.setState({ 40 | removedItems: takeLast(10, this.state.removedItems.concat(weakmapId(item))), 41 | }); 42 | } 43 | 44 | shouldShowItem(item) { 45 | return !this.state.removedItems.includes(weakmapId(item)); 46 | } 47 | 48 | itemText(item) { 49 | if (item.type === 'error') { 50 | return `${item.error.name}: ${item.error.message}`; 51 | } 52 | 53 | return actionTypeText[item.action] || item.action; 54 | } 55 | 56 | componentDidUpdate(previousProps) { 57 | const newItems = differenceWith((a, b) => a === b, this.props.log.items, previousProps.log.items); 58 | newItems.forEach(item => setTimeout(() => { 59 | this.removeItem(item); 60 | }, this.props.itemLifetime)); 61 | } 62 | 63 | render() { 64 | return r.div({ 65 | className: 'log', 66 | }, r(TransitionGroup, compose( 67 | map(item => r(CSSTransition, { 68 | key: weakmapId(item), 69 | className: 'log-item-transition', 70 | timeout: { enter: 300, leave: 2000 }, 71 | }, r.div({ 72 | classSet: { 73 | 'log-item': true, 74 | 'log-item-error': item.type === 'error', 75 | 'log-item-info': item.type === 'info', 76 | }, 77 | }, this.itemText(item)))), 78 | filter(item => this.shouldShowItem(item)), 79 | )(this.props.log.items))); 80 | } 81 | } 82 | 83 | Log.defaultProps = { 84 | itemLifetime: 5000, 85 | }; 86 | 87 | module.exports = connect( 88 | state => ({ 89 | log: state.pulse[primaryPulseServer].log, 90 | }), 91 | )(Log); 92 | -------------------------------------------------------------------------------- /utils/gtk-theme/index.js: -------------------------------------------------------------------------------- 1 | 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const ini = require('ini'); 6 | 7 | const gtkIniPaths = []; 8 | 9 | gtkIniPaths.push('/etc/gtk-3.0/settings.ini'); 10 | 11 | if (process.env.XDG_CONFIG_DIRS) { 12 | gtkIniPaths.push(...process.env.XDG_CONFIG_DIRS.split(':') 13 | .map(dir => path.join(dir, 'gtk-3.0', 'settings.ini'))); 14 | } 15 | 16 | gtkIniPaths.push(path.join( 17 | process.env.HOME, 18 | process.env.XDG_CONFIG_HOME || '.config', 19 | 'gtk-3.0', 20 | 'settings.ini', 21 | )); 22 | 23 | const gtkInis = []; 24 | try { 25 | gtkIniPaths.forEach(path => { 26 | const gtk = ini.parse(fs.readFileSync(path, 'utf-8')); 27 | gtkInis.push(gtk); 28 | }); 29 | } catch (error) { 30 | if (error.code !== 'ENOENT') { 31 | console.warn(error); 32 | } 33 | } 34 | 35 | let themeName = 'Adwaita'; 36 | const iconThemeNames = [ 'Adwaita', 'hicolor' ]; 37 | 38 | gtkInis.forEach(gtk => { 39 | if (gtk && gtk.Settings) { 40 | if (gtk.Settings['gtk-icon-theme-name']) { 41 | iconThemeNames[0] = gtk.Settings['gtk-icon-theme-name']; 42 | } 43 | 44 | if (gtk.Settings['gtk-fallback-icon-theme']) { 45 | iconThemeNames[1] = gtk.Settings['gtk-fallback-icon-theme']; 46 | } 47 | 48 | if (gtk.Settings['gtk-theme-name']) { 49 | themeName = gtk.Settings['gtk-theme-name']; 50 | } 51 | } 52 | }); 53 | 54 | const themePaths = [ 55 | path.join( 56 | '/', 57 | 'usr', 58 | 'share', 59 | 'themes', 60 | ), 61 | path.join( 62 | process.env.HOME, 63 | '.themes', 64 | ), 65 | path.join( 66 | process.env.HOME, 67 | '.local', 68 | 'share', 69 | 'themes', 70 | ), 71 | ]; 72 | 73 | let css = ''; 74 | 75 | for (const themePath of themePaths) { 76 | const themeNamePath = path.join(themePath, themeName); 77 | 78 | try { 79 | fs.readdirSync(themeNamePath); 80 | } catch (error) { 81 | if (error.code === 'ENOENT') { 82 | continue; 83 | } 84 | 85 | throw error; 86 | } 87 | 88 | try { 89 | css = fs.readFileSync(path.join(themeNamePath, 'gtk-3.0', 'gtk.css'), 'utf8'); 90 | break; 91 | } catch (error) { 92 | if (error.code === 'ENOENT') { 93 | continue; 94 | } 95 | 96 | throw error; 97 | } 98 | } 99 | 100 | module.exports = { 101 | iconThemeNames, 102 | themeName, 103 | css, 104 | }; 105 | -------------------------------------------------------------------------------- /components/modals/load-module.js: -------------------------------------------------------------------------------- 1 | 2 | const r = require('r-dom'); 3 | 4 | const React = require('react'); 5 | 6 | const { connect } = require('react-redux'); 7 | const { bindActionCreators } = require('redux'); 8 | 9 | const Modal = require('react-modal'); 10 | 11 | const Button = require('../button'); 12 | const Label = require('../label'); 13 | const Input = require('../input'); 14 | 15 | const { 16 | pulse: pulseActions, 17 | } = require('../../actions'); 18 | 19 | class LoadModuleModal extends React.PureComponent { 20 | constructor(props) { 21 | super(props); 22 | 23 | this.state = { 24 | name: props.defaults.name, 25 | args: props.defaults.args, 26 | }; 27 | 28 | this.handleSubmit = this.handleSubmit.bind(this); 29 | } 30 | 31 | handleSubmit(event) { 32 | event.preventDefault(); 33 | 34 | const { name, args } = this.state; 35 | this.props.loadModule(name, args); 36 | this.props.onRequestClose(); 37 | } 38 | 39 | render() { 40 | const { isOpen, onRequestClose } = this.props; 41 | 42 | return r(Modal, { 43 | isOpen, 44 | onRequestClose, 45 | }, [ 46 | r.h3('Load a module'), 47 | 48 | r.form({ 49 | onSubmit: this.handleSubmit, 50 | }, [ 51 | r(Label, [ 52 | r.div('Module name:'), 53 | r.p([ 54 | r(Input, { 55 | style: { width: '100%' }, 56 | autoFocus: true, 57 | value: this.state.name, 58 | onChange: ({ target: { value } }) => this.setState({ name: value }), 59 | }), 60 | ]), 61 | ]), 62 | 63 | r(Label, [ 64 | r.div('Arguments:'), 65 | r.p([ 66 | r(Input, { 67 | style: { width: '100%' }, 68 | value: this.state.args, 69 | onChange: ({ target: { value } }) => this.setState({ args: value }), 70 | }), 71 | ]), 72 | ]), 73 | 74 | r.div({ 75 | className: 'button-group', 76 | }, [ 77 | r(Button, { 78 | onClick: onRequestClose, 79 | }, 'Cancel'), 80 | 81 | r(Button, { 82 | type: 'submit', 83 | }, 'Confirm'), 84 | ]), 85 | ]), 86 | ]); 87 | } 88 | } 89 | 90 | LoadModuleModal.defaultProps = { 91 | defaults: { 92 | name: '', 93 | args: '', 94 | }, 95 | }; 96 | 97 | module.exports = connect( 98 | null, 99 | dispatch => bindActionCreators(pulseActions, dispatch), 100 | )(LoadModuleModal); 101 | -------------------------------------------------------------------------------- /actions/pulse.js: -------------------------------------------------------------------------------- 1 | 2 | const { map } = require('ramda'); 3 | 4 | const { createActions: createActionCreators } = require('redux-actions'); 5 | 6 | const withMetaPulseServerId = payloadCreator => { 7 | const metaCreator = (...args) => ({ 8 | pulseServerId: args[payloadCreator.length], 9 | }); 10 | 11 | return [ 12 | payloadCreator, 13 | metaCreator, 14 | ]; 15 | }; 16 | 17 | const noop = () => null; 18 | const identity = x => x; 19 | 20 | module.exports = createActionCreators({ 21 | PULSE: map(withMetaPulseServerId, { 22 | READY: noop, 23 | CLOSE: noop, 24 | 25 | CONNECT: noop, 26 | DISCONNECT: noop, 27 | 28 | ERROR: identity, 29 | 30 | NEW: identity, 31 | CHANGE: identity, 32 | REMOVE: identity, 33 | 34 | INFO: identity, 35 | 36 | SERVER_INFO: identity, 37 | 38 | MOVE_SINK_INPUT: (sinkInputIndex, destSinkIndex) => ({ sinkInputIndex, destSinkIndex }), 39 | MOVE_SOURCE_OUTPUT: (sourceOutputIndex, destSourceIndex) => ({ sourceOutputIndex, destSourceIndex }), 40 | 41 | KILL_CLIENT_BY_INDEX: clientIndex => ({ clientIndex }), 42 | 43 | KILL_SINK_INPUT_BY_INDEX: sinkInputIndex => ({ sinkInputIndex }), 44 | KILL_SOURCE_OUTPUT_BY_INDEX: sourceOutputIndex => ({ sourceOutputIndex }), 45 | 46 | LOAD_MODULE: (name, argument) => ({ name, argument }), 47 | UNLOAD_MODULE_BY_INDEX: moduleIndex => ({ moduleIndex }), 48 | 49 | SET_SINK_VOLUMES: (index, channelVolumes) => ({ index, channelVolumes }), 50 | SET_SOURCE_VOLUMES: (index, channelVolumes) => ({ index, channelVolumes }), 51 | SET_SINK_INPUT_VOLUMES: (index, channelVolumes) => ({ index, channelVolumes }), 52 | SET_SOURCE_OUTPUT_VOLUMES: (index, channelVolumes) => ({ index, channelVolumes }), 53 | 54 | SET_SINK_CHANNEL_VOLUME: (index, channelIndex, volume) => ({ index, channelIndex, volume }), 55 | SET_SOURCE_CHANNEL_VOLUME: (index, channelIndex, volume) => ({ index, channelIndex, volume }), 56 | SET_SINK_INPUT_CHANNEL_VOLUME: (index, channelIndex, volume) => ({ index, channelIndex, volume }), 57 | SET_SOURCE_OUTPUT_CHANNEL_VOLUME: (index, channelIndex, volume) => ({ index, channelIndex, volume }), 58 | 59 | SET_CARD_PROFILE: (index, profileName) => ({ index, profileName }), 60 | 61 | SET_SINK_PORT: (index, portName) => ({ index, portName }), 62 | SET_SOURCE_PORT: (index, portName) => ({ index, portName }), 63 | 64 | SET_SINK_MUTE: (index, muted) => ({ index, muted }), 65 | SET_SOURCE_MUTE: (index, muted) => ({ index, muted }), 66 | SET_SINK_INPUT_MUTE_BY_INDEX: (index, muted) => ({ index, muted }), 67 | SET_SOURCE_OUTPUT_MUTE_BY_INDEX: (index, muted) => ({ index, muted }), 68 | 69 | SET_DEFAULT_SINK_BY_NAME: name => ({ name }), 70 | SET_DEFAULT_SOURCE_BY_NAME: name => ({ name }), 71 | }), 72 | }); 73 | -------------------------------------------------------------------------------- /components/hot-keys/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | keys, 4 | pick, 5 | map, 6 | bind, 7 | } = require('ramda'); 8 | 9 | const React = require('react'); 10 | 11 | const r = require('r-dom'); 12 | 13 | const { HotKeys } = require('react-hotkeys'); 14 | 15 | const keyMap = { 16 | hotKeyEscape: 'escape', 17 | 18 | hotKeyFocusCards: 'c', 19 | hotKeyFocusNetwork: 'n', 20 | hotKeyFocusGraph: 'g', 21 | hotKeyFocusPreferences: 'p', 22 | 23 | hotKeyFocusDown: [ 'j', 'down' ], 24 | hotKeyFocusUp: [ 'k', 'up' ], 25 | hotKeyFocusLeft: [ 'h', 'left' ], 26 | hotKeyFocusRight: [ 'l', 'right' ], 27 | 28 | hotKeyMove: 'm', 29 | 30 | hotKeyVolumeDown: [ '/', '9' ], 31 | hotKeyVolumeUp: [ '*', '0' ], 32 | 33 | hotKeyMute: [ 34 | 'space', 35 | 'shift+space', 36 | 'ctrl+space', 37 | 'ctrl+shift+space', 38 | ], 39 | 40 | hotKeySetAsDefault: 'f', 41 | 42 | hotKeyAdd: 'a', 43 | }; 44 | 45 | class MyHotKeys extends React.Component { 46 | constructor(props) { 47 | super(props); 48 | 49 | this.graphRef = React.createRef(); 50 | this.cardsRef = React.createRef(); 51 | this.networkRef = React.createRef(); 52 | this.preferencesRef = React.createRef(); 53 | } 54 | 55 | componentDidMount() { 56 | this.hotKeyFocusGraph(); 57 | } 58 | 59 | hotKeyFocusGraph() { 60 | this.cardsRef.current.close(); 61 | this.networkRef.current.close(); 62 | this.preferencesRef.current.close(); 63 | this.graphRef.current.focus(); 64 | } 65 | 66 | hotKeyFocusCards() { 67 | this.networkRef.current.close(); 68 | this.preferencesRef.current.close(); 69 | 70 | const cards = this.cardsRef.current; 71 | cards.toggle(); 72 | if (!cards.isOpen()) { 73 | this.graphRef.current.focus(); 74 | } 75 | } 76 | 77 | hotKeyFocusNetwork() { 78 | this.cardsRef.current.close(); 79 | this.preferencesRef.current.close(); 80 | 81 | const network = this.networkRef.current; 82 | network.toggle(); 83 | if (!network.isOpen()) { 84 | this.graphRef.current.focus(); 85 | } 86 | } 87 | 88 | hotKeyFocusPreferences() { 89 | this.cardsRef.current.close(); 90 | this.networkRef.current.close(); 91 | 92 | const preferences = this.preferencesRef.current; 93 | preferences.toggle(); 94 | if (!preferences.isOpen()) { 95 | this.graphRef.current.focus(); 96 | } 97 | } 98 | 99 | hotKeyEscape() { 100 | this.hotKeyFocusGraph(); 101 | } 102 | 103 | render() { 104 | const handlers = map(f => bind(f, this), pick(keys(keyMap), this)); 105 | return r(HotKeys, { 106 | keyMap, 107 | handlers, 108 | }, this.props.children({ 109 | graphRef: this.graphRef, 110 | cardsRef: this.cardsRef, 111 | networkRef: this.networkRef, 112 | preferencesRef: this.preferencesRef, 113 | 114 | actions: { 115 | focusGraph: handlers.hotKeyFocusGraph, 116 | focusCards: handlers.hotKeyFocusCards, 117 | focusNetwork: handlers.hotKeyFocusNetwork, 118 | focusPreferences: handlers.hotKeyFocusPreferences, 119 | }, 120 | })); 121 | } 122 | } 123 | 124 | module.exports = { 125 | HotKeys: MyHotKeys, 126 | keyMap, 127 | }; 128 | -------------------------------------------------------------------------------- /components/graph/layout-engine.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | filter, 4 | } = require('ramda'); 5 | 6 | const { size } = require('../../constants/view'); 7 | 8 | const plusMinus = require('../../utils/plus-minus'); 9 | 10 | const margin = size / 10; 11 | const step = size + (2 * margin); 12 | 13 | const offsetY = 1080 / 2; 14 | 15 | const centerColumnsCount = 5; 16 | 17 | module.exports = class LayoutEngine { 18 | constructor() { 19 | Object.assign(this, { 20 | size, 21 | margin, 22 | }); 23 | } 24 | 25 | nodesIntersect(a, b) { 26 | if (a.x === undefined || a.y === undefined || b.x === undefined || b.y === undefined) { 27 | return undefined; 28 | } 29 | 30 | return (((a.x - size - margin) < b.x) && (b.x < (a.x + size + margin))) 31 | && (((a.y - size - margin) < b.y) && (b.y < (a.y + size + margin))); 32 | } 33 | 34 | calculatePosition(node) { 35 | return node; 36 | } 37 | 38 | adjustNodes(nodes) { 39 | const targetClientsColumnHeight = Math.round(filter( 40 | x => x.type === 'sink' || x.type === 'source', 41 | nodes, 42 | ).length * 0.75); 43 | 44 | const estimatedColumnHeights = { 45 | total: 0, 46 | 47 | get(k) { 48 | return this[k] || 0; 49 | }, 50 | 51 | inc(k) { 52 | this[k] = this[k] || 0; 53 | this[k] += 1; 54 | this.total += 1; 55 | return this[k]; 56 | }, 57 | }; 58 | 59 | const nodeColumn = node => Math.round(node.x / step) - 2; 60 | 61 | const unpositionedNodes = nodes.filter(node => { 62 | if (node.type === 'satellite') { 63 | return false; 64 | } 65 | 66 | if (node.x !== undefined) { 67 | estimatedColumnHeights.inc(nodeColumn(node)); 68 | return false; 69 | } 70 | 71 | return true; 72 | }); 73 | 74 | unpositionedNodes.forEach(node => { 75 | if (node.type === 'source') { 76 | node.x = 0 * step; 77 | } else if (node.type === 'sink') { 78 | node.x = 8 * step; 79 | } else { 80 | let targetCol = node.index % centerColumnsCount; 81 | if (estimatedColumnHeights.get(2) < targetClientsColumnHeight) { 82 | targetCol = 2; 83 | } 84 | 85 | node.x = (2 * step) + (targetCol * step); 86 | } 87 | 88 | const columnHeight = estimatedColumnHeights.inc(nodeColumn(node)); 89 | 90 | const direction = Math.sign(plusMinus(node.index)); 91 | 92 | node.y = offsetY + (direction * (Math.floor(columnHeight / 2) - 1) * (size + margin)); 93 | 94 | let intersected = true; 95 | let iterations = 0; 96 | while (intersected && iterations < 10) { 97 | intersected = false; 98 | for (const otherNode of nodes) { 99 | if (otherNode.type === 'satellite') { 100 | continue; 101 | } 102 | 103 | if (otherNode === node) { 104 | continue; 105 | } 106 | 107 | iterations += 1; 108 | 109 | if (this.nodesIntersect(node, otherNode)) { 110 | node.y += direction * (size + margin); 111 | intersected = true; 112 | } 113 | } 114 | } 115 | }); 116 | 117 | return nodes; 118 | } 119 | 120 | getPositionForNode(node) { 121 | return this.calculatePosition(node); 122 | } 123 | }; 124 | -------------------------------------------------------------------------------- /components/volume-peaks-provider/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { EventEmitter } = require('events'); 3 | 4 | const { spawn } = require('child_process'); 5 | 6 | const { connect } = require('react-redux'); 7 | 8 | const React = require('react'); 9 | 10 | const r = require('r-dom'); 11 | 12 | const { primaryPulseServer } = require('../../reducers/pulse'); 13 | 14 | const PA_SUBSCRIPTION_EVENT_SOURCE = 0x0001; 15 | const PA_SUBSCRIPTION_EVENT_SINK_INPUT = 0x0002; 16 | 17 | const VolumePeaksContext = React.createContext(null); 18 | 19 | function spawnProcess({ onPeak, onExit }) { 20 | const process = spawn('papeaks', [ 21 | '--output', 22 | 'binary', 23 | ], { 24 | shell: true, 25 | stdio: [ 'ignore', 'pipe', 'inherit' ], 26 | }); 27 | 28 | let leftover = null; 29 | const handleData = data => { 30 | if (leftover) { 31 | data = Buffer.concat([ leftover, data ]); 32 | } 33 | 34 | let p = 0; 35 | while (p < data.length) { 36 | const left = data.length - p; 37 | if (left >= 12) { 38 | leftover = null; 39 | } else { 40 | leftover = data.slice(p); 41 | break; 42 | } 43 | 44 | const type = data.readInt32LE(p); 45 | p += 4; 46 | const index = data.readInt32LE(p); 47 | p += 4; 48 | const peak = data.readFloatLE(p); 49 | p += 4; 50 | 51 | const typeString = type === PA_SUBSCRIPTION_EVENT_SOURCE 52 | ? 'source' 53 | : (type === PA_SUBSCRIPTION_EVENT_SINK_INPUT 54 | ? 'sinkInput' 55 | : 'unexpected'); 56 | onPeak(typeString, index, peak); 57 | } 58 | }; 59 | 60 | const handleExit = () => { 61 | process.off('data', handleData); 62 | process.off('exit', handleExit); 63 | if (onExit) { 64 | onExit(); 65 | } 66 | }; 67 | 68 | process.stdout.on('data', handleData); 69 | process.on('exit', handleExit); 70 | 71 | return process; 72 | } 73 | 74 | class VolumePeaksProvider extends React.Component { 75 | constructor(props) { 76 | super(props); 77 | 78 | this.state = {}; 79 | 80 | this.emitter = new EventEmitter(); 81 | } 82 | 83 | static getDerivedStateFromProps(props) { 84 | const state = props.hideLiveVolumePeaks ? 'closed' : props.state; 85 | return { state }; 86 | } 87 | 88 | componentDidMount() { 89 | if (this.state.state === 'ready') { 90 | this._spawnProcess(); 91 | } 92 | } 93 | 94 | componentDidUpdate(previousProps, previousState) { 95 | if (this.state.state !== 'ready' && previousState.state === 'ready') { 96 | this._killProcess(); 97 | } else if (this.state.state === 'ready' && previousState.state !== 'ready') { 98 | this._spawnProcess(); 99 | } 100 | } 101 | 102 | componentWillUnmount() { 103 | this._killProcess(); 104 | this.emitter.removeAllListeners(); 105 | } 106 | 107 | _spawnProcess() { 108 | this.process = spawnProcess({ 109 | onPeak: (type, index, peak) => { 110 | this.emitter.emit('peak', type, index, peak); 111 | }, 112 | }); 113 | } 114 | 115 | _killProcess() { 116 | if (this.process && !this.process.killed) { 117 | this.process.kill(); 118 | } 119 | } 120 | 121 | render() { 122 | return r(VolumePeaksContext.Provider, { 123 | value: this.emitter, 124 | }, this.props.children); 125 | } 126 | } 127 | 128 | module.exports = { 129 | VolumePeaksProvider: connect( 130 | state => ({ 131 | state: state.pulse[primaryPulseServer].state, 132 | 133 | hideLiveVolumePeaks: state.preferences.hideLiveVolumePeaks, 134 | }), 135 | )(VolumePeaksProvider), 136 | 137 | VolumePeaksConsumer: VolumePeaksContext.Consumer, 138 | }; 139 | -------------------------------------------------------------------------------- /selectors/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | map, 4 | prop, 5 | path, 6 | filter, 7 | find, 8 | indexBy, 9 | pickBy, 10 | propEq, 11 | values, 12 | } = require('ramda'); 13 | 14 | const { createSelector } = require('reselect'); 15 | 16 | const { things } = require('../constants/pulse'); 17 | 18 | const { primaryPulseServer } = require('../reducers/pulse'); 19 | 20 | const storeKeyByType = map(prop('key'), indexBy(prop('type'), things)); 21 | 22 | const getPaiByTypeAndIndex = (type, index, pulseServerId = primaryPulseServer) => 23 | state => path([ pulseServerId, 'infos', storeKeyByType[type], index ], state.pulse); 24 | 25 | const getPaiByTypeAndIndexFromInfos = (type, index) => infos => path([ storeKeyByType[type], index ], infos); 26 | const getPaiByDgoFromInfos = ({ type, index }) => infos => path([ storeKeyByType[type], index ], infos); 27 | 28 | const getClientSinkInputs = (client, pulseServerId = primaryPulseServer) => state => pickBy( 29 | si => si.clientIndex === client.index, 30 | state.pulse[pulseServerId].infos.sinkInputs, 31 | ); 32 | 33 | const getModuleSinkInputs = (module, pulseServerId = primaryPulseServer) => state => pickBy( 34 | si => si.moduleIndex === module.index, 35 | state.pulse[pulseServerId].infos.sinkInputs, 36 | ); 37 | 38 | const getClientSourceOutputs = (client, pulseServerId = primaryPulseServer) => state => pickBy( 39 | so => so.clientIndex === client.index, 40 | state.pulse[pulseServerId].infos.sourceOutputs, 41 | ); 42 | 43 | const getModuleSourceOutputs = (module, pulseServerId = primaryPulseServer) => state => pickBy( 44 | so => so.moduleIndex === module.index, 45 | state.pulse[pulseServerId].infos.sourceOutputs, 46 | ); 47 | 48 | const getSinkSinkInputs = (sink, pulseServerId = primaryPulseServer) => state => pickBy( 49 | si => si.sinkIndex === sink.index, 50 | state.pulse[pulseServerId].infos.sinkInputs, 51 | ); 52 | 53 | const getDerivedMonitorSources = createSelector( 54 | state => state.pulse[primaryPulseServer].infos.sources, 55 | sources => map(source => ({ 56 | index: source.index, 57 | type: 'monitorSource', 58 | sinkIndex: source.monitorSourceIndex, 59 | sourceIndex: source.index, 60 | }), filter(source => source.monitorSourceIndex >= 0, sources)), 61 | ); 62 | 63 | const getDefaultSourcePai = createSelector( 64 | state => state.pulse[primaryPulseServer].infos.sources, 65 | state => state.pulse[primaryPulseServer].serverInfo.defaultSourceName, 66 | (sources, defaultSourceName) => find(propEq('name', defaultSourceName), values(sources)), 67 | ); 68 | 69 | const getDefaultSinkPai = createSelector( 70 | state => state.pulse[primaryPulseServer].infos.sinks, 71 | state => state.pulse[primaryPulseServer].serverInfo.defaultSinkName, 72 | (sinks, defaultSinkName) => find(propEq('name', defaultSinkName), values(sinks)), 73 | ); 74 | 75 | const createGetCardSinks = cardIndex => createSelector( 76 | state => state.pulse[primaryPulseServer].infos.sinks, 77 | sinks => filter(propEq('cardIndex', cardIndex), sinks), 78 | ); 79 | 80 | const createGetCardSources = cardIndex => createSelector( 81 | state => state.pulse[primaryPulseServer].infos.sources, 82 | sources => filter(propEq('cardIndex', cardIndex), sources), 83 | ); 84 | 85 | module.exports = { 86 | getPaiByTypeAndIndex, 87 | getPaiByTypeAndIndexFromInfos, 88 | getPaiByDgoFromInfos, 89 | 90 | getDerivedMonitorSources, 91 | 92 | getClientSinkInputs, 93 | getModuleSinkInputs, 94 | 95 | getClientSourceOutputs, 96 | getModuleSourceOutputs, 97 | 98 | getSinkSinkInputs, 99 | 100 | getDefaultSinkPai, 101 | getDefaultSourcePai, 102 | 103 | createGetCardSinks, 104 | createGetCardSources, 105 | }; 106 | -------------------------------------------------------------------------------- /components/volume-slider/index.js: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | 3 | const r = require('r-dom'); 4 | 5 | const d3 = require('d3'); 6 | 7 | const width = 300; 8 | const height = 18; 9 | 10 | const clamp = x => Math.min( 11 | width - (height / 2), 12 | Math.max( 13 | (height / 2), 14 | x, 15 | ), 16 | ); 17 | 18 | const vol2pix = (v, maxVolume) => (v / maxVolume) * (width - height); 19 | const pix2vol = (x, maxVolume) => (x * maxVolume) / (width - height); 20 | 21 | module.exports = class VolumeSlider extends React.Component { 22 | constructor(props) { 23 | super(props); 24 | 25 | this.svg = React.createRef(); 26 | 27 | this.state = { 28 | draggingX: null, 29 | }; 30 | 31 | Object.assign(this, { 32 | handleDragStart: this.handleDragStart.bind(this), 33 | handleDrag: this.handleDrag.bind(this), 34 | handleDragEnd: this.handleDragEnd.bind(this), 35 | }); 36 | } 37 | 38 | componentDidMount() { 39 | const dragFunction = d3 40 | .drag() 41 | .on('start', this.handleDragStart) 42 | .on('drag', this.handleDrag) 43 | .on('end', this.handleDragEnd); 44 | 45 | this._selection = d3 46 | .select(this.svg.current.querySelector('.volume-slider-handle')) 47 | .call(dragFunction); 48 | } 49 | 50 | componentWillUnmount() { 51 | this._selection.on('.node', null); 52 | } 53 | 54 | handleDragStart() { 55 | this._startX = d3.event.x; 56 | this._offsetX = d3.event.sourceEvent.offsetX || (this._lastRenderedX); 57 | this.setState({ 58 | draggingX: clamp(this._offsetX), 59 | }); 60 | } 61 | 62 | handleDrag() { 63 | if (this.state.draggingX !== null) { 64 | const draggingX = ((d3.event.x - this._startX) + this._offsetX); 65 | this.setState({ 66 | draggingX: clamp(draggingX), 67 | }); 68 | } 69 | } 70 | 71 | handleDragEnd() { 72 | this.setState({ 73 | draggingX: null, 74 | }); 75 | } 76 | 77 | componentDidUpdate() { 78 | const { draggingX } = this.state; 79 | const { maxVolume } = this.props; 80 | 81 | if (draggingX === null) { 82 | return; 83 | } 84 | 85 | const targetValue = Math.floor(pix2vol(draggingX - (height / 2), maxVolume)); 86 | 87 | this.props.onChange(targetValue); 88 | } 89 | 90 | render() { 91 | const { 92 | muted, 93 | baseVolume, 94 | normVolume, 95 | maxVolume, 96 | value, 97 | } = this.props; 98 | 99 | const { 100 | draggingX, 101 | } = this.state; 102 | 103 | const x = draggingX === null 104 | ? ((height / 2) + vol2pix(value, maxVolume)) 105 | : draggingX; 106 | 107 | this._lastRenderedX = x; 108 | 109 | const baseX = (height / 2) + vol2pix(baseVolume, maxVolume); 110 | const normX = (height / 2) + vol2pix(normVolume, maxVolume); 111 | 112 | return r.svg({ 113 | ref: this.svg, 114 | classSet: { 115 | 'volume-slider': true, 116 | 'volume-slider-muted': muted, 117 | }, 118 | width, 119 | height, 120 | }, [ 121 | baseVolume && r.line({ 122 | className: 'volume-slider-base-mark', 123 | x1: baseX, 124 | x2: baseX, 125 | y1: 0, 126 | y2: height, 127 | }), 128 | 129 | r.line({ 130 | className: 'volume-slider-norm-mark', 131 | x1: normX, 132 | x2: normX, 133 | y1: 0, 134 | y2: height, 135 | }), 136 | 137 | r.line({ 138 | className: 'volume-slider-bg', 139 | x1: height / 2, 140 | x2: width - (height / 2), 141 | y1: height / 2, 142 | y2: height / 2, 143 | }), 144 | 145 | !muted && r.line({ 146 | className: 'volume-slider-fill', 147 | x1: height / 2, 148 | x2: x, 149 | y1: height / 2, 150 | y2: height / 2, 151 | }), 152 | 153 | r.circle({ 154 | className: 'volume-slider-handle', 155 | cx: x, 156 | cy: height / 2, 157 | r: (height - 2) / 2, 158 | }), 159 | ]); 160 | } 161 | }; 162 | -------------------------------------------------------------------------------- /components/graph/peaks.js: -------------------------------------------------------------------------------- 1 | /* global window, performance */ 2 | 3 | const React = require('react'); 4 | 5 | const r = require('r-dom'); 6 | 7 | const PIXI = require('pixi.js'); 8 | 9 | const theme = require('../../utils/theme'); 10 | 11 | PIXI.Ticker.shared.autoStart = false; 12 | 13 | class Peaks extends React.Component { 14 | constructor(props) { 15 | super(props); 16 | 17 | this.state = {}; 18 | 19 | this.containerRef = React.createRef(); 20 | 21 | this.handleTick = this.handleTick.bind(this); 22 | this.handlePeak = this.handlePeak.bind(this); 23 | this.handleResize = this.handleResize.bind(this); 24 | this.handleAnimationFrame = this.handleAnimationFrame.bind(this); 25 | } 26 | 27 | componentDidMount() { 28 | this.app = new PIXI.Application({ 29 | autoStart: false, 30 | transparent: true, 31 | }); 32 | this.app.ticker.add(this.handleTick); 33 | 34 | this.trailTexture = PIXI.Texture.from('assets/trail.png'); 35 | this.points = [ 36 | new PIXI.Point(0, 0), 37 | new PIXI.Point(100, 100), 38 | ]; 39 | this.rope = new PIXI.SimpleRope(this.trailTexture, this.points); 40 | this.rope.blendmode = PIXI.BLEND_MODES.ADD; 41 | this.app.stage.addChild(this.rope); 42 | 43 | this.ropes = {}; 44 | 45 | this.containerRef.current.append(this.app.view); 46 | 47 | this.peaks = {}; 48 | this.props.peaks.on('peak', this.handlePeak); 49 | 50 | this.graph = window.document.querySelector('#graph .graph'); 51 | this.view = this.graph.querySelector('.view'); 52 | 53 | window.addEventListener('resize', this.handleResize); 54 | this.handleResize(); 55 | 56 | this.lastAnimationFrameTimeStamp = 0; 57 | this.requestAnimationFrame(); 58 | } 59 | 60 | componentWillUnmount() { 61 | this.app.destroy(); 62 | 63 | this.props.peaks.off('peak', this.handlePeak); 64 | 65 | window.removeEventListener('resize', this.handleResize); 66 | 67 | window.cancelAnimationFrame(this.animationFrameRequest); 68 | } 69 | 70 | requestAnimationFrame() { 71 | this.animationFrameRequest = window.requestAnimationFrame(this.handleAnimationFrame); 72 | } 73 | 74 | get targetDelay() { 75 | if (window.document.hidden) { 76 | return 2 * 1000; 77 | } 78 | 79 | if (this.props.accommodateGraphAnimation) { 80 | return 1000 / 70; 81 | } 82 | 83 | return 1000 / 25; 84 | } 85 | 86 | handleAnimationFrame(timeStamp) { 87 | if (timeStamp < this.lastAnimationFrameTimeStamp + this.targetDelay) { 88 | this.requestAnimationFrame(); 89 | return; 90 | } 91 | 92 | this.lastAnimationFrameTimeStamp = timeStamp; 93 | 94 | this.app.ticker.update(timeStamp); 95 | 96 | this.requestAnimationFrame(); 97 | } 98 | 99 | handleTick() { 100 | const matrix = this.view.getScreenCTM(); 101 | const point = this.graph.createSVGPoint(); 102 | 103 | const p = ({ x = 0, y = 0 }) => { 104 | point.x = x; 105 | point.y = y; 106 | 107 | const p = point.matrixTransform(matrix); 108 | 109 | return new PIXI.Point(p.x, p.y); 110 | }; 111 | 112 | const ropes = this.props.edges 113 | .filter(edge => { 114 | return edge.type === 'sinkInput' || edge.type === 'sourceOutput'; 115 | }) 116 | .map(edge => { 117 | const source = this.props.nodes.find(n => n.id === edge.source); 118 | const target = this.props.nodes.find(n => n.id === edge.target); 119 | 120 | const peak = this.peaks[target.target] || this.peaks[target.edge]; 121 | 122 | const points = [ 123 | p(target), 124 | p(source), 125 | ]; 126 | const rope = new PIXI.SimpleRope(this.trailTexture, points); 127 | rope.blendmode = PIXI.BLEND_MODES.ADD; 128 | rope.alpha = peak === undefined ? 0 : peak ** (1 / 3); 129 | rope.tint = Number.parseInt(theme.colors.themeSelectedBgColor.replace(/#/g, ''), 16); 130 | 131 | return rope; 132 | }); 133 | 134 | this.app.stage.removeChildren(); 135 | ropes.forEach(r => this.app.stage.addChild(r)); 136 | } 137 | 138 | handlePeak(type, id, peak) { 139 | this.peaks[`${type}-${id}`] = peak; 140 | } 141 | 142 | handleResize() { 143 | this.app.renderer.resize(window.innerWidth, window.innerHeight); 144 | this.app.ticker.update(performance.now()); 145 | } 146 | 147 | render() { 148 | return r.div({ 149 | className: 'peaks', 150 | ref: this.containerRef, 151 | }); 152 | } 153 | } 154 | 155 | module.exports = Peaks; 156 | -------------------------------------------------------------------------------- /reducers/pulse.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | always, 4 | merge, 5 | omit, 6 | fromPairs, 7 | map, 8 | pick, 9 | equals, 10 | takeLast, 11 | over, 12 | lensProp, 13 | } = require('ramda'); 14 | 15 | const { combineReducers } = require('redux'); 16 | 17 | const { handleActions } = require('redux-actions'); 18 | 19 | const { pulse } = require('../actions'); 20 | 21 | const { things } = require('../constants/pulse'); 22 | 23 | const primaryPulseServer = '__PRIMARY_PULSE_SERVER__'; 24 | 25 | const serverInitialState = { 26 | state: 'closed', 27 | targetState: 'closed', 28 | 29 | serverInfo: {}, 30 | 31 | objects: fromPairs(map(({ key }) => [ key, {} ], things)), 32 | infos: fromPairs(map(({ key }) => [ key, {} ], things)), 33 | 34 | log: { items: [] }, 35 | }; 36 | 37 | const initialState = {}; 38 | 39 | const logMaxItems = 3; 40 | 41 | const serverReducer = combineReducers({ 42 | state: handleActions({ 43 | [pulse.ready]: always('ready'), 44 | [pulse.close]: always('closed'), 45 | }, serverInitialState.state), 46 | 47 | targetState: handleActions({ 48 | [pulse.connect]: always('ready'), 49 | [pulse.disconnect]: always('closed'), 50 | }, serverInitialState.targetState), 51 | 52 | serverInfo: handleActions({ 53 | [pulse.serverInfo]: (state, { payload }) => { 54 | return equals(state, payload) 55 | ? state 56 | : payload; 57 | }, 58 | [pulse.close]: always(serverInitialState.serverInfo), 59 | }, serverInitialState.serverInfo), 60 | 61 | objects: combineReducers(fromPairs(map(({ key, type }) => [ key, handleActions({ 62 | [pulse.new]: (state, { payload }) => { 63 | if (payload.type !== type) { 64 | return state; 65 | } 66 | 67 | if (payload.type === 'sinkInput' || payload.type === 'sourceOutput') { 68 | return state; 69 | } 70 | 71 | return merge(state, { 72 | [payload.index]: payload, 73 | }); 74 | }, 75 | [pulse.remove]: (state, { payload }) => { 76 | if (payload.type !== type) { 77 | return state; 78 | } 79 | 80 | return omit([ payload.index ], state); 81 | }, 82 | [pulse.info]: (state, { payload }) => { 83 | if (payload.type !== type) { 84 | return state; 85 | } 86 | 87 | if (payload.type === 'sinkInput' || payload.type === 'sourceOutput') { 88 | const newPao = pick([ 89 | 'type', 90 | 'index', 91 | 'moduleIndex', 92 | 'clientIndex', 93 | 'sinkIndex', 94 | 'sourceIndex', 95 | ], payload); 96 | 97 | const oldPao = state[payload.index]; 98 | 99 | if (equals(newPao, oldPao)) { 100 | return state; 101 | } 102 | 103 | return merge(state, { 104 | [newPao.index]: newPao, 105 | }); 106 | } 107 | 108 | return state; 109 | }, 110 | [pulse.close]: () => serverInitialState.objects[key], 111 | }, serverInitialState.objects[key]) ], things))), 112 | 113 | infos: combineReducers(fromPairs(map(({ key, type }) => [ key, handleActions({ 114 | [pulse.remove]: (state, { payload }) => { 115 | if (payload.type !== type) { 116 | return state; 117 | } 118 | 119 | return omit([ payload.index ], state); 120 | }, 121 | [pulse.info]: (state, { payload }) => { 122 | if (payload.type !== type) { 123 | return state; 124 | } 125 | 126 | return merge(state, { 127 | [payload.index]: payload, 128 | }); 129 | }, 130 | [pulse.close]: () => serverInitialState.objects[key], 131 | }, serverInitialState.infos[key]) ], things))), 132 | 133 | log: combineReducers({ 134 | items: handleActions({ 135 | [pulse.error]: (state, { payload }) => takeLast(logMaxItems, state.concat({ 136 | type: 'error', 137 | error: payload, 138 | })), 139 | [pulse.close]: (state, { type }) => takeLast(logMaxItems, state.concat({ 140 | type: 'info', 141 | action: type, 142 | })), 143 | [pulse.ready]: (state, { type }) => takeLast(logMaxItems, state.concat({ 144 | type: 'info', 145 | action: type, 146 | })), 147 | }, serverInitialState.log.items), 148 | }), 149 | }); 150 | 151 | const reducer = (state = initialState, action) => { // eslint-disable-line default-param-last 152 | const { pulseServerId = primaryPulseServer } = action.meta || {}; 153 | return over(lensProp(pulseServerId), s => serverReducer(s, action), state); 154 | }; 155 | 156 | module.exports = { 157 | initialState, 158 | reducer, 159 | 160 | primaryPulseServer, 161 | }; 162 | -------------------------------------------------------------------------------- /components/cards/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | values, 4 | map, 5 | path, 6 | sortBy, 7 | filter, 8 | } = require('ramda'); 9 | 10 | const React = require('react'); 11 | 12 | const r = require('r-dom'); 13 | 14 | const { connect } = require('react-redux'); 15 | const { bindActionCreators } = require('redux'); 16 | 17 | const { pulse: pulseActions } = require('../../actions'); 18 | 19 | const { primaryPulseServer } = require('../../reducers/pulse'); 20 | 21 | const { createGetCardSinks, createGetCardSources } = require('../../selectors'); 22 | 23 | const Button = require('../button'); 24 | const Label = require('../label'); 25 | const Select = require('../select'); 26 | 27 | const SinksOrSourcesPresenter = ({ sinksOrSources, setSinkOrSourcePort }) => map(sinkOrSource => sinkOrSource.ports.length > 1 && r(Label, { 28 | key: sinkOrSource.index, 29 | title: sinkOrSource.name, 30 | }, [ 31 | r(Label, [ 32 | path([ 'properties', 'device', 'description' ], sinkOrSource), 33 | ]), 34 | 35 | r(Select, { 36 | options: sortBy(p => -p.priority, sinkOrSource.ports), 37 | optionValue: p => p.name, 38 | optionText: p => [ 39 | p.description, 40 | p.availability === 'unavailable' && '(unavailable)', 41 | ] 42 | .filter(Boolean) 43 | .join(' '), 44 | value: sinkOrSource.activePortName, 45 | onChange: ({ target: { value } }) => setSinkOrSourcePort(sinkOrSource.index, value), 46 | }), 47 | ]), values(filter(s => s.ports.length > 0, sinksOrSources))); 48 | 49 | const CardSinks = connect( 50 | (state, { cardIndex }) => ({ 51 | kind: 'sinks', 52 | sinksOrSources: createGetCardSinks(cardIndex)(state), 53 | }), 54 | dispatch => ({ 55 | setSinkOrSourcePort: (...args) => dispatch(pulseActions.setSinkPort(...args)), 56 | }), 57 | )(SinksOrSourcesPresenter); 58 | 59 | const CardSources = connect( 60 | (state, { cardIndex }) => ({ 61 | kind: 'sources', 62 | sinksOrSources: createGetCardSources(cardIndex)(state), 63 | }), 64 | dispatch => ({ 65 | setSinkOrSourcePort: (...args) => dispatch(pulseActions.setSourcePort(...args)), 66 | }), 67 | )(SinksOrSourcesPresenter); 68 | 69 | class Cards extends React.Component { 70 | constructor(props) { 71 | super(props); 72 | 73 | this.state = { 74 | open: false, 75 | }; 76 | } 77 | 78 | toggle() { 79 | this.setState({ open: !this.state.open }); 80 | } 81 | 82 | close() { 83 | this.setState({ open: false }); 84 | } 85 | 86 | isOpen() { 87 | return this.state.open; 88 | } 89 | 90 | render() { 91 | const { open } = this.state; 92 | const toggle = this.toggle.bind(this); 93 | 94 | return r.div({ 95 | classSet: { 96 | panel: true, 97 | cards: true, 98 | open, 99 | }, 100 | }, open ? [ 101 | !this.props.preferences.hideOnScreenButtons && r(React.Fragment, [ 102 | r(Button, { 103 | style: { width: '100%' }, 104 | autoFocus: true, 105 | onClick: toggle, 106 | }, 'Close'), 107 | 108 | r.hr(), 109 | ]), 110 | 111 | ...map(card => r(React.Fragment, [ 112 | r(Label, { 113 | title: card.name, 114 | }, [ 115 | r(Label, [ 116 | path([ 'properties', 'device', 'description' ], card), 117 | ]), 118 | 119 | r(Select, { 120 | options: sortBy(p => -p.priority, card.profiles), 121 | optionValue: p => p.name, 122 | optionText: p => [ 123 | p.description, 124 | !p.available && '(unavailable)', 125 | ] 126 | .filter(Boolean) 127 | .join(' '), 128 | value: card.activeProfileName, 129 | onChange: ({ target: { value } }) => { 130 | this.props.actions.setCardProfile(card.index, value); 131 | }, 132 | }), 133 | ]), 134 | 135 | r(CardSinks, { cardIndex: card.index }), 136 | 137 | r(CardSources, { cardIndex: card.index }), 138 | 139 | r.hr(), 140 | ]), values(this.props.cards)), 141 | 142 | this.props.preferences.showDebugInfo && r.pre({ 143 | style: { 144 | fontSize: '0.75em', 145 | }, 146 | }, [ 147 | JSON.stringify(this.props.cards, null, 2), 148 | ]), 149 | ] : []); 150 | } 151 | } 152 | 153 | module.exports = connect( 154 | state => ({ 155 | cards: state.pulse[primaryPulseServer].infos.cards, 156 | preferences: state.preferences, 157 | }), 158 | dispatch => ({ 159 | actions: bindActionCreators(pulseActions, dispatch), 160 | }), 161 | null, 162 | { forwardRef: true }, 163 | )(Cards); 164 | -------------------------------------------------------------------------------- /components/modals/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | mapObjIndexed, 4 | map, 5 | merge, 6 | path, 7 | } = require('ramda'); 8 | 9 | const r = require('r-dom'); 10 | 11 | const React = require('react'); 12 | 13 | const Modal = require('react-modal'); 14 | 15 | const { 16 | connect, 17 | ReactReduxContext: { Consumer: ReduxConsumer }, 18 | } = require('react-redux'); 19 | const { bindActionCreators } = require('redux'); 20 | 21 | const { 22 | compose, 23 | fromRenderProps, 24 | } = require('recompose'); 25 | 26 | const { 27 | pulse: pulseActions, 28 | preferences: preferencesActions, 29 | } = require('../../actions'); 30 | 31 | const { 32 | getPaiByTypeAndIndex, 33 | } = require('../../selectors'); 34 | 35 | const { modules } = require('../../constants/pulse'); 36 | 37 | const { primaryPulseServer } = require('../../reducers/pulse'); 38 | 39 | const ConnectToServerModal = require('./connect-to-server'); 40 | const ConfirmationModal = require('./confirmation'); 41 | const NewGraphObjectModal = require('./new-graph-object'); 42 | const LoadModuleModal = require('./load-module'); 43 | const AddRemoteServerModal = require('./add-remote-server-modal'); 44 | 45 | Modal.setAppElement('#root'); 46 | 47 | Modal.defaultStyles = { 48 | overlay: {}, 49 | content: {}, 50 | }; 51 | 52 | const bindMemo = new WeakMap(); 53 | const bind = that => f => { 54 | if (!bindMemo.has(that)) { 55 | bindMemo.set(that, new WeakMap()); 56 | } 57 | 58 | const bounds = bindMemo.get(that); 59 | if (!bounds.has(f)) { 60 | bounds.set(f, f.bind(that)); 61 | } 62 | 63 | return bounds.get(f); 64 | }; 65 | 66 | class Modals extends React.PureComponent { 67 | constructor(props) { 68 | super(props); 69 | 70 | this.initialState = { 71 | target: null, 72 | confirmation: null, 73 | continuation: null, 74 | 75 | connectToServerModalOpen: false, 76 | newGraphObjectModalOpen: false, 77 | loadModuleModalOpen: false, 78 | addRemoteServerModalOpen: false, 79 | 80 | modalDefaults: undefined, 81 | 82 | actions: { 83 | openConnectToServerModal: this.openConnectToServerModal, 84 | 85 | openNewGraphObjectModal: this.openNewGraphObjectModal, 86 | openLoadModuleModal: this.openLoadModuleModal, 87 | openAddRemoteServerModal: this.openAddRemoteServerModal, 88 | }, 89 | }; 90 | this.state = this.initialState; 91 | 92 | this.handleCancel = this.handleCancel.bind(this); 93 | } 94 | 95 | static getDerivedStateFromProps(props, state) { 96 | return { 97 | actions: merge(state.actions, mapObjIndexed((f, name) => function (...args) { 98 | const continuation = () => { 99 | props[name](...args); 100 | this.setState(this.initialState); 101 | }; 102 | 103 | if (props.preferences.doNotAskForConfirmations) { 104 | return continuation(); 105 | } 106 | 107 | const target = f.apply(this, args); 108 | 109 | if (!target) { 110 | return continuation(); 111 | } 112 | 113 | this.setState({ 114 | target, 115 | continuation, 116 | confirmation: name, 117 | }); 118 | }, { 119 | unloadModuleByIndex(index) { 120 | const pai = getPaiByTypeAndIndex('module', index)(this.props.store.getState()); 121 | 122 | if (pai && path([ pai.name, 'confirmUnload' ], modules)) { 123 | return pai; 124 | } 125 | 126 | return null; 127 | }, 128 | })), 129 | }; 130 | } 131 | 132 | openConnectToServerModal(modalDefaults) { 133 | this.setState({ 134 | connectToServerModalOpen: true, 135 | modalDefaults, 136 | }); 137 | } 138 | 139 | openNewGraphObjectModal() { 140 | this.setState({ newGraphObjectModalOpen: true }); 141 | } 142 | 143 | openLoadModuleModal(modalDefaults) { 144 | this.setState({ 145 | loadModuleModalOpen: true, 146 | modalDefaults, 147 | }); 148 | } 149 | 150 | openAddRemoteServerModal() { 151 | this.setState({ addRemoteServerModalOpen: true }); 152 | } 153 | 154 | handleCancel() { 155 | this.setState(this.initialState); 156 | } 157 | 158 | render() { 159 | const { preferences, toggle, children } = this.props; 160 | const { actions, target, confirmation, continuation } = this.state; 161 | 162 | return r(React.Fragment, [ 163 | ...[].concat(children({ actions: map(bind(this), actions) })), 164 | 165 | r(ConfirmationModal, { 166 | target, 167 | confirmation, 168 | onConfirm: continuation, 169 | onCancel: this.handleCancel, 170 | 171 | preferences, 172 | toggle, 173 | }), 174 | 175 | this.state.connectToServerModalOpen && r(ConnectToServerModal, { 176 | isOpen: true, 177 | onRequestClose: this.handleCancel, 178 | 179 | defaults: this.state.modalDefaults, 180 | }), 181 | 182 | r(NewGraphObjectModal, { 183 | isOpen: this.state.newGraphObjectModalOpen, 184 | onRequestClose: this.handleCancel, 185 | 186 | openLoadModuleModal: this.state.actions.openLoadModuleModal, 187 | }), 188 | 189 | this.state.loadModuleModalOpen && r(LoadModuleModal, { 190 | isOpen: true, 191 | onRequestClose: this.handleCancel, 192 | 193 | defaults: this.state.modalDefaults, 194 | }), 195 | 196 | r(AddRemoteServerModal, { 197 | isOpen: this.state.addRemoteServerModalOpen, 198 | onRequestClose: this.handleCancel, 199 | }), 200 | ]); 201 | } 202 | } 203 | 204 | module.exports = compose( 205 | connect( 206 | state => ({ 207 | infos: state.pulse[primaryPulseServer].infos, 208 | preferences: state.preferences, 209 | }), 210 | dispatch => bindActionCreators(merge(pulseActions, preferencesActions), dispatch), 211 | ), 212 | 213 | fromRenderProps( 214 | ReduxConsumer, 215 | ({ store }) => ({ store }), 216 | ), 217 | )(Modals); 218 | -------------------------------------------------------------------------------- /components/preferences/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | pick, 4 | defaultTo, 5 | } = require('ramda'); 6 | 7 | const React = require('react'); 8 | 9 | const r = require('r-dom'); 10 | 11 | const { connect } = require('react-redux'); 12 | const { bindActionCreators } = require('redux'); 13 | 14 | const { preferences: preferencesActions } = require('../../actions'); 15 | 16 | const Button = require('../button'); 17 | const Checkbox = require('../checkbox'); 18 | const NumberInput = require('../number-input'); 19 | 20 | const VolumeRatioInput = ({ pref, fallback, preferences, actions, children }) => r(NumberInput, { 21 | type: 'number', 22 | value: defaultTo(fallback, Math.round(preferences[pref] * 100)), 23 | onChange: ({ target: { value } }) => { 24 | value = defaultTo(fallback, Math.max(0, Number.parseInt(value, 10))); 25 | actions.set({ [pref]: value / 100 }); 26 | }, 27 | }, children); 28 | 29 | class Preferences extends React.Component { 30 | constructor(props) { 31 | super(props); 32 | 33 | this.state = { 34 | open: false, 35 | }; 36 | } 37 | 38 | toggle() { 39 | this.setState({ open: !this.state.open }); 40 | } 41 | 42 | close() { 43 | this.setState({ open: false }); 44 | } 45 | 46 | isOpen() { 47 | return this.state.open; 48 | } 49 | 50 | render() { 51 | const { open } = this.state; 52 | const toggle = this.toggle.bind(this); 53 | 54 | return r.div({ 55 | classSet: { 56 | panel: true, 57 | preferences: true, 58 | open, 59 | }, 60 | }, open ? [ 61 | !this.props.preferences.hideOnScreenButtons && r(React.Fragment, [ 62 | r(Button, { 63 | style: { width: '100%' }, 64 | autoFocus: true, 65 | onClick: toggle, 66 | }, 'Close'), 67 | 68 | r.hr(), 69 | ]), 70 | 71 | r.div([ 72 | r(Checkbox, { 73 | checked: this.props.preferences.hideDisconnectedClients, 74 | onChange: () => this.props.actions.toggle('hideDisconnectedClients'), 75 | }, 'Hide disconnected clients'), 76 | ]), 77 | 78 | r.div([ 79 | r(Checkbox, { 80 | checked: this.props.preferences.hideDisconnectedModules, 81 | onChange: () => this.props.actions.toggle('hideDisconnectedModules'), 82 | }, 'Hide disconnected modules'), 83 | ]), 84 | 85 | r.div([ 86 | r(Checkbox, { 87 | checked: this.props.preferences.hideDisconnectedSources, 88 | onChange: () => this.props.actions.toggle('hideDisconnectedSources'), 89 | }, 'Hide disconnected source'), 90 | ]), 91 | 92 | r.div([ 93 | r(Checkbox, { 94 | checked: this.props.preferences.hideDisconnectedSinks, 95 | onChange: () => this.props.actions.toggle('hideDisconnectedSinks'), 96 | }, 'Hide disconnected sinks'), 97 | ]), 98 | 99 | r.hr(), 100 | 101 | r.div([ 102 | r(Checkbox, { 103 | checked: this.props.preferences.hideMonitorSourceEdges, 104 | onChange: () => this.props.actions.toggle('hideMonitorSourceEdges'), 105 | }, 'Hide monitor source edges'), 106 | ]), 107 | 108 | r.div([ 109 | r(Checkbox, { 110 | checked: this.props.preferences.hideMonitors, 111 | onChange: () => this.props.actions.toggle('hideMonitors'), 112 | }, 'Hide monitors'), 113 | ]), 114 | 115 | r.div([ 116 | r(Checkbox, { 117 | checked: this.props.preferences.hidePulseaudioApps, 118 | onChange: () => this.props.actions.toggle('hidePulseaudioApps'), 119 | title: 'Including volume control apps and some internal machinery', 120 | }, 'Hide pulseaudio applications'), 121 | ]), 122 | 123 | r.hr(), 124 | 125 | r.div([ 126 | r(Checkbox, { 127 | checked: this.props.preferences.hideVolumeThumbnails, 128 | onChange: () => this.props.actions.toggle('hideVolumeThumbnails'), 129 | }, 'Hide volume thumbnails'), 130 | ]), 131 | 132 | r.div([ 133 | r(Checkbox, { 134 | checked: this.props.preferences.lockChannelsTogether, 135 | onChange: () => this.props.actions.toggle('lockChannelsTogether'), 136 | }, 'Lock channels together'), 137 | ]), 138 | 139 | r.div([ 140 | r(VolumeRatioInput, { 141 | pref: 'maxVolume', 142 | fallback: 150, 143 | ...this.props, 144 | }, 'Maximum volume: '), 145 | ]), 146 | 147 | r.div([ 148 | r(VolumeRatioInput, { 149 | pref: 'volumeStep', 150 | fallback: 10, 151 | ...this.props, 152 | }, 'Volume step: '), 153 | ]), 154 | 155 | r.hr(), 156 | 157 | r.div([ 158 | r(Checkbox, { 159 | checked: this.props.preferences.hideLiveVolumePeaks, 160 | onChange: () => this.props.actions.toggle('hideLiveVolumePeaks'), 161 | }, 'Hide live volume peaks'), 162 | ]), 163 | 164 | r.hr(), 165 | 166 | r.div([ 167 | r(Checkbox, { 168 | checked: this.props.preferences.hideOnScreenButtons, 169 | onChange: () => this.props.actions.toggle('hideOnScreenButtons'), 170 | }, 'Hide on-screen buttons'), 171 | ]), 172 | 173 | r.div([ 174 | r(Checkbox, { 175 | checked: this.props.preferences.doNotAskForConfirmations, 176 | onChange: () => this.props.actions.toggle('doNotAskForConfirmations'), 177 | }, 'Do not ask for confirmations'), 178 | ]), 179 | 180 | r.div([ 181 | r(Checkbox, { 182 | checked: this.props.preferences.showDebugInfo, 183 | onChange: () => this.props.actions.toggle('showDebugInfo'), 184 | }, 'Show debug info'), 185 | ]), 186 | 187 | r.hr(), 188 | 189 | r.div([ 190 | r(Button, { 191 | style: { width: '100%' }, 192 | onClick: this.props.actions.resetDefaults, 193 | }, 'Reset to defaults'), 194 | ]), 195 | ] : [ 196 | !this.props.preferences.hideOnScreenButtons && r(Button, { 197 | autoFocus: true, 198 | onClick: toggle, 199 | }, 'Preferences'), 200 | ]); 201 | } 202 | } 203 | 204 | module.exports = connect( 205 | state => pick([ 'preferences' ], state), 206 | dispatch => ({ 207 | actions: bindActionCreators(preferencesActions, dispatch), 208 | }), 209 | null, 210 | { forwardRef: true }, 211 | )(Preferences); 212 | -------------------------------------------------------------------------------- /components/network/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | values, 4 | map, 5 | path, 6 | filter, 7 | propEq, 8 | sortBy, 9 | prop, 10 | merge, 11 | keys, 12 | } = require('ramda'); 13 | 14 | const React = require('react'); 15 | 16 | const r = require('r-dom'); 17 | 18 | const { connect } = require('react-redux'); 19 | const { bindActionCreators } = require('redux'); 20 | 21 | const { 22 | pulse: pulseActions, 23 | preferences: preferencesActions, 24 | } = require('../../actions'); 25 | const { formatModuleArgs } = require('../../utils/module-args'); 26 | 27 | const { primaryPulseServer } = require('../../reducers/pulse'); 28 | 29 | const Button = require('../button'); 30 | const Label = require('../label'); 31 | 32 | const RemoteServer = connect( 33 | (state, props) => ({ 34 | remoteServer: state.pulse[props.address], 35 | }), 36 | dispatch => ({ 37 | actions: bindActionCreators(merge(pulseActions, preferencesActions), dispatch), 38 | }), 39 | )(({ address, remoteServer = {}, actions, ...props }) => { 40 | const { targetState, state } = remoteServer; 41 | const hostname = path([ 'serverInfo', 'hostname' ], remoteServer); 42 | 43 | return r.div([ 44 | r.div({ 45 | style: { display: 'flex', justifyContent: 'space-between' }, 46 | }, [ 47 | r.div([ 48 | r.div([ hostname ]), 49 | r.code(address), 50 | ]), 51 | 52 | r.div([ 53 | r(Button, { 54 | onClick: () => { 55 | props.openConnectToServerModal({ address }); 56 | }, 57 | }, 'Open'), 58 | 59 | ' ', 60 | 61 | targetState === 'ready' ? r(Button, { 62 | onClick: () => { 63 | actions.disconnect(address); 64 | }, 65 | }, 'Disconnect') : r(React.Fragment, [ 66 | r(Button, { 67 | onClick: () => { 68 | actions.disconnect(address); 69 | actions.setDelete('remoteServerAddresses', address); 70 | }, 71 | }, 'Forget'), 72 | 73 | ' ', 74 | 75 | r(Button, { 76 | onClick: () => { 77 | actions.connect(address); 78 | }, 79 | }, 'Connect'), 80 | ]), 81 | ]), 82 | ]), 83 | 84 | state === 'ready' ? r(Label, { 85 | passive: true, 86 | }, [ 87 | keys(remoteServer.objects.sinks).length, 88 | ' sinks and ', 89 | keys(remoteServer.objects.sources).length, 90 | ' sources.', 91 | ]) : (targetState === 'ready' ? r(Label, { 92 | passive: true, 93 | }, [ 94 | 'Connecting...', 95 | ]) : null), 96 | ]); 97 | }); 98 | 99 | class Cards extends React.Component { 100 | constructor(props) { 101 | super(props); 102 | 103 | this.state = { 104 | open: false, 105 | }; 106 | } 107 | 108 | toggle() { 109 | this.setState({ open: !this.state.open }); 110 | } 111 | 112 | close() { 113 | this.setState({ open: false }); 114 | } 115 | 116 | isOpen() { 117 | return this.state.open; 118 | } 119 | 120 | render() { 121 | const { open } = this.state; 122 | const toggle = this.toggle.bind(this); 123 | 124 | const nativeProtocolTcpModules = sortBy(prop('index'), filter( 125 | propEq('name', 'module-native-protocol-tcp'), 126 | values(this.props.modules), 127 | )); 128 | 129 | const remoteServerAddresses = keys(this.props.preferences.remoteServerAddresses); 130 | 131 | return r.div({ 132 | classSet: { 133 | panel: true, 134 | cards: true, 135 | open, 136 | }, 137 | }, open ? [ 138 | !this.props.preferences.hideOnScreenButtons && r(React.Fragment, [ 139 | r(Button, { 140 | style: { width: '100%' }, 141 | autoFocus: true, 142 | onClick: toggle, 143 | }, 'Close'), 144 | 145 | r.hr(), 146 | ]), 147 | 148 | nativeProtocolTcpModules.length > 0 ? r(React.Fragment, [ 149 | r(Label, [ 150 | 'This server:', 151 | ]), 152 | 153 | ...map(module => r.div([ 154 | r.div({ 155 | style: { display: 'flex', justifyContent: 'space-between' }, 156 | }, [ 157 | r(Label, { 158 | passive: true, 159 | }, [ 160 | path([ 'properties', 'module', 'description' ], module), 161 | ]), 162 | 163 | r(Button, { 164 | onClick: () => { 165 | this.props.actions.unloadModuleByIndex(module.index); 166 | }, 167 | }, 'Unload'), 168 | ]), 169 | 170 | r(Label, { 171 | userSelect: true, 172 | }, [ 173 | r.code([ 174 | module.name, 175 | ' ', 176 | module.args, 177 | ]), 178 | ]), 179 | ]), nativeProtocolTcpModules), 180 | ]) : r(Label, { 181 | title: 'No loaded `module-native-protocol-tcp` found', 182 | }, [ 183 | 'This server does not currently accept tcp connections.', 184 | ]), 185 | 186 | r(Button, { 187 | onClick: () => { 188 | this.props.openLoadModuleModal({ 189 | name: 'module-native-protocol-tcp', 190 | args: formatModuleArgs({ 191 | 'auth-ip-acl': [ 192 | '127.0.0.0/8', 193 | '10.0.0.0/8', 194 | '172.16.0.0/12', 195 | '192.168.0.0/16', 196 | ], 197 | }), 198 | }); 199 | }, 200 | }, 'Allow incoming connections...'), 201 | 202 | r.hr(), 203 | 204 | remoteServerAddresses.length > 0 ? r(React.Fragment, [ 205 | r(Label, [ 206 | 'Remote servers:', 207 | ]), 208 | 209 | ...map(address => r(RemoteServer, { 210 | address, 211 | openConnectToServerModal: this.props.openConnectToServerModal, 212 | }), remoteServerAddresses), 213 | ]) : r(Label, [ 214 | 'No known servers', 215 | ]), 216 | 217 | r(Button, { 218 | onClick: () => { 219 | this.props.openAddRemoteServerModal(); 220 | }, 221 | }, 'Add a server...'), 222 | 223 | this.props.preferences.showDebugInfo && r.pre({ 224 | style: { 225 | fontSize: '0.75em', 226 | }, 227 | }, [ 228 | JSON.stringify(this.props.modules, null, 2), 229 | ]), 230 | ] : []); 231 | } 232 | } 233 | 234 | module.exports = connect( 235 | state => ({ 236 | modules: state.pulse[primaryPulseServer].infos.modules, 237 | preferences: state.preferences, 238 | }), 239 | dispatch => ({ 240 | actions: bindActionCreators(pulseActions, dispatch), 241 | }), 242 | null, 243 | { forwardRef: true }, 244 | )(Cards); 245 | -------------------------------------------------------------------------------- /components/graph/satellites-graph.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | map, 4 | prop, 5 | groupBy, 6 | flatten, 7 | } = require('ramda'); 8 | 9 | const React = require('react'); 10 | 11 | const r = require('r-dom'); 12 | 13 | const plusMinus = require('../../utils/plus-minus'); 14 | 15 | const memoize = require('../../utils/memoize'); 16 | 17 | const { 18 | GraphView: GraphViewBase, 19 | } = require('./base'); 20 | 21 | const Peaks = require('./peaks'); 22 | 23 | const originalEdgeToSatelliteNode = edge => ({ 24 | id: `${edge.target}__satellite__${edge.id}`, 25 | type: 'satellite', 26 | 27 | edge: edge.id, 28 | edgeType: edge.type, 29 | 30 | source: edge.source, 31 | sourceType: edge.source.type, 32 | 33 | target: edge.target, 34 | targetType: edge.target.type, 35 | }); 36 | 37 | const originalEdgeAndSatelliteNodeToSatelliteEdge = (edge, satelliteNode) => { 38 | const satelliteEdge = { 39 | id: edge.id, 40 | source: edge.source, 41 | target: satelliteNode.id, 42 | originalTarget: edge.target, 43 | index: edge.index, 44 | type: edge.type, 45 | }; 46 | 47 | satelliteEdgeToOriginalEdge.set(satelliteEdge, edge); 48 | return satelliteEdge; 49 | }; 50 | 51 | const originalEdgeToSatellites = memoize(edge => { 52 | const satelliteNode = originalEdgeToSatelliteNode(edge); 53 | const satelliteEdge = originalEdgeAndSatelliteNodeToSatelliteEdge(edge, satelliteNode); 54 | return { satelliteEdge, satelliteNode }; 55 | }); 56 | 57 | const Satellite = () => r(React.Fragment); 58 | 59 | const satelliteSpread = 36; 60 | 61 | const satelliteEdgeToOriginalEdge = new WeakMap(); 62 | 63 | class SatellitesGraphView extends React.Component { 64 | constructor(props) { 65 | super(props); 66 | 67 | this.state = { 68 | originalEdgesByTargetNodeKey: {}, 69 | satelliteNodesByTargetNodeKey: {}, 70 | satelliteEdges: [], 71 | selected: null, 72 | }; 73 | 74 | this.graphViewRef = this.props.graphViewRef || React.createRef(); 75 | 76 | Object.assign(this, { 77 | onNodeMove: this.onNodeMove.bind(this), 78 | 79 | onSelectEdge: this.onSelectEdge.bind(this), 80 | onEdgeMouseDown: this.onEdgeMouseDown.bind(this), 81 | onCreateEdge: this.onCreateEdge.bind(this), 82 | 83 | renderNode: this.renderNode.bind(this), 84 | renderNodeText: this.renderNodeText.bind(this), 85 | 86 | renderEdge: this.renderEdge.bind(this), 87 | renderEdgeText: this.renderEdgeText.bind(this), 88 | 89 | afterRenderEdge: this.afterRenderEdge.bind(this), 90 | }); 91 | } 92 | 93 | static getDerivedStateFromProps(props) { 94 | const originalEdgesByTargetNodeKey = groupBy(prop('target'), props.edges); 95 | 96 | let { selected, moved } = props; 97 | 98 | const satelliteEdges = []; 99 | 100 | const satelliteNodesByTargetNodeKey = map(edges => map(edge => { 101 | const { 102 | satelliteNode, 103 | satelliteEdge, 104 | } = originalEdgeToSatellites(edge); 105 | 106 | if (edge === selected) { 107 | selected = satelliteEdge; 108 | } 109 | 110 | if (edge === moved) { 111 | moved = satelliteEdge; 112 | } 113 | 114 | satelliteEdges.push(satelliteEdge); 115 | 116 | return satelliteNode; 117 | }, edges), originalEdgesByTargetNodeKey); 118 | 119 | const satelliteNodes = flatten(map(node => { 120 | const satelliteNodes = satelliteNodesByTargetNodeKey[node.id] || []; 121 | SatellitesGraphView.repositionSatellites(node, satelliteNodes); 122 | return satelliteNodes.concat(node); 123 | }, props.nodes)); 124 | 125 | return { 126 | originalEdgesByTargetNodeKey, 127 | satelliteNodesByTargetNodeKey, 128 | satelliteEdges, 129 | satelliteNodes, 130 | 131 | selected, 132 | moved, 133 | }; 134 | } 135 | 136 | static repositionSatellites(position, satelliteNodes) { 137 | const offsetY = (satelliteNodes % 2) ? 0 : (satelliteSpread / 2); 138 | 139 | satelliteNodes.forEach((satelliteNode, i) => { 140 | if (satelliteNode.edgeType === 'monitorSource') { 141 | satelliteNode.x = position.x; 142 | satelliteNode.y = position.y; 143 | return; 144 | } 145 | 146 | satelliteNode.x = position.x; 147 | satelliteNode.y = position.y 148 | + offsetY 149 | + (satelliteSpread * plusMinus(i)) 150 | + ((satelliteSpread / 2) * ((satelliteNodes.length + 1) % 2)); 151 | }); 152 | } 153 | 154 | onCreateEdge(source, target) { 155 | const { nodeKey, onCreateEdge } = this.props; 156 | onCreateEdge(source, target); 157 | this.graphViewRef.current.removeEdgeElement(source[nodeKey], target[nodeKey]); 158 | } 159 | 160 | onNodeMove(position, nodeId, shiftKey) { 161 | const { nodeKey } = this.props; 162 | const satelliteNodes = this.state.satelliteNodesByTargetNodeKey[nodeId]; 163 | if (satelliteNodes) { 164 | this.constructor.repositionSatellites(position, satelliteNodes); 165 | satelliteNodes.forEach(satelliteNode => { 166 | this.graphViewRef.current.handleNodeMove(satelliteNode, satelliteNode[nodeKey], shiftKey); 167 | }); 168 | } 169 | } 170 | 171 | onSelectEdge(edge) { 172 | const originalEdge = satelliteEdgeToOriginalEdge.get(edge); 173 | if (this.props.onSelectEdge) { 174 | this.props.onSelectEdge(originalEdge || edge); 175 | } 176 | } 177 | 178 | onEdgeMouseDown(event, edge) { 179 | const originalEdge = satelliteEdgeToOriginalEdge.get(edge); 180 | if (this.props.onEdgeMouseDown) { 181 | this.props.onEdgeMouseDown(event, originalEdge || edge); 182 | } 183 | } 184 | 185 | renderNode(nodeRef, dgo, key, selected, hovered) { 186 | if (dgo.type !== 'satellite') { 187 | return this.props.renderNode(nodeRef, dgo, key, selected, hovered); 188 | } 189 | 190 | return r(Satellite); 191 | } 192 | 193 | renderNodeText(dgo, ...rest) { 194 | if (dgo.type !== 'satellite') { 195 | return this.props.renderNodeText(dgo, ...rest); 196 | } 197 | 198 | return r(React.Fragment); 199 | } 200 | 201 | renderEdge(...args) { 202 | return this.props.renderEdge(...args); 203 | } 204 | 205 | renderEdgeText(...args) { 206 | return this.props.renderEdgeText(...args); 207 | } 208 | 209 | afterRenderEdge(id, element, edge, edgeContainer) { 210 | const originalEdge = satelliteEdgeToOriginalEdge.get(edge); 211 | this.props.afterRenderEdge(id, element, originalEdge || edge, edgeContainer); 212 | } 213 | 214 | render() { 215 | const { 216 | satelliteEdges: edges, 217 | satelliteNodes: nodes, 218 | 219 | selected, 220 | moved, 221 | } = this.state; 222 | 223 | const { 224 | hideLiveVolumePeaks, 225 | accommodateGraphAnimation, 226 | peaks, 227 | 228 | ...props 229 | } = this.props; 230 | 231 | return r(React.Fragment, [ 232 | !hideLiveVolumePeaks && r(Peaks, { 233 | key: 'peaks', 234 | nodes, 235 | edges, 236 | accommodateGraphAnimation, 237 | peaks, 238 | }), 239 | 240 | r(GraphViewBase, { 241 | key: 'graph', 242 | 243 | ...props, 244 | 245 | selected, 246 | moved, 247 | 248 | ref: this.graphViewRef, 249 | 250 | nodes, 251 | edges, 252 | 253 | onSwapEdge: this.props.onSwapEdge, 254 | onNodeMove: this.onNodeMove, 255 | 256 | onSelectEdge: this.onSelectEdge, 257 | 258 | onCreateEdge: this.onCreateEdge, 259 | 260 | onEdgeMouseDown: this.onEdgeMouseDown, 261 | 262 | renderNode: this.renderNode, 263 | renderNodeText: this.renderNodeText, 264 | 265 | renderEdge: this.renderEdge, 266 | renderEdgeText: this.renderEdgeText, 267 | 268 | afterRenderEdge: this.props.afterRenderEdge && this.afterRenderEdge, 269 | }), 270 | ]); 271 | } 272 | } 273 | 274 | module.exports = { SatellitesGraphView }; 275 | -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background: var(--themeBaseColor); 4 | color: var(--themeTextColor); 5 | font: -webkit-control; 6 | } 7 | 8 | div { 9 | box-sizing: border-box; 10 | } 11 | 12 | div[tabindex="-1"]:focus { 13 | outline: 0; 14 | } 15 | 16 | .button { 17 | background: var(--themeBgColor); 18 | color: var(--themeTextColor); 19 | border: 1px solid var(--borders); 20 | user-select: none; 21 | 22 | padding: 8px; 23 | 24 | cursor: pointer; 25 | } 26 | 27 | .button:hover { 28 | border-color: var(--themeSelectedBgColor); 29 | } 30 | 31 | .button:focus { 32 | outline: none; 33 | border-color: var(--themeSelectedBgColor); 34 | } 35 | 36 | .button:active { 37 | background: var(--themeSelectedBgColor); 38 | position: relative; 39 | top: 1px; 40 | } 41 | 42 | .button:disabled { 43 | border-color: var(--unfocusedBorders); 44 | cursor: not-allowed; 45 | } 46 | 47 | .button-group { 48 | display: flex; 49 | justify-content: space-between; 50 | } 51 | 52 | .label { 53 | user-select: none; 54 | 55 | cursor: pointer; 56 | 57 | display: block; 58 | padding: 0.5rem 0; 59 | } 60 | .label-user-select { 61 | user-select: initial; 62 | cursor: text; 63 | } 64 | .label-passive { 65 | cursor: initial; 66 | } 67 | 68 | .checkbox { 69 | } 70 | 71 | .input { 72 | background: var(--themeUnfocusedBaseColor); 73 | color: var(--themeUnfocusedFgColor); 74 | border: 1px solid var(--borders); 75 | padding: 4px; 76 | } 77 | .input:hover { 78 | border-color: var(--themeSelectedBgColor); 79 | } 80 | .input:focus { 81 | outline: none; 82 | border-color: var(--themeSelectedBgColor); 83 | } 84 | .input[type="number"] { 85 | width: 64px; 86 | } 87 | 88 | .select { 89 | background: var(--themeUnfocusedBgColor); 90 | color: var(--themeUnfocusedFgColor); 91 | border: 1px solid var(--borders); 92 | padding: 4px; 93 | cursor: pointer; 94 | width: 100%; 95 | } 96 | .select:hover { 97 | border-color: var(--themeSelectedBgColor); 98 | } 99 | .select:focus { 100 | outline: none; 101 | border-color: var(--themeSelectedBgColor); 102 | } 103 | 104 | .peaks { 105 | position: absolute; 106 | top: 0; 107 | left: 0; 108 | bottom: 0; 109 | right: 0; 110 | pointer-events: none; 111 | } 112 | 113 | .view-wrapper.view-wrapper { 114 | background: transparent; 115 | 116 | position: absolute; 117 | top: 0; 118 | left: 0; 119 | right: 0; 120 | bottom: 0; 121 | } 122 | 123 | .view-wrapper .grid-dot { 124 | fill: var(--themeBgColor); 125 | } 126 | 127 | .view-wrapper .node { 128 | fill: var(--themeBgColor); 129 | stroke: var(--borders); 130 | } 131 | 132 | .view-wrapper .node.hovered { 133 | stroke: var(--themeSelectedBgColor); 134 | } 135 | .view-wrapper .node.targeted { 136 | stroke: var(--themeSelectedBgColor); 137 | } 138 | .view-wrapper .node.selected { 139 | fill: var(--themeSelectedBgColor); 140 | color: var(--themeSelectedFgColor); 141 | } 142 | 143 | .view-wrapper .edge-container:hover .edge { 144 | stroke: var(--themeSelectedBgColor); 145 | } 146 | .view-wrapper .edge-container:hover .volume-thumbnail-ruler-line { 147 | stroke: var(--themeSelectedBgColor); 148 | } 149 | 150 | .view-wrapper .graph .edge.selected { 151 | stroke: var(--themeSelectedBgColor); 152 | } 153 | 154 | #my-source-arrow-selected > .arrow, #my-sink-arrow-selected > .arrow { 155 | fill: var(--themeSelectedBgColor); 156 | } 157 | 158 | .view-wrapper .edge.edge { 159 | marker-end: none; 160 | } 161 | .view-wrapper .sourceOutput .edge-path { 162 | marker-end: url(#my-source-arrow); 163 | } 164 | .view-wrapper .sourceOutput .selected .edge-path { 165 | marker-end: url(#my-source-arrow-selected); 166 | } 167 | 168 | .view-wrapper .sinkInput .edge-path { 169 | marker-end: url(#my-sink-arrow); 170 | } 171 | .view-wrapper .sinkInput .selected .edge-path { 172 | marker-end: url(#my-sink-arrow-selected); 173 | } 174 | 175 | .view-wrapper .monitorSource { 176 | stroke-dasharray: 40; 177 | pointer-events: none; 178 | } 179 | .view-wrapper .monitorSource .edge-mouse-handler { 180 | pointer-events: none; 181 | } 182 | 183 | #edge-custom-container .edge-path { 184 | marker-end: none; 185 | stroke: var(--themeSelectedBgColor); 186 | } 187 | 188 | #edge-custom-container, #edge-custom-container .edge-mouse-handler { 189 | pointer-events: none; 190 | } 191 | 192 | .view-wrapper .graph .edge { 193 | stroke: var(--borders); 194 | } 195 | 196 | .view-wrapper .graph .arrow { 197 | fill: var(--borders); 198 | } 199 | 200 | .view-wrapper .edge-mouse-handler.edge-mouse-handler { 201 | opacity: 1; 202 | } 203 | .view-wrapper .edge-mouse-handler .edge-overlay-path { 204 | opacity: 0; 205 | } 206 | .view-wrapper .edge-mouse-handler .edge-text { 207 | opacity: 1; 208 | color: var(--themeTextColor); 209 | } 210 | 211 | .server-info { 212 | position: absolute; 213 | bottom: 0; 214 | right: 0; 215 | padding: 1rem; 216 | pointer-events: none; 217 | opacity: 0.5; 218 | } 219 | 220 | .log { 221 | position: absolute; 222 | bottom: 0; 223 | left: 0; 224 | padding: 1rem; 225 | overflow: hidden; 226 | pointer-events: none; 227 | } 228 | 229 | .log-item-error { 230 | color: var(--errorColor); 231 | } 232 | 233 | .log-item-transition-enter { 234 | opacity: 0.01; 235 | } 236 | .log-item-transition-enter.log-item-transition-enter-active { 237 | opacity: 1; 238 | transition: opacity .3s ease-in; 239 | } 240 | 241 | .log-item-transition-leave { 242 | opacity: 1; 243 | } 244 | .log-item-transition-leave.log-item-transition-leave-active { 245 | opacity: 0.01; 246 | transition: opacity 2s ease-out; 247 | } 248 | 249 | .panel { 250 | position: absolute; 251 | top: 0; 252 | bottom: 0; 253 | padding: 1rem; 254 | overflow: auto; 255 | } 256 | 257 | .cards { 258 | left: 0; 259 | } 260 | 261 | .preferences { 262 | right: 0; 263 | } 264 | 265 | .panel:not(.open) { 266 | pointer-events: none; 267 | } 268 | 269 | .panel:not(.open) .button { 270 | pointer-events: initial; 271 | } 272 | 273 | .panel.open { 274 | background: var(--themeBgColor); 275 | } 276 | 277 | .panel > hr { 278 | border: none; 279 | border-top: 1px solid var(--borders); 280 | } 281 | 282 | .top-left-on-screen-button-group .button { 283 | margin-right: 1rem; 284 | } 285 | 286 | .ReactModal__Overlay { 287 | position: fixed; 288 | top: 0; 289 | left: 0; 290 | right: 0; 291 | bottom: 0; 292 | display: flex; 293 | align-items: center; 294 | justify-content : center; 295 | } 296 | 297 | .ReactModal__Content { 298 | background: var(--themeBgColor); 299 | border: 1px solid var(--borders); 300 | padding: 1rem; 301 | width: 400px; 302 | } 303 | 304 | .view-wrapper .graph .edge-mouse-handler { 305 | stroke-width: 30px; 306 | } 307 | 308 | .node { 309 | cursor: pointer; 310 | } 311 | 312 | .graph foreignObject { 313 | overflow: visible; 314 | } 315 | 316 | .node-text, .edge-text { 317 | pointer-events: none; 318 | 319 | padding: 2; 320 | 321 | white-space: pre; 322 | 323 | background-repeat: no-repeat; 324 | background-size: 60%; 325 | background-position: center; 326 | } 327 | 328 | .node-text { 329 | display: flex; 330 | flex-direction: column; 331 | } 332 | 333 | .node-text > .node-main { 334 | flex-grow: 1; 335 | } 336 | 337 | .node-name { 338 | pointer-events: initial; 339 | user-select: none; 340 | 341 | overflow: hidden; 342 | text-overflow: ellipsis; 343 | } 344 | 345 | .node-name-icon { 346 | height: 1em; 347 | vertical-align: text-top; 348 | } 349 | 350 | .node-tunnel-info { 351 | text-align: right; 352 | } 353 | 354 | .volume-thumbnail-ruler-line { 355 | stroke-width: 2px; 356 | stroke: var(--borders); 357 | } 358 | .volume-thumbnail-volume-line { 359 | stroke-width: 2px; 360 | stroke: var(--successColor); 361 | } 362 | .volume-thumbnail-volume-line-warning { 363 | stroke: var(--warningColor) 364 | } 365 | .volume-thumbnail-volume-line-error { 366 | stroke: var(--errorColor) 367 | } 368 | 369 | .volume-thumbnail-muted .volume-thumbnail-volume-line { 370 | stroke: var(--borders) 371 | } 372 | 373 | .edge-text { 374 | pointer-events: none; 375 | } 376 | 377 | .volume-controls { 378 | background: var(--themeBgColor); 379 | border: 1px solid var(--borders); 380 | 381 | pointer-events: initial; 382 | padding: 2px; 383 | 384 | width: 308px; 385 | 386 | margin-left: -50%; 387 | } 388 | 389 | .node.selected .volume-controls { 390 | border-color: var(--themeSelectedBgColor); 391 | } 392 | 393 | .volume-slider-norm-mark, .volume-slider-base-mark { 394 | stroke: var(--borders); 395 | stroke-width: 1px; 396 | } 397 | 398 | .volume-slider-bg { 399 | stroke: var(--borders); 400 | stroke-width: 6px; 401 | stroke-linecap: round; 402 | } 403 | 404 | .volume-slider-fill { 405 | stroke: var(--successColor); 406 | stroke-width: 6px; 407 | stroke-linecap: round; 408 | } 409 | 410 | .volume-slider-handle { 411 | fill: var(--themeBgColor); 412 | stroke: var(--borders); 413 | stroke-width: 1px; 414 | } 415 | 416 | .volume-slider-handle:hover { 417 | stroke: var(--themeSelectedBgColor); 418 | } 419 | -------------------------------------------------------------------------------- /components/graph/base.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const { 4 | merge, 5 | } = require('ramda'); 6 | 7 | const r = require('r-dom'); 8 | 9 | const { 10 | GraphView: GraphViewBase, 11 | Node: NodeBase, 12 | Edge: EdgeBase, 13 | GraphUtils, 14 | } = require('react-digraph'); 15 | 16 | const math = require('mathjs'); 17 | 18 | const d3 = require('d3'); 19 | 20 | const { size } = require('../../constants/view'); 21 | 22 | class GraphView extends GraphViewBase { 23 | constructor(props) { 24 | super(props); 25 | 26 | if (props.layoutEngine) { 27 | this.layoutEngine = props.layoutEngine; 28 | } 29 | 30 | Object.assign(this, { 31 | _super_handleZoomStart: this.handleZoomStart, 32 | handleZoomStart: this.constructor.prototype.handleZoomStart.bind(this), 33 | _super_handleZoomEnd: this.handleZoomEnd, 34 | handleZoomEnd: this.constructor.prototype.handleZoomEnd.bind(this), 35 | 36 | _super_handleNodeMove: this.handleNodeMove, 37 | handleNodeMove: this.constructor.prototype.handleNodeMove.bind(this), 38 | 39 | _super_getEdgeComponent: this.getEdgeComponent, 40 | getEdgeComponent: this.constructor.prototype.getEdgeComponent.bind(this), 41 | 42 | _super_getNodeComponent: this.getNodeComponent, 43 | getNodeComponent: this.constructor.prototype.getNodeComponent.bind(this), 44 | 45 | _super_handleNodeMouseEnter: this.handleNodeMouseEnter, 46 | handleNodeMouseEnter: this.constructor.prototype.handleNodeMouseEnter.bind(this), 47 | }); 48 | } 49 | 50 | static getDerivedStateFromProps(props, state) { 51 | const derivedState = super.getDerivedStateFromProps(props, state); 52 | 53 | if (props.layoutEngine) { 54 | derivedState.nodes = props.layoutEngine.adjustNodes(derivedState.nodes, derivedState.nodesMap); 55 | } 56 | 57 | if (props.moved && props.selected) { 58 | const edgeKey = `${props.moved.source}_${props.moved.target}`; 59 | const nodeKey = `key-${props.selected.id}`; 60 | 61 | if (derivedState.edgesMap[edgeKey] && derivedState.nodesMap[nodeKey]) { 62 | derivedState.previousMoved = props.moved; 63 | 64 | derivedState.draggingEdge = true; 65 | derivedState.draggedEdge = props.moved; 66 | 67 | derivedState.edgeEndNode = props.selected; 68 | 69 | derivedState.hoveredNode = true; 70 | derivedState.hoveredNodeData = props.selected; 71 | 72 | derivedState.selectedNodeObj = { 73 | nodeId: null, 74 | node: null, 75 | }; 76 | } 77 | } else if (!props.moved && state.previousMoved) { 78 | derivedState.previousMoved = null; 79 | 80 | derivedState.draggingEdge = false; 81 | derivedState.draggedEdge = null; 82 | 83 | derivedState.edgeEndNode = null; 84 | 85 | derivedState.hoveredNode = false; 86 | derivedState.hoveredNodeData = null; 87 | } 88 | 89 | return derivedState; 90 | } 91 | 92 | shouldComponentUpdate(nextProps, nextState) { 93 | return super.shouldComponentUpdate(nextProps, nextState) 94 | || this.state.edgeEndNode !== nextState.edgeEndNode; 95 | } 96 | 97 | componentDidUpdate(previousProps, previousState) { 98 | const { nodeKey } = this.props; 99 | 100 | if (this.state.edgeEndNode !== previousState.edgeEndNode) { 101 | if (previousState.edgeEndNode) { 102 | const previousNode = document.querySelector('#node-' + previousState.edgeEndNode[nodeKey]); 103 | previousNode.classList.remove('targeted'); 104 | } 105 | 106 | if (this.state.edgeEndNode) { 107 | const node = document.querySelector('#node-' + this.state.edgeEndNode[nodeKey]); 108 | node.classList.add('targeted'); 109 | } 110 | } 111 | 112 | if (!previousProps.moved && this.props.moved) { 113 | this.removeEdgeElement(this.props.moved.source, this.props.moved.target); 114 | } else if (previousProps.moved && !this.props.moved) { 115 | const container = document.querySelector('#edge-custom-container'); 116 | if (container) { 117 | container.remove(); 118 | } 119 | } 120 | 121 | if (this.props.selected 122 | && this.props.moved 123 | && ( 124 | previousProps.selected !== this.props.selected 125 | || previousProps.moved !== this.props.moved 126 | ) 127 | && this.state.draggedEdge 128 | ) { 129 | this.dragEdge(); 130 | } 131 | 132 | super.componentDidUpdate(previousProps, previousState); 133 | } 134 | 135 | getMouseCoordinates(...args) { 136 | if (this.props.selected && this.props.moved) { 137 | return [ 138 | this.props.selected.x, 139 | this.props.selected.y, 140 | ]; 141 | } 142 | 143 | return super.getMouseCoordinates(...args); 144 | } 145 | 146 | getNodeComponent(id, node) { 147 | const { nodeTypes, nodeSubtypes, nodeSize, renderNode, renderNodeText, nodeKey } = this.props; 148 | return r(Node, { 149 | key: id, 150 | id, 151 | data: node, 152 | nodeTypes, 153 | nodeSize, 154 | nodeKey, 155 | nodeSubtypes, 156 | onNodeMouseDown: this.props.onNodeMouseDown, 157 | onNodeMouseEnter: this.handleNodeMouseEnter, 158 | onNodeMouseLeave: this.handleNodeMouseLeave, 159 | onNodeDragStart: this.props.onNodeDragStart, 160 | onNodeDragEnd: this.props.onNodeDragEnd, 161 | onNodeMove: this.handleNodeMove, 162 | onNodeUpdate: this.handleNodeUpdate, 163 | onNodeSelected: this.handleNodeSelected, 164 | renderNode, 165 | renderNodeText, 166 | isSelected: this.state.selectedNodeObj.node === node, 167 | layoutEngine: this.layoutEngine, 168 | viewWrapperElem: this.viewWrapper.current, 169 | }); 170 | } 171 | 172 | handleZoomStart(...args) { 173 | if (this.props.onZoomStart) { 174 | this.props.onZoomStart(); 175 | } 176 | 177 | return this._super_handleZoomStart(...args); 178 | } 179 | 180 | handleZoomEnd(...args) { 181 | if (this.props.onZoomEnd) { 182 | this.props.onZoomEnd(); 183 | } 184 | 185 | return this._super_handleZoomEnd(...args); 186 | } 187 | 188 | handleNodeMove(position, nodeId, shiftKey) { 189 | this._super_handleNodeMove(position, nodeId, shiftKey); 190 | if (this.props.onNodeMove) { 191 | this.props.onNodeMove(position, nodeId, shiftKey); 192 | } 193 | } 194 | 195 | handleNodeMouseEnter(event, data, hovered) { 196 | if (hovered && !this.state.hoveredNode) { 197 | this.setState({ 198 | hoveredNode: true, 199 | hoveredNodeData: data, 200 | }); 201 | } else if (!hovered && this.state.draggingEdge) { 202 | this.setState({ 203 | edgeEndNode: data, 204 | }); 205 | } else { 206 | this.setState({ 207 | hoveredNode: true, 208 | hoveredNodeData: data, 209 | }); 210 | } 211 | } 212 | 213 | getEdgeComponent(edge, nodeMoving) { 214 | if (!this.props.renderEdge) { 215 | return this._super_getEdgeComponent(edge); 216 | } 217 | 218 | const sourceNodeMapNode = this.getNodeById(edge.source); 219 | const sourceNode = sourceNodeMapNode ? sourceNodeMapNode.node : null; 220 | const targetNodeMapNode = this.getNodeById(edge.target); 221 | const targetNode = targetNodeMapNode ? targetNodeMapNode.node : null; 222 | const { targetPosition } = edge; 223 | const { edgeTypes, edgeHandleSize, nodeSize, nodeKey, renderEdgeText } = this.props; 224 | const selected = this.isEdgeSelected(edge); 225 | 226 | return r(this.props.renderEdge || Edge, { 227 | data: edge, 228 | edgeTypes, 229 | edgeHandleSize, 230 | nodeSize, 231 | sourceNode, 232 | targetNode: targetNode || targetPosition, 233 | nodeKey, 234 | isSelected: selected, 235 | nodeMoving, 236 | renderEdgeText, 237 | onEdgeMouseDown: this.props.onEdgeMouseDown, 238 | }); 239 | } 240 | 241 | syncRenderEdge(edge, nodeMoving = false) { 242 | if (!edge.source) { 243 | return; 244 | } 245 | 246 | // XXX WORKAROUND: this can be called from `requestAnimationFrame` callback after the edge has already been removed. 247 | // Might be a react-digraph bug. 248 | const edgeKey = [ edge.source, edge.target ].join('_'); 249 | if (edge.source && edge.target && !this.state.edgesMap[edgeKey]) { 250 | return; 251 | } 252 | 253 | const idVar = edge.target ? `${edge.source}-${edge.target}` : 'custom'; 254 | const id = `edge-${idVar}`; 255 | const element = this.getEdgeComponent(edge, nodeMoving); 256 | this.renderEdge(id, element, edge, nodeMoving); 257 | 258 | if (this.isEdgeSelected(edge)) { 259 | const container = document.querySelector(`#${id}-container`); 260 | container.parentNode.append(container); 261 | } 262 | } 263 | } 264 | 265 | GraphView.defaultProps = merge(GraphViewBase.defaultProps, { 266 | layoutEngineType: null, 267 | }); 268 | 269 | class Node extends NodeBase { 270 | constructor(props) { 271 | super(props); 272 | 273 | Object.assign(this, { 274 | _super_handleDragStart: this.handleDragStart, 275 | handleDragStart: this.constructor.prototype.handleDragStart.bind(this), 276 | 277 | _super_handleDragEnd: this.handleDragEnd, 278 | handleDragEnd: this.constructor.prototype.handleDragEnd.bind(this), 279 | 280 | handleMouseDown: this.constructor.prototype.handleMouseDown.bind(this), 281 | }); 282 | } 283 | 284 | componentDidMount() { 285 | d3 286 | .select(this.nodeRef.current) 287 | .on('mousedown', this.handleMouseDown); 288 | 289 | super.componentDidMount(); 290 | } 291 | 292 | componentWillUnmount() { 293 | d3 294 | .select(this.nodeRef.current) 295 | .on('mousedown', null); 296 | 297 | super.componentWillUnmount(); 298 | } 299 | 300 | handleMouseDown() { 301 | if (this.props.onNodeMouseDown) { 302 | this.props.onNodeMouseDown(d3.event, this.props.data); 303 | } 304 | } 305 | 306 | handleDragStart(...args) { 307 | if (this.props.onNodeDragStart) { 308 | this.props.onNodeDragStart(...args); 309 | } 310 | 311 | return this._super_handleDragStart(...args); 312 | } 313 | 314 | handleDragEnd(...args) { 315 | if (this.props.onNodeDragEnd) { 316 | this.props.onNodeDragEnd(...args); 317 | } 318 | 319 | this.oldSibling = null; 320 | return this._super_handleDragEnd(...args); 321 | } 322 | } 323 | 324 | EdgeBase.calculateOffset = function (nodeSize, source, target) { 325 | const arrowVector = math.matrix([ target.x - source.x, target.y - source.y ]); 326 | const offsetLength = Math.max(0, Math.min((0.75 * size), (math.norm(arrowVector) / 2) - 40)); 327 | const offsetVector = math.dotMultiply(arrowVector, (offsetLength / math.norm(arrowVector)) || 0); 328 | 329 | return { 330 | xOff: offsetVector.get([ 0 ]), 331 | yOff: offsetVector.get([ 1 ]), 332 | }; 333 | }; 334 | 335 | class Edge extends EdgeBase { 336 | constructor(props) { 337 | super(props); 338 | 339 | Object.assign(this, { 340 | handleMouseDown: this.constructor.prototype.handleMouseDown.bind(this), 341 | }); 342 | } 343 | 344 | componentDidMount() { 345 | d3 346 | .select(this.edgeOverlayRef.current) 347 | .on('mousedown', this.handleMouseDown); 348 | } 349 | 350 | componentWillUnmount() { 351 | d3 352 | .select(this.edgeOverlayRef.current) 353 | .on('mousedown', null); 354 | } 355 | 356 | handleMouseDown() { 357 | if (this.props.onEdgeMouseDown) { 358 | this.props.onEdgeMouseDown(d3.event, this.props.data); 359 | } 360 | } 361 | 362 | render() { 363 | const { data } = this.props; 364 | const id = `${data.source || ''}_${data.target}`; 365 | const className = GraphUtils.classNames('edge', { 366 | selected: this.props.isSelected, 367 | }); 368 | 369 | return r.g({ 370 | className: 'edge-container ' + (this.props.className || ''), 371 | 'data-source': data.source, 372 | 'data-target': data.target, 373 | }, [ 374 | r.g({ 375 | className, 376 | }, [ 377 | r.path({ 378 | className: 'edge-path', 379 | d: this.getPathDescription(data) || undefined, 380 | }), 381 | ]), 382 | r.g({ 383 | className: 'edge-mouse-handler', 384 | }, [ 385 | r.path({ 386 | className: 'edge-overlay-path', 387 | ref: this.edgeOverlayRef, 388 | id, 389 | 'data-source': data.source, 390 | 'data-target': data.target, 391 | d: this.getPathDescription(data) || undefined, 392 | }), 393 | this.props.renderEdgeText && !this.props.nodeMoving && r(this.props.renderEdgeText, { 394 | data, 395 | transform: this.getEdgeHandleTranslation(), 396 | selected: this.props.isSelected, 397 | }), 398 | ]), 399 | ]); 400 | } 401 | } 402 | 403 | module.exports = { 404 | GraphView, 405 | Edge, 406 | }; 407 | -------------------------------------------------------------------------------- /store/pulse-middleware.js: -------------------------------------------------------------------------------- 1 | 2 | const { 3 | difference, 4 | keys, 5 | filter, 6 | values, 7 | propEq, 8 | compose, 9 | indexBy, 10 | } = require('ramda'); 11 | 12 | const Bluebird = require('bluebird'); 13 | 14 | const PAClient = require('@futpib/paclient'); 15 | 16 | const { handleActions } = require('redux-actions'); 17 | 18 | const { pulse: pulseActions } = require('../actions'); 19 | 20 | const { things } = require('../constants/pulse'); 21 | 22 | const { getPaiByTypeAndIndex } = require('../selectors'); 23 | 24 | const { primaryPulseServer } = require('../reducers/pulse'); 25 | 26 | const { parseModuleArgs, formatModuleArgs } = require('../utils/module-args'); 27 | 28 | function getFnFromType(type) { 29 | let fn; 30 | switch (type) { 31 | case 'sink': 32 | case 'card': 33 | case 'source': 34 | fn = type; 35 | break; 36 | case 'sinkInput': 37 | case 'sourceOutput': 38 | case 'client': 39 | case 'module': 40 | fn = `${type}ByIndex`; 41 | break; 42 | default: 43 | throw new Error('Unexpected type: ' + type); 44 | } 45 | 46 | return 'get' + fn[0].toUpperCase() + fn.slice(1); 47 | } 48 | 49 | function setSinkChannelVolume(pa, store, index, channelIndex, volume, cb) { 50 | const pai = getPaiByTypeAndIndex('sink', index)(store.getState()); 51 | pa.setSinkVolumes(index, pai.channelVolumes.map((v, i) => i === channelIndex ? volume : v), cb); 52 | } 53 | 54 | function setSourceChannelVolume(pa, store, index, channelIndex, volume, cb) { 55 | const pai = getPaiByTypeAndIndex('source', index)(store.getState()); 56 | pa.setSourceVolumes(index, pai.channelVolumes.map((v, i) => i === channelIndex ? volume : v), cb); 57 | } 58 | 59 | function setSinkInputChannelVolume(pa, store, index, channelIndex, volume, cb) { 60 | const pai = getPaiByTypeAndIndex('sinkInput', index)(store.getState()); 61 | pa.setSinkInputVolumesByIndex(index, pai.channelVolumes.map((v, i) => i === channelIndex ? volume : v), cb); 62 | } 63 | 64 | function setSourceOutputChannelVolume(pa, store, index, channelIndex, volume, cb) { 65 | const pai = getPaiByTypeAndIndex('sourceOutput', index)(store.getState()); 66 | pa.setSourceOutputVolumesByIndex(index, pai.channelVolumes.map((v, i) => i === channelIndex ? volume : v), cb); 67 | } 68 | 69 | const createPulseClient = (store, pulseServerId = primaryPulseServer) => { 70 | let state = store.getState(); 71 | 72 | const getPulseServerState = (s = state) => s.pulse[pulseServerId] || {}; 73 | 74 | const pa = new PAClient(); 75 | 76 | const getInfo = (type, index) => { 77 | let method; 78 | try { 79 | method = getFnFromType(type); 80 | } catch (error) { 81 | if (error.message.startsWith('Unexpected type:')) { 82 | console.warn(error); 83 | return; 84 | } 85 | 86 | throw error; 87 | } 88 | 89 | pa[method](index, (err, info) => { 90 | if (err) { 91 | if (err.message === 'No such entity') { 92 | console.warn(err.message, type, index); 93 | return; 94 | } 95 | 96 | throw err; 97 | } 98 | 99 | info.type = info.type || type; 100 | store.dispatch(pulseActions.info(info, pulseServerId)); 101 | }); 102 | }; 103 | 104 | pa 105 | .on('ready', () => { 106 | store.dispatch(pulseActions.ready(pulseServerId)); 107 | pa.subscribe('all'); 108 | 109 | getServerInfo(); 110 | 111 | things.forEach(({ method, type }) => { 112 | pa[method]((err, infos) => { 113 | handleError(err); 114 | infos.forEach(info => { 115 | const { index } = info; 116 | info.type = info.type || type; 117 | store.dispatch(pulseActions.new({ type, index }, pulseServerId)); 118 | store.dispatch(pulseActions.info(info, pulseServerId)); 119 | }); 120 | }); 121 | }); 122 | }) 123 | .on('close', () => { 124 | store.dispatch(pulseActions.close(pulseServerId)); 125 | reconnect(); 126 | }) 127 | .on('new', (type, index) => { 128 | if (type === 'server') { 129 | getServerInfo(); 130 | return; 131 | } 132 | 133 | store.dispatch(pulseActions.new({ type, index }, pulseServerId)); 134 | getInfo(type, index); 135 | }) 136 | .on('change', (type, index) => { 137 | if (type === 'server') { 138 | getServerInfo(); 139 | return; 140 | } 141 | 142 | store.dispatch(pulseActions.change({ type, index }, pulseServerId)); 143 | getInfo(type, index); 144 | }) 145 | .on('remove', (type, index) => { 146 | store.dispatch(pulseActions.remove({ type, index }, pulseServerId)); 147 | }) 148 | .on('error', error => { 149 | handleError(error); 150 | }); 151 | 152 | const reconnect = () => new Bluebird((resolve, reject) => { 153 | const server = getPulseServerState(); 154 | if (server.targetState !== 'ready') { 155 | resolve(); 156 | return; 157 | } 158 | 159 | pa.once('ready', resolve); 160 | pa.once('error', reject); 161 | 162 | if (pulseServerId === primaryPulseServer) { 163 | pa.connect(); 164 | } else { 165 | pa.connect({ 166 | serverString: pulseServerId, 167 | }); 168 | } 169 | }).catch(error => { 170 | if (error.message === 'Unable to connect to PulseAudio server') { 171 | return Bluebird.delay(5000).then(reconnect); 172 | } 173 | 174 | throw error; 175 | }); 176 | 177 | const getServerInfo = () => { 178 | pa.getServerInfo((err, info) => { 179 | if (err) { 180 | handleError(err); 181 | } else { 182 | store.dispatch(pulseActions.serverInfo(info, pulseServerId)); 183 | } 184 | }); 185 | }; 186 | 187 | const handleError = error => { 188 | if (!error) { 189 | return; 190 | } 191 | 192 | console.error(error); 193 | 194 | store.dispatch(pulseActions.error(error, pulseServerId)); 195 | }; 196 | 197 | const handlePulseActions = handleActions({ 198 | [pulseActions.moveSinkInput]: (state, { payload: { sinkInputIndex, destSinkIndex } }) => { 199 | pa.moveSinkInput(sinkInputIndex, destSinkIndex, handleError); 200 | return state; 201 | }, 202 | [pulseActions.moveSourceOutput]: (state, { payload: { sourceOutputIndex, destSourceIndex } }) => { 203 | pa.moveSourceOutput(sourceOutputIndex, destSourceIndex, handleError); 204 | return state; 205 | }, 206 | 207 | [pulseActions.killClientByIndex]: (state, { payload: { clientIndex } }) => { 208 | pa.killClientByIndex(clientIndex, handleError); 209 | return state; 210 | }, 211 | 212 | [pulseActions.killSinkInputByIndex]: (state, { payload: { sinkInputIndex } }) => { 213 | pa.killSinkInputByIndex(sinkInputIndex, handleError); 214 | return state; 215 | }, 216 | [pulseActions.killSourceOutputByIndex]: (state, { payload: { sourceOutputIndex } }) => { 217 | pa.killSourceOutputByIndex(sourceOutputIndex, handleError); 218 | return state; 219 | }, 220 | 221 | [pulseActions.loadModule]: (state, { payload: { name, argument } }) => { 222 | pa.loadModule(name, argument, handleError); 223 | return state; 224 | }, 225 | [pulseActions.unloadModuleByIndex]: (state, { payload: { moduleIndex } }) => { 226 | pa.unloadModuleByIndex(moduleIndex, handleError); 227 | return state; 228 | }, 229 | 230 | [pulseActions.setSinkVolumes]: (state, { payload: { index, channelVolumes } }) => { 231 | pa.setSinkVolumes(index, channelVolumes, handleError); 232 | return state; 233 | }, 234 | [pulseActions.setSourceVolumes]: (state, { payload: { index, channelVolumes } }) => { 235 | pa.setSourceVolumes(index, channelVolumes, handleError); 236 | return state; 237 | }, 238 | [pulseActions.setSinkInputVolumes]: (state, { payload: { index, channelVolumes } }) => { 239 | pa.setSinkInputVolumesByIndex(index, channelVolumes, handleError); 240 | return state; 241 | }, 242 | [pulseActions.setSourceOutputVolumes]: (state, { payload: { index, channelVolumes } }) => { 243 | pa.setSourceOutputVolumesByIndex(index, channelVolumes, handleError); 244 | return state; 245 | }, 246 | 247 | [pulseActions.setSinkChannelVolume]: (state, { payload: { index, channelIndex, volume } }) => { 248 | return setSinkChannelVolume(pa, store, index, channelIndex, volume, handleError); 249 | }, 250 | [pulseActions.setSourceChannelVolume]: (state, { payload: { index, channelIndex, volume } }) => { 251 | return setSourceChannelVolume(pa, store, index, channelIndex, volume, handleError); 252 | }, 253 | [pulseActions.setSinkInputChannelVolume]: (state, { payload: { index, channelIndex, volume } }) => { 254 | return setSinkInputChannelVolume(pa, store, index, channelIndex, volume, handleError); 255 | }, 256 | [pulseActions.setSourceOutputChannelVolume]: (state, { payload: { index, channelIndex, volume } }) => { 257 | return setSourceOutputChannelVolume(pa, store, index, channelIndex, volume, handleError); 258 | }, 259 | 260 | [pulseActions.setCardProfile]: (state, { payload: { index, profileName } }) => { 261 | pa.setCardProfile(index, profileName, handleError); 262 | return state; 263 | }, 264 | 265 | [pulseActions.setSinkPort]: (state, { payload: { index, portName } }) => { 266 | pa.setSinkPort(index, portName, handleError); 267 | return state; 268 | }, 269 | [pulseActions.setSourcePort]: (state, { payload: { index, portName } }) => { 270 | pa.setSourcePort(index, portName, handleError); 271 | return state; 272 | }, 273 | 274 | [pulseActions.setSinkMute]: (state, { payload: { index, muted } }) => { 275 | pa.setSinkMute(index, muted, handleError); 276 | return state; 277 | }, 278 | [pulseActions.setSourceMute]: (state, { payload: { index, muted } }) => { 279 | pa.setSourceMute(index, muted, handleError); 280 | return state; 281 | }, 282 | [pulseActions.setSinkInputMuteByIndex]: (state, { payload: { index, muted } }) => { 283 | pa.setSinkInputMuteByIndex(index, muted, handleError); 284 | return state; 285 | }, 286 | [pulseActions.setSourceOutputMuteByIndex]: (state, { payload: { index, muted } }) => { 287 | pa.setSourceOutputMuteByIndex(index, muted, handleError); 288 | return state; 289 | }, 290 | 291 | [pulseActions.setDefaultSinkByName]: (state, { payload: { name } }) => { 292 | pa.setDefaultSinkByName(name, handleError); 293 | return state; 294 | }, 295 | [pulseActions.setDefaultSourceByName]: (state, { payload: { name } }) => { 296 | pa.setDefaultSourceByName(name, handleError); 297 | return state; 298 | }, 299 | }, null); 300 | 301 | return { 302 | handleAction: action => handlePulseActions(null, action), 303 | 304 | storeWillUpdate(previousState, nextState) { 305 | state = nextState; 306 | const previous = getPulseServerState(previousState); 307 | const next = getPulseServerState(nextState); 308 | 309 | if (previous === next) { 310 | return; 311 | } 312 | 313 | if (previous.targetState !== next.targetState) { 314 | if (next.targetState === 'ready') { 315 | reconnect(); 316 | } else if (next.targetState === 'closed') { 317 | pa.end(); 318 | } 319 | } 320 | }, 321 | }; 322 | }; 323 | 324 | const tunnelAttempts = {}; 325 | const tunnelAttemptTimeout = 15000; 326 | const isNotMonitor = s => s.monitorSourceIndex < 0; 327 | const updateTunnels = (dispatch, primaryState, remoteServerId, remoteState) => { 328 | const sourceTunnels = compose( 329 | indexBy(m => parseModuleArgs(m.args).source), 330 | filter(propEq('name', 'module-tunnel-source')), 331 | values, 332 | )(primaryState.infos.modules); 333 | const sinkTunnels = compose( 334 | indexBy(m => parseModuleArgs(m.args).sink), 335 | filter(propEq('name', 'module-tunnel-sink')), 336 | values, 337 | )(primaryState.infos.modules); 338 | 339 | const remoteSources = filter(isNotMonitor, values(remoteState.infos.sources)); 340 | const remoteSinks = values(remoteState.infos.sinks); 341 | 342 | // FIXME: BUG: sounce/sink name collisions are possible, should also check server id 343 | 344 | remoteSinks.forEach(sink => { 345 | if ((tunnelAttempts[sink.name] || 0) + tunnelAttemptTimeout > Date.now()) { 346 | return; 347 | } 348 | 349 | if (!sinkTunnels[sink.name]) { 350 | tunnelAttempts[sink.name] = Date.now(); 351 | dispatch(pulseActions.loadModule('module-tunnel-sink', formatModuleArgs({ 352 | server: remoteServerId, 353 | sink: sink.name, 354 | }))); 355 | } 356 | }); 357 | 358 | remoteSources.forEach(source => { 359 | if ((tunnelAttempts[source.name] || 0) + tunnelAttemptTimeout > Date.now()) { 360 | return; 361 | } 362 | 363 | if (!sourceTunnels[source.name]) { 364 | tunnelAttempts[source.name] = Date.now(); 365 | dispatch(pulseActions.loadModule('module-tunnel-source', formatModuleArgs({ 366 | server: remoteServerId, 367 | source: source.name, 368 | }))); 369 | } 370 | }); 371 | }; 372 | 373 | module.exports = store => { 374 | const clients = { 375 | [primaryPulseServer]: createPulseClient(store, primaryPulseServer), 376 | }; 377 | 378 | return next => action => { 379 | const { pulseServerId = primaryPulseServer } = action.meta || {}; 380 | 381 | const previousState = store.getState(); 382 | 383 | const returnValue = next(action); 384 | 385 | const nextState = store.getState(); 386 | 387 | const newPulseServerIds = difference(keys(nextState.pulse), keys(clients)); 388 | 389 | newPulseServerIds.forEach(pulseServerId => { 390 | clients[pulseServerId] = createPulseClient(store, pulseServerId); 391 | }); 392 | 393 | const client = clients[pulseServerId]; 394 | if (client) { 395 | client.handleAction(action); 396 | if (previousState !== nextState) { 397 | client.storeWillUpdate(previousState, nextState); 398 | } 399 | } 400 | 401 | const primaryState = nextState.pulse[primaryPulseServer]; 402 | keys(nextState.pulse).forEach(pulseServerId => { 403 | if (pulseServerId === primaryPulseServer) { 404 | return; 405 | } 406 | 407 | const remoteState = nextState.pulse[pulseServerId]; 408 | 409 | if (primaryState.state === 'ready' 410 | && remoteState.state === 'ready' 411 | && primaryState.targetState === 'ready' 412 | && primaryState.targetState === 'ready' 413 | ) { 414 | updateTunnels(store.dispatch, primaryState, pulseServerId, remoteState); 415 | } 416 | }); 417 | 418 | return returnValue; 419 | }; 420 | }; 421 | -------------------------------------------------------------------------------- /components/graph/index.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const { 4 | all, 5 | allPass, 6 | bind, 7 | compose, 8 | defaultTo, 9 | filter, 10 | find, 11 | flatten, 12 | forEach, 13 | keys, 14 | map, 15 | max, 16 | merge, 17 | min, 18 | omit, 19 | path, 20 | pick, 21 | prop, 22 | reduce, 23 | repeat, 24 | sortBy, 25 | values, 26 | scan, 27 | range, 28 | } = require('ramda'); 29 | 30 | const React = require('react'); 31 | 32 | const r = require('r-dom'); 33 | 34 | const { 35 | connect, 36 | Provider: ReduxProvider, 37 | ReactReduxContext: { Consumer: ReduxConsumer }, 38 | } = require('react-redux'); 39 | const { bindActionCreators } = require('redux'); 40 | 41 | const { 42 | fromRenderProps, 43 | } = require('recompose'); 44 | 45 | const { HotKeys } = require('react-hotkeys'); 46 | 47 | const { PopupMenu, MenuItem } = require('@futpib/react-electron-menu'); 48 | 49 | const d = require('../../utils/d'); 50 | const memoize = require('../../utils/memoize'); 51 | const { 52 | forwardRef, 53 | unforwardRef, 54 | } = require('../../utils/recompose'); 55 | 56 | const { 57 | pulse: pulseActions, 58 | icons: iconsActions, 59 | } = require('../../actions'); 60 | 61 | const { 62 | getPaiByTypeAndIndex, 63 | getPaiByDgoFromInfos, 64 | 65 | getDerivedMonitorSources, 66 | 67 | getClientSinkInputs, 68 | getModuleSinkInputs, 69 | 70 | getClientSourceOutputs, 71 | getModuleSourceOutputs, 72 | 73 | getSinkSinkInputs, 74 | 75 | getDefaultSinkPai, 76 | getDefaultSourcePai, 77 | } = require('../../selectors'); 78 | 79 | const { 80 | PA_VOLUME_NORM, 81 | } = require('../../constants/pulse'); 82 | 83 | const { size } = require('../../constants/view'); 84 | 85 | const VolumeSlider = require('../../components/volume-slider'); 86 | 87 | const { primaryPulseServer } = require('../../reducers/pulse'); 88 | 89 | const { keyMap } = require('../hot-keys'); 90 | 91 | const { 92 | SatellitesGraphView, 93 | } = require('./satellites-graph'); 94 | 95 | const { 96 | Edge, 97 | } = require('./base'); 98 | 99 | const LayoutEngine = require('./layout-engine'); 100 | 101 | const maximum = reduce(max, -Infinity); 102 | const clamp = (v, lo, hi) => min(hi, max(lo, v)); 103 | 104 | const leftOf = (x, xs) => { 105 | const i = ((xs.indexOf(x) + xs.length - 1) % xs.length); 106 | return xs[i]; 107 | }; 108 | 109 | const rightOf = (x, xs) => { 110 | const i = ((xs.indexOf(x) + 1) % xs.length); 111 | return xs[i]; 112 | }; 113 | 114 | const selectionObjectTypes = { 115 | order: [ 116 | 'source', 117 | 'sourceOutput', 118 | 'client|module', 119 | 'sinkInput', 120 | 'sink', 121 | ], 122 | 123 | left(type) { 124 | return leftOf(type, this.order); 125 | }, 126 | 127 | right(type) { 128 | return rightOf(type, this.order); 129 | }, 130 | 131 | fromPulseType(type) { 132 | if (type === 'client' || type === 'module') { 133 | return 'client|module'; 134 | } 135 | 136 | return type; 137 | }, 138 | 139 | toPulsePredicate(type) { 140 | type = this.fromPulseType(type); 141 | if (type === 'client|module') { 142 | return o => (o.type === 'client' || o.type === 'module'); 143 | } 144 | 145 | return o => o.type === type; 146 | }, 147 | }; 148 | 149 | const key = pao => `${pao.type}-${pao.index}`; 150 | 151 | const sourceKey = pai => { 152 | if (pai.type === 'monitorSource') { 153 | return `sink-${pai.sinkIndex}`; 154 | } 155 | 156 | if (pai.clientIndex === -1) { 157 | return `module-${pai.moduleIndex}`; 158 | } 159 | 160 | return `client-${pai.clientIndex}`; 161 | }; 162 | 163 | const targetKey = pai => { 164 | if (pai.type === 'monitorSource') { 165 | return `source-${pai.sourceIndex}`; 166 | } 167 | 168 | if (pai.type === 'sinkInput') { 169 | return `sink-${pai.sinkIndex}`; 170 | } 171 | 172 | return `source-${pai.sourceIndex}`; 173 | }; 174 | 175 | const paoToNode = memoize(pao => ({ 176 | id: key(pao), 177 | index: pao.index, 178 | type: pao.type, 179 | })); 180 | 181 | const paoToEdge = memoize(pao => ({ 182 | id: key(pao), 183 | source: sourceKey(pao), 184 | target: targetKey(pao), 185 | index: pao.index, 186 | type: pao.type, 187 | })); 188 | 189 | const getPaiIcon = memoize(pai => { 190 | return null 191 | || path([ 'properties', 'application', 'icon_name' ], pai) 192 | || path([ 'properties', 'device', 'icon_name' ], pai); 193 | }); 194 | 195 | const s2 = size / 2; 196 | 197 | const Sink = () => r.path({ 198 | d: d() 199 | .moveTo(-s2, 0) 200 | .lineTo(-s2 * 1.3, -s2) 201 | .lineTo(s2, -s2) 202 | .lineTo(s2, s2) 203 | .lineTo(-s2 * 1.3, s2) 204 | .close() 205 | .toString(), 206 | }); 207 | 208 | const Source = () => r.path({ 209 | d: d() 210 | .moveTo(s2 * 1.3, 0) 211 | .lineTo(s2, s2) 212 | .lineTo(-s2, s2) 213 | .lineTo(-s2, -s2) 214 | .lineTo(s2, -s2) 215 | .close() 216 | .toString(), 217 | }); 218 | 219 | const Client = () => r.path({ 220 | d: d() 221 | .moveTo(s2 * 1.3, 0) 222 | .lineTo(s2, s2) 223 | .lineTo(-s2 * 1.3, s2) 224 | .lineTo(-s2, 0) 225 | .lineTo(-s2 * 1.3, -s2) 226 | .lineTo(s2, -s2) 227 | .close() 228 | .toString(), 229 | }); 230 | 231 | const Module = Client; 232 | 233 | const gridDotSize = 2; 234 | const gridSpacing = 36; 235 | 236 | const Marker = ({ id, d }) => r('marker', { 237 | id, 238 | viewBox: '0 -8 18 16', 239 | refX: '16', 240 | markerWidth: '16', 241 | markerHeight: '16', 242 | orient: 'auto', 243 | }, r.path({ 244 | className: 'arrow', 245 | d, 246 | })); 247 | 248 | const sourceArrowPathDescription = 'M 16,-8 L 0,0 L 16,8'; 249 | const sinkArrowPathDescription = 'M 2,-8 L 18,0 L 2,8'; 250 | 251 | const renderDefs = () => r(React.Fragment, [ 252 | r.pattern({ 253 | id: 'background-pattern', 254 | key: 'background-pattern', 255 | width: gridSpacing, 256 | height: gridSpacing, 257 | patternUnits: 'userSpaceOnUse', 258 | }, r.circle({ 259 | className: 'grid-dot', 260 | cx: (gridSpacing || 0) / 2, 261 | cy: (gridSpacing || 0) / 2, 262 | r: gridDotSize, 263 | })), 264 | 265 | r(Marker, { 266 | id: 'my-source-arrow', 267 | d: sourceArrowPathDescription, 268 | }), 269 | 270 | r(Marker, { 271 | id: 'my-sink-arrow', 272 | d: sinkArrowPathDescription, 273 | }), 274 | 275 | // WORKAROUND: `context-fill` did not work 276 | r(Marker, { 277 | id: 'my-source-arrow-selected', 278 | d: sourceArrowPathDescription, 279 | }), 280 | 281 | r(Marker, { 282 | id: 'my-sink-arrow-selected', 283 | d: sinkArrowPathDescription, 284 | }), 285 | ]); 286 | 287 | const renderBackground = ({ 288 | gridSize = 40960 / 4, 289 | onMouseDown, 290 | }) => r.rect({ 291 | className: 'background', 292 | x: -(gridSize || 0) / 4, 293 | y: -(gridSize || 0) / 4, 294 | width: gridSize, 295 | height: gridSize, 296 | fill: 'url(#background-pattern)', 297 | onMouseDown, 298 | }); 299 | 300 | const renderNode = (nodeRef, data, key, selected, hovered) => r({ 301 | sink: Sink, 302 | source: Source, 303 | client: Client, 304 | module: Module, 305 | }[data.type] || Module, { 306 | selected, 307 | hovered, 308 | }); 309 | 310 | const getVolumesForThumbnail = ({ pai, lockChannelsTogether }) => { 311 | let volumes = (pai && pai.channelVolumes) || []; 312 | if (lockChannelsTogether) { 313 | if (volumes.every(v => v === volumes[0])) { 314 | volumes = [ 315 | maximum(volumes), 316 | ]; 317 | } 318 | } 319 | 320 | return volumes; 321 | }; 322 | 323 | const VolumeThumbnail = connect( 324 | state => ({ 325 | hideVolumeThumbnails: state.preferences.hideVolumeThumbnails, 326 | lockChannelsTogether: state.preferences.lockChannelsTogether, 327 | }), 328 | )(({ pai, hideVolumeThumbnails, lockChannelsTogether }) => { 329 | if (hideVolumeThumbnails) { 330 | return r(React.Fragment); 331 | } 332 | 333 | const normVolume = PA_VOLUME_NORM; 334 | const baseVolume = defaultTo(normVolume, pai && pai.baseVolume); 335 | 336 | const volumes = getVolumesForThumbnail({ pai, lockChannelsTogether }); 337 | const muted = !pai || pai.muted; 338 | 339 | const step = size / 32; 340 | const padding = 2; 341 | const width = size - 8; 342 | const height = ((1 + volumes.length) * step); 343 | 344 | return r.svg({ 345 | classSet: { 346 | 'volume-thumbnail': true, 347 | 'volume-thumbnail-muted': muted, 348 | }, 349 | height: (2 * padding) + height, 350 | }, [ 351 | r.line({ 352 | className: 'volume-thumbnail-ruler-line', 353 | x1: padding, 354 | x2: padding, 355 | y1: padding, 356 | y2: padding + height, 357 | }), 358 | 359 | baseVolume && r.line({ 360 | className: 'volume-thumbnail-ruler-line', 361 | x1: padding + ((baseVolume / normVolume) * width), 362 | x2: padding + ((baseVolume / normVolume) * width), 363 | y1: padding, 364 | y2: padding + height, 365 | }), 366 | 367 | r.line({ 368 | className: 'volume-thumbnail-ruler-line', 369 | x1: padding + width, 370 | x2: padding + width, 371 | y1: padding, 372 | y2: padding + height, 373 | }), 374 | 375 | ...volumes.map((v, i) => { 376 | const a = min(v / normVolume, baseVolume / normVolume); 377 | const b = min(v / normVolume, 1); 378 | const c = v / normVolume; 379 | 380 | return r(React.Fragment, [ 381 | r.line({ 382 | className: 'volume-thumbnail-volume-line', 383 | x1: padding, 384 | x2: padding + (a * width), 385 | y1: padding + ((1 + i) * step), 386 | y2: padding + ((1 + i) * step), 387 | }), 388 | 389 | r.line({ 390 | className: 'volume-thumbnail-volume-line volume-thumbnail-volume-line-warning', 391 | x1: padding + (a * width), 392 | x2: padding + (b * width), 393 | y1: padding + ((1 + i) * step), 394 | y2: padding + ((1 + i) * step), 395 | }), 396 | 397 | r.line({ 398 | className: 'volume-thumbnail-volume-line volume-thumbnail-volume-line-error', 399 | x1: padding + (b * width), 400 | x2: padding + (c * width), 401 | y1: padding + ((1 + i) * step), 402 | y2: padding + ((1 + i) * step), 403 | }), 404 | ]); 405 | }), 406 | ]); 407 | }); 408 | 409 | const getVolumes = ({ pai, lockChannelsTogether }) => { 410 | let volumes = (pai && pai.channelVolumes) || []; 411 | if (lockChannelsTogether) { 412 | volumes = [ 413 | maximum(volumes), 414 | ]; 415 | } 416 | 417 | return volumes; 418 | }; 419 | 420 | const VolumeControls = connect( 421 | state => pick([ 422 | 'maxVolume', 423 | 'volumeStep', 424 | 'lockChannelsTogether', 425 | ], state.preferences), 426 | dispatch => bindActionCreators(pulseActions, dispatch), 427 | )(({ pai, maxVolume, volumeStep, lockChannelsTogether, ...props }) => { 428 | const volumes = getVolumes({ pai, lockChannelsTogether }); 429 | const baseVolume = pai && pai.baseVolume; 430 | const muted = !pai || pai.muted; 431 | 432 | return r.div({ 433 | className: 'volume-controls', 434 | }, [ 435 | ...volumes.map((v, channelIndex) => r(VolumeSlider, { 436 | muted, 437 | baseVolume, 438 | normVolume: PA_VOLUME_NORM, 439 | maxVolume: PA_VOLUME_NORM * maxVolume, 440 | volumeStep, 441 | value: v, 442 | onChange: v => { 443 | if (pai.type === 'sink') { 444 | if (lockChannelsTogether) { 445 | props.setSinkVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); 446 | } else { 447 | props.setSinkChannelVolume(pai.index, channelIndex, v); 448 | } 449 | } else if (pai.type === 'source') { 450 | if (lockChannelsTogether) { 451 | props.setSourceVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); 452 | } else { 453 | props.setSourceChannelVolume(pai.index, channelIndex, v); 454 | } 455 | } else if (pai.type === 'sinkInput') { 456 | if (lockChannelsTogether) { 457 | props.setSinkInputVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); 458 | } else { 459 | props.setSinkInputChannelVolume(pai.index, channelIndex, v); 460 | } 461 | } else if (pai.type === 'sourceOutput') { 462 | if (lockChannelsTogether) { 463 | props.setSourceOutputVolumes(pai.index, repeat(v, pai.sampleSpec.channels)); 464 | } else { 465 | props.setSourceOutputChannelVolume(pai.index, channelIndex, v); 466 | } 467 | } 468 | }, 469 | })), 470 | ]); 471 | }); 472 | 473 | const Icon = connect( 474 | state => ({ 475 | icons: state.icons, 476 | }), 477 | )(({ icons, name, title }) => { 478 | const src = icons[name]; 479 | 480 | if (!src) { 481 | return r(React.Fragment); 482 | } 483 | 484 | return r.img({ 485 | className: 'node-name-icon', 486 | src, 487 | title, 488 | }); 489 | }); 490 | 491 | const RemoteTunnelInfo = ({ pai }) => { 492 | const fqdn = path([ 'properties', 'tunnel', 'remote', 'fqdn' ], pai); 493 | 494 | if (!fqdn) { 495 | return r(React.Fragment); 496 | } 497 | 498 | return r.div({ 499 | className: 'node-tunnel-info', 500 | }, [ 501 | fqdn, 502 | ]); 503 | }; 504 | 505 | const DebugText = connect( 506 | state => ({ 507 | showDebugInfo: state.preferences.showDebugInfo, 508 | }), 509 | )(({ dgo, pai, showDebugInfo }) => { 510 | if (!showDebugInfo) { 511 | return r(React.Fragment); 512 | } 513 | 514 | return r.div({ 515 | style: { 516 | fontSize: '50%', 517 | }, 518 | }, [ 519 | JSON.stringify(dgo, null, 2), 520 | JSON.stringify(pai, null, 2), 521 | ]); 522 | }); 523 | 524 | const SinkText = connect( 525 | state => ({ 526 | defaultSinkName: state.pulse[primaryPulseServer].serverInfo.defaultSinkName, 527 | }), 528 | )(({ dgo, pai, selected, defaultSinkName }) => r(React.Fragment, [ 529 | r.div({ 530 | className: 'node-name', 531 | }, [ 532 | defaultSinkName === pai.name && r(React.Fragment, [ 533 | r(Icon, { 534 | name: 'starred', 535 | title: 'Default sink', 536 | }), 537 | ' ', 538 | ]), 539 | r.span({ 540 | title: pai.name, 541 | }, pai.description), 542 | ]), 543 | 544 | r.div({ 545 | className: 'node-main', 546 | }, [ 547 | r(selected ? VolumeControls : VolumeThumbnail, { pai }), 548 | ]), 549 | 550 | r(RemoteTunnelInfo, { pai }), 551 | r(DebugText, { dgo, pai }), 552 | ])); 553 | 554 | const SourceText = connect( 555 | state => ({ 556 | defaultSourceName: state.pulse[primaryPulseServer].serverInfo.defaultSourceName, 557 | }), 558 | )(({ dgo, pai, selected, defaultSourceName }) => r(React.Fragment, [ 559 | r.div({ 560 | className: 'node-name', 561 | }, [ 562 | defaultSourceName === pai.name && r(React.Fragment, [ 563 | r(Icon, { 564 | name: 'starred', 565 | title: 'Default source', 566 | }), 567 | ' ', 568 | ]), 569 | r.span({ 570 | title: pai.name, 571 | }, pai.description), 572 | ]), 573 | 574 | r.div({ 575 | className: 'node-main', 576 | }, [ 577 | r(selected ? VolumeControls : VolumeThumbnail, { pai }), 578 | ]), 579 | 580 | r(RemoteTunnelInfo, { pai }), 581 | r(DebugText, { dgo, pai }), 582 | ])); 583 | 584 | const ClientText = connect( 585 | state => ({ 586 | modules: state.pulse[primaryPulseServer].infos.modules, 587 | }), 588 | )(({ dgo, pai, modules }) => { 589 | let title = path('properties.application.process.binary'.split('.'), pai); 590 | 591 | const module = modules[pai.moduleIndex]; 592 | if (module && module.name === 'module-native-protocol-tcp') { 593 | title = path([ 'properties', 'native-protocol', 'peer' ], pai) || title; 594 | } 595 | 596 | return r(React.Fragment, [ 597 | r.div({ 598 | className: 'node-name', 599 | title, 600 | }, pai.name), 601 | r(DebugText, { dgo, pai }), 602 | ]); 603 | }); 604 | 605 | const ModuleText = ({ dgo, pai }) => r(React.Fragment, [ 606 | r.div({ 607 | className: 'node-name', 608 | title: path([ 'properties', 'module', 'description' ], pai) || pai.name, 609 | }, pai.name), 610 | r(DebugText, { dgo, pai }), 611 | ]); 612 | 613 | const NodeText = connect( 614 | (state, { dgo }) => ({ 615 | icons: state.icons, 616 | pai: dgo.type && getPaiByTypeAndIndex(dgo.type, dgo.index)(state), 617 | }), 618 | )(({ dgo, pai, selected, icons }) => { 619 | if (!pai) { 620 | return r(React.Fragment); 621 | } 622 | 623 | return r('foreignObject', { 624 | x: -s2, 625 | y: -s2, 626 | }, r.div({ 627 | className: 'node-text', 628 | style: { 629 | width: size, 630 | height: size, 631 | 632 | backgroundImage: (icon => icon && `url(${icon})`)(icons[getPaiIcon(pai)]), 633 | }, 634 | }, r({ 635 | sink: SinkText, 636 | source: SourceText, 637 | client: ClientText, 638 | module: ModuleText, 639 | }[dgo.type] || ModuleText, { 640 | dgo, 641 | pai, 642 | selected, 643 | }))); 644 | }); 645 | 646 | const withStorePassthrough = component => store => 647 | (...args) => r(ReduxProvider, { store }, component(...args)); 648 | 649 | const renderNodeText = withStorePassthrough((dgo, i, selected) => { 650 | return r(NodeText, { dgo, selected }); 651 | }); 652 | 653 | const renderEdge = props => r(Edge, { 654 | classSet: { 655 | [props.data.type]: true, 656 | }, 657 | ...props, 658 | }); 659 | 660 | const EdgeText = connect( 661 | (state, { dgo }) => ({ 662 | pai: dgo.type && getPaiByTypeAndIndex(dgo.type, dgo.index)(state), 663 | }), 664 | )(({ dgo, pai, transform, selected }) => r('foreignObject', { 665 | transform, 666 | }, r.div({ 667 | className: 'edge-text', 668 | style: { 669 | width: size, 670 | height: size, 671 | }, 672 | }, [ 673 | pai && (!selected) && r(VolumeThumbnail, { pai }), 674 | pai && selected && r(VolumeControls, { pai }), 675 | r(DebugText, { dgo, pai }), 676 | ]))); 677 | 678 | const renderEdgeText = withStorePassthrough(({ data: dgo, transform, selected }) => { 679 | return r(EdgeText, { dgo, transform, selected }); 680 | }); 681 | 682 | const layoutEngine = new LayoutEngine(); 683 | 684 | class BackgroundContextMenu extends React.PureComponent { 685 | render() { 686 | return r(PopupMenu, { 687 | onClose: this.props.onClose, 688 | }, [ 689 | r(MenuItem, { 690 | label: 'Create', 691 | }, [ 692 | r(MenuItem, { 693 | label: 'Loopback', 694 | onClick: this.props.onLoadModuleLoopback, 695 | }), 696 | 697 | r(MenuItem, { 698 | label: 'Simultaneous output', 699 | onClick: this.props.onLoadModuleCombineSink, 700 | }), 701 | 702 | r(MenuItem, { 703 | label: 'Null output', 704 | onClick: this.props.onLoadModuleNullSink, 705 | }), 706 | ]), 707 | 708 | r(MenuItem, { 709 | label: 'Load a module...', 710 | onClick: this.props.onLoadModule, 711 | }), 712 | ]); 713 | } 714 | } 715 | 716 | class GraphObjectContextMenu extends React.PureComponent { 717 | render() { 718 | return r(PopupMenu, { 719 | onClose: this.props.onClose, 720 | }, [ 721 | this.props.canSetAsDefault() && r(React.Fragment, [ 722 | r(MenuItem, { 723 | label: 'Set as default', 724 | onClick: this.props.onSetAsDefault, 725 | }), 726 | r(MenuItem.Separator), 727 | ]), 728 | 729 | this.props.canDelete() && r(MenuItem, { 730 | label: 'Delete', 731 | onClick: this.props.onDelete, 732 | }), 733 | ]); 734 | } 735 | } 736 | 737 | const backgroundSymbol = Symbol('graph.backgroundSymbol'); 738 | 739 | class Graph extends React.PureComponent { 740 | constructor(props) { 741 | super(props); 742 | 743 | this.state = { 744 | selected: null, 745 | moved: null, 746 | contexted: null, 747 | 748 | isDraggingNode: false, 749 | isZooming: false, 750 | }; 751 | 752 | this._requestedIcons = new Set(); 753 | 754 | Object.assign(this, { 755 | renderBackground: this.renderBackground.bind(this), 756 | onBackgroundMouseDown: this.onBackgroundMouseDown.bind(this), 757 | 758 | onZoomStart: this.onZoomStart.bind(this), 759 | onZoomEnd: this.onZoomEnd.bind(this), 760 | 761 | onSelectNode: this.onSelectNode.bind(this), 762 | onCreateNode: this.onCreateNode.bind(this), 763 | onUpdateNode: this.onUpdateNode.bind(this), 764 | onDeleteNode: this.onDeleteNode.bind(this), 765 | onNodeMouseDown: this.onNodeMouseDown.bind(this), 766 | onNodeDragStart: this.onNodeDragStart.bind(this), 767 | onNodeDragEnd: this.onNodeDragEnd.bind(this), 768 | 769 | onSelectEdge: this.onSelectEdge.bind(this), 770 | canCreateEdge: this.canCreateEdge.bind(this), 771 | onCreateEdge: this.onCreateEdge.bind(this), 772 | onSwapEdge: this.onSwapEdge.bind(this), 773 | onDeleteEdge: this.onDeleteEdge.bind(this), 774 | onEdgeMouseDown: this.onEdgeMouseDown.bind(this), 775 | 776 | onContextMenuClose: this.onContextMenuClose.bind(this), 777 | 778 | canContextMenuSetAsDefault: this.canContextMenuSetAsDefault.bind(this), 779 | onContextMenuSetAsDefault: this.onContextMenuSetAsDefault.bind(this), 780 | 781 | canContextMenuDelete: this.canContextMenuDelete.bind(this), 782 | onContextMenuDelete: this.onContextMenuDelete.bind(this), 783 | 784 | onLoadModuleLoopback: this.onLoadModuleLoopback.bind(this), 785 | onLoadModuleCombineSink: this.onLoadModuleCombineSink.bind(this), 786 | onLoadModuleNullSink: this.onLoadModuleNullSink.bind(this), 787 | }); 788 | } 789 | 790 | static getDerivedStateFromProps(props, state) { 791 | let edges = map(paoToEdge, flatten(map(values, [ 792 | props.objects.sinkInputs, 793 | props.objects.sourceOutputs, 794 | props.derivations.monitorSources, 795 | ]))); 796 | 797 | const connectedNodeKeys = new Set(); 798 | edges.forEach(edge => { 799 | if (edge.type === 'monitorSource') { 800 | return; 801 | } 802 | 803 | connectedNodeKeys.add(edge.source); 804 | connectedNodeKeys.add(edge.target); 805 | }); 806 | 807 | const filteredNodeKeys = new Set(); 808 | 809 | const nodes = filter(node => { 810 | if ((props.preferences.hideDisconnectedClients && node.type === 'client') 811 | || (props.preferences.hideDisconnectedModules && node.type === 'module') 812 | || (props.preferences.hideDisconnectedSources && node.type === 'source') 813 | || (props.preferences.hideDisconnectedSinks && node.type === 'sink') 814 | ) { 815 | if (!connectedNodeKeys.has(node.id)) { 816 | return false; 817 | } 818 | } 819 | 820 | const pai = getPaiByDgoFromInfos(node)(props.infos); 821 | if (pai) { 822 | if (props.preferences.hideMonitors 823 | && pai.properties.device 824 | && pai.properties.device.class === 'monitor' 825 | ) { 826 | return false; 827 | } 828 | 829 | if (props.preferences.hidePulseaudioApps) { 830 | const binary = path([ 'properties', 'application', 'process', 'binary' ], pai) || ''; 831 | const name = path([ 'properties', 'application', 'name' ], pai) || ''; 832 | if (binary.startsWith('pavucontrol') 833 | || binary.startsWith('kmix') 834 | || binary === 'pulseaudio' 835 | || name === 'papeaks' 836 | || name === 'paclient.js' 837 | ) { 838 | return false; 839 | } 840 | } 841 | } 842 | 843 | filteredNodeKeys.add(node.id); 844 | return true; 845 | }, map(paoToNode, flatten(map(values, [ 846 | props.objects.sinks, 847 | props.objects.sources, 848 | props.objects.clients, 849 | props.objects.modules, 850 | ])))); 851 | 852 | edges = filter(edge => { 853 | if (props.preferences.hideMonitorSourceEdges && edge.type === 'monitorSource') { 854 | return false; 855 | } 856 | 857 | return filteredNodeKeys.has(edge.source) && filteredNodeKeys.has(edge.target); 858 | }, edges); 859 | 860 | let { selected, moved, contexted } = state; 861 | 862 | if (contexted && contexted !== backgroundSymbol && selected !== contexted) { 863 | contexted = null; 864 | } 865 | 866 | if (selected) { 867 | selected = find(x => x.id === selected.id, nodes) 868 | || find(x => x.id === selected.id, edges); 869 | } 870 | 871 | if (moved) { 872 | moved = find(x => x.id === moved.id, nodes) 873 | || find(x => x.id === moved.id, edges); 874 | } 875 | 876 | if (contexted && contexted !== backgroundSymbol) { 877 | contexted = find(x => x.id === contexted.id, nodes) 878 | || find(x => x.id === contexted.id, edges); 879 | } 880 | 881 | return { 882 | nodes, 883 | edges, 884 | 885 | selected, 886 | moved, 887 | contexted, 888 | }; 889 | } 890 | 891 | componentDidMount() { 892 | this.getIconPath('starred'); 893 | 894 | this.graphViewElement = document.querySelector('#graph .view-wrapper'); 895 | this.graphViewElement.setAttribute('tabindex', '-1'); 896 | 897 | this.props.connect(); 898 | } 899 | 900 | componentDidUpdate() { 901 | forEach(pai => { 902 | const icon = getPaiIcon(pai); 903 | if (icon) { 904 | this.getIconPath(icon); 905 | } 906 | }, flatten(map(values, [ 907 | this.props.infos.sinks, 908 | this.props.infos.sources, 909 | this.props.infos.clients, 910 | this.props.infos.modules, 911 | ]))); 912 | } 913 | 914 | getIconPath(icon) { 915 | if (!this._requestedIcons.has(icon) && !this.props.icons[icon]) { 916 | this.props.getIconPath(icon, 128); 917 | } 918 | 919 | this._requestedIcons.add(icon); 920 | } 921 | 922 | onBackgroundMouseDown(event) { 923 | if (event.button === 1) { 924 | this.toggleAllMute(this.props.infos.sinks); 925 | } else if (event.button === 2) { 926 | this.setState({ 927 | contexted: backgroundSymbol, 928 | }); 929 | } 930 | } 931 | 932 | onSelectNode(selected) { 933 | this.setState({ selected }); 934 | } 935 | 936 | onCreateNode() { 937 | } 938 | 939 | onUpdateNode() { 940 | } 941 | 942 | onDeleteNode(selected) { 943 | this.onDelete(selected); 944 | } 945 | 946 | onNodeMouseDown(event, data) { 947 | const pai = getPaiByDgoFromInfos(data)(this.props.infos); 948 | if (pai && event.button === 1) { 949 | if (pai.type === 'sink' 950 | || pai.type === 'source' 951 | || pai.type === 'client' 952 | || pai.type === 'module' 953 | ) { 954 | this.toggleMute(pai); 955 | } 956 | } else if (pai && event.button === 2) { 957 | this.setState({ 958 | selected: data, 959 | contexted: data, 960 | }); 961 | } 962 | } 963 | 964 | onNodeDragStart() { 965 | this.setState({ 966 | isDraggingNode: true, 967 | }); 968 | } 969 | 970 | onNodeDragEnd() { 971 | this.setState({ 972 | isDraggingNode: false, 973 | }); 974 | } 975 | 976 | onSelectEdge(selected) { 977 | this.setState({ selected }); 978 | } 979 | 980 | canCreateEdge(source, target) { 981 | if (!target) { 982 | return true; 983 | } 984 | 985 | if (source.type === 'source' && target.type === 'sink') { 986 | return true; 987 | } 988 | 989 | return false; 990 | } 991 | 992 | onCreateEdge(source, target) { 993 | const sourcePai = getPaiByDgoFromInfos(source)(this.props.infos); 994 | const targetPai = getPaiByDgoFromInfos(target)(this.props.infos); 995 | if (sourcePai && targetPai 996 | && source.type === 'source' && target.type === 'sink' 997 | ) { 998 | this.props.loadModule('module-loopback', `source=${sourcePai.name} sink=${targetPai.name}`); 999 | } else { 1000 | this.forceUpdate(); 1001 | } 1002 | } 1003 | 1004 | onSwapEdge(sourceNode, targetNode, edge) { 1005 | if (edge.type === 'sinkInput') { 1006 | this.props.moveSinkInput(edge.index, targetNode.index); 1007 | } else if (edge.type === 'sourceOutput') { 1008 | this.props.moveSourceOutput(edge.index, targetNode.index); 1009 | } 1010 | } 1011 | 1012 | onDeleteEdge(selected) { 1013 | this.onDelete(selected); 1014 | } 1015 | 1016 | onEdgeMouseDown(event, data) { 1017 | const pai = getPaiByDgoFromInfos(data)(this.props.infos); 1018 | if (pai && event.button === 1) { 1019 | if (pai.type === 'sinkInput' 1020 | || pai.type === 'sourceOutput' 1021 | ) { 1022 | this.toggleMute(pai); 1023 | } 1024 | } else if (pai && event.button === 2) { 1025 | this.setState({ 1026 | selected: data, 1027 | contexted: data, 1028 | }); 1029 | } 1030 | } 1031 | 1032 | toggleAllMute(pais) { 1033 | pais = values(pais); 1034 | const allMuted = all(prop('muted'), pais); 1035 | pais.forEach(pai => this.toggleMute(pai, !allMuted)); 1036 | } 1037 | 1038 | toggleMute(pai, muted = !pai.muted, sourceBiased = false) { 1039 | if (pai.muted === muted) { 1040 | return; 1041 | } 1042 | 1043 | if (pai.type === 'sinkInput') { 1044 | this.props.setSinkInputMuteByIndex(pai.index, muted); 1045 | } else if (pai.type === 'sourceOutput') { 1046 | this.props.setSourceOutputMuteByIndex(pai.index, muted); 1047 | } else if (pai.type === 'sink') { 1048 | if (sourceBiased) { 1049 | const sinkInputs = getSinkSinkInputs(pai)(this.props.store.getState()); 1050 | this.toggleAllMute(sinkInputs); 1051 | } else { 1052 | this.props.setSinkMute(pai.index, muted); 1053 | } 1054 | } else if (pai.type === 'source') { 1055 | this.props.setSourceMute(pai.index, muted); 1056 | } else if (pai.type === 'client') { 1057 | if (sourceBiased) { 1058 | const sourceOutputs = getClientSourceOutputs(pai)(this.props.store.getState()); 1059 | this.toggleAllMute(sourceOutputs); 1060 | } else { 1061 | const sinkInputs = getClientSinkInputs(pai)(this.props.store.getState()); 1062 | this.toggleAllMute(sinkInputs); 1063 | } 1064 | } else if (pai.type === 'module') { 1065 | if (sourceBiased) { 1066 | const sourceOutputs = getModuleSourceOutputs(pai)(this.props.store.getState()); 1067 | this.toggleAllMute(sourceOutputs); 1068 | } else { 1069 | const sinkInputs = getModuleSinkInputs(pai)(this.props.store.getState()); 1070 | this.toggleAllMute(sinkInputs); 1071 | } 1072 | } 1073 | } 1074 | 1075 | onDelete(selected) { 1076 | const pai = getPaiByDgoFromInfos(selected)(this.props.infos); 1077 | 1078 | if (selected.type === 'client') { 1079 | this.props.killClientByIndex(selected.index); 1080 | } else if (selected.type === 'module') { 1081 | this.props.unloadModuleByIndex(selected.index); 1082 | } else if (selected.type === 'sinkInput') { 1083 | this.props.killSinkInputByIndex(selected.index); 1084 | } else if (selected.type === 'sourceOutput') { 1085 | this.props.killSourceOutputByIndex(selected.index); 1086 | } else if ( 1087 | (selected.type === 'sink' || selected.type === 'source') 1088 | && pai 1089 | && pai.moduleIndex >= 0 1090 | ) { 1091 | this.props.unloadModuleByIndex(pai.moduleIndex); 1092 | } 1093 | } 1094 | 1095 | canContextMenuDelete() { 1096 | return this.state.contexted !== backgroundSymbol; 1097 | } 1098 | 1099 | onContextMenuDelete() { 1100 | this.onDelete(this.state.contexted); 1101 | } 1102 | 1103 | onContextMenuClose() { 1104 | this.setState({ 1105 | contexted: null, 1106 | }); 1107 | } 1108 | 1109 | canContextMenuSetAsDefault() { 1110 | const pai = getPaiByDgoFromInfos(this.state.contexted)(this.props.infos); 1111 | 1112 | if (pai && pai.type === 'sink' && pai.name !== this.props.serverInfo.defaultSinkName) { 1113 | return true; 1114 | } 1115 | 1116 | if (pai && pai.type === 'source' && pai.name !== this.props.serverInfo.defaultSourceName) { 1117 | return true; 1118 | } 1119 | 1120 | return false; 1121 | } 1122 | 1123 | setAsDefault(data) { 1124 | const pai = getPaiByDgoFromInfos(data)(this.props.infos); 1125 | 1126 | if (pai.type === 'sink') { 1127 | this.props.setDefaultSinkByName(pai.name); 1128 | } 1129 | 1130 | if (pai.type === 'source') { 1131 | this.props.setDefaultSourceByName(pai.name); 1132 | } 1133 | } 1134 | 1135 | onContextMenuSetAsDefault() { 1136 | this.setAsDefault(this.state.contexted); 1137 | } 1138 | 1139 | hotKeySetAsDefault() { 1140 | this.setAsDefault(this.state.selected); 1141 | } 1142 | 1143 | focus() { 1144 | this.graphViewElement.focus(); 1145 | } 1146 | 1147 | onZoomStart() { 1148 | this.setState({ 1149 | isZooming: true, 1150 | }); 1151 | } 1152 | 1153 | onZoomEnd() { 1154 | this.setState({ 1155 | isZooming: false, 1156 | }); 1157 | } 1158 | 1159 | hotKeyEscape() { 1160 | const { moved } = this.state; 1161 | 1162 | if (moved) { 1163 | this.setState({ 1164 | selected: moved, 1165 | moved: null, 1166 | }); 1167 | return; 1168 | } 1169 | 1170 | this.setState({ 1171 | selected: null, 1172 | }); 1173 | } 1174 | 1175 | hotKeyMute({ shiftKey: sourceBiased, ctrlKey: all }) { 1176 | if (!this.state.selected) { 1177 | if (sourceBiased) { 1178 | if (all) { 1179 | this.toggleAllMute(this.props.infos.sources); 1180 | } else { 1181 | const defaultSource = getDefaultSourcePai(this.props.store.getState()); 1182 | this.toggleMute(defaultSource); 1183 | } 1184 | } else { 1185 | if (all) { // eslint-disable-line no-lonely-if 1186 | this.toggleAllMute(this.props.infos.sinks); 1187 | } else { 1188 | const defaultSink = getDefaultSinkPai(this.props.store.getState()); 1189 | this.toggleMute(defaultSink); 1190 | } 1191 | } 1192 | 1193 | return; 1194 | } 1195 | 1196 | const pai = getPaiByDgoFromInfos(this.state.selected)(this.props.infos); 1197 | 1198 | if (!pai) { 1199 | return; 1200 | } 1201 | 1202 | this.toggleMute(pai, undefined, sourceBiased); 1203 | } 1204 | 1205 | _volume(pai, direction) { 1206 | const { lockChannelsTogether, maxVolume, volumeStep } = this.props.preferences; 1207 | 1208 | const d = direction === 'up' ? 1 : -1; 1209 | 1210 | let newVolumes = map( 1211 | v => clamp(v + (d * (volumeStep * PA_VOLUME_NORM)), 0, maxVolume * PA_VOLUME_NORM), 1212 | pai.channelVolumes, 1213 | ); 1214 | 1215 | if (lockChannelsTogether) { 1216 | const max = maximum(newVolumes); 1217 | newVolumes = map(() => max, newVolumes); 1218 | } 1219 | 1220 | if (pai.type === 'sink') { 1221 | this.props.setSinkVolumes(pai.index, newVolumes); 1222 | } else if (pai.type === 'source') { 1223 | this.props.setSourceVolumes(pai.index, newVolumes); 1224 | } else if (pai.type === 'sinkInput') { 1225 | this.props.setSinkInputVolumes(pai.index, newVolumes); 1226 | } else if (pai.type === 'sourceOutput') { 1227 | this.props.setSourceOutputVolumes(pai.index, newVolumes); 1228 | } 1229 | } 1230 | 1231 | _volumeAll(pais, direction) { 1232 | forEach(pai => this._volume(pai, direction), values(pais)); 1233 | } 1234 | 1235 | _hotKeyVolume(direction) { 1236 | let pai; 1237 | 1238 | if (this.state.selected) { 1239 | pai = getPaiByDgoFromInfos(this.state.selected)(this.props.infos); 1240 | } else { 1241 | pai = getDefaultSinkPai(this.props.store.getState()); 1242 | } 1243 | 1244 | if (!pai) { 1245 | return; 1246 | } 1247 | 1248 | if (pai.type === 'client') { 1249 | const sinkInputs = getClientSinkInputs(pai)(this.props.store.getState()); 1250 | this._volumeAll(sinkInputs, direction); 1251 | return; 1252 | } 1253 | 1254 | if (pai.type === 'module') { 1255 | const sinkInputs = getModuleSinkInputs(pai)(this.props.store.getState()); 1256 | this._volumeAll(sinkInputs, direction); 1257 | return; 1258 | } 1259 | 1260 | if (![ 'sink', 'source', 'sinkInput', 'sourceOutput' ].includes(pai.type)) { 1261 | return; 1262 | } 1263 | 1264 | this._volume(pai, direction); 1265 | } 1266 | 1267 | hotKeyVolumeDown() { 1268 | this._hotKeyVolume('down'); 1269 | } 1270 | 1271 | hotKeyVolumeUp() { 1272 | this._hotKeyVolume('up'); 1273 | } 1274 | 1275 | _findNextObjectForSelection(object, direction) { 1276 | const { type } = object || { type: 'client' }; 1277 | const predicate = selectionObjectTypes.toPulsePredicate(type); 1278 | const candidates = compose( 1279 | sortBy(prop('index')), 1280 | filter(predicate), 1281 | )(this.state.nodes.concat(this.state.edges)); 1282 | return (direction === 'up' ? leftOf : rightOf)(object, candidates); 1283 | } 1284 | 1285 | hotKeyFocusDown() { 1286 | if (this._hotKeyMovePosition('down')) { 1287 | return; 1288 | } 1289 | 1290 | const selected = this._findNextObjectForSelection(this.state.selected, 'down'); 1291 | this.setState({ selected }); 1292 | } 1293 | 1294 | hotKeyFocusUp() { 1295 | if (this._hotKeyMovePosition('up')) { 1296 | return; 1297 | } 1298 | 1299 | const selected = this._findNextObjectForSelection(this.state.selected, 'up'); 1300 | this.setState({ selected }); 1301 | } 1302 | 1303 | _findAnyObjectForSelection(types, isBest) { 1304 | let node = null; 1305 | for (const type of types) { 1306 | const predicate = selectionObjectTypes.toPulsePredicate(type); 1307 | node 1308 | = (isBest && find(allPass([ predicate, isBest ]), this.state.nodes)) 1309 | || (isBest && find(allPass([ predicate, isBest ]), this.state.edges)) 1310 | || find(predicate, this.state.nodes) 1311 | || find(predicate, this.state.edges); 1312 | if (node) { 1313 | break; 1314 | } 1315 | } 1316 | 1317 | return node; 1318 | } 1319 | 1320 | _focusHorizontal(direction) { 1321 | const { selected } = this.state; 1322 | 1323 | if (!selected) { 1324 | this.setState({ 1325 | selected: this._findAnyObjectForSelection(direction === 'left' ? [ 1326 | 'sourceOutput', 1327 | 'source', 1328 | ] : [ 1329 | 'sinkInput', 1330 | 'sink', 1331 | ]), 1332 | }); 1333 | return; 1334 | } 1335 | 1336 | const next = t => selectionObjectTypes[direction](t); 1337 | const types = scan( 1338 | next, 1339 | next(selectionObjectTypes.fromPulseType(selected.type)), 1340 | range(0, 3), 1341 | ); 1342 | 1343 | const bestSelectionPredicate = x => null 1344 | || x.source === selected.id 1345 | || x.target === selected.id 1346 | || selected.source === x.id 1347 | || selected.target === x.id; 1348 | 1349 | this.setState({ 1350 | selected: this._findAnyObjectForSelection(types, bestSelectionPredicate), 1351 | }); 1352 | } 1353 | 1354 | hotKeyFocusLeft() { 1355 | if (this._hotKeyMovePosition('left')) { 1356 | return; 1357 | } 1358 | 1359 | this._focusHorizontal('left'); 1360 | } 1361 | 1362 | hotKeyFocusRight() { 1363 | if (this._hotKeyMovePosition('right')) { 1364 | return; 1365 | } 1366 | 1367 | this._focusHorizontal('right'); 1368 | } 1369 | 1370 | _hotKeyMovePosition(direction) { 1371 | const { selected, moved } = this.state; 1372 | 1373 | if (!selected 1374 | || selected !== moved 1375 | || ![ 'sink', 'source', 'client', 'module' ].includes(moved.type) 1376 | ) { 1377 | return false; 1378 | } 1379 | 1380 | const x = direction === 'right' ? 1 : (direction === 'left' ? -1 : 0); 1381 | const y = direction === 'down' ? 1 : (direction === 'up' ? -1 : 0); 1382 | 1383 | moved.x += x * (size + (size / 12)); 1384 | moved.y += y * (size + (size / 12)); 1385 | 1386 | this.forceUpdate(); 1387 | 1388 | return true; 1389 | } 1390 | 1391 | hotKeyMove() { 1392 | let { selected, moved } = this.state; 1393 | 1394 | if (!selected) { 1395 | return; 1396 | } 1397 | 1398 | if (moved) { 1399 | this.onSwapEdge(null, selected, moved); 1400 | this.setState({ 1401 | selected: moved, 1402 | moved: null, 1403 | }); 1404 | return; 1405 | } 1406 | 1407 | moved = selected; 1408 | 1409 | if (moved.type === 'sinkInput') { 1410 | selected = find( 1411 | node => node.id !== moved.target && node.type === 'sink', 1412 | this.state.nodes, 1413 | ); 1414 | } else if (moved.type === 'sourceOutput') { 1415 | selected = find( 1416 | node => node.id !== moved.target && node.type === 'source', 1417 | this.state.nodes, 1418 | ); 1419 | } 1420 | 1421 | this.setState({ 1422 | selected, 1423 | moved, 1424 | }); 1425 | } 1426 | 1427 | hotKeyAdd() { 1428 | this.props.openNewGraphObjectModal(); 1429 | } 1430 | 1431 | onLoadModuleLoopback() { 1432 | this.props.loadModule('module-loopback', ''); 1433 | } 1434 | 1435 | onLoadModuleCombineSink() { 1436 | this.props.loadModule('module-combine-sink', ''); 1437 | } 1438 | 1439 | onLoadModuleNullSink() { 1440 | this.props.loadModule('module-null-sink', ''); 1441 | } 1442 | 1443 | renderBackground() { 1444 | return renderBackground({ 1445 | onMouseDown: this.onBackgroundMouseDown, 1446 | }); 1447 | } 1448 | 1449 | render() { 1450 | const { nodes, edges } = this.state; 1451 | 1452 | return r(HotKeys, { 1453 | handlers: map(f => bind(f, this), pick(keys(keyMap), this)), 1454 | }, r.div({ 1455 | id: 'graph', 1456 | }, [ 1457 | r(SatellitesGraphView, { 1458 | key: 'graph', 1459 | 1460 | nodeKey: 'id', 1461 | edgeKey: 'id', 1462 | 1463 | nodes, 1464 | edges, 1465 | 1466 | selected: this.state.selected, 1467 | moved: this.state.moved, 1468 | 1469 | nodeTypes: {}, 1470 | nodeSubtypes: {}, 1471 | edgeTypes: {}, 1472 | 1473 | onZoomStart: this.onZoomStart, 1474 | onZoomEnd: this.onZoomEnd, 1475 | 1476 | onSelectNode: this.onSelectNode, 1477 | onCreateNode: this.onCreateNode, 1478 | onUpdateNode: this.onUpdateNode, 1479 | onDeleteNode: this.onDeleteNode, 1480 | onNodeMouseDown: this.onNodeMouseDown, 1481 | onNodeDragStart: this.onNodeDragStart, 1482 | onNodeDragEnd: this.onNodeDragEnd, 1483 | 1484 | onSelectEdge: this.onSelectEdge, 1485 | canCreateEdge: this.canCreateEdge, 1486 | onCreateEdge: this.onCreateEdge, 1487 | onSwapEdge: this.onSwapEdge, 1488 | onDeleteEdge: this.onDeleteEdge, 1489 | onEdgeMouseDown: this.onEdgeMouseDown, 1490 | 1491 | showGraphControls: false, 1492 | 1493 | edgeArrowSize: 64, 1494 | 1495 | layoutEngine, 1496 | 1497 | renderBackground: this.renderBackground, 1498 | 1499 | renderDefs, 1500 | 1501 | renderNode, 1502 | renderNodeText: renderNodeText(this.props.store), 1503 | 1504 | renderEdge, 1505 | renderEdgeText: renderEdgeText(this.props.store), 1506 | 1507 | hideLiveVolumePeaks: this.props.preferences.hideLiveVolumePeaks, 1508 | accommodateGraphAnimation: this.state.isDraggingNode || this.state.isZooming, 1509 | peaks: this.props.peaks, 1510 | }), 1511 | 1512 | this.state.contexted && ( 1513 | this.state.contexted === backgroundSymbol 1514 | ? r(BackgroundContextMenu, { 1515 | key: 'background-context-menu', 1516 | 1517 | onClose: this.onContextMenuClose, 1518 | 1519 | onLoadModule: this.props.openLoadModuleModal, 1520 | 1521 | onLoadModuleLoopback: this.onLoadModuleLoopback, 1522 | onLoadModuleCombineSink: this.onLoadModuleCombineSink, 1523 | onLoadModuleNullSink: this.onLoadModuleNullSink, 1524 | }) 1525 | : r(GraphObjectContextMenu, { 1526 | key: 'graph-object-context-menu', 1527 | 1528 | onClose: this.onContextMenuClose, 1529 | 1530 | canSetAsDefault: this.canContextMenuSetAsDefault, 1531 | onSetAsDefault: this.onContextMenuSetAsDefault, 1532 | 1533 | canDelete: this.canContextMenuDelete, 1534 | onDelete: this.onContextMenuDelete, 1535 | }) 1536 | ), 1537 | ])); 1538 | } 1539 | } 1540 | 1541 | module.exports = compose( 1542 | forwardRef(), 1543 | 1544 | connect( 1545 | state => ({ 1546 | serverInfo: state.pulse[primaryPulseServer].serverInfo, 1547 | 1548 | objects: state.pulse[primaryPulseServer].objects, 1549 | infos: state.pulse[primaryPulseServer].infos, 1550 | 1551 | derivations: { 1552 | monitorSources: getDerivedMonitorSources(state), 1553 | }, 1554 | 1555 | icons: state.icons, 1556 | 1557 | preferences: state.preferences, 1558 | }), 1559 | dispatch => bindActionCreators(omit([ 1560 | 'serverInfo', 1561 | 'unloadModuleByIndex', 1562 | ], merge(pulseActions, iconsActions)), dispatch), 1563 | ), 1564 | 1565 | fromRenderProps( 1566 | ReduxConsumer, 1567 | ({ store }) => ({ store }), 1568 | ), 1569 | 1570 | unforwardRef(), 1571 | )(Graph); 1572 | --------------------------------------------------------------------------------