├── .editorconfig ├── .eslintrc ├── .github └── workflows │ ├── project.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── @types ├── osc.d.ts └── react-piano.d.ts ├── LICENSE.txt ├── README.md ├── conanfile.py ├── debian ├── DEBIAN │ ├── control.in │ ├── copyright │ ├── postinst │ ├── postrm │ └── prerm └── lib │ └── systemd │ └── system │ └── rnbo-runner-panel.service ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── c74-dark.svg ├── c74-light.svg └── favicon.ico ├── scripts ├── package-conan.mjs ├── package-linux.mjs └── utils.mjs ├── server.py ├── src ├── actions │ ├── appStatus.ts │ ├── datafiles.ts │ ├── editor.ts │ ├── graph.ts │ ├── meta.ts │ ├── notifications.ts │ ├── patchers.ts │ ├── recording.ts │ ├── sets.ts │ ├── settings.ts │ └── transport.ts ├── components │ ├── datafile │ │ ├── datafile.module.css │ │ ├── item.tsx │ │ ├── managementView.tsx │ │ └── uploadModal.tsx │ ├── dataref │ │ ├── datarefs.module.css │ │ ├── item.tsx │ │ └── list.tsx │ ├── editor │ │ ├── addNodeMenu.module.css │ │ ├── addNodeMenu.tsx │ │ ├── edge.tsx │ │ ├── editor.module.css │ │ ├── graphMenu.tsx │ │ ├── index.tsx │ │ ├── patcherNode.tsx │ │ ├── port.tsx │ │ ├── setTitle.module.css │ │ ├── setTitle.tsx │ │ ├── systemNode.tsx │ │ └── util.ts │ ├── elements │ │ ├── editableTableCell.tsx │ │ ├── elements.module.css │ │ ├── icon.tsx │ │ └── tableHeaderCell.tsx │ ├── header │ │ ├── cpu.module.css │ │ ├── cpu.tsx │ │ ├── header.module.css │ │ ├── index.tsx │ │ └── record.tsx │ ├── instance │ │ ├── actions.tsx │ │ ├── datarefTab.tsx │ │ ├── index.tsx │ │ ├── inportTab.tsx │ │ ├── instance.module.css │ │ ├── outportTab.tsx │ │ ├── paramTab.tsx │ │ └── title.tsx │ ├── keyroll │ │ ├── index.tsx │ │ ├── keyroll.module.css │ │ ├── modal.tsx │ │ ├── note.tsx │ │ └── octave.tsx │ ├── messages │ │ ├── inport.tsx │ │ ├── inportList.tsx │ │ ├── outport.tsx │ │ ├── outportList.tsx │ │ └── ports.module.css │ ├── meta │ │ ├── metaEditorModal.module.css │ │ └── metaEditorModal.tsx │ ├── midi │ │ ├── mappedParameterItem.tsx │ │ ├── mappedParameterList.tsx │ │ └── midi.module.css │ ├── nav │ │ ├── button.tsx │ │ ├── index.tsx │ │ ├── link.tsx │ │ └── nav.module.css │ ├── notifications │ │ ├── index.tsx │ │ ├── item.tsx │ │ └── notifications.module.css │ ├── page │ │ ├── about.tsx │ │ ├── drawer.tsx │ │ ├── endpoint.tsx │ │ ├── page.module.css │ │ ├── searchInput.tsx │ │ ├── sectionTitle.tsx │ │ ├── settings.tsx │ │ ├── statusWrapper.tsx │ │ ├── theme.tsx │ │ ├── title.tsx │ │ └── transport.tsx │ ├── parameter │ │ ├── item.tsx │ │ ├── list.tsx │ │ ├── parameters.module.css │ │ ├── withMidiActions.tsx │ │ └── withSetViewActions.tsx │ ├── patchers │ │ ├── item.tsx │ │ ├── managementView.tsx │ │ └── patchers.module.css │ ├── presets │ │ ├── index.tsx │ │ ├── item.tsx │ │ └── presets.module.css │ ├── resources │ │ ├── tabs.module.css │ │ └── tabs.tsx │ ├── setViews │ │ ├── drawer.tsx │ │ ├── item.tsx │ │ ├── paramModal.tsx │ │ ├── parameterList.tsx │ │ └── setviews.module.css │ ├── sets │ │ ├── item.tsx │ │ ├── managementView.tsx │ │ └── sets.module.css │ └── settings │ │ ├── index.tsx │ │ ├── item.tsx │ │ ├── list.tsx │ │ └── settings.module.css ├── controller │ └── oscqueryBridgeController.ts ├── hooks │ ├── useAppDispatch.ts │ ├── useIsMobileDevice.ts │ ├── useTheme.ts │ └── useTitle.ts ├── layouts │ ├── app.module.css │ └── app.tsx ├── lib │ ├── constants.ts │ ├── dialogs.tsx │ ├── editorUtils.ts │ ├── meta.ts │ ├── reconnectingWs.ts │ ├── settings.ts │ ├── store.ts │ ├── theme.ts │ ├── types.ts │ └── util.ts ├── models │ ├── config.ts │ ├── datafile.ts │ ├── dataref.ts │ ├── graph.ts │ ├── instance.ts │ ├── messageport.ts │ ├── notification.ts │ ├── parameter.ts │ ├── patcher.ts │ ├── preset.ts │ ├── runnerInfo.ts │ ├── set.ts │ └── settings.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── index.tsx │ ├── instances │ │ └── [id].tsx │ ├── midimappings.tsx │ ├── resources.tsx │ └── setviews.tsx ├── reducers │ ├── appStatus.ts │ ├── datafiles.ts │ ├── editor.ts │ ├── graph.ts │ ├── index.ts │ ├── notifications.ts │ ├── patchers.ts │ ├── recording.ts │ ├── sets.ts │ ├── settings.ts │ └── transport.ts └── selectors │ ├── appStatus.ts │ ├── datafiles.ts │ ├── editor.ts │ ├── graph.ts │ ├── notifications.ts │ ├── patchers.ts │ ├── recording.ts │ ├── sets.ts │ ├── settings.ts │ └── transport.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [package.json] 10 | indent_style = space 11 | tab_width = 2 12 | 13 | [*.yaml] 14 | indent_style = space 15 | tab_width = 2 16 | 17 | [*.yml] 18 | indent_style = space 19 | tab_width = 2 20 | 21 | [*.md] 22 | indent_style = space 23 | tab_width = 2 24 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "next/core-web-vitals", "c74-ts"], 3 | "env": { 4 | "browser": true 5 | }, 6 | "rules": { 7 | // disable the rule for all files 8 | "@typescript-eslint/explicit-function-return-type": "off", 9 | "@typescript-eslint/no-explicit-any": "off", 10 | "no-shadow": "off", 11 | "@typescript-eslint/no-shadow": "off", 12 | "@next/next/no-img-element": "off", 13 | "new-cap": [ 14 | "error", { 15 | "capIsNewExceptionPattern": "^Immu*" 16 | } 17 | ] 18 | }, 19 | "root": true 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/project.yml: -------------------------------------------------------------------------------- 1 | name: Add Issues and PRs to RNBO Project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request: 8 | types: 9 | - opened 10 | 11 | jobs: 12 | add-to-project: 13 | name: Add issue to RNBO Project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/add-to-project@v0.3.0 17 | with: 18 | project-url: https://github.com/orgs/Cycling74/projects/${{ secrets.RNBO_PROJECT_NUMBER }} 19 | github-token: ${{ secrets.RNBO_PROJECT_PAT }} 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Lint 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | api: 7 | runs-on: ubuntu-latest 8 | name: runner panel build and lint 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Use Node.js 12 | uses: actions/setup-node@v2 13 | with: 14 | node-version: '20' 15 | - name: install dependencies 16 | run: npm ci 17 | - name: Run lint 18 | run: npm run lint 19 | - name: build 20 | run: npm run build 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # packaging 37 | /debian/DEBIAN/control 38 | /debian/usr/* 39 | *.deb 40 | -------------------------------------------------------------------------------- /@types/osc.d.ts: -------------------------------------------------------------------------------- 1 | declare module "osc" { 2 | 3 | export type OSCTimeTag = { 4 | raw: [number, number]; 5 | native: number; 6 | }; 7 | 8 | export type OSCArgument = { 9 | type: string; 10 | value: string | number; 11 | }; 12 | 13 | export type OSCMessage = { 14 | address: string; 15 | args: OSCArgument[]; 16 | }; 17 | 18 | export type OSCBundle = { 19 | timeTag: OSCTimeTag; 20 | packets: Array; 21 | }; 22 | 23 | export type PacketOptions = { 24 | metadata?: boolean; 25 | unpackSingleArgs?: boolean; 26 | }; 27 | 28 | export type OffsetState = { 29 | idx: number; 30 | length: number; 31 | }; 32 | 33 | export function readPacket(packet: Uint8Array, options: PacketOptions, offsetState?: OffsetState): OSCMessage | OSCBundle; 34 | export function writePacket(msg: OSCMessage | OSCBundle, options?: PacketOptions): Uint8Array; 35 | } 36 | -------------------------------------------------------------------------------- /@types/react-piano.d.ts: -------------------------------------------------------------------------------- 1 | declare module "react-piano" { 2 | import { Component, ReactNode } from "react"; 3 | export interface KeyboardShortcut { 4 | key: string; 5 | midiNumber: number; 6 | } 7 | 8 | export class Piano extends Component<{ 9 | noteRange: { first: number; last: number }; 10 | playNote: (midi: number) => any; 11 | stopNote: (midi: number) => any; 12 | width?: number; 13 | activeNotes?: number[]; 14 | keyWidthToHeight?: number; 15 | renderNoteLabel?: ({ keyboardShortcut: any, midiNumber: number, isActive: boolean, isAccidental: boolean }) => ReactNode; 16 | className?: string; 17 | disabled?: boolean; 18 | keyboardShortcuts?: KeyboardShortcut[]; 19 | onPlayNoteInput?: (midi: number, { prevActiveNotes }: { prevActiveNotes: number[] }) => any; 20 | onStopNoteInput?: (midi: number, { prevActiveNotes }: { prevActiveNotes: number[] }) => any; 21 | }> {} 22 | 23 | interface MidiNumbersHelpers { 24 | fromNote: (note: string) => number; 25 | } 26 | 27 | export const MidiNumbers: MidiNumbersHelpers; 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Cycling '74 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /conanfile.py: -------------------------------------------------------------------------------- 1 | from conans import ConanFile, tools 2 | 3 | class RNBORunnerPanelConan(ConanFile): 4 | name = "rnborunnerpanel" 5 | description = "Packaged build outputs from rnbo-runner-panel" 6 | author = "Cycling'74" 7 | url = "https://github.com/Cycling74/rnbo-runner-panel" 8 | settings = None 9 | license = "MIT" 10 | no_copy_source = True 11 | 12 | def export_sources(self): 13 | self.copy("bin/**", src="build/usr/") 14 | self.copy("share/**", src="build/usr/") 15 | 16 | def build(self): 17 | #do nothing 18 | return 19 | 20 | def package(self): 21 | self.copy("*") 22 | -------------------------------------------------------------------------------- /debian/DEBIAN/control.in: -------------------------------------------------------------------------------- 1 | Package: rnbo-runner-panel 2 | Architecture: all 3 | Maintainer: Cycling '74 4 | Description: Web-based control panel for the RNBO OSCQuery Runner 5 | Homepage: https://rnbo.cycling74.com/ 6 | Depends: python3 7 | Recommends: rnbooscquery 8 | Breaks: rnbooscquery (<< 1.4.0) 9 | -------------------------------------------------------------------------------- /debian/DEBIAN/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: rnbo-runner-panel 3 | Source: https://github.com/Cycling74/rnbo-runner-panel 4 | 5 | Files: * 6 | Copyright: 2021 Cycling '74 7 | License: MIT 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /debian/DEBIAN/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Automatically added by dh_installsystemd/13.2.1ubuntu1 4 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then 5 | # This will only remove masks created by d-s-h on package removal. 6 | deb-systemd-helper unmask 'rnbo-runner-panel.service' >/dev/null || true 7 | 8 | # was-enabled defaults to true, so new installations run enable. 9 | if deb-systemd-helper --quiet was-enabled 'rnbo-runner-panel.service'; then 10 | # Enables the unit on first installation, creates new 11 | # symlinks on upgrades if the unit file has changed. 12 | deb-systemd-helper enable 'rnbo-runner-panel.service' >/dev/null || true 13 | else 14 | # Update the statefile to add new symlinks (if any), which need to be 15 | # cleaned up on purge. Also remove old symlinks. 16 | deb-systemd-helper update-state 'rnbo-runner-panel.service' >/dev/null || true 17 | fi 18 | fi 19 | # End automatically added section 20 | # Automatically added by dh_installsystemd/13.2.1ubuntu1 21 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then 22 | if [ -d /run/systemd/system ]; then 23 | systemctl --system daemon-reload >/dev/null || true 24 | if [ -n "$2" ]; then 25 | _dh_action=restart 26 | else 27 | _dh_action=start 28 | fi 29 | deb-systemd-invoke $_dh_action 'rnbo-runner-panel.service' >/dev/null || true 30 | fi 31 | fi 32 | # End automatically added section 33 | -------------------------------------------------------------------------------- /debian/DEBIAN/postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Automatically added by dh_installsystemd/13.2.1ubuntu1 4 | if [ -d /run/systemd/system ]; then 5 | systemctl --system daemon-reload >/dev/null || true 6 | fi 7 | # End automatically added section 8 | # Automatically added by dh_installsystemd/13.2.1ubuntu1 9 | if [ "$1" = "remove" ]; then 10 | if [ -x "/usr/bin/deb-systemd-helper" ]; then 11 | deb-systemd-helper mask 'rnbo-runner-panel.service' >/dev/null || true 12 | fi 13 | fi 14 | 15 | if [ "$1" = "purge" ]; then 16 | if [ -x "/usr/bin/deb-systemd-helper" ]; then 17 | deb-systemd-helper purge 'rnbo-runner-panel.service' >/dev/null || true 18 | deb-systemd-helper unmask 'rnbo-runner-panel.service' >/dev/null || true 19 | fi 20 | fi 21 | # End automatically added section 22 | -------------------------------------------------------------------------------- /debian/DEBIAN/prerm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Automatically added by dh_installsystemd/13.2.1ubuntu1 4 | if [ -d /run/systemd/system ] && [ "$1" = remove ]; then 5 | deb-systemd-invoke stop 'rnbo-runner-panel.service' >/dev/null || true 6 | fi 7 | # End automatically added section 8 | -------------------------------------------------------------------------------- /debian/lib/systemd/system/rnbo-runner-panel.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=RNBO Runner Panel 3 | After=multi-user.target 4 | StartLimitIntervalSec=500 5 | StartLimitBurst=5 6 | StartLimitInterval=0 7 | 8 | [Service] 9 | Type=idle 10 | ExecStart=/usr/bin/rnbo-runner-panel --directory /usr/share/rnbo-runner-panel/www/ 11 | KillSignal=SIGINT 12 | User=www-data 13 | Group=www-data 14 | Restart=on-failure 15 | RestartSec=5s 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require("fs"); 2 | const { join } = require("path"); 3 | 4 | const pkgInfo = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); 5 | 6 | module.exports = { 7 | output: "export", 8 | reactStrictMode: false, 9 | webpack: (config, { isServer }) => { 10 | // Fixes npm packages that depend on `child_process` module 11 | if (!isServer) { 12 | config.resolve.fallback = { 13 | child_process: false, 14 | dgram: false, 15 | fs: false, 16 | net: false, 17 | path: false, 18 | stream: false 19 | }; 20 | } 21 | 22 | return config; 23 | }, 24 | env: { 25 | appVersion: pkgInfo.version 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rnbo-runner-panel", 3 | "version": "2.1.1-beta.12", 4 | "private": true, 5 | "engines": { 6 | "node": ">=20 <21" 7 | }, 8 | "scripts": { 9 | "dev": "next dev", 10 | "build": "next build", 11 | "start": "next start", 12 | "lint": "next lint", 13 | "prepackage-debian": "npm run build", 14 | "package-debian": "node scripts/package-linux.mjs --debian ./debian/", 15 | "prepackage-linux": "npm run build", 16 | "package-linux": "node scripts/package-linux.mjs ./build/", 17 | "prepackage-conan": "npm run package-linux", 18 | "package-conan": "node scripts/package-conan.mjs", 19 | "preversion": "next lint" 20 | }, 21 | "dependencies": { 22 | "@dagrejs/dagre": "^1.1.4", 23 | "@mantine/core": "^7.10.2", 24 | "@mantine/dropzone": "^7.10.2", 25 | "@mantine/hooks": "^7.10.2", 26 | "@mantine/modals": "^7.10.2", 27 | "@mdi/js": "^7.4.47", 28 | "@mdi/react": "^1.6.1", 29 | "@types/websocket": "^1.0.2", 30 | "dayjs": "^1.11.13", 31 | "immutable": "^4.0.0-rc.12", 32 | "js-base64": "^3.7.7", 33 | "lodash.debounce": "^4.0.8", 34 | "lodash.throttle": "^4.1.1", 35 | "next": "^13.5.3", 36 | "osc": "^2.4.1", 37 | "react": "^18.2.0", 38 | "react-dom": "^18.2.0", 39 | "react-redux": "^7.2.4", 40 | "reactflow": "^11.9.4", 41 | "redux": "^4.1.0", 42 | "redux-thunk": "^2.3.0", 43 | "reselect": "^5.1.1", 44 | "uuid": "^9.0.1" 45 | }, 46 | "devDependencies": { 47 | "@types/js-base64": "^3.3.1", 48 | "@types/lodash.debounce": "^4.0.9", 49 | "@types/lodash.throttle": "^4.1.6", 50 | "@types/node": "^20.6.1", 51 | "@types/react": "^18.2.0", 52 | "@types/react-fontawesome": "^1.6.5", 53 | "@types/styled-components": "^5.1.14", 54 | "@types/uuid": "^9.0.5", 55 | "eslint": "^8.50.0", 56 | "eslint-config-c74-ts": "^3.0.0", 57 | "eslint-config-next": "^13.5.3", 58 | "fs-extra": "^10.0.0", 59 | "postcss": "^8.4.31", 60 | "postcss-preset-mantine": "^1.13.0", 61 | "postcss-simple-vars": "^7.0.1", 62 | "typescript": "^5.2.2" 63 | }, 64 | "browser": { 65 | "child_process": false 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-preset-mantine": {}, 4 | "postcss-simple-vars": { 5 | variables: { 6 | "mantine-breakpoint-xs": "36em", 7 | "mantine-breakpoint-sm": "48em", 8 | "mantine-breakpoint-md": "62em", 9 | "mantine-breakpoint-lg": "75em", 10 | "mantine-breakpoint-xl": "88em" 11 | } 12 | } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /public/c74-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/c74-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cycling74/rnbo-runner-panel/37726d52277c48588760eb4c38de1158d1cfc02d/public/favicon.ico -------------------------------------------------------------------------------- /scripts/package-conan.mjs: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import { readPkgInfoVersion } from "./utils.mjs"; 3 | import { dirname, join, resolve } from "path"; 4 | import { fileURLToPath } from "url"; 5 | 6 | const basedir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); 7 | const version = process.env.PKG_VERSION || readPkgInfoVersion(join(basedir, "package.json")); 8 | const tag = "c74/testing"; 9 | 10 | execSync(`conan create . ${version}@${tag}`); 11 | -------------------------------------------------------------------------------- /scripts/package-linux.mjs: -------------------------------------------------------------------------------- 1 | import { dirname, join, resolve } from "path"; 2 | import fs from "fs-extra"; 3 | import { fileURLToPath } from "url"; 4 | import { execSync } from "child_process"; 5 | import { readPkgInfoVersion } from "./utils.mjs"; 6 | 7 | const { readFileSync, writeFileSync, rmSync, copySync } = fs; 8 | 9 | const debian = process.argv.includes("--debian"); 10 | const outdir = process.argv.at(-1); 11 | 12 | const basedir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); 13 | const name = process.env.PKG_NAME || "rnbo-runner-panel"; 14 | 15 | // cleanup if we have an existing export 16 | rmSync(join(outdir, "usr"), { recursive: true, force: true }); 17 | 18 | copySync(join(basedir, "out"), join(outdir, "usr", "share", name, "www"), { overwrite: true } ); 19 | copySync(join(basedir, "server.py"), join(outdir, "usr", "bin", name), { overwrite: true } ); 20 | 21 | //do debian specific packaging 22 | if (debian) { 23 | const version = process.env.PKG_VERSION || readPkgInfoVersion(join(basedir, "package.json")); 24 | // add the version into the control file 25 | const control = readFileSync(join(outdir, "DEBIAN", "control.in"), "utf8").replace(/[\s\n]*$/, "") + `\nVersion: ${version}\n`; 26 | writeFileSync(join(outdir, "DEBIAN", "control"), control); 27 | 28 | const deb = `${name}_${version}.deb`; 29 | execSync(`dpkg-deb --build . ../${deb}`, { cwd: outdir }); 30 | console.log(`created ${deb}`); 31 | } 32 | -------------------------------------------------------------------------------- /scripts/utils.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | const { readFileSync } = fs; 3 | 4 | export const readPkgInfoVersion = fpath => { 5 | const info = JSON.parse(readFileSync(fpath, { encoding: "utf8"} )); 6 | if (!info.version) throw new Error("Missing version property in pacakge.json file"); 7 | return info.version; 8 | }; 9 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | from pathlib import Path 4 | from os import chdir, getcwd, path 5 | from http.server import SimpleHTTPRequestHandler 6 | from socketserver import TCPServer 7 | 8 | 9 | def dir_path(p): 10 | 11 | expandedPath = path.normpath(path.join(getcwd(), p)) if not path.isabs(p) else p 12 | 13 | if path.isdir(expandedPath): 14 | return expandedPath 15 | else: 16 | raise argparse.ArgumentTypeError(f"readable_dir:{expandedPath} is not a valid path") 17 | 18 | parser = argparse.ArgumentParser(description='Start the RNBO Runner Panel HTTP Server') 19 | parser.add_argument('--port', type=int, default=3000, 20 | help='The port to listen on') 21 | parser.add_argument('--directory', type=dir_path, required=True, 22 | help='The directory to serve the web content from') 23 | 24 | args = parser.parse_args() 25 | 26 | print(f'RNBO Runner Panel Serving from {args.directory}') 27 | print(f'RNBO Runner Panel Serving on port {args.port}') 28 | 29 | # Change to provided directory 30 | chdir(args.directory) 31 | 32 | class MyRequestHandler(SimpleHTTPRequestHandler): 33 | 34 | def end_headers(self): 35 | self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") 36 | self.send_header("Pragma", "no-cache") 37 | self.send_header("Expires", "0") 38 | super().end_headers() 39 | 40 | 41 | def do_GET(self): 42 | 43 | fext = path.splitext(self.path)[1] 44 | normPath = path.normpath(self.path).split('/') 45 | normPath.remove('') 46 | 47 | fpath = path.join(args.directory, '{os.sep}'.join(normPath)) 48 | # handle /instances/12 -> /instances/[id].html mapping 49 | ipath = path.join(normPath[0], "[id].html") 50 | 51 | if not path.isfile(fpath) and not fext and path.isfile(fpath + '.html'): 52 | self.path = self.path + '.html' 53 | elif len(normPath) == 2 and path.isfile(path.join(args.directory, ipath)): 54 | self.path = path.join("/", ipath) 55 | 56 | return SimpleHTTPRequestHandler.do_GET(self) 57 | 58 | Handler = MyRequestHandler 59 | 60 | # if we restart the socket is sometimes still bound 61 | # https://stackoverflow.com/questions/19071512/socket-error-errno-48-address-already-in-use 62 | TCPServer.allow_reuse_address=True 63 | 64 | server = TCPServer(('0.0.0.0', args.port), Handler) 65 | 66 | 67 | try: 68 | server.serve_forever() 69 | finally: 70 | server.server_close() 71 | -------------------------------------------------------------------------------- /src/actions/meta.ts: -------------------------------------------------------------------------------- 1 | import debounce from "lodash.debounce"; 2 | import { Map as ImmuMap } from "immutable"; 3 | import { GraphNodeRecord, NodePositionRecord } from "../models/graph"; 4 | import { oscQueryBridge } from "../controller/oscqueryBridgeController"; 5 | import { writePacket } from "osc"; 6 | import { AppThunk } from "../lib/store"; 7 | import { getNodePositions, getNodes } from "../selectors/graph"; 8 | import { serializeSetMeta } from "../lib/meta"; 9 | 10 | const doUpdateNodesMeta = debounce((nodes: GraphNodeRecord[], positions: ImmuMap) => { 11 | try { 12 | const relevantPos = nodes.reduce((result, node) => { 13 | const pos = positions.get(node.id); 14 | if (pos) result.push(pos); 15 | return result; 16 | }, [] as NodePositionRecord[]); 17 | 18 | const value = serializeSetMeta(relevantPos); 19 | const message = { 20 | address: "/rnbo/inst/control/sets/meta", 21 | args: [ 22 | { type: "s", value } 23 | ] 24 | }; 25 | oscQueryBridge.sendPacket(writePacket(message)); 26 | } catch (err) { 27 | console.warn(`Failed to update Set Meta on remote: ${err.message}`); 28 | } 29 | 30 | }, 150, { leading: false, trailing: true }); 31 | 32 | export const updateSetMetaOnRemoteFromNodes = (nodes: GraphNodeRecord[]): AppThunk => 33 | (dispatch, getState) => { 34 | doUpdateNodesMeta( 35 | nodes, 36 | getNodePositions(getState()) 37 | ); 38 | }; 39 | 40 | export const triggerSetMetaUpdateOnRemote = (): AppThunk => 41 | (dispatch, getState) => { 42 | const state = getState(); 43 | doUpdateNodesMeta( 44 | getNodes(state).valueSeq().toArray(), 45 | getNodePositions(state) 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/actions/notifications.ts: -------------------------------------------------------------------------------- 1 | import { ActionBase } from "../lib/store"; 2 | import { NotificationLevel, NotificationRecord } from "../models/notification"; 3 | 4 | export enum NotificationActionType { 5 | ADD_NOTIFICATION = "ADD_NOTIFICATION", 6 | DELETE_NOTIFICATION = "DELETE_NOTIFICATION" 7 | } 8 | 9 | export interface AddNotificationAction extends ActionBase { 10 | type: NotificationActionType.ADD_NOTIFICATION; 11 | payload: { 12 | notification: NotificationRecord; 13 | }; 14 | } 15 | 16 | export interface DeleteNotificationAction extends ActionBase { 17 | type: NotificationActionType.DELETE_NOTIFICATION; 18 | payload: { 19 | id: string; 20 | }; 21 | } 22 | 23 | 24 | export type NotificationAction = AddNotificationAction | DeleteNotificationAction; 25 | 26 | export const showNotification = ({ title, message, level = NotificationLevel.info }: { title: string, message?: string; level?: NotificationLevel }): NotificationAction => { 27 | return { 28 | type: NotificationActionType.ADD_NOTIFICATION, 29 | payload: { 30 | notification: NotificationRecord.create({ level, message: message || "", title }) 31 | } 32 | }; 33 | }; 34 | 35 | export const deleteNotification = (notification: NotificationRecord): NotificationAction => { 36 | return { 37 | type: NotificationActionType.DELETE_NOTIFICATION, 38 | payload: { 39 | id: notification.id 40 | } 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/actions/recording.ts: -------------------------------------------------------------------------------- 1 | import { writePacket } from "osc"; 2 | import { ActionBase, AppThunk } from "../lib/store"; 3 | import { NotificationLevel } from "../models/notification"; 4 | import { getIsStreamRecording } from "../selectors/recording"; 5 | import { showNotification } from "./notifications"; 6 | import { OSCQueryRNBOJackRecord, OSCQueryValueType } from "../lib/types"; 7 | import { oscQueryBridge } from "../controller/oscqueryBridgeController"; 8 | 9 | export enum StreamRecordingActionType { 10 | INIT = "INIT_STREAM_RECORDING", 11 | 12 | SET_ACTIVE = "SET_STREAM_RECORDING_ACTIVE", 13 | SET_CAPTURED = "SET_STREAM_RECORDING_CAPTURED_TIME" 14 | } 15 | 16 | export interface IInitStreamRecording extends ActionBase { 17 | type: StreamRecordingActionType.INIT; 18 | payload: { 19 | active: boolean; 20 | capturedTime: number; 21 | }; 22 | } 23 | 24 | export interface ISetStreamRecordingActive extends ActionBase { 25 | type: StreamRecordingActionType.SET_ACTIVE; 26 | payload: { 27 | active: boolean; 28 | }; 29 | } 30 | 31 | export interface ISetStreamRecordingCapturedTime extends ActionBase { 32 | type: StreamRecordingActionType.SET_CAPTURED; 33 | payload: { 34 | capturedTime: number; 35 | }; 36 | } 37 | 38 | export type StreamRecordingAction = IInitStreamRecording | ISetStreamRecordingActive | ISetStreamRecordingCapturedTime; 39 | 40 | export const initStreamRecording = (state?: OSCQueryRNBOJackRecord): IInitStreamRecording => { 41 | return { 42 | type: StreamRecordingActionType.INIT, 43 | payload: { 44 | active: state?.CONTENTS?.active?.TYPE === OSCQueryValueType.True, 45 | capturedTime: state?.CONTENTS?.captured?.VALUE || 0 46 | } 47 | }; 48 | }; 49 | 50 | export const toggleStreamRecording = () : AppThunk => 51 | (dispatch, getState) => { 52 | try { 53 | const state = getState(); 54 | const isActive = getIsStreamRecording(state); 55 | const message = { 56 | address: "/rnbo/jack/record/active", 57 | args: [ 58 | isActive 59 | ? { type: OSCQueryValueType.False, value: "false" } 60 | : { type: OSCQueryValueType.True, value: "true" } 61 | ] 62 | }; 63 | oscQueryBridge.sendPacket(writePacket(message)); 64 | } catch (err) { 65 | dispatch(showNotification({ 66 | level: NotificationLevel.error, 67 | title: "Error while trying to change recording state", 68 | message: "Please check the console for further details." 69 | })); 70 | console.log(err); 71 | } 72 | }; 73 | 74 | // Updates from Runner 75 | export const updateStreamRecordingActiveState = (active: boolean): ISetStreamRecordingActive => { 76 | return { 77 | type: StreamRecordingActionType.SET_ACTIVE, 78 | payload: { active } 79 | }; 80 | }; 81 | 82 | export const updateStreamRecordingCapturedTime = (capturedTime: number): ISetStreamRecordingCapturedTime => { 83 | return { 84 | type: StreamRecordingActionType.SET_CAPTURED, 85 | payload: { capturedTime } 86 | }; 87 | }; 88 | -------------------------------------------------------------------------------- /src/components/datafile/datafile.module.css: -------------------------------------------------------------------------------- 1 | .dataFileWrap { 2 | height: 100%; 3 | gap: var(--mantine-spacing-md); 4 | } 5 | 6 | .fileDropZone { 7 | border: 1px dashed var(--mantine-color-default-border); 8 | border-radius: var(--dropzone-radius); 9 | cursor: pointer; 10 | 11 | &[data-idle]:hover { 12 | background-color: var(--mantine-color-default-hover); 13 | } 14 | 15 | &[data-accept] { 16 | background-color: var(--dropzone-accept-bg); 17 | color: var(--dropzone-accept-color); 18 | } 19 | 20 | &[data-reject] { 21 | background-color: var(--dropzone-reject-bg); 22 | color: var(--dropzone-reject-color); 23 | } 24 | 25 | 26 | } 27 | 28 | .fileDropGroup { 29 | justify-content: center; 30 | gap: var(--mantine-spacing-xl); 31 | min-height: 200px; 32 | pointer-events: none; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/datafile/item.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon, Menu, Table, Text } from "@mantine/core"; 2 | import { DataFileRecord } from "../../models/datafile"; 3 | import { FC, memo, useCallback } from "react"; 4 | import { IconElement } from "../elements/icon"; 5 | import { mdiDotsVertical, mdiTrashCan } from "@mdi/js"; 6 | 7 | export type DataFileListItemProps = { 8 | dataFile: DataFileRecord; 9 | onDelete: (file: DataFileRecord) => any; 10 | }; 11 | 12 | export const DataFileListItem: FC = memo(function WrappedDataFileListItem({ 13 | dataFile, 14 | onDelete 15 | }) { 16 | 17 | const onTriggerDelete = useCallback(() => onDelete(dataFile), [onDelete, dataFile]); 18 | return ( 19 | 20 | 21 | 22 | { dataFile.fileName } 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Audio File 34 | } onClick={ onTriggerDelete } >Delete 35 | 36 | 37 | 38 | 39 | ); 40 | }); 41 | -------------------------------------------------------------------------------- /src/components/datafile/managementView.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo, useCallback, useState } from "react"; 2 | import { useAppDispatch, useAppSelector } from "../../hooks/useAppDispatch"; 3 | import { useDisclosure } from "@mantine/hooks"; 4 | import { SortOrder } from "../../lib/constants"; 5 | import { RootStateType } from "../../lib/store"; 6 | import { getDataFilesSortedByName } from "../../selectors/datafiles"; 7 | import { DataFileRecord } from "../../models/datafile"; 8 | import { deleteDataFileOnRemote } from "../../actions/datafiles"; 9 | import { showNotification } from "../../actions/notifications"; 10 | import { DataFileUploadModal, UploadFile } from "./uploadModal"; 11 | import { NotificationLevel } from "../../models/notification"; 12 | import { ActionIcon, Group, Stack, Table, Tooltip } from "@mantine/core"; 13 | import { IconElement } from "../elements/icon"; 14 | import { mdiUpload } from "@mdi/js"; 15 | import { TableHeaderCell } from "../elements/tableHeaderCell"; 16 | import { DataFileListItem } from "./item"; 17 | import { SearchInput } from "../page/searchInput"; 18 | 19 | export const DataFileManagementView: FC = memo(function WrappedDataFileView() { 20 | 21 | const [showUploadModal, uploadModalHandlers] = useDisclosure(false); 22 | const [sortOrder, setSortOrder] = useState(SortOrder.Asc); 23 | const [searchValue, setSearchValue] = useState(""); 24 | 25 | const dispatch = useAppDispatch(); 26 | const [files] = useAppSelector((state: RootStateType) => [ 27 | getDataFilesSortedByName(state, sortOrder, searchValue) 28 | ]); 29 | 30 | const onToggleSort = useCallback(() => { 31 | setSortOrder(sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc); 32 | }, [setSortOrder, sortOrder]); 33 | 34 | const onDeleteFile = useCallback((file: DataFileRecord) => { 35 | dispatch(deleteDataFileOnRemote(file)); 36 | }, [dispatch]); 37 | 38 | const onFileUploadSuccess = useCallback((files: UploadFile[]) => { 39 | dispatch(showNotification({ title: "Upload Complete", message: `Successfully uploaded ${files.length === 1 ? files[0].file.name : `${files.length} files`}`, level: NotificationLevel.success })); 40 | uploadModalHandlers.close(); 41 | }, [uploadModalHandlers, dispatch]); 42 | 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | { showUploadModal ? : null } 54 | 55 | 56 | 57 | 58 | Filename 59 | 60 | 61 | 62 | 63 | 64 | { 65 | files.map(f => ( 66 | 71 | )) 72 | } 73 | 74 |
75 |
76 | ); 77 | }); 78 | -------------------------------------------------------------------------------- /src/components/dataref/datarefs.module.css: -------------------------------------------------------------------------------- 1 | .dataref { 2 | width: 100%; 3 | } 4 | 5 | .datarefItemFileName { 6 | flex: 1; 7 | } 8 | 9 | .datarefFileLabel { 10 | 11 | cursor: pointer; 12 | 13 | > button { 14 | visibility: hidden; 15 | } 16 | 17 | &:hover > button { 18 | visibility: visible; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/dataref/list.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, memo } from "react"; 2 | import { Map as ImmuMap } from "immutable"; 3 | import DataRefEntry from "./item"; 4 | import { DataRefRecord } from "../../models/dataref"; 5 | import { Seq } from "immutable"; 6 | import { Table } from "@mantine/core"; 7 | import classes from "./datarefs.module.css"; 8 | import { DataFileRecord } from "../../models/datafile"; 9 | 10 | export type DataRefListProps = { 11 | onClearDataRef: (dataref: DataRefRecord) => void; 12 | onSetDataRef: (dataref: DataRefRecord, file: DataFileRecord) => void; 13 | onRestoreMetadata: (dataref: DataRefRecord) => void; 14 | onSaveMetadata: (dataref: DataRefRecord, meta: string) => void; 15 | onExportDataRef: (dataref: DataRefRecord) => void; 16 | dataRefs: ImmuMap; 17 | options: Seq.Indexed; // soundfile list 18 | } 19 | 20 | const DataRefList: FunctionComponent = memo(function WrappedDataRefList({ 21 | onClearDataRef, 22 | onSetDataRef, 23 | dataRefs, 24 | options, 25 | onSaveMetadata, 26 | onRestoreMetadata, 27 | onExportDataRef 28 | }) { 29 | 30 | return ( 31 | 32 | 33 | 34 | Buffer 35 | File 36 | 37 | 38 | 39 | 40 | { 41 | dataRefs.valueSeq().map(ref => ( 42 | 52 | )) 53 | } 54 | 55 |
56 | ); 57 | }); 58 | 59 | export default DataRefList; 60 | -------------------------------------------------------------------------------- /src/components/editor/addNodeMenu.module.css: -------------------------------------------------------------------------------- 1 | .patcherMenuSection { 2 | display: flex; 3 | flex-direction: column; 4 | max-height: inherit; 5 | } 6 | 7 | .patcherMenuSectionList { 8 | flex: 1; 9 | overflow: auto; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/editor/addNodeMenu.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo, useCallback, useRef, useState } from "react"; 2 | import { PatcherExportRecord } from "../../models/patcher"; 3 | import { Seq } from "immutable"; 4 | import { ActionIcon, Alert, Anchor, Menu, Text, Tooltip, useMantineTheme } from "@mantine/core"; 5 | import { useDisclosure } from "@mantine/hooks"; 6 | import { mdiPlusBoxOutline } from "@mdi/js"; 7 | import { IconElement } from "../elements/icon"; 8 | import classes from "./addNodeMenu.module.css"; 9 | 10 | type PatcherMenuEntryProps = { 11 | onLoad: (p: PatcherExportRecord) => void; 12 | patcher: PatcherExportRecord; 13 | }; 14 | 15 | const PatcherMenuEntry: FC = ({ patcher, onLoad }) => { 16 | return ( 17 | onLoad(patcher) } > 18 | { patcher.name } 19 | 20 | ); 21 | }; 22 | 23 | type AddPatcherInstanceMenuSectionProps = { 24 | onLoadPatcherInstance: (p: PatcherExportRecord) => void; 25 | patchers: Seq.Indexed; 26 | }; 27 | 28 | const AddPatcherInstanceMenuSection: FC = memo(function WrappedAddPatcherSection({ 29 | onLoadPatcherInstance, 30 | patchers 31 | }) { 32 | return ( 33 |
34 | Patchers 35 | { 36 | !patchers.size ? ( 37 | 38 | 39 | Please export a RNBO patcher to load on the runner. 40 | 41 | 42 | ) : null 43 | } 44 |
45 | { 46 | patchers.map(p => ) 47 | } 48 |
49 |
50 | ); 51 | }); 52 | 53 | export type AddNodeMenuProps = { 54 | onAddPatcherInstance: (patcher: PatcherExportRecord) => void; 55 | patchers: Seq.Indexed; 56 | }; 57 | 58 | export const AddNodeMenu: FC = memo(function WrappedAddNodeMenu({ 59 | onAddPatcherInstance, 60 | patchers 61 | }) { 62 | 63 | const dropdownRef = useRef(); 64 | const theme = useMantineTheme(); 65 | const [maxDropdownMenuHeight, setMaxDropdownMenuHeight] = useState("0px"); 66 | const [addNodeMenuIsOpen, { close: closeMenu, open: openMenu }] = useDisclosure(); 67 | 68 | const onTriggerOpen = useCallback(() => { 69 | if (!dropdownRef.current) return; 70 | 71 | const { bottom } = dropdownRef.current.getBoundingClientRect(); 72 | setMaxDropdownMenuHeight(`calc(${window.innerHeight}px - ${bottom}px - 2 * ${theme.spacing.md}`); 73 | openMenu(); 74 | 75 | }, [setMaxDropdownMenuHeight, openMenu, dropdownRef, theme.spacing.md]); 76 | 77 | return ( 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 |
88 | 92 |
93 |
94 |
95 | ); 96 | }); 97 | -------------------------------------------------------------------------------- /src/components/editor/edge.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useCallback } from "react"; 2 | import { getBezierPath, EdgeLabelRenderer, BaseEdge } from "reactflow"; 3 | import classes from "./editor.module.css"; 4 | import { ActionIcon, CloseIcon, Tooltip } from "@mantine/core"; 5 | import { EditorEdgeProps } from "./util"; 6 | 7 | export const RNBOGraphEdgeType = "rnbo-edge"; 8 | 9 | const GraphEdge: FunctionComponent = ({ 10 | id, 11 | sourceX, 12 | sourceY, 13 | targetX, 14 | targetY, 15 | selected, 16 | sourcePosition, 17 | targetPosition, 18 | data: { onDelete } 19 | }) => { 20 | 21 | const [edgePath, labelX, labelY] = getBezierPath({ 22 | sourceX, 23 | sourceY, 24 | sourcePosition, 25 | targetX, 26 | targetY, 27 | targetPosition 28 | }); 29 | 30 | const onTriggerDelete = useCallback(() => onDelete(id), [id, onDelete]); 31 | 32 | return ( 33 | <> 34 | 35 | 36 |
43 | 44 | 45 | 46 | 47 | 48 |
49 |
50 | 51 | ); 52 | }; 53 | 54 | export default GraphEdge; 55 | -------------------------------------------------------------------------------- /src/components/editor/graphMenu.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon, Menu, Tooltip } from "@mantine/core"; 2 | import { FC, memo } from "react"; 3 | import { mdiArrowUpBoldBoxOutline, mdiContentSave, mdiContentSaveMove, mdiDatabaseCog, mdiDotsVertical, mdiPencil, mdiPlus, mdiReload, mdiTrashCan } from "@mdi/js"; 4 | import { IconElement } from "../elements/icon"; 5 | import Link from "next/link"; 6 | import { useRouter } from "next/router"; 7 | 8 | export type GraphSetMenuProps = { 9 | hasLoadedGraph?: boolean; 10 | 11 | onLoadEmptySet: () => void; 12 | onTriggerLoadSet: () => void; 13 | 14 | onDeleteCurrentSet: () => void; 15 | onReloadCurrentSet: () => void; 16 | onSaveCurrentSet: () => void; 17 | onSaveCurrentSetAs: () => void; 18 | onTriggerRenameCurrentSet: () => void; 19 | }; 20 | 21 | export const GraphSetMenu: FC = memo(function WrapedSaveGraphButtton({ 22 | hasLoadedGraph, 23 | 24 | onLoadEmptySet, 25 | onTriggerLoadSet, 26 | 27 | onDeleteCurrentSet, 28 | onReloadCurrentSet, 29 | onSaveCurrentSet, 30 | onSaveCurrentSetAs, 31 | onTriggerRenameCurrentSet 32 | }) { 33 | 34 | const { query } = useRouter(); 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | Graph 48 | 49 | } > 50 | New Graph 51 | 52 | } > 53 | Load Graph 54 | 55 | 56 | } disabled={ !hasLoadedGraph } > 57 | Reload Graph 58 | 59 | } disabled={ !hasLoadedGraph } > 60 | Rename Graph 61 | 62 | 63 | } > 64 | Save Graph 65 | 66 | } > 67 | Save Graph As... 68 | 69 | } 71 | component={ Link } 72 | href={{ pathname: "/resources", query: { ...query } }} 73 | > 74 | Manage Graphs 75 | 76 | 77 | } color="red" disabled={ !hasLoadedGraph } > 78 | Delete Graph 79 | 80 | 81 | 82 | ); 83 | }); 84 | -------------------------------------------------------------------------------- /src/components/editor/patcherNode.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, memo, useCallback } from "react"; 2 | import { EditorNodeProps, calcPortOffset } from "./util"; 3 | import EditorPort from "./port"; 4 | import classes from "./editor.module.css"; 5 | import { ActionIcon, Menu, Paper, Tooltip } from "@mantine/core"; 6 | import Link from "next/link"; 7 | import { useRouter } from "next/router"; 8 | import { IconElement } from "../elements/icon"; 9 | import { mdiDotsVertical, mdiPencil, mdiTrashCan, mdiVectorSquare } from "@mdi/js"; 10 | 11 | const EditorPatcherNode: FunctionComponent = memo(function WrappedGraphPatcherNode({ 12 | data: { 13 | onDelete, 14 | onRename, 15 | 16 | contentHeight, 17 | displayName, 18 | node, 19 | sinks, 20 | sources, 21 | width 22 | }, 23 | selected 24 | }) { 25 | 26 | const { query } = useRouter(); 27 | const portSizeLimit = sinks.length && sources.length ? Math.round(width / 2) : width; 28 | 29 | const onTriggerRename = useCallback(() => { 30 | onRename(node); 31 | }, [onRename, node]); 32 | 33 | const onTriggerDelete = useCallback(() => { 34 | onDelete(node); 35 | }, [onDelete, node]); 36 | 37 | return ( 38 | 39 |
40 |
{ displayName }
41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Device Actions 53 | 54 | } component={ Link } href={{ pathname: "/instances/[id]", query: { ...query, id: node.instanceId } }} > 55 | Open Device Control 56 | 57 | } onClick={ onTriggerRename } > 58 | Rename Device 59 | 60 | 61 | } onClick={ onTriggerDelete } > 62 | Delete Device 63 | 64 | 65 | 66 |
67 |
68 |
69 | { 70 | sinks.map((port, i) => ( 71 | 77 | )) 78 | } 79 | { 80 | sources.map((port, i) => ( 81 | 87 | )) 88 | } 89 |
90 |
91 | ); 92 | }); 93 | 94 | export default EditorPatcherNode; 95 | -------------------------------------------------------------------------------- /src/components/editor/port.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, FunctionComponent, memo } from "react"; 2 | import { GraphPortRecord, PortDirection } from "../../models/graph"; 3 | import { Handle, HandleType, Position } from "reactflow"; 4 | 5 | export type PortProps = { 6 | offset: number; 7 | port: GraphPortRecord; 8 | maxWidth: number; 9 | }; 10 | 11 | const handleTypeByPortDirection: Record = { 12 | [PortDirection.Sink]: "target", 13 | [PortDirection.Source]: "source" 14 | }; 15 | 16 | const handlePositionByPortDirection: Record = { 17 | [PortDirection.Sink]: Position.Left, 18 | [PortDirection.Source]: Position.Right 19 | }; 20 | 21 | const EditorPort: FunctionComponent = memo(function WrappedPort({ 22 | port, 23 | maxWidth, 24 | offset 25 | }) { 26 | 27 | return ( 28 | 36 | ); 37 | }); 38 | 39 | export default EditorPort; 40 | -------------------------------------------------------------------------------- /src/components/editor/setTitle.module.css: -------------------------------------------------------------------------------- 1 | .wrap { 2 | align-items: center; 3 | display: flex; 4 | flex: 1; 5 | flex-wrap: nowrap; 6 | gap: 1px; 7 | min-width: 0; 8 | } 9 | 10 | .title { 11 | min-width: 0; 12 | overflow: hidden; 13 | white-space: nowrap; 14 | text-overflow: ellipsis; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/editor/setTitle.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import styles from "./setTitle.module.css"; 3 | import { PageTitle } from "../page/title"; 4 | 5 | export type GraphSetTitleProps = { 6 | isDirty: boolean; 7 | name: string; 8 | } 9 | 10 | export const GraphSetTitle: FC = ({ 11 | isDirty, 12 | name 13 | }) => { 14 | return ( 15 |
16 | 17 | { name } 18 | 19 | { isDirty ? * : null } 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/editor/systemNode.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, memo } from "react"; 2 | import { EditorNodeProps, calcPortOffset } from "./util"; 3 | import EditorPort from "./port"; 4 | import classes from "./editor.module.css"; 5 | import { Paper } from "@mantine/core"; 6 | 7 | const EditorSystemNode: FunctionComponent = memo(function WrappedGraphPatcherNode({ 8 | data: { 9 | contentHeight, 10 | displayName, 11 | sinks, 12 | sources, 13 | width 14 | }, 15 | selected 16 | }) { 17 | 18 | const portSizeLimit = sinks.length && sources.length ? Math.round(width / 2) : width; 19 | 20 | return ( 21 | 22 |
23 | { displayName } 24 |
25 |
26 | { 27 | sinks.map((port, i) => ( 28 | 34 | )) 35 | } 36 | { 37 | sources.map((port, i) => ( 38 | 44 | )) 45 | } 46 |
47 |
48 | ); 49 | }); 50 | 51 | export default EditorSystemNode; 52 | -------------------------------------------------------------------------------- /src/components/editor/util.ts: -------------------------------------------------------------------------------- 1 | import { EdgeProps, NodeProps } from "reactflow"; 2 | import { GraphConnectionRecord, GraphNodeRecord, GraphPortRecord } from "../../models/graph"; 3 | 4 | export type NodeActions = { 5 | onDelete: (node: GraphNodeRecord) => void; 6 | onRename: (node: GraphNodeRecord) => void; 7 | }; 8 | 9 | export type NodeDataProps = NodeActions & { 10 | contentHeight: number; 11 | displayName: string; 12 | node: GraphNodeRecord; 13 | sinks: GraphPortRecord[]; 14 | sources: GraphPortRecord[]; 15 | x: number; 16 | y: number; 17 | width: number; 18 | height: number; 19 | }; 20 | 21 | export type PatcherNodeDataProps = NodeDataProps & { 22 | onRename: (node: GraphNodeRecord) => void; 23 | }; 24 | 25 | export type EdgeDataProps = { 26 | onDelete: (id: GraphConnectionRecord["id"]) => void; 27 | }; 28 | 29 | export type EditorNodeProps = NodeProps; 30 | export type EditorEdgeProps = EdgeProps; 31 | 32 | export const calcPortOffset = (total: number, index: number): number => { 33 | return (index + 1) * (1 / (total + 1)) * 100; 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/elements/elements.module.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | width: 1.3em; 3 | } 4 | 5 | .editableTableCellText { 6 | border-bottom: 1px solid transparent; 7 | } 8 | 9 | .editableTableCellWrapper { 10 | border-bottom: 1px solid var(--mantine-primary-color-filled); 11 | padding: 0; 12 | } 13 | 14 | .editableTableCellInputWrapper { 15 | border: none; 16 | flex: 1; 17 | } 18 | 19 | .editableTableCellInput { 20 | border: none; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/elements/icon.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@mdi/react"; 2 | import classes from "./elements.module.css"; 3 | import { parseThemeColor, useMantineTheme } from "@mantine/core"; 4 | import { forwardRef, RefObject } from "react"; 5 | import { IconProps } from "@mdi/react/dist/IconProps"; 6 | 7 | export const IconElement = forwardRef(function WrappedIconElement({ color, ...props }, ref) { 8 | const theme = useMantineTheme(); 9 | 10 | let iconColor: string | undefined = undefined; 11 | if (color) { 12 | const p = parseThemeColor({ color, theme }); 13 | iconColor = p.isThemeColor ? `var(${p.variable})` : color; 14 | } 15 | 16 | return ( 17 | } /> 18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/elements/tableHeaderCell.tsx: -------------------------------------------------------------------------------- 1 | import { Group, MantineFontSize, MantineStyleProps, Table, Text, UnstyledButton } from "@mantine/core"; 2 | import { FC, PropsWithChildren, useCallback } from "react"; 3 | import { SortOrder } from "../../lib/constants"; 4 | import { IconElement } from "./icon"; 5 | import { mdiChevronDown, mdiChevronUp, mdiUnfoldMoreHorizontal } from "@mdi/js"; 6 | 7 | export type TableHeaderCellProps = PropsWithChildren<{ 8 | className?: string; 9 | fz?: MantineFontSize; 10 | 11 | onSort?: (sortKey: string) => void; 12 | sorted?: boolean; 13 | sortKey?: string; 14 | sortOrder?: SortOrder; 15 | width?: MantineStyleProps["w"]; 16 | }>; 17 | 18 | export const TableHeaderCell: FC = ({ 19 | children, 20 | className, 21 | fz = "sm", 22 | 23 | onSort, 24 | sorted = false, 25 | sortKey, 26 | sortOrder = SortOrder.Asc, 27 | 28 | width = undefined 29 | 30 | }) => { 31 | 32 | const onTriggerSort = useCallback(() => { 33 | onSort?.(sortKey); 34 | }, [onSort, sortKey]); 35 | 36 | return ( 37 | 38 | { 39 | onSort ? ( 40 | 41 | 42 | 43 | { children } 44 | 45 | { 46 | sorted 47 | ? 48 | : 49 | } 50 | 51 | 52 | ) : ( 53 | 54 | { children } 55 | 56 | ) 57 | } 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/header/cpu.module.css: -------------------------------------------------------------------------------- 1 | .cpuRoot { 2 | border-radius: var(--mantine-radius-sm); 3 | background-color: var(--mantine-color-dark-3); 4 | overflow: hidden; 5 | position: relative; 6 | height: 20px; 7 | width: 40px; 8 | } 9 | 10 | .cpuBar { 11 | background-color: var(--mantine-primary-color-filled); 12 | border-bottom-left-radius: var(--mantine-radius-sm); 13 | border-top-left-radius: var(--mantine-radius-sm); 14 | height: 100%; 15 | left: 0; 16 | top: 0; 17 | position: absolute; 18 | width: 100%; 19 | z-index: 2; 20 | } 21 | 22 | .cpuLabel { 23 | color: var(--mantine-primary-color-contrast); 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | font-size: var(--mantine-font-size-xs); 28 | font-weight: bold; 29 | left: 0; 30 | padding: 0 var(--mantine-spacing-xs); 31 | position: absolute; 32 | top: 0; 33 | width: 100%; 34 | height: 100%; 35 | z-index: 3; 36 | } 37 | -------------------------------------------------------------------------------- /src/components/header/cpu.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, FunctionComponent, memo } from "react"; 2 | import classes from "./cpu.module.css"; 3 | 4 | export type CPUStatusProps = { 5 | load: number; 6 | }; 7 | 8 | export const CPUStatus: FunctionComponent = memo(forwardRef( 9 | function WrappedCPUStatusComponent({ load }, ref) { 10 | return ( 11 |
12 |
13 | 16 |
17 | ); 18 | } 19 | )); 20 | -------------------------------------------------------------------------------- /src/components/header/header.module.css: -------------------------------------------------------------------------------- 1 | .headerWrapper { 2 | align-items: center; 3 | height: 100%; 4 | justify-content: space-between; 5 | padding: 0 var(--mantine-spacing-md); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, memo, useCallback } from "react"; 2 | import { ActionIcon, AppShell, Burger, Group, Tooltip } from "@mantine/core"; 3 | import classes from "./header.module.css"; 4 | import { useThemeColorScheme } from "../../hooks/useTheme"; 5 | import { useAppDispatch, useAppSelector } from "../../hooks/useAppDispatch"; 6 | import { toggleEndpointInfo } from "../../actions/appStatus"; 7 | import { toggleTransportControl } from "../../actions/transport"; 8 | import { getTransportControlState } from "../../selectors/transport"; 9 | import { RootStateType } from "../../lib/store"; 10 | import { getAppStatus, getRunnerInfoRecord } from "../../selectors/appStatus"; 11 | import { AppStatus, JackInfoKey } from "../../lib/constants"; 12 | import { IconElement } from "../elements/icon"; 13 | import { mdiMetronome, mdiSatelliteUplink } from "@mdi/js"; 14 | import { CPUStatus } from "./cpu"; 15 | import { toggleStreamRecording } from "../../actions/recording"; 16 | import { getStreamRecordingState, getStreamRecordingTimeout } from "../../selectors/recording"; 17 | import { RecordStatus } from "./record"; 18 | 19 | export type HeaderProps = { 20 | navOpen: boolean; 21 | onToggleNav: () => any; 22 | } 23 | 24 | export const Header: FunctionComponent = memo(function WrappedHeaderComponent({ 25 | navOpen, 26 | onToggleNav 27 | }) { 28 | 29 | const scheme = useThemeColorScheme(); 30 | const dispatch = useAppDispatch(); 31 | 32 | const [ 33 | recordingState, 34 | recordingTimeout, 35 | transportIsRolling, 36 | cpuLoad 37 | ] = useAppSelector((state: RootStateType) => { 38 | const status = getAppStatus(state); 39 | return [ 40 | getStreamRecordingState(state), 41 | getStreamRecordingTimeout(state), 42 | getTransportControlState(state).rolling, 43 | status === AppStatus.Ready ? getRunnerInfoRecord(state, JackInfoKey.CPULoad) : null 44 | ]; 45 | }); 46 | 47 | const onToggleEndpointInfo = useCallback(() => dispatch(toggleEndpointInfo()), [dispatch]); 48 | const onToggleTransportControl = useCallback(() => dispatch(toggleTransportControl()), [dispatch]); 49 | const onToggleRecording = useCallback(() => dispatch(toggleStreamRecording()), [dispatch]); 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | Cycling '74 Logo 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | ); 79 | }); 80 | -------------------------------------------------------------------------------- /src/components/header/record.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, FunctionComponent, memo } from "react"; 2 | import { Button } from "@mantine/core"; 3 | import { IconElement } from "../elements/icon"; 4 | import { mdiRecord } from "@mdi/js"; 5 | import { Duration } from "dayjs/plugin/duration"; 6 | 7 | export type RecordStatusProps = { 8 | active: boolean; 9 | capturedTime: Duration; 10 | timeout: Duration | null; 11 | onToggleRecording: () => void; 12 | }; 13 | 14 | const formatDuration = (current: Duration, timeout: Duration): string => { 15 | if (!timeout) { 16 | return current.format(current.hours() > 0 ? "hh:mm:ss" : "mm:ss"); 17 | } 18 | 19 | const d = timeout.subtract(current); 20 | return `- ${d.format(d.hours() > 0 ? "hh:mm:ss" : "mm:ss")}`; 21 | }; 22 | 23 | export const RecordStatus: FunctionComponent = memo(forwardRef( 24 | function WrappedRecordComponent({ active, capturedTime, onToggleRecording, timeout }, ref) { 25 | return ( 26 | 36 | ); 37 | } 38 | )); 39 | -------------------------------------------------------------------------------- /src/components/instance/actions.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@mantine/core"; 2 | import { FunctionComponent, memo } from "react"; 3 | import { IconElement } from "../elements/icon"; 4 | import { mdiTrashCan } from "@mdi/js"; 5 | 6 | const DeviceActions: FunctionComponent = memo(function WrapedDeviceActions() { 7 | return ( 8 | 9 | 12 | 13 | 14 | ); 15 | }); 16 | 17 | export default DeviceActions; 18 | -------------------------------------------------------------------------------- /src/components/instance/datarefTab.tsx: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap, Seq } from "immutable"; 2 | import { Group, Stack } from "@mantine/core"; 3 | import { FunctionComponent, memo, useCallback, useState } from "react"; 4 | import { useAppDispatch } from "../../hooks/useAppDispatch"; 5 | import DataRefList from "../dataref/list"; 6 | import classes from "./instance.module.css"; 7 | import { PatcherInstanceRecord } from "../../models/instance"; 8 | import { clearInstanceDataRefValueOnRemote, exportInstanceDataRef, restoreDefaultDataRefMetaOnRemote, setInstanceDataRefMetaOnRemote, setInstanceDataRefValueOnRemote } from "../../actions/patchers"; 9 | import { DataRefRecord } from "../../models/dataref"; 10 | import { DataFileRecord } from "../../models/datafile"; 11 | import { SearchInput } from "../page/searchInput"; 12 | 13 | export type InstanceDataRefTabProps = { 14 | instance: PatcherInstanceRecord; 15 | datafiles: Seq.Indexed; 16 | dataRefs: ImmuMap; 17 | } 18 | 19 | const InstanceDataRefsTab: FunctionComponent = memo(function WrappedInstanceDataRefsTab({ 20 | instance, 21 | datafiles, 22 | dataRefs 23 | }) { 24 | 25 | const dispatch = useAppDispatch(); 26 | const [searchValue, setSearchValue] = useState(""); 27 | 28 | const onSetDataRef = useCallback((dataref: DataRefRecord, file: DataFileRecord) => { 29 | dispatch(setInstanceDataRefValueOnRemote(dataref, file)); 30 | }, [dispatch]); 31 | 32 | const onClearDataRef = useCallback((dataref: DataRefRecord) => { 33 | dispatch(clearInstanceDataRefValueOnRemote(dataref)); 34 | }, [dispatch]); 35 | 36 | const onSaveMetadata = useCallback((dataref: DataRefRecord, value: string) => { 37 | dispatch(setInstanceDataRefMetaOnRemote(dataref, value)); 38 | }, [dispatch]); 39 | 40 | const onRestoreMetadata = useCallback((dataref: DataRefRecord) => { 41 | dispatch(restoreDefaultDataRefMetaOnRemote(dataref)); 42 | }, [dispatch]); 43 | 44 | const onExportDataRef = useCallback((dataref: DataRefRecord) => { 45 | dispatch(exportInstanceDataRef(dataref)); 46 | }, [dispatch]); 47 | 48 | return ( 49 | 50 | 51 | 52 | 53 | { 54 | !dataRefs.size ? ( 55 |
56 | This device has no buffers. 57 |
58 | ) : ( 59 | ref.matchesQuery(searchValue)) } 61 | options={ datafiles } 62 | onSetDataRef={ onSetDataRef } 63 | onClearDataRef={ onClearDataRef } 64 | onRestoreMetadata={ onRestoreMetadata } 65 | onSaveMetadata={ onSaveMetadata } 66 | onExportDataRef={ onExportDataRef } 67 | /> 68 | ) 69 | } 70 |
71 | ); 72 | }); 73 | 74 | export default InstanceDataRefsTab; 75 | -------------------------------------------------------------------------------- /src/components/instance/inportTab.tsx: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap } from "immutable"; 2 | import { FunctionComponent, memo, useCallback, useState } from "react"; 3 | import { useAppDispatch } from "../../hooks/useAppDispatch"; 4 | import MessageInportList from "../messages/inportList"; 5 | import classes from "./instance.module.css"; 6 | import { PatcherInstanceRecord } from "../../models/instance"; 7 | import { triggerSendInstanceInportMessage, sendInstanceInportBang } from "../../actions/patchers"; 8 | import { MessagePortRecord } from "../../models/messageport"; 9 | import { restoreDefaultMessagePortMetaOnRemote, setInstanceMessagePortMetaOnRemote } from "../../actions/patchers"; 10 | import { Group, Stack } from "@mantine/core"; 11 | import { SearchInput } from "../page/searchInput"; 12 | 13 | export type InstanceInportTabProps = { 14 | instance: PatcherInstanceRecord; 15 | messageInports: ImmuMap; 16 | } 17 | 18 | const InstanceInportTab: FunctionComponent = memo(function WrappedInstanceMessagesTab({ 19 | instance, 20 | messageInports 21 | }) { 22 | 23 | const dispatch = useAppDispatch(); 24 | const [searchValue, setSearchValue] = useState(""); 25 | 26 | const onSendInportMessage = useCallback((port: MessagePortRecord) => { 27 | dispatch(triggerSendInstanceInportMessage(instance, port)); 28 | }, [dispatch, instance]); 29 | 30 | const onSendInportBang = useCallback((port: MessagePortRecord) => { 31 | dispatch(sendInstanceInportBang(instance, port)); 32 | }, [dispatch, instance]); 33 | 34 | const onSavePortMetadata = useCallback((port: MessagePortRecord, meta: string) => { 35 | dispatch(setInstanceMessagePortMetaOnRemote(instance, port, meta)); 36 | }, [dispatch, instance]); 37 | 38 | const onRestoreDefaultPortMetadata = useCallback((port: MessagePortRecord) => { 39 | dispatch(restoreDefaultMessagePortMetaOnRemote(instance, port)); 40 | }, [dispatch, instance]); 41 | 42 | return ( 43 | 44 | 45 | 46 | 47 | { 48 | !messageInports.size ? ( 49 |
50 | This device has no inports. 51 |
52 | ) : 53 | p.matchesQuery(searchValue)) } 55 | onSendBang={ onSendInportBang } 56 | onSendMessage={ onSendInportMessage } 57 | onRestoreMetadata={ onRestoreDefaultPortMetadata } 58 | onSaveMetadata={ onSavePortMetadata } 59 | /> 60 | } 61 |
62 | ); 63 | }); 64 | 65 | export default InstanceInportTab; 66 | -------------------------------------------------------------------------------- /src/components/instance/instance.module.css: -------------------------------------------------------------------------------- 1 | .instanceNotFound { 2 | align-items: center; 3 | color: var(--mantine-color-dimmed); 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | height: 100%; 8 | } 9 | 10 | .instanceWrap { 11 | height: 100%; 12 | gap: var(--mantine-spacing-md); 13 | } 14 | 15 | .instanceTabWrap { 16 | display: flex; 17 | flex: 1; 18 | flex-direction: column; 19 | } 20 | 21 | .instanceTabContentWrap { 22 | flex: 1; 23 | padding-top: var(--mantine-spacing-lg); 24 | 25 | > div { 26 | height: 100%; 27 | } 28 | } 29 | 30 | .emptySection { 31 | color: var(--mantine-color-dimmed); 32 | font-size: var(--mantine-font-size-sm); 33 | flex: 1; 34 | margin-top: var(--mantine-spacing-sm); 35 | text-align: center; 36 | } 37 | 38 | .disabledMessageOutput { 39 | color: var(--mantine-color-dimmed); 40 | font-size: var(--mantine-font-size-sm); 41 | } 42 | 43 | .paramSectionWrap { 44 | flex: 1; 45 | } 46 | 47 | .tabLabel { 48 | display: none; 49 | } 50 | 51 | @media (min-width: $mantine-breakpoint-xs) { 52 | .tabLabel { 53 | display: block; 54 | } 55 | } 56 | 57 | .title { 58 | font-weight: var(--mantine-h1-font-weight); 59 | font-size: var(--mantine-font-size-xl); 60 | line-height: 34px; 61 | max-width: 90%; 62 | width: fit-content; 63 | } 64 | -------------------------------------------------------------------------------- /src/components/instance/outportTab.tsx: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap } from "immutable"; 2 | import { FunctionComponent, memo, useCallback, useState } from "react"; 3 | import { useAppDispatch } from "../../hooks/useAppDispatch"; 4 | import MessageOutportList from "../messages/outportList"; 5 | import classes from "./instance.module.css"; 6 | import { PatcherInstanceRecord } from "../../models/instance"; 7 | import { MessagePortRecord } from "../../models/messageport"; 8 | import { restoreDefaultMessagePortMetaOnRemote, setInstanceMessagePortMetaOnRemote } from "../../actions/patchers"; 9 | import { Center, Group, SegmentedControl, Stack, Tooltip } from "@mantine/core"; 10 | import { IconElement } from "../elements/icon"; 11 | import { mdiEye, mdiEyeOff } from "@mdi/js"; 12 | import { setAppSetting } from "../../actions/settings"; 13 | import { AppSetting } from "../../models/settings"; 14 | import { SearchInput } from "../page/searchInput"; 15 | 16 | export type InstanceOutportTabProps = { 17 | instance: PatcherInstanceRecord; 18 | messageOutports: ImmuMap; 19 | outputEnabled: boolean; 20 | } 21 | 22 | const InstanceOutportTab: FunctionComponent = memo(function WrappedInstanceMessagesTab({ 23 | instance, 24 | messageOutports, 25 | outputEnabled 26 | }) { 27 | 28 | const dispatch = useAppDispatch(); 29 | const [searchValue, setSearchValue] = useState(""); 30 | 31 | const onSavePortMetadata = useCallback((port: MessagePortRecord, meta: string) => { 32 | dispatch(setInstanceMessagePortMetaOnRemote(instance, port, meta)); 33 | }, [dispatch, instance]); 34 | 35 | const onRestoreDefaultPortMetadata = useCallback((port: MessagePortRecord) => { 36 | dispatch(restoreDefaultMessagePortMetaOnRemote(instance, port)); 37 | }, [dispatch, instance]); 38 | 39 | const onSetOutportMonitoring = useCallback((v: string) => { 40 | dispatch(setAppSetting(AppSetting.debugMessageOutput, v === "true")); 41 | }, [dispatch]); 42 | 43 | return ( 44 | 45 | 46 | 47 | 55 |
56 | 57 | ) 58 | }, 59 | { 60 | value: "true", 61 | label: ( 62 | 63 |
64 |
65 | ) 66 | } 67 | ]} 68 | onChange={ onSetOutportMonitoring } 69 | value={ `${outputEnabled}` } 70 | /> 71 |
72 | { 73 | !messageOutports.size ? ( 74 |
75 | This device has no outports. 76 |
77 | ) : 78 | p.matchesQuery(searchValue)) } 80 | outputEnabled={ outputEnabled } 81 | onRestoreMetadata={ onRestoreDefaultPortMetadata } 82 | onSaveMetadata={ onSavePortMetadata } 83 | /> 84 | } 85 |
86 | ); 87 | }); 88 | 89 | export default InstanceOutportTab; 90 | -------------------------------------------------------------------------------- /src/components/instance/title.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, FC, memo, useCallback } from "react"; 2 | import { PatcherInstanceRecord } from "../../models/instance"; 3 | import { NativeSelect } from "@mantine/core"; 4 | import { Map as ImmuMap } from "immutable"; 5 | import styles from "./instance.module.css"; 6 | import { IconElement } from "../elements/icon"; 7 | import { mdiVectorSquare } from "@mdi/js"; 8 | 9 | export type InstanceSelectTitleProps = { 10 | currentInstanceId: PatcherInstanceRecord["id"]; 11 | instances: ImmuMap; 12 | onChangeInstance: (instance: PatcherInstanceRecord) => void; 13 | }; 14 | 15 | const collator = new Intl.Collator("en-US", { numeric: true }); 16 | 17 | export const InstanceSelectTitle: FC = memo(function WrappedInstanceSelectTitle({ 18 | currentInstanceId, 19 | instances, 20 | onChangeInstance 21 | }) { 22 | 23 | const onTriggerChangeInstance = useCallback((e: ChangeEvent) => { 24 | const instance = instances.get(e.currentTarget.value); 25 | onChangeInstance(instance); 26 | }, [instances, onChangeInstance]); 27 | 28 | return ( 29 | collator.compare(a.id, b.id)).toArray().map(d => ({ value: d.id, label: d.displayName })) } 32 | leftSection={ } 33 | onChange={ onTriggerChangeInstance } 34 | value={ currentInstanceId } 35 | variant="unstyled" 36 | /> 37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /src/components/keyroll/keyroll.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | height: 100%; 4 | flex-direction: column; 5 | gap: var(--mantine-spacing-md); 6 | } 7 | 8 | .octaveLabel { 9 | font-size: var(--mantine-font-size-sm); 10 | touch-action: none; 11 | user-select: none; 12 | } 13 | 14 | .keyroll { 15 | flex: 1; 16 | touch-action: none; 17 | user-select: none; 18 | } 19 | 20 | .octaveWrap { 21 | display: flex; 22 | justify-content: flex-start; 23 | position: relative; 24 | touch-action: none; 25 | transform: none; 26 | transform-origin: top left; 27 | user-select: none; 28 | 29 | &[data-orientation="horizontal"] { 30 | height: 350px; 31 | max-height: var(--keyroll-height); 32 | width: var(--keyroll-width); 33 | } 34 | 35 | &[data-orientation="vertical"] { 36 | height: var(--keyroll-width); 37 | width: var(--keyroll-height); 38 | transform: rotate(90deg) translateY(-100%); 39 | } 40 | } 41 | 42 | .octave { 43 | height: 100%; 44 | width: calc(100% / var(--octave-count)); 45 | max-width: 500px; 46 | position: relative; 47 | user-select: none; 48 | 49 | &:not(:last-child) { 50 | border-right: none; 51 | } 52 | 53 | > .octaveLabel { 54 | left: var(--mantine-spacing-xs); 55 | bottom: calc(var(--mantine-spacing-xs) / 2); 56 | color: var(--mantine-color-dark-7); 57 | font-size: var(--mantine-font-size-sm); 58 | font-weight: 700; 59 | position: absolute; 60 | z-index: 3; 61 | } 62 | 63 | > .octaveKeys { 64 | height: 100%; 65 | left: 0; 66 | position: absolute; 67 | top: 0; 68 | width: 100%; 69 | } 70 | } 71 | 72 | 73 | 74 | .key { 75 | 76 | border-bottom-left-radius: var(--mantine-radius-xs); 77 | border-bottom-right-radius: var(--mantine-radius-xs); 78 | border-color: var(--mantine-color-default-border); 79 | border-style: solid; 80 | border-width: 1px; 81 | position: absolute; 82 | top: 0; 83 | user-select: none; 84 | width: calc(100% / 7); 85 | 86 | } 87 | 88 | .whiteKey { 89 | background-color: var(--mantine-color-white); 90 | height: 100%; 91 | z-index: 2; 92 | 93 | @mixin dark { 94 | background-color: var(--mantine-color-text); 95 | } 96 | 97 | &[data-active="true"] { 98 | background-color: var(--mantine-primary-color-filled); 99 | } 100 | } 101 | 102 | .blackKey { 103 | background-color: var(--mantine-color-dark-8); 104 | height: 60%; 105 | z-index: 3; 106 | 107 | @mixin dark { 108 | background-color: var(--mantine-color-dark-7); 109 | } 110 | 111 | &[data-active="true"] { 112 | background-color: var(--mantine-primary-color-filled); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/components/keyroll/modal.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, memo, useCallback, useEffect } from "react"; 2 | import { useAppDispatch } from "../../hooks/useAppDispatch"; 3 | import KeyRoll from "../keyroll"; 4 | import { PatcherInstanceRecord } from "../../models/instance"; 5 | import { triggerInstanceMidiNoteOffEventOnRemote, triggerInstanceMidiNoteOnEventOnRemote } from "../../actions/patchers"; 6 | import { Group, Modal } from "@mantine/core"; 7 | import { useIsMobileDevice } from "../../hooks/useIsMobileDevice"; 8 | import { IconElement } from "../elements/icon"; 9 | import { mdiPiano } from "@mdi/js"; 10 | 11 | export type InstanceKeyboardModalProps = { 12 | instance: PatcherInstanceRecord; 13 | keyboardEnabled: boolean; 14 | open: boolean; 15 | onClose: () => any; 16 | } 17 | 18 | const InstanceKeyboardModal: FunctionComponent = memo(function WrappedInstanceMIDITab({ 19 | instance, 20 | keyboardEnabled, 21 | open, 22 | onClose 23 | }) { 24 | 25 | const dispatch = useAppDispatch(); 26 | const showFullScreen = useIsMobileDevice(); 27 | 28 | const triggerMIDINoteOn = useCallback((p: number) => { 29 | dispatch(triggerInstanceMidiNoteOnEventOnRemote(instance, p)); 30 | }, [dispatch, instance]); 31 | 32 | const triggerMIDINoteOff = useCallback((p: number) => { 33 | dispatch(triggerInstanceMidiNoteOffEventOnRemote(instance, p)); 34 | }, [dispatch, instance]); 35 | 36 | useEffect(() => { 37 | if (open && document.activeElement && document.activeElement instanceof HTMLElement) { 38 | document.activeElement.blur(); 39 | } 40 | }, [open]); 41 | 42 | return ( 43 | 50 | 51 | 52 | 53 | 54 | 55 | Virtual Keyboard 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | ); 65 | }); 66 | 67 | export default InstanceKeyboardModal; 68 | -------------------------------------------------------------------------------- /src/components/keyroll/note.tsx: -------------------------------------------------------------------------------- 1 | import { PointerEvent, FunctionComponent, memo } from "react"; 2 | import classes from "./keyroll.module.css"; 3 | 4 | const oneSeventh = 100 / 7; 5 | const oneFourteenth = 100 / 14; 6 | 7 | interface NoteProps { 8 | index: number; 9 | isActive: boolean; 10 | isWhiteKey: boolean; 11 | note: number; 12 | onNoteOn: (n: number) => any; 13 | onNoteOff: (n: number) => any; 14 | } 15 | 16 | const Note: FunctionComponent = memo(({ 17 | index, 18 | isActive, 19 | isWhiteKey, 20 | note, 21 | onNoteOff, 22 | onNoteOn 23 | }) => { 24 | 25 | const onPointerDown = (e: PointerEvent) => { 26 | if (isActive) return; 27 | onNoteOn(note); 28 | }; 29 | 30 | const onPointerEnter = (e: PointerEvent) => { 31 | if (e.pointerType === "mouse" && !e.buttons) return; 32 | onNoteOn(note); 33 | }; 34 | 35 | const onPointerLeave = (e: PointerEvent) => { 36 | if (!isActive) return; 37 | if (e.pointerType === "mouse" && !e.buttons) return; 38 | onNoteOff(note); 39 | }; 40 | 41 | const onPointerUp = (e: PointerEvent) => { 42 | if (!isActive) return; 43 | onNoteOff(note); 44 | }; 45 | 46 | return ( 47 |
59 | ); 60 | }); 61 | 62 | Note.displayName = "Note"; 63 | 64 | export default Note; 65 | -------------------------------------------------------------------------------- /src/components/keyroll/octave.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, memo } from "react"; 2 | import { Set as ImmuSet } from "immutable"; 3 | import Note from "./note"; 4 | import classes from "./keyroll.module.css"; 5 | 6 | const Octave: FunctionComponent<{ 7 | octave: number; 8 | activeNotes: ImmuSet; 9 | onNoteOn: (note: number) => any; 10 | onNoteOff: (note: number) => any; 11 | }> = memo(({ 12 | octave, 13 | activeNotes, 14 | onNoteOn, 15 | onNoteOff 16 | }) => { 17 | 18 | const start = 12 * octave; 19 | const whiteNotes: JSX.Element[] = []; 20 | const blackNotes: JSX.Element[] = []; 21 | 22 | for (let i = 0, key = start; i < 7; i++) { 23 | 24 | // create a white key for every entry 25 | whiteNotes.push(); 34 | key++; 35 | 36 | // create black key?! 37 | if (i !== 2 && i !== 6) { 38 | blackNotes.push( 39 | 48 | ); 49 | key++; 50 | } 51 | } 52 | 53 | return ( 54 |
55 |
56 | C{octave} 57 |
58 |
59 | { blackNotes } 60 | { whiteNotes } 61 |
62 |
63 | ); 64 | }); 65 | 66 | Octave.displayName = "OctaveName"; 67 | 68 | export default Octave; 69 | -------------------------------------------------------------------------------- /src/components/messages/inport.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, memo, useCallback } from "react"; 2 | import classes from "./ports.module.css"; 3 | import { ActionIcon, Group, Menu, Table, Text, Tooltip } from "@mantine/core"; 4 | import { useDisclosure } from "@mantine/hooks"; 5 | import { MessagePortRecord } from "../../models/messageport"; 6 | import { MetaEditorModal } from "../meta/metaEditorModal"; 7 | import { MetadataScope } from "../../lib/constants"; 8 | import { IconElement } from "../elements/icon"; 9 | import { mdiCodeBraces, mdiDotsVertical, mdiRadioboxMarked, mdiSend } from "@mdi/js"; 10 | 11 | interface MessageInportEntryProps { 12 | port: MessagePortRecord; 13 | onSendBang: (port: MessagePortRecord) => void; 14 | onSendMessage: (port: MessagePortRecord) => void; 15 | onRestoreMetadata: (param: MessagePortRecord) => void; 16 | onSaveMetadata: (param: MessagePortRecord, meta: string) => void; 17 | } 18 | 19 | const MessageInportEntry: FunctionComponent = memo(function WrappedMessageInportEntry({ 20 | port, 21 | onSendBang, 22 | onSendMessage, 23 | onSaveMetadata, 24 | onRestoreMetadata 25 | }) { 26 | 27 | const [showMetaEditor, { toggle: toggleMetaEditor, close: closeMetaEditor }] = useDisclosure(); 28 | 29 | const onSaveMeta = useCallback((meta: string) => { 30 | onSaveMetadata(port, meta); 31 | }, [port, onSaveMetadata]); 32 | 33 | const onRestoreMeta = useCallback(() => { 34 | onRestoreMetadata(port); 35 | }, [port, onRestoreMetadata]); 36 | 37 | const sendMessage = useCallback(() => { 38 | onSendMessage(port); 39 | }, [onSendMessage, port]); 40 | 41 | const sendBang = useCallback(() => { 42 | onSendBang(port); 43 | }, [onSendBang, port]); 44 | 45 | return ( 46 | 47 | { 48 | showMetaEditor ? ( 49 | 57 | ) : null 58 | } 59 | 60 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | Inport 88 | } onClick={ toggleMetaEditor }> 89 | Edit Metadata 90 | 91 | 92 | 93 | 94 | 95 | 96 | ); 97 | }); 98 | 99 | export default MessageInportEntry; 100 | -------------------------------------------------------------------------------- /src/components/messages/inportList.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, memo } from "react"; 2 | import InportEntry from "./inport"; 3 | import classes from "./ports.module.css"; 4 | import { Seq } from "immutable"; 5 | import { MessagePortRecord } from "../../models/messageport"; 6 | import { Table } from "@mantine/core"; 7 | 8 | export type MessageInportListProps = { 9 | inports: Seq.Indexed; 10 | onSendBang: (port: MessagePortRecord) => void; 11 | onSendMessage: (port: MessagePortRecord) => void; 12 | onRestoreMetadata: (param: MessagePortRecord) => void; 13 | onSaveMetadata: (param: MessagePortRecord, meta: string) => void; 14 | } 15 | 16 | const MessageInportList: FunctionComponent = memo(function WrappedMessageInportList({ 17 | inports, 18 | onSendBang, 19 | onSendMessage, 20 | onRestoreMetadata, 21 | onSaveMetadata 22 | }) { 23 | 24 | return ( 25 | 26 | 27 | 28 | Inport 29 | 30 | 31 | 32 | 33 | { 34 | inports.map(port => ) 42 | } 43 | 44 |
45 | ); 46 | }); 47 | 48 | export default MessageInportList; 49 | -------------------------------------------------------------------------------- /src/components/messages/outport.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, memo, useCallback } from "react"; 2 | import classes from "./ports.module.css"; 3 | import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; 4 | import { useDisclosure } from "@mantine/hooks"; 5 | import { MessagePortRecord } from "../../models/messageport"; 6 | import { MetaEditorModal } from "../meta/metaEditorModal"; 7 | import { MetadataScope } from "../../lib/constants"; 8 | import { IconElement } from "../elements/icon"; 9 | import { mdiCodeBraces, mdiDotsVertical } from "@mdi/js"; 10 | 11 | interface MessageOutportEntryProps { 12 | port: MessagePortRecord; 13 | outputEnabled: boolean; 14 | onRestoreMetadata: (param: MessagePortRecord) => any; 15 | onSaveMetadata: (param: MessagePortRecord, meta: string) => any; 16 | } 17 | 18 | const MessageOutportEntry: FunctionComponent = memo(function WrappedMessageOutportEntry({ 19 | port, 20 | outputEnabled, 21 | onSaveMetadata, 22 | onRestoreMetadata 23 | }) { 24 | 25 | const [showMetaEditor, { toggle: toggleMetaEditor, close: closeMetaEditor }] = useDisclosure(); 26 | 27 | const onSaveMeta = useCallback((meta: string) => { 28 | onSaveMetadata(port, meta); 29 | }, [port, onSaveMetadata]); 30 | 31 | const onRestoreMeta = useCallback(() => { 32 | onRestoreMetadata(port); 33 | }, [port, onRestoreMetadata]); 34 | 35 | return ( 36 | 37 | { 38 | showMetaEditor ? ( 39 | 47 | ) : null 48 | } 49 | 50 | 55 | 56 | { 57 | outputEnabled ? ( 58 | 59 | 60 | { port.value === "" ? "No Value Received" : port.value } 61 | 62 | 63 | ) : null 64 | } 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | Outport 75 | } onClick={ toggleMetaEditor }> 76 | Edit Metadata 77 | 78 | 79 | 80 | 81 | 82 | 83 | ); 84 | }); 85 | 86 | export default MessageOutportEntry; 87 | -------------------------------------------------------------------------------- /src/components/messages/outportList.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, memo } from "react"; 2 | import classes from "./ports.module.css"; 3 | import MessageOutportEntry from "./outport"; 4 | import { Seq } from "immutable"; 5 | import { MessagePortRecord } from "../../models/messageport"; 6 | import { Table } from "@mantine/core"; 7 | 8 | export type MessageOutportListProps = { 9 | outports: Seq.Indexed; 10 | outputEnabled: boolean; 11 | onRestoreMetadata: (param: MessagePortRecord) => any; 12 | onSaveMetadata: (param: MessagePortRecord, meta: string) => any; 13 | } 14 | 15 | const MessageOutportList: FunctionComponent = memo(function WrappedMessageOutportList({ 16 | outports, 17 | outputEnabled, 18 | onRestoreMetadata, 19 | onSaveMetadata 20 | }) { 21 | 22 | return ( 23 | 24 | 25 | 26 | Outport 27 | { outputEnabled ? Value : null } 28 | 29 | 30 | 31 | 32 | { 33 | outports.map(port => ) 40 | } 41 | 42 |
43 | ); 44 | }); 45 | 46 | export default MessageOutportList; 47 | -------------------------------------------------------------------------------- /src/components/messages/ports.module.css: -------------------------------------------------------------------------------- 1 | .portItem { 2 | break-inside: avoid-column; 3 | page-break-inside: avoid; 4 | padding-bottom: var(--mantine-spacing-xl); 5 | } 6 | 7 | .portItemLabel { 8 | font-size: var(--mantine-font-size-sm); 9 | font-weight: 700; 10 | overflow: hidden; 11 | text-overflow: ellipsis; 12 | } 13 | 14 | .portList { 15 | } 16 | -------------------------------------------------------------------------------- /src/components/meta/metaEditorModal.module.css: -------------------------------------------------------------------------------- 1 | .textArea { 2 | font-family: var(--mantine-font-family-monospace); 3 | } 4 | -------------------------------------------------------------------------------- /src/components/midi/mappedParameterList.tsx: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap, Set as ImmuOrderedSet } from "immutable"; 2 | import { Table } from "@mantine/core"; 3 | import { FC, memo } from "react"; 4 | import classes from "./midi.module.css"; 5 | import { PatcherInstanceRecord } from "../../models/instance"; 6 | import { ParameterRecord } from "../../models/parameter"; 7 | import MIDIMappedParameter from "./mappedParameterItem"; 8 | import { TableHeaderCell } from "../elements/tableHeaderCell"; 9 | import { MIDIMappedParameterSortAttr, SortOrder } from "../../lib/constants"; 10 | 11 | export type MIDIMappedParameterListProps = { 12 | parameters: ImmuOrderedSet; 13 | patcherInstances: ImmuMap; 14 | onClearParameterMIDIMapping: (param: ParameterRecord) => void; 15 | onUpdateParameterMIDIMapping: (param: ParameterRecord, value: string) => void; 16 | onSort: (sortAttr: MIDIMappedParameterSortAttr) => void; 17 | sortAttr: MIDIMappedParameterSortAttr; 18 | sortOrder: SortOrder; 19 | }; 20 | 21 | const MIDIMappedParameterList: FC = memo(function WrappedMIDIMappedParameterList({ 22 | patcherInstances, 23 | parameters, 24 | onClearParameterMIDIMapping, 25 | onUpdateParameterMIDIMapping, 26 | onSort, 27 | sortAttr, 28 | sortOrder 29 | }) { 30 | 31 | return ( 32 | 33 | 34 | 35 | 42 | Source 43 | 44 | 51 | Parameter 52 | 53 | 60 | Device 61 | 62 | 63 | Value 64 | 65 | 66 | 67 | 68 | 69 | { 70 | parameters.map(p => { 71 | const pInstance = patcherInstances.get(p.instanceId); 72 | if (!pInstance) return null; 73 | return ( 74 | 81 | ); 82 | }) 83 | } 84 | 85 |
86 | ); 87 | }); 88 | 89 | export default MIDIMappedParameterList; 90 | -------------------------------------------------------------------------------- /src/components/midi/midi.module.css: -------------------------------------------------------------------------------- 1 | .midiWrappingsMap { 2 | height: 100%; 3 | gap: var(--mantine-spacing-md); 4 | } 5 | 6 | .midiSourceColumnHeader { 7 | width: 120px; 8 | 9 | @media (min-width: $mantine-breakpoint-md) { 10 | width: 160px; 11 | } 12 | } 13 | 14 | .midiSourceColumn { 15 | cursor: text; 16 | } 17 | 18 | .parameterValueColumnHeader, 19 | .parameterValueColumn { 20 | cursor: default; 21 | display: none; 22 | user-select: none; 23 | 24 | @media (min-width: $mantine-breakpoint-md) { 25 | display: table-cell; 26 | width: 150px; 27 | } 28 | } 29 | 30 | .patcherInstanceColumnHeader { 31 | width: 100px; 32 | 33 | @media (min-width: $mantine-breakpoint-md) { 34 | width: initial; 35 | } 36 | } 37 | 38 | .patcherInstanceColumn { 39 | cursor: default; 40 | overflow: hidden; 41 | text-overflow: ellipsis; 42 | user-select: none; 43 | white-space: nowrap; 44 | 45 | .patcherInstanceName { 46 | display: none; 47 | 48 | @media (min-width: $mantine-breakpoint-md) { 49 | display: inline-block; 50 | } 51 | } 52 | 53 | .patcherInstanceIndex { 54 | display: inline-block; 55 | 56 | @media (min-width: $mantine-breakpoint-md) { 57 | display: none; 58 | } 59 | } 60 | } 61 | 62 | .parameterNameColumnHeader { 63 | width: 120px; 64 | 65 | @media (min-width: $mantine-breakpoint-md) { 66 | width: initial; 67 | } 68 | } 69 | 70 | .parameterNameColumn { 71 | cursor: default; 72 | overflow: hidden; 73 | text-overflow: ellipsis; 74 | user-select: none; 75 | white-space: nowrap; 76 | } 77 | 78 | .actionColumnHeader { 79 | width: 10px; 80 | } 81 | .actionColumn {} 82 | -------------------------------------------------------------------------------- /src/components/nav/button.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, MouseEventHandler } from "react"; 2 | import { Text, Tooltip, UnstyledButton } from "@mantine/core"; 3 | import classes from "./nav.module.css"; 4 | import { IconElement } from "../elements/icon"; 5 | 6 | interface NavLinkProps { 7 | isActive: boolean; 8 | label: string; 9 | onClick: MouseEventHandler; 10 | icon: string; 11 | } 12 | 13 | export const NavButton: FunctionComponent = ({ isActive, icon, label, onClick }) => { 14 | 15 | return ( 16 | 17 | 22 | 23 | { label } 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/nav/index.tsx: -------------------------------------------------------------------------------- 1 | import { AppShell, Stack } from "@mantine/core"; 2 | import { FunctionComponent, memo, useCallback } from "react"; 3 | import classes from "./nav.module.css"; 4 | import { NavButton } from "./button"; 5 | import { useAppDispatch, useAppSelector } from "../../hooks/useAppDispatch"; 6 | import { toggleShowSettings } from "../../actions/settings"; 7 | import { RootStateType } from "../../lib/store"; 8 | import { getShowSettingsModal } from "../../selectors/settings"; 9 | import { ExternalNavLink, NavLink } from "./link"; 10 | import { useRouter } from "next/router"; 11 | import { getFirstPatcherNodeInstanceId } from "../../selectors/graph"; 12 | import { mdiChartSankeyVariant, mdiCog, mdiHelpCircle, mdiMidiPort, mdiVectorSquare, mdiTableEye, mdiDatabaseCog } from "@mdi/js"; 13 | 14 | const AppNav: FunctionComponent = memo(function WrappedNav() { 15 | 16 | const { pathname, query } = useRouter(); 17 | 18 | const dispatch = useAppDispatch(); 19 | const onToggleSettings = useCallback(() => dispatch(toggleShowSettings()), [dispatch]); 20 | const [ 21 | settingsAreShown, 22 | instanceId 23 | ] = useAppSelector((state: RootStateType) => [ 24 | getShowSettingsModal(state), 25 | getFirstPatcherNodeInstanceId(state) 26 | ]); 27 | 28 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 29 | const { id, ...restQuery } = query; // slurp out potential id query element for a clean query 30 | 31 | return ( 32 | 33 | 34 | 35 | 41 | 48 | 54 | 60 | 61 | 62 | 68 | 73 | 74 | 75 | 76 | 77 | ); 78 | 79 | }); 80 | 81 | export default AppNav; 82 | -------------------------------------------------------------------------------- /src/components/nav/link.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | import Link from "next/link"; 3 | import { Text, Tooltip, UnstyledButton } from "@mantine/core"; 4 | import classes from "./nav.module.css"; 5 | import { UrlObject } from "url"; 6 | import { IconElement } from "../elements/icon"; 7 | 8 | interface CommonNavLinkProps { 9 | disabled?: boolean; 10 | label: string; 11 | icon: string; 12 | } 13 | 14 | interface NavLinkProps extends CommonNavLinkProps { 15 | isActive: boolean; 16 | href: string | UrlObject; 17 | } 18 | 19 | export const NavLink: FunctionComponent = ({ disabled = false, href, icon, isActive, label }) => { 20 | return ( 21 | 22 | 29 | 30 | { label } 31 | 32 | 33 | ); 34 | }; 35 | 36 | interface ExternalNavLinkProps extends CommonNavLinkProps { 37 | href: string; 38 | } 39 | 40 | export const ExternalNavLink: FunctionComponent = ({ disabled = false, href, icon, label }) => { 41 | return ( 42 | 43 | 51 | 52 | { label } 53 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/nav/nav.module.css: -------------------------------------------------------------------------------- 1 | .navWrapper { 2 | height: 100%; 3 | justify-content: space-between; 4 | margin: var(--mantine-spacing-sm) 0; 5 | 6 | .navMenu { 7 | align-items: center; 8 | gap: var(--mantine-spacing-md); 9 | } 10 | } 11 | 12 | 13 | .navLink { 14 | 15 | align-items: center; 16 | color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); 17 | display: flex; 18 | height: rem(50px); 19 | padding: 0 var(--mantine-spacing-md); 20 | justify-content: flex-start; 21 | width: 100%; 22 | 23 | @media (min-width: $mantine-breakpoint-md) { 24 | border-radius: var(--mantine-radius-md); 25 | padding: 0; 26 | justify-content: center; 27 | width: rem(50px); 28 | } 29 | 30 | &:hover { 31 | background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)); 32 | } 33 | 34 | &[data-active="true"], 35 | &[data-active="true"]:hover { 36 | background-color: var(--mantine-color-blue-light); 37 | color: var(--mantine-color-blue-light-color); 38 | } 39 | 40 | &:disabled { 41 | cursor: not-allowed; 42 | opacity: 0.5; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/notifications/index.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useCallback } from "react"; 2 | import { useAppDispatch, useAppSelector } from "../../hooks/useAppDispatch"; 3 | import { getNotifications } from "../../selectors/notifications"; 4 | import { RootStateType } from "../../lib/store"; 5 | import { NotificationRecord } from "../../models/notification"; 6 | import NotificationItem from "./item"; 7 | import { deleteNotification } from "../../actions/notifications"; 8 | import classes from "./notifications.module.css"; 9 | 10 | const Notifications: FunctionComponent = () => { 11 | 12 | const notifications = useAppSelector((state: RootStateType) => getNotifications(state)); 13 | const dispatch = useAppDispatch(); 14 | const onDismiss = useCallback((notification: NotificationRecord) => { 15 | dispatch(deleteNotification(notification)); 16 | }, [dispatch]); 17 | 18 | return ( 19 |
20 | { 21 | notifications.valueSeq().map((notif: NotificationRecord) => ) 22 | } 23 |
24 | ); 25 | }; 26 | 27 | export default Notifications; 28 | -------------------------------------------------------------------------------- /src/components/notifications/item.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, memo, useEffect } from "react"; 2 | import { NotificationLevel, NotificationRecord, NotificationTimeout } from "../../models/notification"; 3 | import { Notification } from "@mantine/core"; 4 | 5 | export interface NotificationItemProps { 6 | notification: NotificationRecord; 7 | onDismiss: (notif: NotificationRecord) => any; 8 | } 9 | 10 | const notifColors: Record = { 11 | [NotificationLevel.error]: "red", 12 | [NotificationLevel.warn]: "yellow", 13 | [NotificationLevel.info]: "blue", 14 | [NotificationLevel.success]: "green" 15 | }; 16 | 17 | 18 | const NotificationItem: FunctionComponent = memo(function Notif({ 19 | onDismiss, 20 | notification 21 | }) { 22 | 23 | useEffect(() => { 24 | const dismissTO = setTimeout(() => onDismiss(notification), NotificationTimeout); 25 | return () => clearTimeout(dismissTO); 26 | }, [onDismiss, notification]); 27 | 28 | return ( 29 | onDismiss(notification) } 33 | > 34 | { notification.message } 35 | 36 | ); 37 | }); 38 | 39 | export default NotificationItem; 40 | -------------------------------------------------------------------------------- /src/components/notifications/notifications.module.css: -------------------------------------------------------------------------------- 1 | .notificationsList { 2 | bottom: 0; 3 | right: 0; 4 | max-width: 100vw; 5 | padding: var(--mantine-spacing-sm); 6 | position: fixed; 7 | touch-action: none; 8 | width: calc(400px + var(--mantine-spacing-sm)); 9 | z-index: 3; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/page/about.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | import { ActionIcon, Anchor, Group, Pill } from "@mantine/core"; 3 | import classes from "./page.module.css"; 4 | import { useThemeColorScheme } from "../../hooks/useTheme"; 5 | import { IconElement } from "../elements/icon"; 6 | import { mdiGithub } from "@mdi/js"; 7 | 8 | const AboutInfo: FunctionComponent = () => { 9 | 10 | const scheme = useThemeColorScheme(); 11 | 12 | return ( 13 |
14 |

15 | This is an open-source web app that lets you control a RNBO patch exported to the RNBO Runner which can be used to debug RNBO patches sent to your Raspberry Pi (or anywhere the RNBO Runner is active). 16 |
17 |
18 | The code for this app is available on Github and MIT licensed. 19 |
20 |
21 | Cycling '74 Logo 22 |
23 |
24 | 2024 Cycling '74 25 |

26 | 27 | 35 | 36 | 37 | v{ process.env.appVersion } 38 | 39 |
40 | ); 41 | }; 42 | 43 | export default AboutInfo; 44 | -------------------------------------------------------------------------------- /src/components/page/drawer.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | import classes from "./page.module.css"; 3 | 4 | export const DrawerSectionTitle = ({ children }: PropsWithChildren) => { 5 | return ( 6 |

{ children }

7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/page/page.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | margin-top: 0; 3 | margin-bottom: 0; 4 | height: 34px; 5 | font-size: var(--mantine-font-size-xl); 6 | line-height: 34px; 7 | } 8 | 9 | .sectionTitle { 10 | margin: var(--mantine-spacing-md) 0; 11 | } 12 | 13 | .aboutText { 14 | font-size: var(--mantine-font-size-sm); 15 | width: 100%; 16 | } 17 | 18 | .appStatus { 19 | align-items: center; 20 | display: flex; 21 | justify-content: center; 22 | flex-direction: column; 23 | gap: var(--mantine-spacing-md); 24 | height: 100%; 25 | 26 | &[data-status="connecting"], 27 | &[data-status="initializingstate"], 28 | &[data-status="reconnecting"], 29 | &[data-status="resyncingstate"] { 30 | color: var(--mantine-color-dimmed); 31 | } 32 | 33 | &[data-status="closed"] { 34 | color: var(--mantine-color-yellow-7); 35 | } 36 | 37 | &[data-status="error"] { 38 | color: var(--mantine-color-red-7); 39 | } 40 | 41 | h2 { 42 | font-weight: 900; 43 | } 44 | 45 | p { 46 | text-align: center; 47 | padding: 0 var(--mantine-spacing-md); 48 | } 49 | } 50 | 51 | 52 | .drawerSectionTitle { 53 | margin: var(--mantine-spacing-md) 0; 54 | font-size: var(--input-label-size, var(--mantine-font-size-sm)); 55 | font-weight: 700; 56 | } 57 | 58 | .transportTempoInput { 59 | cursor: pointer; 60 | user-select: none; 61 | } 62 | 63 | .transportTempoControl { 64 | display: flex; 65 | flex-direction: column; 66 | height: 100%; 67 | width: 100%; 68 | 69 | > button { 70 | align-items: center; 71 | appearance: none; 72 | background-color: transparent; 73 | border: none; 74 | border-radius: 0; 75 | border-inline-start: 0.0625rem solid var(--mantine-color-gray-4); 76 | color: var(--mantine-color-text); 77 | cursor: pointer; 78 | display: flex; 79 | flex: 0 0 50%; 80 | height: 50%; 81 | justify-content: center; 82 | margin: 0; 83 | padding: 0; 84 | } 85 | 86 | > button:last-of-type { 87 | border-top: 0.0625rem solid var(--mantine-color-gray-4); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/components/page/searchInput.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon, TextInput } from "@mantine/core"; 2 | import { useDisclosure } from "@mantine/hooks"; 3 | import { ChangeEvent, FC, KeyboardEvent, memo, useCallback, useEffect, useRef, useState } from "react"; 4 | import { IconElement } from "../elements/icon"; 5 | import { mdiClose, mdiMagnify } from "@mdi/js"; 6 | 7 | export type SearchInputProps = { 8 | onSearch: (query: string) => any; 9 | }; 10 | 11 | export const SearchInput: FC = memo(function WrappedSearchInput({ 12 | onSearch 13 | }) { 14 | 15 | const [showSearchInput, showSearchInputActions] = useDisclosure(); 16 | const [searchValue, setSearchValue] = useState(""); 17 | const searchInputRef = useRef(); 18 | 19 | const onChangeSearchValue = useCallback((e: ChangeEvent) => { 20 | setSearchValue(e.target.value); 21 | }, [setSearchValue]); 22 | 23 | const onBlur = useCallback(() => { 24 | if (!searchValue?.length) showSearchInputActions.close(); 25 | }, [searchValue, showSearchInputActions]); 26 | 27 | const onClear = useCallback(() => { 28 | setSearchValue(""); 29 | searchInputRef.current?.focus(); 30 | }, [setSearchValue]); 31 | 32 | const onKeyDown = useCallback((e: KeyboardEvent) => { 33 | if (e.key === "Escape") { 34 | if (searchValue.length) { 35 | setSearchValue(""); 36 | } else { 37 | searchInputRef.current?.blur(); 38 | } 39 | } 40 | }, [setSearchValue, searchInputRef, searchValue]); 41 | 42 | useEffect(() => { 43 | onSearch(searchValue); 44 | }, [searchValue, onSearch]); 45 | 46 | return ( 47 | showSearchInput || searchValue?.length ? ( 48 | } size="xs" 55 | rightSection={( 56 | 57 | 58 | 59 | )} 60 | value={ searchValue } 61 | /> 62 | ) : ( 63 | 64 | 65 | 66 | ) 67 | ); 68 | }); 69 | -------------------------------------------------------------------------------- /src/components/page/sectionTitle.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren } from "react"; 2 | import classes from "./page.module.css"; 3 | 4 | export type SectionTitleProps = PropsWithChildren; 5 | 6 | export const SectionTitle: FunctionComponent = ({ children }) => ( 7 |

{ children }

8 | ); 9 | -------------------------------------------------------------------------------- /src/components/page/settings.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren, useEffect } from "react"; 2 | import { useAppDispatch, useAppSelector } from "../../hooks/useAppDispatch"; 3 | import { loadAppSettings } from "../../actions/settings"; 4 | import { RootStateType } from "../../lib/store"; 5 | import { getSettingsAreLoaded } from "../../selectors/settings"; 6 | 7 | export const PageSettings: FunctionComponent = ({ children }) => { 8 | const settingsAreLoaded = useAppSelector((state: RootStateType) => getSettingsAreLoaded(state)); 9 | const dispatch = useAppDispatch(); 10 | 11 | useEffect(() => { 12 | dispatch(loadAppSettings()); 13 | }, [dispatch]); 14 | 15 | return settingsAreLoaded ? children : null; 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/page/statusWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren, ReactNode, memo, useCallback } from "react"; 2 | import { useAppSelector, useAppDispatch } from "../../hooks/useAppDispatch"; 3 | import { RootStateType } from "../../lib/store"; 4 | import { getAppStatus } from "../../selectors/appStatus"; 5 | import { AppStatus } from "../../lib/constants"; 6 | import { Anchor } from "@mantine/core"; 7 | import { showSettings } from "../../actions/settings"; 8 | 9 | import classes from "./page.module.css"; 10 | import { IconElement } from "../elements/icon"; 11 | import { mdiLanDisconnect, mdiLoading, mdiVolumeVariantOff } from "@mdi/js"; 12 | 13 | const AppStatusWrapper: FunctionComponent = memo(function WrappedStatusWrapper({ 14 | children 15 | }) { 16 | 17 | const status = useAppSelector((state: RootStateType) => getAppStatus(state)); 18 | const dispatch = useAppDispatch(); 19 | 20 | const openSettings = useCallback(() => { 21 | dispatch(showSettings()); 22 | }, [dispatch]); 23 | 24 | let icon: string; 25 | let title: string; 26 | let helpText: ReactNode | undefined; 27 | switch (status) { 28 | 29 | // Return nested children when all ready and good to go 30 | case AppStatus.Ready: 31 | return children; 32 | 33 | case AppStatus.Connecting: 34 | title = "Connecting"; 35 | icon = mdiLoading; 36 | break; 37 | case AppStatus.InitializingState: 38 | title = "Initializing State"; 39 | icon = mdiLoading; 40 | break; 41 | case AppStatus.Reconnecting: 42 | title = "Reconnecting"; 43 | icon = mdiLoading; 44 | break; 45 | case AppStatus.ResyncingState: 46 | title = "Synchronizing State"; 47 | icon = mdiLoading; 48 | break; 49 | case AppStatus.Closed: 50 | title = "Connection Lost"; 51 | icon = mdiLanDisconnect; 52 | break; 53 | case AppStatus.AudioOff: 54 | title = "Audio is Off"; 55 | icon = mdiVolumeVariantOff; 56 | helpText = ( 57 | <> 58 | Go to Settings to update audio configuration. 59 | 60 | ); 61 | break; 62 | case AppStatus.Error: 63 | title = "Failed to establish Connection"; 64 | icon = mdiLanDisconnect; 65 | helpText = ( 66 | <> 67 | Need help or further documentation? 68 |
69 | Please refer to the 70 |
71 | Raspberry Pi Target Documentation. 72 | 73 | ); 74 | break; 75 | default: { 76 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 77 | const c: never = status; 78 | } 79 | } 80 | 81 | 82 | return ( 83 |
84 | 85 |

{ title }

86 | { 87 | helpText ? ( 88 |

89 | { helpText } 90 |

91 | ) : null 92 | } 93 |
94 | ); 95 | }); 96 | 97 | export default AppStatusWrapper; 98 | -------------------------------------------------------------------------------- /src/components/page/theme.tsx: -------------------------------------------------------------------------------- 1 | import { MantineProvider } from "@mantine/core"; 2 | import { rnboTheme } from "../../lib/theme"; 3 | import { FunctionComponent, PropsWithChildren } from "react"; 4 | import { useAppSelector } from "../../hooks/useAppDispatch"; 5 | import { RootStateType } from "../../lib/store"; 6 | import { getAppSetting } from "../../selectors/settings"; 7 | import { AppSetting } from "../../models/settings"; 8 | 9 | export type PageThemeProps = PropsWithChildren & { 10 | fontFamily: string; 11 | fontFamilyMonospace: string; 12 | }; 13 | 14 | export const PageTheme: FunctionComponent = ({ 15 | children, 16 | fontFamily, 17 | fontFamilyMonospace 18 | }) => { 19 | 20 | const colorScheme = useAppSelector((state: RootStateType) => getAppSetting(state, AppSetting.colorScheme).value as "light" | "dark"); 21 | return ( 22 | 23 | { children } 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/page/title.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren } from "react"; 2 | import classes from "./page.module.css"; 3 | import { Title } from "@mantine/core"; 4 | 5 | export type PageTitleProps = PropsWithChildren<{ className?: string; }>; 6 | 7 | export const PageTitle: FunctionComponent = ({ className, children }) => ( 8 | { children } 9 | ); 10 | -------------------------------------------------------------------------------- /src/components/parameter/list.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, useEffect, useRef, useState } from "react"; 2 | import { parameterBoxHeight, ParameterItemProps } from "./item"; 3 | import classes from "./parameters.module.css"; 4 | import { useViewportSize } from "@mantine/hooks"; 5 | import { Breakpoints } from "../../lib/constants"; 6 | import { clamp, genericMemo } from "../../lib/util"; 7 | import { ParameterRecord } from "../../models/parameter"; 8 | import { OrderedSet } from "immutable"; 9 | import { useThemeColorScheme } from "../../hooks/useTheme"; 10 | 11 | export type ParameterListProps = { 12 | onRestoreMetadata: (param: ParameterRecord) => any; 13 | onSaveMetadata: (param: ParameterRecord, meta: string) => any; 14 | onSetNormalizedValue: (parameter: ParameterRecord, nValue: number) => any; 15 | parameters: OrderedSet; 16 | extraParameterProps: ExtraProps; 17 | ParamComponentType: ComponentType; 18 | } 19 | 20 | const ParameterList = genericMemo(function WrappedParameterList({ 21 | onRestoreMetadata, 22 | onSaveMetadata, 23 | onSetNormalizedValue, 24 | parameters, 25 | extraParameterProps, 26 | ParamComponentType 27 | }: ParameterListProps) { 28 | 29 | const ref = useRef(); 30 | const [topCoord, setTopCoord] = useState(0); 31 | const { height, width } = useViewportSize(); 32 | const colorScheme = useThemeColorScheme(); 33 | 34 | const paramOverflow = Math.ceil((parameters.size * parameterBoxHeight) / (height - topCoord)); 35 | 36 | let columnCount = 1; 37 | if (width >= Breakpoints.xl) { 38 | columnCount = clamp(paramOverflow, 1, 4); 39 | } else if (width >= Breakpoints.lg) { 40 | columnCount = clamp(paramOverflow, 1, 3); 41 | } else if (width >= Breakpoints.sm) { // treat SM and MD equal 42 | columnCount = clamp(paramOverflow, 1, 2); 43 | } 44 | 45 | useEffect(() => { 46 | setTopCoord(ref.current?.getBoundingClientRect().top); 47 | }, [ref, height]); 48 | 49 | let index = -1; 50 | 51 | return ( 52 |
53 | { 54 | ref.current === null ? null : parameters.map(p => 55 | 64 | ) 65 | } 66 |
67 | ); 68 | }); 69 | 70 | export default ParameterList; 71 | -------------------------------------------------------------------------------- /src/components/parameter/parameters.module.css: -------------------------------------------------------------------------------- 1 | .parameterWrap { 2 | background-color: var(--parameter-bg-color); 3 | break-inside: avoid-column; 4 | flex: 1; 5 | outline-color: transparent; 6 | outline-style: solid; 7 | outline-width: 3px; 8 | page-break-inside: avoid; 9 | padding: 2px 2px var(--mantine-spacing-xl) 2px; 10 | margin-bottom: 6px; 11 | } 12 | 13 | .paramWithMIDIMapping { 14 | 15 | &[data-instance-mapping="true"] { 16 | cursor: pointer; 17 | background-color: var(--parameter-mapping-bg-color); 18 | 19 | &:hover { 20 | background-color: var(--parameter-active-mapping-bg-color); 21 | } 22 | 23 | > * { 24 | pointer-events: none; 25 | } 26 | } 27 | 28 | &[data-param-mappping="true"] { 29 | background-color: var(--parameter-active-mapping-bg-color); 30 | outline-color: var(--parameter-active-mapping-outline); 31 | } 32 | } 33 | 34 | .parameterList { 35 | column-gap: var(--mantine-spacing-xl); 36 | min-height: 100%; 37 | 38 | &[data-color-scheme="light"] { 39 | --parameter-bg-color: transparent; 40 | --parameter-mapping-bg-color: var(--mantine-color-violet-1); 41 | --parameter-active-mapping-bg-color: var(--mantine-color-violet-2); 42 | --parameter-active-mapping-outline: var(--mantine-color-violet-5); 43 | } 44 | 45 | &[data-color-scheme="dark"] { 46 | --parameter-bg-color: transparent; 47 | --parameter-mapping-bg-color: var(--mantine-color-violet-9); 48 | --parameter-active-mapping-bg-color: var(--mantine-color-violet-4); 49 | --parameter-active-mapping-outline: var(--mantine-color-violet-2); 50 | } 51 | } 52 | 53 | .parameterItemLabel { 54 | font-size: var(--mantine-font-size-sm); 55 | font-weight: 700; 56 | overflow: hidden; 57 | text-overflow: ellipsis; 58 | user-select: none; 59 | } 60 | 61 | .markWrapper { 62 | 63 | &:first-child { 64 | .markLabel { 65 | transform: translate(calc(-50% + var(--slider-size)), calc(var(--mantine-spacing-xs)/2)); 66 | } 67 | } 68 | 69 | &:last-child { 70 | .markLabel { 71 | transform: translate(calc(-100% + var(--slider-size)), calc(var(--mantine-spacing-xs)/2)); 72 | } 73 | } 74 | } 75 | 76 | .parameterItemMIDIIndicator { 77 | --indicator-size: 8px; 78 | --indicator-translate-x: 150%!important; 79 | --indicator-translate-y: -40%!important; 80 | --indicator-color: var(--mantine-color-violet-4); 81 | } 82 | -------------------------------------------------------------------------------- /src/components/parameter/withMidiActions.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, FC, memo, useCallback } from "react"; 2 | import { ParameterRecord } from "../../models/parameter"; 3 | import { ParameterItemProps, ParameterMenuEntryType } from "./item"; 4 | import classes from "./parameters.module.css"; 5 | import { mdiEraser } from "@mdi/js"; 6 | 7 | export type ParameterMIDIActionsProps = { 8 | instanceIsMIDIMapping: boolean; 9 | onActivateMIDIMapping: (param: ParameterRecord) => any; 10 | onClearMidiMapping: (param: ParameterRecord) => void; 11 | }; 12 | 13 | export function withParameterMIDIActions( 14 | WrappedComponent: ComponentType 15 | ) { 16 | 17 | const compDisplayName = WrappedComponent.displayName || WrappedComponent.name || "Component"; 18 | 19 | const ParameterWithMIDIActions: FC = memo(({ 20 | instanceIsMIDIMapping, 21 | onActivateMIDIMapping, 22 | onClearMidiMapping, 23 | menuItems = [], 24 | param, 25 | ...props 26 | }) => { 27 | 28 | const onClearMidiMap = useCallback(() => { 29 | onClearMidiMapping(param); 30 | }, [param, onClearMidiMapping]); 31 | 32 | const onTriggerActivateMIDIMapping = useCallback(() => { 33 | if (param.waitingForMidiMapping) return; 34 | onActivateMIDIMapping(param); 35 | }, [param, onActivateMIDIMapping]); 36 | 37 | return ( 38 | 52 | ); 53 | }); 54 | 55 | ParameterWithMIDIActions.displayName = `withParameterMIDIMapping(${compDisplayName})`; 56 | 57 | return ParameterWithMIDIActions; 58 | } 59 | -------------------------------------------------------------------------------- /src/components/parameter/withSetViewActions.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, FC, memo, useCallback } from "react"; 2 | import { ParameterRecord } from "../../models/parameter"; 3 | import { ParameterItemProps, ParameterMenuEntryType } from "./item"; 4 | import { mdiArrowDown, mdiArrowUp, mdiMinusBox } from "@mdi/js"; 5 | 6 | export type ParameterSetViewActionsProps = { 7 | listSize: number; 8 | onDecreaseIndex: (param: ParameterRecord) => void; 9 | onIncreaseIndex: (param: ParameterRecord) => void; 10 | onRemoveFromSetView: (param: ParameterRecord) => void; 11 | }; 12 | 13 | export function withParameterSetViewActions( 14 | WrappedComponent: ComponentType 15 | ) { 16 | 17 | const compDisplayName = WrappedComponent.displayName || WrappedComponent.name || "Component"; 18 | 19 | const ParameterWithSetViewActions: FC = memo(({ 20 | menuItems = [], 21 | index, 22 | listSize, 23 | onDecreaseIndex, 24 | onIncreaseIndex, 25 | onRemoveFromSetView, 26 | param, 27 | ...props 28 | }) => { 29 | 30 | const onTriggerRemoveFromSetView = useCallback(() => { 31 | onRemoveFromSetView(param); 32 | }, [param, onRemoveFromSetView]); 33 | 34 | const onTriggerMoveUp = useCallback(() => { 35 | onDecreaseIndex(param); 36 | }, [param, onDecreaseIndex]); 37 | 38 | const onTriggerMoveDown = useCallback(() => { 39 | onIncreaseIndex(param); 40 | }, [param, onIncreaseIndex]); 41 | 42 | return ( 43 | 56 | ); 57 | }); 58 | 59 | ParameterWithSetViewActions.displayName = `withParameterSetViewMapping(${compDisplayName})`; 60 | 61 | return ParameterWithSetViewActions; 62 | } 63 | -------------------------------------------------------------------------------- /src/components/patchers/item.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, MouseEvent, memo, useCallback, useState } from "react"; 2 | import { ActionIcon, Menu, Table } from "@mantine/core"; 3 | import { PatcherExportRecord } from "../../models/patcher"; 4 | import classes from "./patchers.module.css"; 5 | import { IconElement } from "../elements/icon"; 6 | import { mdiDotsVertical, mdiPencil, mdiTrashCan } from "@mdi/js"; 7 | import { EditableTableTextCell } from "../elements/editableTableCell"; 8 | 9 | export type PatcherItemProps = { 10 | patcher: PatcherExportRecord; 11 | onDelete: (p: PatcherExportRecord) => any; 12 | onRename: (p: PatcherExportRecord, name: string) => any; 13 | }; 14 | 15 | export const PatcherItem: FunctionComponent = memo(function WrappedPatcherItem({ 16 | patcher, 17 | onDelete, 18 | onRename 19 | }: PatcherItemProps) { 20 | 21 | const [isEditingName, setIsEditingName] = useState(false); 22 | 23 | const onDeletePatcher = useCallback((_e: MouseEvent) => { 24 | onDelete(patcher); 25 | }, [onDelete, patcher]); 26 | 27 | const onUpdateName = useCallback((name: string): void => { 28 | onRename(patcher, name); 29 | }, [patcher, onRename]); 30 | 31 | const onTriggerRenamePatcher = useCallback(() => { 32 | setIsEditingName(true); 33 | }, [setIsEditingName]); 34 | 35 | return ( 36 | 37 | 44 | 45 | { patcher.createdAt.fromNow() } 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Patcher 56 | } onClick={ onTriggerRenamePatcher } >Rename 57 | 58 | } onClick={ onDeletePatcher } >Delete 59 | 60 | 61 | 62 | 63 | ); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/patchers/managementView.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Anchor, Group, Stack, Table } from "@mantine/core"; 2 | import { FC, memo, useCallback, useState } from "react"; 3 | import { PatcherSortAttr, SortOrder } from "../../lib/constants"; 4 | import { useAppDispatch, useAppSelector } from "../../hooks/useAppDispatch"; 5 | import { getHasPatcherExports, getSortedPatcherExports } from "../../selectors/patchers"; 6 | import { RootStateType } from "../../lib/store"; 7 | import { destroyPatcherOnRemote, renamePatcherOnRemote } from "../../actions/patchers"; 8 | import { PatcherExportRecord } from "../../models/patcher"; 9 | import { TableHeaderCell } from "../elements/tableHeaderCell"; 10 | import { PatcherItem } from "./item"; 11 | import { SearchInput } from "../page/searchInput"; 12 | 13 | export const PatcherManagementView: FC = memo(function WrappedPatcherView() { 14 | 15 | const [sortOrder, setSortOrder] = useState(SortOrder.Asc); 16 | const [sortAttr, setSortAttr] = useState(PatcherSortAttr.Name); 17 | const [searchValue, setSearchValue] = useState(""); 18 | const dispatch = useAppDispatch(); 19 | 20 | const [ 21 | hasPatchers, 22 | patchers 23 | ] = useAppSelector((state: RootStateType) => [ 24 | getHasPatcherExports(state), 25 | getSortedPatcherExports(state, sortAttr, sortOrder, searchValue) 26 | ]); 27 | 28 | const onSort = useCallback((attr: PatcherSortAttr): void => { 29 | if (attr === sortAttr) return void setSortOrder(sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc); 30 | 31 | setSortAttr(attr); 32 | setSortOrder(SortOrder.Asc); 33 | 34 | }, [sortOrder, sortAttr, setSortOrder, setSortAttr]); 35 | 36 | const onDeletePatcher = useCallback((patcher: PatcherExportRecord) => { 37 | dispatch(destroyPatcherOnRemote(patcher)); 38 | }, [dispatch]); 39 | 40 | const onRenamePatcher = useCallback((patcher: PatcherExportRecord, newName: string) => { 41 | dispatch(renamePatcherOnRemote(patcher, newName)); 42 | }, [dispatch]); 43 | 44 | if (!hasPatchers) { 45 | return ( 46 | 47 | Please export a RNBO patcher to load on the runner. 48 | 49 | ); 50 | } 51 | 52 | return ( 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | Name 62 | 63 | 64 | Exported 65 | 66 | 67 | 68 | 69 | 70 | { 71 | patchers.map(p => ( 72 | 78 | )) 79 | } 80 | 81 |
82 |
83 | ); 84 | }); 85 | -------------------------------------------------------------------------------- /src/components/patchers/patchers.module.css: -------------------------------------------------------------------------------- 1 | .patcherItem { 2 | --patcher-name-height: 36px; 3 | } 4 | 5 | .patcherItemName { 6 | display: flex; 7 | align-items: center; 8 | height: var(--patcher-name-height); 9 | } 10 | 11 | .patcherItemNameInputWrap { 12 | flex: 1; 13 | } 14 | 15 | .patcherItemNameInput { 16 | border: none; 17 | height: var(--patcher-name-height); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/presets/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Divider, Drawer, Flex, Group, Stack } from "@mantine/core"; 2 | import { FunctionComponent, memo, useCallback } from "react"; 3 | import { PresetItem } from "./item"; 4 | import { PresetRecord } from "../../models/preset"; 5 | import { Seq } from "immutable"; 6 | import { IconElement } from "../elements/icon"; 7 | import { mdiCamera, mdiPlus } from "@mdi/js"; 8 | import styles from "./presets.module.css"; 9 | 10 | export type PresetDrawerProps = { 11 | open: boolean; 12 | onClose: () => any; 13 | onDeletePreset: (preset: PresetRecord) => any; 14 | onLoadPreset: (preset: PresetRecord) => any; 15 | onCreatePreset: () => any; 16 | onOverwritePreset: (preset: PresetRecord) => any; 17 | onRenamePreset: (preset: PresetRecord, name: string) => any; 18 | onSetInitialPreset?: (set: PresetRecord) => any; 19 | presets: Seq.Indexed; 20 | }; 21 | 22 | const PresetDrawer: FunctionComponent = memo(function WrappedPresetDrawer({ 23 | open, 24 | onClose, 25 | onCreatePreset, 26 | onDeletePreset, 27 | onLoadPreset, 28 | onOverwritePreset, 29 | onRenamePreset, 30 | onSetInitialPreset, 31 | presets 32 | }: PresetDrawerProps) { 33 | 34 | const onTriggerDeletePreset = useCallback((preset: PresetRecord) => { 35 | onDeletePreset(preset); 36 | }, [onDeletePreset]); 37 | 38 | const validateUniquePresetName = useCallback((name: string): boolean => { 39 | return !presets.find(p => p.name === name); 40 | }, [presets]); 41 | 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Presets 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | { 61 | presets.map(preset => ( 62 | 72 | )) 73 | } 74 | 75 | 76 | 77 | 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | }); 87 | 88 | export default PresetDrawer; 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/components/presets/presets.module.css: -------------------------------------------------------------------------------- 1 | .presetButton { 2 | flex: 1; 3 | } 4 | 5 | .presetNameForm { 6 | border: 1px solid var(--mantine-primary-color-filled); 7 | border-radius: var(--mantine-radius-default); 8 | padding: 0 calc(0.5 * var(--mantine-spacing-xs)) 0 var(--mantine-spacing-md); 9 | } 10 | 11 | .presetNameInput { 12 | border: none; 13 | flex: 1; 14 | 15 | input { 16 | border: none; 17 | } 18 | } 19 | 20 | .presetListWrapper { 21 | flex: 1; 22 | padding-top: var(--mantine-spacing-xs); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/resources/tabs.module.css: -------------------------------------------------------------------------------- 1 | .wrapper {} 2 | 3 | .tabContent { 4 | margin-top: var(--mantine-spacing-sm); 5 | } 6 | 7 | .tabLabel { 8 | display: none; 9 | } 10 | 11 | @media (min-width: $mantine-breakpoint-xs) { 12 | .tabLabel { 13 | display: block; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/resources/tabs.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useState } from "react"; 2 | import { ResourceTab } from "../../lib/constants"; 3 | import { Tabs, Text } from "@mantine/core"; 4 | import { IconElement } from "../elements/icon"; 5 | import { DataFileManagementView } from "../datafile/managementView"; 6 | import { PatcherManagementView } from "../patchers/managementView"; 7 | import { mdiFileExport, mdiFileMusic, mdiGroup } from "@mdi/js"; 8 | import styles from "./tabs.module.css"; 9 | import SetManagementView from "../sets/managementView"; 10 | 11 | const tabs = [ 12 | { icon: mdiGroup, value: ResourceTab.Graphs, label: "Graphs" }, 13 | { icon: mdiFileExport, value: ResourceTab.Patchers, label: "Patchers" }, 14 | { icon: mdiFileMusic, value: ResourceTab.AudioFiles, label: "Audio Files" } 15 | ]; 16 | 17 | export const ResourceTabs = memo(function WrappedResourceTabs() { 18 | 19 | const [activeTab, setActiveTab] = useState(ResourceTab.Graphs); 20 | const onChangeTab = useCallback((v: ResourceTab) => setActiveTab(v), [setActiveTab]); 21 | 22 | return ( 23 |
24 | 25 | 26 | { 27 | tabs.map(info => ( 28 | } > 29 | { info.label } 30 | 31 | )) 32 | } 33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 |
46 |
47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /src/components/setViews/drawer.tsx: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap }from "immutable"; 2 | import { Button, Divider, Drawer, Flex, Group, Stack } from "@mantine/core"; 3 | import { FC, useCallback } from "react"; 4 | import { IconElement } from "../elements/icon"; 5 | import { mdiPlus, mdiTableEye } from "@mdi/js"; 6 | import { GraphSetViewRecord } from "../../models/set"; 7 | import { GraphSetViewItem } from "./item"; 8 | import styles from "./setviews.module.css"; 9 | 10 | export type CreateSetViewModalProps = { 11 | onClose: () => void; 12 | open: boolean; 13 | 14 | onCreateSetView: () => void; 15 | onDeleteSetView: (setView: GraphSetViewRecord) => void; 16 | onLoadSetView: (setView: GraphSetViewRecord) => void; 17 | onRenameSetView: (setView: GraphSetViewRecord, name: string) => void; 18 | 19 | currentSetView?: GraphSetViewRecord; 20 | setViews: ImmuMap; 21 | }; 22 | 23 | const SetViewDrawer: FC = ({ 24 | onClose, 25 | open, 26 | onCreateSetView, 27 | onDeleteSetView, 28 | onLoadSetView, 29 | onRenameSetView, 30 | 31 | currentSetView, 32 | setViews 33 | }) => { 34 | 35 | const onTriggerDeleteSetView = useCallback((setView: GraphSetViewRecord) => { 36 | onDeleteSetView(setView); 37 | }, [onDeleteSetView]); 38 | 39 | const validateUniqueSetViewName = useCallback((name: string): boolean => { 40 | return !setViews.find(v => v.name === name); 41 | }, [setViews]); 42 | 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Parameter Views 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | { 62 | setViews.valueSeq().map(v => ( 63 | 72 | )) 73 | } 74 | 75 | 76 | 77 | 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | }; 87 | 88 | export default SetViewDrawer; 89 | -------------------------------------------------------------------------------- /src/components/setViews/parameterList.tsx: -------------------------------------------------------------------------------- 1 | import { OrderedSet as ImmuOrderedSet } from "immutable"; 2 | import { ComponentType, FC, memo } from "react"; 3 | import ParameterList, { ParameterListProps } from "../parameter/list"; 4 | import { ParameterRecord } from "../../models/parameter"; 5 | import ParameterItem from "../parameter/item"; 6 | import { ParameterSetViewActionsProps, withParameterSetViewActions } from "../parameter/withSetViewActions"; 7 | import { ParameterMIDIActionsProps, withParameterMIDIActions } from "../parameter/withMidiActions"; 8 | 9 | const ParameterComponentType = withParameterMIDIActions(withParameterSetViewActions(ParameterItem)); 10 | const ParameterListComponent: ComponentType> = ParameterList; 11 | 12 | export type SetViewParameterListProps = { 13 | parameters: ImmuOrderedSet; 14 | waitingForMidiMapping: boolean; 15 | onClearParamMIDIMapping: (param: ParameterRecord) => void; 16 | onActivateParamMIDIMapping: (param: ParameterRecord) => void; 17 | onRestoreParamMetadata: (param: ParameterRecord) => void; 18 | onSaveParamMetadata: (param: ParameterRecord, meta: string) => void; 19 | onDecreaseParamIndex: (param: ParameterRecord) => void; 20 | onIncreaseParamIndex: (param: ParameterRecord) => void; 21 | onRemoveParamFromSetView: (param: ParameterRecord) => void; 22 | onSetNormalizedParamValue: (param: ParameterRecord, value: number) => void; 23 | } 24 | 25 | export const SetViewParameterList: FC = memo(function WrappedSetViewParameterList({ 26 | parameters, 27 | waitingForMidiMapping, 28 | onActivateParamMIDIMapping, 29 | onClearParamMIDIMapping, 30 | onRestoreParamMetadata, 31 | onSaveParamMetadata, 32 | onDecreaseParamIndex, 33 | onIncreaseParamIndex, 34 | onRemoveParamFromSetView, 35 | onSetNormalizedParamValue 36 | }) { 37 | 38 | return ( 39 |
40 | 56 | 57 |
58 | ); 59 | }); 60 | -------------------------------------------------------------------------------- /src/components/setViews/setviews.module.css: -------------------------------------------------------------------------------- 1 | .setViewButton { 2 | flex: 1; 3 | } 4 | 5 | .setViewNameForm { 6 | border: 1px solid var(--mantine-primary-color-filled); 7 | border-radius: var(--mantine-radius-default); 8 | padding: 0 calc(0.5 * var(--mantine-spacing-xs)) 0 var(--mantine-spacing-md); 9 | } 10 | 11 | .setViewNameInput { 12 | border: none; 13 | flex: 1; 14 | 15 | input { 16 | border: none; 17 | } 18 | } 19 | 20 | .viewListWrapper { 21 | flex: 1; 22 | padding-top: var(--mantine-spacing-xs); 23 | } 24 | 25 | .instanceParameterList { 26 | border: 1px solid var(--mantine-color-default-border); 27 | border-radius: var(--mantine-radius-sm); 28 | margin: 0; 29 | padding: 0; 30 | list-style: none; 31 | } 32 | 33 | .instanceParameterEntry { 34 | border-bottom: 1px solid var(--mantine-color-default-border); 35 | font-size: var(--mantine-font-size-sm); 36 | padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); 37 | 38 | &:last-child { 39 | border-bottom: none; 40 | } 41 | } 42 | 43 | .instanceParameterEntryName { 44 | flex: 1; 45 | overflow: hidden; 46 | text-overflow: ellipsis; 47 | white-space: nowrap; 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/components/sets/item.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, MouseEvent, memo, useCallback, useState } from "react"; 2 | import { GraphSetRecord } from "../../models/set"; 3 | import { ActionIcon, Group, Menu, Table, Tooltip } from "@mantine/core"; 4 | import { IconElement } from "../elements/icon"; 5 | import { mdiArrowUpBoldBoxOutline, mdiDotsVertical, mdiFileReplaceOutline, mdiPencil, mdiStarBoxOutline, mdiTrashCan } from "@mdi/js"; 6 | import { EditableTableTextCell } from "../elements/editableTableCell"; 7 | 8 | export type GraphSetItemProps = { 9 | set: GraphSetRecord; 10 | isCurrent: boolean; 11 | isInitial: boolean; 12 | onDelete: (set: GraphSetRecord) => any; 13 | onLoad: (set: GraphSetRecord) => any; 14 | onRename: (set: GraphSetRecord, name: string) => any; 15 | onOverwrite: (set: GraphSetRecord) => any; 16 | }; 17 | 18 | export const GraphSetItem: FunctionComponent = memo(function WrappedGraphSet({ 19 | set, 20 | isCurrent, 21 | isInitial, 22 | onDelete, 23 | onLoad, 24 | onRename, 25 | onOverwrite 26 | }: GraphSetItemProps) { 27 | 28 | const [isEditingName, setIsEditingName] = useState(false); 29 | 30 | const onLoadSet = useCallback((e: MouseEvent) => { 31 | onLoad(set); 32 | }, [onLoad, set]); 33 | 34 | const onDeleteSet = useCallback((e: MouseEvent) => { 35 | onDelete(set); 36 | }, [onDelete, set]); 37 | 38 | const onOverwriteSet = useCallback((_e: MouseEvent) => { 39 | onOverwrite(set); 40 | }, [onOverwrite, set]); 41 | 42 | const onUpdateName = useCallback((name: string) => { 43 | onRename(set, name); 44 | }, [onRename, set]); 45 | 46 | const onTriggerRenameSet = useCallback(() => { 47 | setIsEditingName(true); 48 | }, [setIsEditingName]); 49 | 50 | return ( 51 | 52 | 53 | 54 | { 55 | isCurrent ? ( 56 | 57 | 58 | 59 | ) : null 60 | } 61 | { 62 | isInitial ? ( 63 | 64 | 65 | 66 | ) : null 67 | } 68 | 69 | 70 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | Graph 86 | } onClick={ onLoadSet }> 87 | Load 88 | 89 | 90 | } onClick={ onTriggerRenameSet } > 91 | Rename 92 | 93 | } onClick={ onOverwriteSet } disabled={ isCurrent } > 94 | Overwrite 95 | 96 | 97 | } onClick={ onDeleteSet } > 98 | Delete 99 | 100 | 101 | 102 | 103 | 104 | ); 105 | }); 106 | -------------------------------------------------------------------------------- /src/components/sets/managementView.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon, Group, Stack, Table, Tooltip } from "@mantine/core"; 2 | import { FC, memo, useCallback, useState } from "react"; 3 | import { GraphSetItem } from "./item"; 4 | import { GraphSetRecord } from "../../models/set"; 5 | import { TableHeaderCell } from "../elements/tableHeaderCell"; 6 | import { useAppDispatch, useAppSelector } from "../../hooks/useAppDispatch"; 7 | import { RootStateType } from "../../lib/store"; 8 | import { SortOrder } from "../../lib/constants"; 9 | import { getCurrentGraphSetId, getGraphSetsSortedByName, getInitialGraphSet } from "../../selectors/sets"; 10 | import { SearchInput } from "../page/searchInput"; 11 | import { destroyGraphSetOnRemote, loadGraphSetOnRemote, overwriteGraphSetOnRemote, renameGraphSetOnRemote, triggerStartupGraphSetDialog } from "../../actions/sets"; 12 | import { IconElement } from "../elements/icon"; 13 | import { mdiStarCog } from "@mdi/js"; 14 | 15 | const SetManagementView: FC = memo(function WrappedSetsView() { 16 | 17 | const [sortOrder, setSortOrder] = useState(SortOrder.Asc); 18 | const [searchValue, setSearchValue] = useState(""); 19 | const dispatch = useAppDispatch(); 20 | 21 | const [ 22 | sets, 23 | currentSetId, 24 | initialGraphSet 25 | ] = useAppSelector((state: RootStateType) => [ 26 | getGraphSetsSortedByName(state, sortOrder, searchValue), 27 | getCurrentGraphSetId(state), 28 | getInitialGraphSet(state) 29 | ]); 30 | 31 | const onToggleSort = useCallback(() => { 32 | setSortOrder(sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc); 33 | }, [setSortOrder, sortOrder]); 34 | 35 | const onDeleteSet = useCallback((set: GraphSetRecord) => { 36 | dispatch(destroyGraphSetOnRemote(set)); 37 | }, [dispatch]); 38 | 39 | const onRenameSet = useCallback((set: GraphSetRecord, name: string): void => { 40 | dispatch(renameGraphSetOnRemote(set, name)); 41 | }, [dispatch]); 42 | 43 | const onLoadSet = useCallback((set: GraphSetRecord) => { 44 | dispatch(loadGraphSetOnRemote(set)); 45 | }, [dispatch]); 46 | 47 | const onOverwriteSet = useCallback((set: GraphSetRecord) => { 48 | dispatch(overwriteGraphSetOnRemote(set)); 49 | }, [dispatch]); 50 | 51 | const onConfigureStartupSet = useCallback(() => { 52 | dispatch(triggerStartupGraphSetDialog()); 53 | }, [dispatch]); 54 | 55 | return ( 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | Name 71 | 72 | 73 | 74 | 75 | 76 | { 77 | sets.map(set => ( 78 | 88 | )) 89 | } 90 | 91 |
92 |
93 | ); 94 | }); 95 | 96 | export default SetManagementView; 97 | -------------------------------------------------------------------------------- /src/components/sets/sets.module.css: -------------------------------------------------------------------------------- 1 | .setItemButton { 2 | flex: 1; 3 | } 4 | 5 | .setItemNameInput { 6 | flex: 1; 7 | } 8 | 9 | .setListWrapper { 10 | flex: 1; 11 | padding-top: var(--mantine-spacing-xs); 12 | } 13 | 14 | .newSetButton { 15 | flex: 1; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/settings/list.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "@mantine/core"; 2 | import { BaseSettingsItemProps, SettingActionProps, SettingsAction, SettingsItem } from "./item"; 3 | import { FunctionComponent, memo } from "react"; 4 | 5 | export type SettingsListProps = { 6 | actions?: Array; 7 | items: Array; 8 | }; 9 | 10 | const SettingsList: FunctionComponent = memo(function SettingsListWrapper({ actions, items }: SettingsListProps) { 11 | 12 | return ( 13 | 14 | { 15 | items.map(item => ) 16 | } 17 | { 18 | actions?.map(action => ) 19 | } 20 | 21 | ); 22 | }); 23 | 24 | export default SettingsList; 25 | -------------------------------------------------------------------------------- /src/components/settings/settings.module.css: -------------------------------------------------------------------------------- 1 | .tabDescription { 2 | font-size: var(--mantine-font-size-xs); 3 | font-style: italic; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | 9 | .item { 10 | 11 | display: flex; 12 | flex-direction: column; 13 | 14 | padding: 0 0 var(--mantine-spacing-xs) 0; 15 | border-bottom: 1px solid var(--mantine-color-default-border); 16 | gap: var(--mantine-spacing-xs); 17 | 18 | &:last-child { 19 | border-bottom: none; 20 | } 21 | 22 | @media (min-width: $mantine-breakpoint-sm) { 23 | flex-direction: row; 24 | } 25 | } 26 | 27 | .itemInputWrap { 28 | display: block; 29 | width: 100%; 30 | 31 | 32 | > * { 33 | max-width: 100%; 34 | } 35 | 36 | @media (min-width: $mantine-breakpoint-sm) { 37 | display: flex; 38 | align-items: center; 39 | justify-content: flex-end; 40 | min-width: 100px; 41 | max-width: 250px; 42 | height: 100%; 43 | padding: var(--mantine-spacing-xs) 0; 44 | } 45 | } 46 | 47 | .itemTitleWrap { 48 | flex: 1; 49 | } 50 | 51 | .itemTitle { 52 | font-weight: 700; 53 | font-size: var(--mantine-font-size-sm); 54 | } 55 | 56 | .itemDescription { 57 | color: var(--mantine-color-dimmed); 58 | font-size: var(--mantine-font-size-xs); 59 | } 60 | -------------------------------------------------------------------------------- /src/hooks/useAppDispatch.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector, TypedUseSelectorHook } from "react-redux"; 2 | import { AppDispatch, RootStateType } from "../lib/store"; 3 | 4 | export const useAppDispatch = () => useDispatch(); 5 | export const useAppSelector: TypedUseSelectorHook = useSelector; 6 | -------------------------------------------------------------------------------- /src/hooks/useIsMobileDevice.ts: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from "@mantine/hooks"; 2 | 3 | export const useIsMobileDevice = () => useMediaQuery("(max-width: 62em)"); 4 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useMantineTheme } from "@mantine/core"; 2 | import { RNBOTheme } from "../lib/theme"; 3 | import { useAppSelector } from "./useAppDispatch"; 4 | import { RootStateType } from "../lib/store"; 5 | import { getAppSetting } from "../selectors/settings"; 6 | import { AppSetting } from "../models/settings"; 7 | 8 | export const useTheme = (): RNBOTheme => useMantineTheme() as RNBOTheme; 9 | 10 | export const useThemeColorScheme = (): "light" | "dark" => { 11 | return useAppSelector((state: RootStateType) => getAppSetting(state, AppSetting.colorScheme).value as "light" | "dark"); 12 | }; 13 | -------------------------------------------------------------------------------- /src/hooks/useTitle.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | interface TitleProps { 4 | mobile: boolean; 5 | } 6 | 7 | export default function useTitle({ mobile }: TitleProps) { 8 | const router = useRouter(); 9 | const currentPath = router.pathname; 10 | switch (currentPath) { 11 | case "/parameters": 12 | return "PARAMETERS"; 13 | case "/io": 14 | return mobile ? "IO" : "INPORTS AND OUTPORTS"; 15 | case "/midi": 16 | return mobile ? "MIDI" : "MIDI CONTROL"; 17 | default: 18 | return ""; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/layouts/app.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | height: 100dvh; 3 | overflow: hidden; 4 | } 5 | 6 | .wrapper { 7 | display: flex; 8 | flex-direction: column; 9 | gap: var(--mantine-spacing-md); 10 | padding: var(--mantine-spacing-md); 11 | height: 100%; 12 | overflow-y: scroll; 13 | overflow-x: hidden; 14 | 15 | & > :nth-child(2) { 16 | padding-top: var(--mantine-spacing-md); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/layouts/app.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, useEffect } from "react"; 2 | import { AppShell } from "@mantine/core"; 3 | import { useTheme } from "../hooks/useTheme"; 4 | import { Header } from "../components/header"; 5 | import classes from "./app.module.css"; 6 | import { useDisclosure } from "@mantine/hooks"; 7 | import AppNav from "../components/nav"; 8 | import { useRouter } from "next/router"; 9 | import AppStatusWrapper from "../components/page/statusWrapper"; 10 | 11 | export const AppLayout: React.FC = ({ children }) => { 12 | 13 | const { other } = useTheme(); 14 | const { events } = useRouter(); 15 | const [navOpen, { close: closeNav, toggle: toggleNav }] = useDisclosure(); 16 | 17 | useEffect(() => { 18 | events.on("routeChangeStart", closeNav); 19 | return () => events.off("routeChangeStart", closeNav); 20 | }, [events, closeNav]); 21 | 22 | return ( 23 | 27 |
28 | 29 | 30 | 31 |
32 | { children } 33 |
34 |
35 |
36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/lib/editorUtils.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from "reactflow"; 2 | import { Map as ImmuMap } from "immutable"; 3 | import Dagre from "@dagrejs/dagre"; 4 | import { RootStateType } from "./store"; 5 | import { GraphConnectionRecord, GraphNodeRecord, GraphPortRecord, NodePositionRecord } from "../models/graph"; 6 | import { defaultNodeGap } from "./constants"; 7 | import { EditorNodeDesc } from "../selectors/graph"; 8 | 9 | export const isValidConnection = (connection: Connection, ports: RootStateType["graph"]["ports"]): { 10 | sourcePort: GraphPortRecord; 11 | sinkPort: GraphPortRecord; 12 | } => { 13 | 14 | if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) { 15 | throw new Error(`Invalid Connection Description (${connection.source}:${connection.sourceHandle} => ${connection.target}:${connection.targetHandle})`); 16 | } 17 | 18 | // Valid Connection? 19 | const sourcePort = ports.get(connection.sourceHandle); 20 | if (!sourcePort) throw new Error(`Invalid Source (${connection.sourceHandle})`); 21 | 22 | const sinkPort = ports.get(connection.targetHandle); 23 | if (!sinkPort) throw new Error(`Invalid Source (${connection.targetHandle})`); 24 | 25 | if (sourcePort.type !== sinkPort.type) throw new Error(`Invalid Connection Type (Can't connect ${sourcePort.type} to ${sinkPort.type})`); 26 | if (sourcePort.direction === sinkPort.direction) throw new Error("Invalid Connection"); 27 | 28 | return { sourcePort, sinkPort }; 29 | }; 30 | 31 | 32 | export const calculateLayout = ( 33 | ports: ImmuMap, 34 | connections: ImmuMap, 35 | nodeInfo: ImmuMap 36 | ) => { 37 | 38 | const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); 39 | g.setGraph({ align: "UL", ranksep: defaultNodeGap, nodesep: defaultNodeGap, rankdir: "LR" }); 40 | 41 | connections.valueSeq().forEach(conn => { 42 | const srcId = ports.get(conn.sourcePortId)?.nodeId; 43 | const sinkId = ports.get(conn.sinkPortId)?.nodeId; 44 | if (srcId === undefined || sinkId === undefined) return; 45 | 46 | g.setEdge(srcId, sinkId); 47 | }); 48 | 49 | nodeInfo.valueSeq().forEach(({ node, height, width }) => g.setNode(node.id, { height, width })); 50 | 51 | Dagre.layout(g); 52 | 53 | const positions: NodePositionRecord[] = nodeInfo.valueSeq().toArray().map(({ node, width, height }): NodePositionRecord => { 54 | // Shift from dagre anchor (center center) to reactflow anchor (top left) 55 | const newPos = g.node(node.id); 56 | return NodePositionRecord.fromDescription(node.id, newPos.x - (width / 2), newPos.y - (height / 2)); 57 | }); 58 | 59 | return positions; 60 | }; 61 | -------------------------------------------------------------------------------- /src/lib/meta.ts: -------------------------------------------------------------------------------- 1 | import { NodePositionRecord } from "../models/graph"; 2 | import { OSCQuerySetMeta } from "./types"; 3 | 4 | export const serializeSetMeta = ( 5 | nodes: NodePositionRecord[] 6 | ): string => { 7 | const result: OSCQuerySetMeta = { nodes: {} }; 8 | for (const node of nodes) { 9 | result.nodes[node.id] = { position: { x: node.x, y: node.y } }; 10 | } 11 | return JSON.stringify(result); 12 | }; 13 | 14 | export const deserializeSetMeta = (metaString: string): OSCQuerySetMeta => { 15 | // I don't know why we're getting strings of length 1 but, they can't be valid JSON anyway 16 | if (metaString && metaString.length > 1) { 17 | try { 18 | const meta = JSON.parse(metaString) as OSCQuerySetMeta; 19 | return meta; 20 | } catch (err) { 21 | console.warn(`Failed to parse Set Meta when creating new node: ${err.message}`); 22 | } 23 | } 24 | return { nodes: {} }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/settings.ts: -------------------------------------------------------------------------------- 1 | import { OrderedMap as ImmuOrderedMap } from "immutable"; 2 | import { AppSetting } from "../models/settings"; 3 | import { AppSettingRecord, AppSettingValue, appSettingDefaults } from "../models/settings"; 4 | 5 | const LS_KEY = "@@rnbo_runner_settings@@"; 6 | const LS_VERSION = 3; 7 | 8 | export const loadSettingsState = (): ImmuOrderedMap => { 9 | let storedData: Partial> = {}; 10 | try { 11 | if (typeof window == "undefined") throw new Error("Not in Browser"); 12 | 13 | const data = window.localStorage?.getItem(LS_KEY); 14 | if (!data?.length) throw new Error("No Saved Settings found"); 15 | 16 | const stored = JSON.parse(data); 17 | if (stored?.version !== LS_VERSION || !stored?.data) throw new Error("Settings version not compatible"); 18 | storedData = stored.data; 19 | } catch (err) { 20 | storedData = {}; 21 | } 22 | return ImmuOrderedMap().withMutations(map => { 23 | for (const id of Object.values(AppSetting)) { 24 | map.set(id, new AppSettingRecord({ 25 | id, 26 | ...appSettingDefaults[id], 27 | value: storedData[id] || appSettingDefaults[id].value 28 | })); 29 | } 30 | }); 31 | }; 32 | 33 | export const storeSettingsState = (settings: ImmuOrderedMap) => { 34 | if (typeof window == "undefined") return; 35 | try { 36 | const data = Object.values(AppSetting).reduce((result, id) => { 37 | if (settings.has(id)) { 38 | result[id] = settings.get(id).value; 39 | } 40 | return result; 41 | }, {} as Record); 42 | 43 | window.localStorage?.setItem(LS_KEY, JSON.stringify({ version: LS_VERSION, data })); 44 | } catch (err) { 45 | // no-op 46 | } 47 | 48 | }; 49 | 50 | export const purgeSettingsState = (): void => { 51 | if (typeof window == "undefined") return; 52 | try { 53 | window.localStorage?.removeItem(LS_KEY); 54 | } catch (err) { 55 | // no-op 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/lib/store.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, applyMiddleware, compose, createStore } from "redux"; 2 | import thunk, { ThunkAction, ThunkDispatch } from "redux-thunk"; 3 | import { rootReducer } from "../reducers"; 4 | import { Dispatch } from "react"; 5 | 6 | type ComposeType = typeof compose; 7 | declare global { 8 | interface Window { 9 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: ComposeType; 10 | } 11 | } 12 | 13 | const composeEnhancers = typeof window !== "undefined" && process.env.NODE_ENV !== "production" ? ( window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ as ComposeType ) || compose : compose; 14 | 15 | export interface ActionBase extends AnyAction { 16 | type: string; 17 | error?: Error; 18 | payload: Record; 19 | } 20 | 21 | export const store = createStore( 22 | rootReducer, 23 | undefined, // reducers define their own initial state 24 | composeEnhancers(applyMiddleware(thunk)) 25 | ); 26 | 27 | export type RootStateType = ReturnType; 28 | export type AppDispatch = Dispatch & ThunkDispatch; 29 | 30 | export type AppThunk = ThunkAction< 31 | ReturnType, 32 | RootStateType, 33 | undefined, 34 | ActionBase 35 | >; 36 | -------------------------------------------------------------------------------- /src/lib/theme.ts: -------------------------------------------------------------------------------- 1 | import { Button, Drawer, MantineTheme, MantineThemeOther, Menu, Modal, Tabs, TextInput, Tooltip, createTheme, rem } from "@mantine/core"; 2 | 3 | export type CustomThemeProps = { 4 | headerHeight: number | string; 5 | navWidth: number | string; 6 | }; 7 | 8 | export const customProps: MantineThemeOther & CustomThemeProps = { 9 | headerHeight: rem(50), 10 | navWidth: rem(60) 11 | }; 12 | 13 | export const rnboTheme = createTheme({ 14 | cursorType: "pointer", 15 | headings: { 16 | sizes: { 17 | h1: { 18 | fontWeight: "900" 19 | }, 20 | h2: { 21 | fontWeight: "700" 22 | }, 23 | h3: { 24 | fontWeight: "700" 25 | }, 26 | h4: { 27 | fontWeight: "700" 28 | }, 29 | h5: { 30 | fontWeight: "700" 31 | }, 32 | h6: { 33 | fontWeight: "700" 34 | } 35 | } 36 | }, 37 | components: { 38 | Button: Button.extend({ 39 | styles: { 40 | label: { fontWeight: "700" } 41 | } 42 | }), 43 | Drawer: Drawer.extend({ 44 | styles: { 45 | title: { fontWeight: "700" } 46 | } 47 | }), 48 | Menu: Menu.extend({ 49 | defaultProps: { 50 | clickOutsideEvents: ["mousedown", "pointerdown", "touchstart"] 51 | } 52 | }), 53 | Modal: Modal.extend({ 54 | styles: { 55 | title: { fontWeight: "700" } 56 | } 57 | }), 58 | Tabs: Tabs.extend({ 59 | styles: { 60 | tabLabel: { fontWeight: "700" } 61 | } 62 | }), 63 | TextInput: TextInput.extend({ 64 | styles: { 65 | label: { fontWeight: "700" } 66 | } 67 | }), 68 | Tooltip: Tooltip.extend({ 69 | defaultProps: { 70 | openDelay: 500 71 | } 72 | }) 73 | }, 74 | other: customProps 75 | }); 76 | 77 | export type RNBOTheme = MantineTheme & { other: typeof customProps }; 78 | -------------------------------------------------------------------------------- /src/models/datafile.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmuRecord } from "immutable"; 2 | import { basename } from "path"; 3 | import { DataRefRecord } from "./dataref"; 4 | 5 | export type DateFileRecordProps = { 6 | fileName: string; 7 | path: string; 8 | }; 9 | 10 | export class DataFileRecord extends ImmuRecord({ 11 | fileName: "", 12 | path: "" 13 | }) { 14 | 15 | public get id(): string { 16 | return this.fileName; 17 | } 18 | 19 | public matchesQuery(query: string): boolean { 20 | return !query.length || this.fileName.toLowerCase().includes(query); 21 | } 22 | 23 | public static fromDescription(path: string): DataFileRecord { 24 | return new DataFileRecord({ 25 | fileName: basename(path).trim(), 26 | path 27 | }); 28 | } 29 | } 30 | 31 | export type PendingDataFileRecordProps = { 32 | fileName: string; 33 | dataRefId: DataRefRecord["id"]; 34 | }; 35 | 36 | export class PendingDataFileRecord extends ImmuRecord({ 37 | fileName: "", 38 | dataRefId: "" 39 | }) { 40 | 41 | public get id(): string { 42 | return this.fileName; 43 | } 44 | 45 | public static fromDescription(path: string, dataRefId: DataRefRecord["id"]): PendingDataFileRecord { 46 | return new PendingDataFileRecord({ 47 | fileName: basename(path).trim(), 48 | dataRefId 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/models/dataref.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmuRecord } from "immutable"; 2 | import { DataRefMetaJsonMap, OSCQueryRNBOInstanceDataRefs } from "../lib/types"; 3 | import { parseMetaJSONString } from "../lib/util"; 4 | import { PatcherInstanceRecord } from "./instance"; 5 | 6 | export type DataRefRecordProps = { 7 | instanceId: PatcherInstanceRecord["id"]; 8 | meta: DataRefMetaJsonMap; 9 | metaString: string; 10 | name: string; 11 | path: string; 12 | value: string; 13 | }; 14 | 15 | export class DataRefRecord extends ImmuRecord({ 16 | instanceId: "", 17 | meta: {}, 18 | metaString: "", 19 | name: "", 20 | path: "", 21 | value: "" 22 | }) { 23 | 24 | public get id(): string { 25 | return this.path; 26 | } 27 | 28 | public get canBeCaptured(): boolean { 29 | return true; 30 | } 31 | 32 | public matchesQuery(query: string): boolean { 33 | return !query.length || this.name.toLowerCase().includes(query); 34 | } 35 | 36 | public setValue(v: string) : DataRefRecord { 37 | return this.set("value", v); 38 | } 39 | 40 | public setMeta(value: string): DataRefRecord { 41 | let parsed: DataRefMetaJsonMap = {}; 42 | try { 43 | parsed = parseMetaJSONString(value); 44 | } catch { 45 | // ignore 46 | } 47 | 48 | return this.withMutations(p => { 49 | return p 50 | .set("metaString", value) 51 | .set("meta", parsed); 52 | }); 53 | } 54 | 55 | public static fromDescription( 56 | instanceId: PatcherInstanceRecord["id"], 57 | datarefDesc: OSCQueryRNBOInstanceDataRefs 58 | ): DataRefRecord[] { 59 | const refs: DataRefRecord[] = []; 60 | for (const [name, desc] of Object.entries(datarefDesc?.CONTENTS || {})) { 61 | refs.push( 62 | new DataRefRecord({ 63 | instanceId, 64 | name, 65 | path: desc.FULL_PATH, 66 | value: desc.VALUE || "" 67 | }).setMeta(desc.CONTENTS?.meta?.VALUE || "") 68 | ); 69 | } 70 | return refs; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/models/instance.ts: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap, Record as ImmuRecord, OrderedMap as ImmuOrderedMap } from "immutable"; 2 | import { PresetRecord } from "./preset"; 3 | import { OSCQueryRNBOInstance, OSCQueryRNBOInstancePresetEntries } from "../lib/types"; 4 | 5 | export type PatcherInstanceProps = { 6 | id: string; 7 | patcher: string; 8 | path: string; 9 | 10 | alias: string; 11 | jackName: string; 12 | 13 | presetInitial: string; 14 | presetLatest: string; 15 | 16 | presets: ImmuOrderedMap; 17 | 18 | waitingForMidiMapping: boolean; 19 | } 20 | 21 | const collator = new Intl.Collator("en-US"); 22 | 23 | function sortPresets(left: PresetRecord, right: PresetRecord) : number { 24 | if (left.initial) { 25 | return -1; 26 | } 27 | if (right.initial) { 28 | return 1; 29 | } 30 | return collator.compare(left.name, right.name); 31 | } 32 | 33 | export class PatcherInstanceRecord extends ImmuRecord({ 34 | 35 | alias: "", // user defined name overwrite 36 | id: "0", 37 | jackName: "", // runner assigned name 38 | patcher: "", 39 | path: "", 40 | presetInitial: "", 41 | presetLatest: "", 42 | presets: ImmuMap(), 43 | 44 | waitingForMidiMapping: false 45 | 46 | }) { 47 | 48 | public get displayName(): string { 49 | return this.alias || this.jackName; 50 | } 51 | 52 | public setWaitingForMapping(value: boolean): PatcherInstanceRecord { 53 | return this.set("waitingForMidiMapping", value); 54 | } 55 | 56 | public setAlias(alias: string): PatcherInstanceRecord { 57 | return this.set("alias", alias); 58 | } 59 | 60 | public static presetsFromDescription(entries: OSCQueryRNBOInstancePresetEntries, latest: string, initial: string): ImmuMap { 61 | return ImmuOrderedMap().withMutations((map) => { 62 | for (const name of entries.VALUE) { 63 | const pr = PresetRecord.fromDescription(name, name === initial, name === latest); 64 | map.set(pr.id, pr); 65 | } 66 | }).sort(sortPresets); 67 | } 68 | 69 | public setPresetLatest(latest: string): PatcherInstanceRecord { 70 | return this.set("presets", this.presets.map(preset => preset.setLatest(preset.name === latest))).set("presetLatest", latest); 71 | } 72 | 73 | public setPresetInitial(initial: string): PatcherInstanceRecord { 74 | return this.set("presetInitial", initial).set("presets", this.presets.map(preset => preset.setInitial(preset.name === initial)).sort(sortPresets)); 75 | } 76 | 77 | public updatePresets(entries: OSCQueryRNBOInstancePresetEntries): PatcherInstanceRecord { 78 | return this.set("presets", PatcherInstanceRecord.presetsFromDescription(entries, this.presetLatest, this.presetInitial)); 79 | } 80 | 81 | public static fromDescription(desc: OSCQueryRNBOInstance): PatcherInstanceRecord { 82 | 83 | const initialPreset: string = desc.CONTENTS.presets.CONTENTS?.initial?.VALUE || ""; 84 | const latestPreset: string = desc.CONTENTS.presets.CONTENTS?.loaded?.VALUE || ""; 85 | 86 | return new PatcherInstanceRecord({ 87 | id: desc.FULL_PATH.split("/").pop(), 88 | alias: desc.CONTENTS.config.CONTENTS.name_alias?.VALUE || "", 89 | jackName: desc.CONTENTS.jack.CONTENTS.name.VALUE, 90 | patcher: desc.CONTENTS.name.VALUE, 91 | path: desc.FULL_PATH, 92 | presets: this.presetsFromDescription(desc.CONTENTS.presets.CONTENTS.entries, latestPreset, initialPreset) 93 | }); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/models/messageport.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmuRecord } from "immutable"; 2 | import { JsonMap, OSCQueryRNBOInstanceMessageInfo, OSCQueryRNBOInstanceMessages, OSCQueryRNBOInstanceMessageValue } from "../lib/types"; 3 | import { PatcherInstanceRecord } from "./instance"; 4 | import { parseMetaJSONString } from "../lib/util"; 5 | 6 | export type MessagePortRecordProps = { 7 | instanceId: string; 8 | tag: string; 9 | meta: JsonMap; 10 | metaString: string; 11 | value: string; 12 | path: string; 13 | }; 14 | 15 | 16 | export class MessagePortRecord extends ImmuRecord({ 17 | instanceId: "0", 18 | tag: "", 19 | meta: {}, 20 | metaString: "", 21 | value: "", 22 | path: "" 23 | }) { 24 | 25 | private static messagesArrayFromDescription(instanceId: PatcherInstanceRecord["id"], desc: OSCQueryRNBOInstanceMessageInfo, name: string): MessagePortRecord[] { 26 | if (typeof desc.VALUE !== "undefined") { 27 | return [ 28 | new MessagePortRecord({ 29 | instanceId, 30 | tag: name, 31 | path: (desc as OSCQueryRNBOInstanceMessageValue).FULL_PATH 32 | }).setMeta((desc as OSCQueryRNBOInstanceMessageValue).CONTENTS?.meta?.VALUE || "") 33 | ]; 34 | } 35 | 36 | const result: MessagePortRecord[] = []; 37 | for (const [subKey, subDesc] of Object.entries(desc.CONTENTS)) { 38 | const subPrefix = name ? `${name}/${subKey}` : subKey; 39 | result.push(...this.messagesArrayFromDescription(instanceId, subDesc, subPrefix)); 40 | } 41 | return result; 42 | } 43 | 44 | public static fromDescription(instanceId: PatcherInstanceRecord["id"], messagesDesc?: OSCQueryRNBOInstanceMessages): MessagePortRecord[] { 45 | const ports: MessagePortRecord[] = []; 46 | for (const [name, desc] of Object.entries(messagesDesc?.CONTENTS || {})) { 47 | ports.push(...this.messagesArrayFromDescription(instanceId, desc, name)); 48 | } 49 | return ports; 50 | } 51 | 52 | public get id(): string { 53 | return this.path; 54 | } 55 | 56 | public get name(): string { 57 | return this.tag; 58 | } 59 | 60 | public matchesQuery(query: string): boolean { 61 | return !query.length || this.tag.toLowerCase().includes(query); 62 | } 63 | 64 | public setMeta(value: string): MessagePortRecord { 65 | // detect midi mapping 66 | let parsed: JsonMap = {}; 67 | try { 68 | // detection simply looks for a 'midi' entry in the meta 69 | parsed = parseMetaJSONString(value); 70 | } catch { 71 | // ignore 72 | } 73 | return this.withMutations(p => { 74 | return p 75 | .set("meta", parsed) 76 | .set("metaString", value); 77 | }); 78 | } 79 | 80 | public setValue(value: string): MessagePortRecord { 81 | return this.set("value", value); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/models/notification.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from "uuid"; 2 | import { Record as ImmuRecord } from "immutable"; 3 | 4 | export const NotificationTimeout = 5000; 5 | 6 | export enum NotificationLevel { 7 | error = -1, 8 | warn = 0, 9 | info = 1, 10 | success = 2, 11 | } 12 | 13 | export class NotificationRecord extends ImmuRecord({ 14 | 15 | id: "", 16 | level: NotificationLevel.info, 17 | message: "", 18 | title: "" 19 | 20 | }) { 21 | 22 | static create({ level, message, title }: { level: NotificationLevel; message: string; title: string }): NotificationRecord { 23 | 24 | return new NotificationRecord({ 25 | id: v4(), 26 | level, 27 | message: message || "", 28 | title 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/models/patcher.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmuRecord } from "immutable"; 2 | import { OSCQueryRNBOPatcher } from "../lib/types"; 3 | import { dayjs } from "../lib/util"; 4 | import { Dayjs } from "dayjs"; 5 | 6 | export const UNLOAD_PATCHER_NAME = ""; 7 | 8 | export type PatcherExportRecordProps = { 9 | createdAt: Dayjs; 10 | name: string; 11 | io: [number, number, number, number]; 12 | } 13 | 14 | export class PatcherExportRecord extends ImmuRecord({ 15 | 16 | createdAt: dayjs(), 17 | name: "", 18 | io: [0, 0, 0, 0] 19 | 20 | }) { 21 | 22 | static fromDescription(name: string, desc: OSCQueryRNBOPatcher): PatcherExportRecord { 23 | return new PatcherExportRecord({ 24 | createdAt: dayjs(desc.CONTENTS.created_at?.VALUE, "YYYY-MM-DD HH:mm:ss"), 25 | name, 26 | io: desc.CONTENTS.io.VALUE 27 | }); 28 | } 29 | 30 | get audioInCount(): number { 31 | return this.io[0] || 0; 32 | } 33 | 34 | get audioOutCount(): number { 35 | return this.io[1] || 0; 36 | } 37 | 38 | get midiInCount(): number { 39 | return this.io[2] || 0; 40 | } 41 | 42 | get midiOutCount(): number { 43 | return this.io[3] || 0; 44 | } 45 | 46 | get id(): string { 47 | return this.name; 48 | } 49 | 50 | public matchesQuery(query: string): boolean { 51 | return !query.length || this.name.toLowerCase().includes(query); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/models/preset.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmuRecord } from "immutable"; 2 | 3 | export type PresetRecordProps = { 4 | name: string; 5 | initial: boolean; 6 | latest: boolean; 7 | }; 8 | 9 | export class PresetRecord extends ImmuRecord({ 10 | 11 | name: "", 12 | initial: false, 13 | latest: false 14 | 15 | }) { 16 | 17 | public static fromDescription(name: string, initial: boolean = false, latest: boolean = false): PresetRecord { 18 | return new PresetRecord({ name, initial, latest }); 19 | } 20 | 21 | get id(): string { 22 | return this.name; 23 | } 24 | 25 | public setLatest(latest: boolean) : PresetRecord { 26 | return this.set("latest", latest); 27 | } 28 | 29 | public setInitial(initial: boolean) : PresetRecord { 30 | return this.set("initial", initial); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/models/runnerInfo.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmuRecord} from "immutable"; 2 | import { OSCQueryBooleanValue, OSCQueryFloatValue, OSCQueryIntValue, OSCQueryStringValue, OSCQueryValueType, RunnerInfoKey } from "../lib/types"; 3 | import { SystemInfoKey } from "../lib/constants"; 4 | 5 | export type RunnerInfoRecordProps = { 6 | id: RunnerInfoKey; 7 | description: string; 8 | oscValue: number | string | boolean | null; 9 | oscType: OSCQueryValueType.String | OSCQueryValueType.True | OSCQueryValueType.False | OSCQueryValueType.Int32 | OSCQueryValueType.Float32 | OSCQueryValueType.Double64; 10 | path: string; 11 | } 12 | 13 | type RunnerInfoOSCDescType = OSCQueryStringValue | OSCQueryIntValue | OSCQueryBooleanValue | OSCQueryFloatValue; 14 | 15 | export class RunnerInfoRecord extends ImmuRecord({ 16 | id: SystemInfoKey.Version, 17 | description: "", 18 | oscValue: 0, 19 | oscType: OSCQueryValueType.Int32, 20 | path: "" 21 | 22 | }) { 23 | 24 | public setValue(value: RunnerInfoRecordProps["oscValue"]): RunnerInfoRecord { 25 | return this.set("oscValue", value); 26 | } 27 | 28 | public static fromDescription(id: RunnerInfoKey, desc: RunnerInfoOSCDescType): RunnerInfoRecord { 29 | return new RunnerInfoRecord({ 30 | id, 31 | description: desc.DESCRIPTION || "", 32 | oscType: desc.TYPE, 33 | oscValue: desc.VALUE, 34 | path: desc.FULL_PATH || "" 35 | }); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/models/set.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmuRecord, List as ImmuList, OrderedSet as ImmuOrderedSet } from "immutable"; 2 | import { OSCQueryRNBOSetView } from "../lib/types"; 3 | import { PatcherInstanceRecord } from "./instance"; 4 | import { ParameterRecord } from "./parameter"; 5 | 6 | export type GraphSetRecordProps = { 7 | name: string; 8 | }; 9 | 10 | export class GraphSetRecord extends ImmuRecord({ 11 | name: "" 12 | }) { 13 | 14 | public static fromDescription(name: string): GraphSetRecord { 15 | return new GraphSetRecord({ name }); 16 | } 17 | 18 | get id(): string { 19 | return this.name; 20 | } 21 | 22 | public matchesQuery(query: string): boolean { 23 | return !query.length || this.name.toLowerCase().includes(query); 24 | } 25 | } 26 | 27 | export type GraphSetViewParameterEntry = { 28 | instanceId: PatcherInstanceRecord["id"]; 29 | paramName: ParameterRecord["name"]; 30 | } 31 | 32 | export type GraphSetViewRecordProps = { 33 | id: number; 34 | name: string; 35 | params: ImmuList; 36 | paramIds: ImmuOrderedSet; 37 | }; 38 | 39 | export class GraphSetViewRecord extends ImmuRecord({ 40 | id: 0, 41 | name: "", 42 | params: ImmuList(), 43 | paramIds: ImmuOrderedSet() 44 | }) { 45 | 46 | private static getParamListFromDesc(params: string[]): ImmuList { 47 | return ImmuList().withMutations(list => { 48 | for (const p of params) { 49 | const [instanceId, paramName] = p.split(":"); 50 | if (instanceId?.length && paramName?.length) { 51 | list.push({ instanceId, paramName }); 52 | } 53 | } 54 | }); 55 | } 56 | 57 | public static getEmptyRecord(id: string): GraphSetViewRecord { 58 | return new GraphSetViewRecord({ 59 | id: parseInt(id, 10), 60 | name: "", 61 | params: ImmuList(), 62 | paramIds: ImmuOrderedSet() 63 | }); 64 | } 65 | 66 | public static fromDescription(id: string, desc: OSCQueryRNBOSetView): GraphSetViewRecord { 67 | return new GraphSetViewRecord({ 68 | id: parseInt(id, 10), 69 | name: desc.CONTENTS.name.VALUE, 70 | paramIds: ImmuOrderedSet(desc.CONTENTS.params.VALUE || []), 71 | params: this.getParamListFromDesc(desc.CONTENTS.params.VALUE) 72 | }); 73 | } 74 | 75 | public get instanceIds(): ImmuOrderedSet { 76 | return ImmuOrderedSet() 77 | .withMutations(set => { 78 | this.params.forEach(p => set.add(p.instanceId)); 79 | }); 80 | } 81 | 82 | public matchesQuery(query: string): boolean { 83 | return !query.length || this.name.toLowerCase().includes(query); 84 | } 85 | 86 | public setName(name: string): GraphSetViewRecord { 87 | return this.set("name", name); 88 | } 89 | 90 | public setParams(params: string[]): GraphSetViewRecord { 91 | const list = GraphSetViewRecord.getParamListFromDesc(params); 92 | return this 93 | .set("params", list) 94 | .set("paramIds", ImmuOrderedSet(params)); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/models/settings.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmuRecord } from "immutable"; 2 | import { ParameterSortAttr, SettingsTab, SortOrder } from "../lib/constants"; 3 | 4 | export enum AppSetting { 5 | colorScheme = "colorscheme", 6 | debugMessageOutput = "message_out_debug", 7 | keyboardMIDIInput = "keyboard_midi_input", 8 | paramSortAttribute = "parameter_sort_attribute", 9 | paramSortOrder = "parameter_sort_order" 10 | } 11 | 12 | export enum AppSettingType { 13 | Boolean, 14 | String, 15 | Switch 16 | } 17 | 18 | export type AppSettingOptions = string[] | Array<{ value: string; label: string; }>; 19 | export type AppSettingValue = string | number | boolean; 20 | 21 | export type AppSettingRecordProps = { 22 | id: AppSetting; 23 | description?: string; 24 | options?: AppSettingOptions; 25 | tab: SettingsTab.UI, 26 | title: string; 27 | type: AppSettingType; 28 | value: AppSettingValue; 29 | } 30 | 31 | export const appSettingDefaults: Record> = { 32 | [AppSetting.colorScheme]: { 33 | description: "Select the color scheme of the user interface", 34 | tab: SettingsTab.UI, 35 | options: ["light", "dark"], 36 | title: "Color Scheme", 37 | type: AppSettingType.String, 38 | value: "light" 39 | }, 40 | [AppSetting.keyboardMIDIInput]: { 41 | description: "Activate this setting to play MIDI notes into a device using your computer's keyboard, when displaying the Virtual MIDI Keyboard", 42 | tab: SettingsTab.UI, 43 | title: "Computer MIDI Keyboard", 44 | type: AppSettingType.Boolean, 45 | value: true 46 | }, 47 | [AppSetting.debugMessageOutput]: { 48 | description: "Activate this setting to monitor data sent out of [outport] objects on the outport tab of a device.", 49 | tab: SettingsTab.UI, 50 | title: "Monitor Outports", 51 | type: AppSettingType.Boolean, 52 | value: true 53 | }, 54 | [AppSetting.paramSortAttribute]: { 55 | description: "Configure whether to sort device parameters by name or 'displayorder'", 56 | tab: SettingsTab.UI, 57 | options: [{ label: "Displayorder", value: ParameterSortAttr.Index }, { label: "Name", value: ParameterSortAttr.Name }], 58 | title: "Parameter List: Sort Attribute", 59 | type: AppSettingType.String, 60 | value: ParameterSortAttr.Name 61 | }, 62 | [AppSetting.paramSortOrder]: { 63 | description: "Configure in which order to sort device parameters", 64 | tab: SettingsTab.UI, 65 | options: [{ label: "Ascending", value: SortOrder.Asc }, { label: "Descending", value: SortOrder.Desc }], 66 | title: "Parameter List: Sort Order", 67 | type: AppSettingType.String, 68 | value: SortOrder.Asc 69 | } 70 | }; 71 | 72 | export class AppSettingRecord extends ImmuRecord({ 73 | id: "" as AppSetting, 74 | description: undefined, 75 | options: undefined, 76 | tab: SettingsTab.UI, 77 | title: "", 78 | type: AppSettingType.Boolean, 79 | value: true 80 | }) { 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex } from "@mantine/core"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | import { IconElement } from "../components/elements/icon"; 5 | import { mdiChartSankeyVariant } from "@mdi/js"; 6 | 7 | const NotFound = () => { 8 | 9 | const { query } = useRouter(); 10 | 11 | return ( 12 | 13 |

Page not Found

14 | 23 |
24 | ); 25 | }; 26 | 27 | export default NotFound; 28 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from "next/app"; 2 | import Head from "next/head"; 3 | import React, { useEffect } from "react"; 4 | import { Provider } from "react-redux"; 5 | 6 | import { Lato as lato, Ubuntu_Mono as ubuntuMono } from "next/font/google"; 7 | const latoFont = lato({ subsets: ["latin-ext"], weight: ["300", "400", "700", "900"], style: ["normal", "italic"] }); 8 | const ubuntuMonoFont = ubuntuMono({display: "block", subsets: ["latin-ext"], weight: ["400"] }); 9 | 10 | import "@mantine/core/styles.css"; 11 | 12 | import { oscQueryBridge, parseConnectionQueryString } from "../controller/oscqueryBridgeController"; 13 | import { store } from "../lib/store"; 14 | 15 | import { AppLayout } from "../layouts/app"; 16 | import { PageSettings } from "../components/page/settings"; 17 | import { PageTheme } from "../components/page/theme"; 18 | import Notifications from "../components/notifications"; 19 | import Settings from "../components/settings"; 20 | import EndpointInfo from "../components/page/endpoint"; 21 | import { ModalsProvider } from "@mantine/modals"; 22 | import TransportControl from "../components/page/transport"; 23 | 24 | function App({ Component, pageProps }: AppProps) { 25 | 26 | useEffect(() => { 27 | oscQueryBridge.connect(parseConnectionQueryString(location.search?.slice(1))) 28 | .catch(err => null); // handled internally 29 | 30 | return () => oscQueryBridge.close(); 31 | }, []); 32 | 33 | return ( 34 | <> 35 | 36 | 37 | 38 | 45 | 46 | 47 | 48 | 49 | 50 | RNBO 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | export default App; 68 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from "next/document"; 2 | import { ColorSchemeScript } from "@mantine/core"; 3 | 4 | 5 | class MyDocument extends Document { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | ); 24 | } 25 | } 26 | 27 | export default MyDocument; 28 | -------------------------------------------------------------------------------- /src/pages/resources.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "@mantine/core"; 2 | import { PageTitle } from "../components/page/title"; 3 | import { ResourceTabs } from "../components/resources/tabs"; 4 | 5 | const Resources = () => { 6 | 7 | return ( 8 | 9 | Manage Resources 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default Resources; 16 | -------------------------------------------------------------------------------- /src/reducers/appStatus.ts: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap } from "immutable"; 2 | import { StatusAction, StatusActionType } from "../actions/appStatus"; 3 | import { AppStatus } from "../lib/constants"; 4 | import { RunnerInfoRecord } from "../models/runnerInfo"; 5 | 6 | export interface AppStatusState { 7 | status: AppStatus, 8 | error: Error | undefined; 9 | endpoint: { hostname: string; port: string; }; 10 | showEndpointInfo: boolean; 11 | runnerInfo: ImmuMap; 12 | } 13 | 14 | export const appStatus = (state: AppStatusState = { 15 | 16 | error: undefined, 17 | status: AppStatus.Connecting, 18 | endpoint: { hostname: "", port: "" }, 19 | showEndpointInfo: false, 20 | runnerInfo: ImmuMap() 21 | 22 | }, action: StatusAction): AppStatusState => { 23 | 24 | switch (action.type) { 25 | 26 | case StatusActionType.SET_STATUS: { 27 | const { status, error } = action.payload; 28 | return { 29 | ...state, 30 | status, 31 | error: error || undefined 32 | }; 33 | } 34 | 35 | case StatusActionType.SET_ENDPOINT: { 36 | const { hostname, port } = action.payload; 37 | return { 38 | ...state, 39 | endpoint: { hostname, port } 40 | }; 41 | } 42 | 43 | case StatusActionType.SET_SHOW_ENDPOINT_INFO: { 44 | const { show } = action.payload; 45 | return { 46 | ...state, 47 | showEndpointInfo: show 48 | }; 49 | } 50 | 51 | case StatusActionType.INIT_RUNNER_INFO: { 52 | const { records } = action.payload; 53 | return { 54 | ...state, 55 | runnerInfo: ImmuMap().withMutations(m => { 56 | for (const r of records) { 57 | m.set(r.id, r); 58 | } 59 | }) 60 | }; 61 | } 62 | 63 | case StatusActionType.SET_RUNNER_INFO_VALUE : { 64 | const { record } = action.payload; 65 | return { 66 | ...state, 67 | runnerInfo: state.runnerInfo.set(record.id, record) 68 | }; 69 | } 70 | 71 | default: 72 | return state; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/reducers/datafiles.ts: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap } from "immutable"; 2 | import { DataFileAction, DataFilesActionType } from "../actions/datafiles"; 3 | import { DataFileRecord, PendingDataFileRecord } from "../models/datafile"; 4 | 5 | export type DataFileState = { 6 | files: ImmuMap; 7 | pendingFiles: ImmuMap; 8 | }; 9 | 10 | export const datafiles = (state: DataFileState = { 11 | files: ImmuMap(), 12 | pendingFiles: ImmuMap() 13 | }, action: DataFileAction): DataFileState => { 14 | switch (action.type) { 15 | case DataFilesActionType.SET_ALL: { 16 | const { files } = action.payload; 17 | return { 18 | ...state, 19 | files: ImmuMap().withMutations(map => { 20 | for (const file of files) { 21 | map.set(file.id, file); 22 | } 23 | }) 24 | }; 25 | } 26 | 27 | case DataFilesActionType.SET_PENDING: { 28 | const { file } = action.payload; 29 | return { 30 | ...state, 31 | pendingFiles: state.pendingFiles.set(file.id, file) 32 | }; 33 | } 34 | 35 | case DataFilesActionType.DELETE_PENDING: { 36 | const { file } = action.payload; 37 | return { 38 | ...state, 39 | pendingFiles: state.pendingFiles.delete(file.id) 40 | }; 41 | } 42 | 43 | default: 44 | return state; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/reducers/editor.ts: -------------------------------------------------------------------------------- 1 | import { EditorAction, EditorActionType } from "../actions/editor"; 2 | import { ReactFlowInstance } from "reactflow"; 3 | 4 | export type EditorState = { 5 | instance?: ReactFlowInstance; 6 | isLocked: boolean; 7 | }; 8 | 9 | export const editor = (state: EditorState = { 10 | isLocked: false 11 | }, action: EditorAction): EditorState => { 12 | switch (action.type) { 13 | 14 | case EditorActionType.INIT: { 15 | return { 16 | ...state, 17 | instance: action.payload.instance 18 | }; 19 | } 20 | 21 | case EditorActionType.UNMOUNT: { 22 | return { 23 | ...state, 24 | instance: undefined 25 | }; 26 | } 27 | 28 | case EditorActionType.SET_LOCKED: { 29 | return { 30 | ...state, 31 | isLocked: action.payload.locked 32 | }; 33 | } 34 | 35 | default: 36 | return state; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | 3 | import { appStatus } from "./appStatus"; 4 | import { datafiles } from "./datafiles"; 5 | import { editor } from "./editor"; 6 | import { patchers } from "./patchers"; 7 | import { graph } from "./graph"; 8 | import { nofitications } from "./notifications"; 9 | import { recording } from "./recording"; 10 | import { settings } from "./settings"; 11 | import { sets } from "./sets"; 12 | import { transport } from "./transport"; 13 | 14 | export const rootReducer = combineReducers({ 15 | appStatus, 16 | datafiles, 17 | editor, 18 | graph, 19 | nofitications, 20 | patchers, 21 | recording, 22 | settings, 23 | sets, 24 | transport 25 | }); 26 | 27 | export type RootReducerType = typeof rootReducer; 28 | -------------------------------------------------------------------------------- /src/reducers/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap } from "immutable"; 2 | import { NotificationRecord } from "../models/notification"; 3 | import { NotificationActionType, NotificationAction } from "../actions/notifications"; 4 | 5 | export interface NotificationState { 6 | items: ImmuMap; 7 | } 8 | export const nofitications = (state: NotificationState = { 9 | items: ImmuMap() 10 | }, action: NotificationAction): NotificationState => { 11 | 12 | switch (action.type) { 13 | case NotificationActionType.ADD_NOTIFICATION: { 14 | const { notification } = action.payload; 15 | return { 16 | ...state, 17 | items: state.items.set(notification.id, notification) 18 | }; 19 | } 20 | 21 | case NotificationActionType.DELETE_NOTIFICATION: { 22 | const { id } = action.payload; 23 | 24 | return { 25 | ...state, 26 | items: state.items.delete(id) 27 | }; 28 | } 29 | 30 | default: 31 | return state; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/reducers/recording.ts: -------------------------------------------------------------------------------- 1 | import { StreamRecordingAction, StreamRecordingActionType } from "../actions/recording"; 2 | import { dayjs } from "../lib/util"; 3 | import { Duration } from "dayjs/plugin/duration"; 4 | 5 | export type StreamRecordingState = { 6 | active: boolean; 7 | capturedTime: Duration; 8 | }; 9 | 10 | export const recording = (state: StreamRecordingState = { 11 | active: false, 12 | capturedTime: dayjs.duration(0, "seconds") 13 | }, action: StreamRecordingAction): StreamRecordingState => { 14 | 15 | switch (action.type) { 16 | case StreamRecordingActionType.INIT: { 17 | const { active, capturedTime } = action.payload; 18 | return { 19 | ...state, 20 | active, 21 | capturedTime: dayjs.duration(capturedTime, "seconds") 22 | }; 23 | } 24 | 25 | case StreamRecordingActionType.SET_ACTIVE: { 26 | const { active } = action.payload; 27 | return { 28 | ...state, 29 | active 30 | }; 31 | } 32 | 33 | case StreamRecordingActionType.SET_CAPTURED: { 34 | const { capturedTime } = action.payload; 35 | return { 36 | ...state, 37 | capturedTime: dayjs.duration(capturedTime, "seconds") 38 | }; 39 | } 40 | 41 | default: 42 | return state; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/reducers/settings.ts: -------------------------------------------------------------------------------- 1 | import { OrderedMap as ImmuOrderedMap } from "immutable"; 2 | import { SettingsAction, SettingsActionType } from "../actions/settings"; 3 | import { AppSetting } from "../models/settings"; 4 | import { ConfigKey, ConfigRecord } from "../models/config"; 5 | import { AppSettingRecord } from "../models/settings"; 6 | import { loadSettingsState, purgeSettingsState, storeSettingsState } from "../lib/settings"; 7 | 8 | export type AppSettings = { 9 | [AppSetting.colorScheme]: string; 10 | [AppSetting.debugMessageOutput]: boolean; 11 | [AppSetting.paramSortAttribute]: boolean; 12 | [AppSetting.paramSortOrder]: boolean; 13 | } 14 | 15 | export type SettingsState = { 16 | loaded: boolean; 17 | show: boolean; 18 | ownsJackServer: boolean; 19 | 20 | appSettings: ImmuOrderedMap; 21 | runnerConfig: ImmuOrderedMap; 22 | } 23 | 24 | const defaultState: SettingsState = { 25 | loaded: false, 26 | show: false, 27 | ownsJackServer: false, 28 | 29 | appSettings: loadSettingsState(), 30 | runnerConfig: ImmuOrderedMap() 31 | }; 32 | 33 | 34 | export const settings = (state: SettingsState = defaultState, action: SettingsAction): SettingsState => { 35 | 36 | switch (action.type) { 37 | 38 | // General 39 | case SettingsActionType.SET_SHOW_SETTINGS: { 40 | return { 41 | ...state, 42 | show: action.payload.show 43 | }; 44 | } 45 | 46 | // App Settings 47 | case SettingsActionType.LOAD_APP_SETTINGS: { 48 | return { 49 | ...state, 50 | loaded: true, 51 | appSettings: loadSettingsState() 52 | }; 53 | } 54 | 55 | case SettingsActionType.SET_APP_SETTING: { 56 | const { record } = action.payload; 57 | const appSettings = state.appSettings.set(record.id, record); 58 | storeSettingsState(appSettings); 59 | return { 60 | ...state, 61 | appSettings 62 | }; 63 | } 64 | 65 | case SettingsActionType.RESET_APP_DEFAULTS: { 66 | purgeSettingsState(); 67 | return { 68 | ...state, 69 | appSettings: defaultState.appSettings 70 | }; 71 | } 72 | 73 | // Runner Config 74 | case SettingsActionType.INIT_RUNNER_CONFIG: { 75 | const { ownsJackServer, records } = action.payload; 76 | return { 77 | ...state, 78 | ownsJackServer, 79 | runnerConfig: ImmuOrderedMap(records.map(r => [r.id, r])) 80 | }; 81 | } 82 | 83 | case SettingsActionType.UPDATE_RUNNER_CONFIG: { 84 | const { record } = action.payload; 85 | return { 86 | ...state, 87 | runnerConfig: state.runnerConfig.set(record.id, record) 88 | }; 89 | } 90 | 91 | default: 92 | return state; 93 | } 94 | }; 95 | -------------------------------------------------------------------------------- /src/reducers/transport.ts: -------------------------------------------------------------------------------- 1 | import { TransportAction, TransportActionType } from "../actions/transport"; 2 | 3 | export interface TransportState { 4 | bpm: number; 5 | rolling: boolean; 6 | sync: boolean; 7 | show: boolean; 8 | } 9 | 10 | const transportDefaults = { 11 | bpm: 100, 12 | rolling: false, 13 | sync: true 14 | }; 15 | 16 | export const transport = (state: TransportState = { 17 | bpm: transportDefaults.bpm, 18 | rolling: transportDefaults.rolling, 19 | sync: transportDefaults.sync, 20 | show: false 21 | 22 | }, action: TransportAction): TransportState => { 23 | 24 | switch (action.type) { 25 | 26 | case TransportActionType.INIT: { 27 | const { bpm, rolling, sync } = action.payload; 28 | 29 | return { 30 | ...state, 31 | bpm: bpm || transportDefaults.bpm, 32 | rolling: rolling || transportDefaults.rolling, 33 | sync: sync || transportDefaults.sync 34 | }; 35 | } 36 | 37 | case TransportActionType.UPDATE_TRANSPORT: { 38 | const { bpm, rolling, sync } = action.payload; 39 | 40 | return { 41 | ...state, 42 | bpm: bpm === undefined ? state.bpm : bpm, 43 | rolling: rolling === undefined ? state.rolling : rolling, 44 | sync: sync === undefined ? state.sync : sync 45 | }; 46 | } 47 | 48 | case TransportActionType.SET_SHOW_TRANSPORT_CONTROL: { 49 | const { show } = action.payload; 50 | return { 51 | ...state, 52 | show 53 | }; 54 | } 55 | 56 | 57 | default: 58 | return state; 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/selectors/appStatus.ts: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap } from "immutable"; 2 | import { createSelector } from "reselect"; 3 | import { AppStatus } from "../lib/constants"; 4 | import { RootStateType } from "../lib/store"; 5 | import { RunnerInfoRecord } from "../models/runnerInfo"; 6 | import { RunnerInfoKey } from "../lib/types"; 7 | 8 | 9 | export const getAppStatus = (state: RootStateType): AppStatus => state.appStatus.status; 10 | export const getAppStatusError = (state: RootStateType): Error | undefined => state.appStatus.error; 11 | 12 | export const getShowEndpointInfoModal = (state: RootStateType): boolean => state.appStatus.showEndpointInfo; 13 | export const getRunnerEndpoint = (state: RootStateType): { hostname: string; port: string; } => state.appStatus.endpoint; 14 | 15 | export const getRunnerInfoRecords = (state: RootStateType): ImmuMap => state.appStatus.runnerInfo; 16 | 17 | export const getRunnerInfoRecord = createSelector( 18 | [ 19 | getRunnerInfoRecords, 20 | (state: RootStateType, key: RunnerInfoKey): RunnerInfoKey => key 21 | ], 22 | (runnerInfo, key): RunnerInfoRecord => runnerInfo.get(key) 23 | ); 24 | -------------------------------------------------------------------------------- /src/selectors/datafiles.ts: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap, Seq } from "immutable"; 2 | import { createSelector } from "reselect"; 3 | import { RootStateType } from "../lib/store"; 4 | import { DataFileRecord, PendingDataFileRecord } from "../models/datafile"; 5 | import { SortOrder } from "../lib/constants"; 6 | 7 | export const getDataFiles = (state: RootStateType): ImmuMap => { 8 | return state.datafiles.files; 9 | }; 10 | 11 | export const getDataFileByFilename = createSelector( 12 | [ 13 | getDataFiles, 14 | (state: RootStateType, id: string): string => id 15 | 16 | ], 17 | (files, id): DataFileRecord | undefined => { 18 | return files.get(id) || undefined; 19 | } 20 | ); 21 | 22 | const collator = new Intl.Collator("en-US"); 23 | 24 | export const getDataFilesSortedByName = createSelector( 25 | [ 26 | getDataFiles, 27 | (state: RootStateType, order: SortOrder): SortOrder => order, 28 | (state: RootStateType, order: SortOrder, query?: string): string => query?.toLowerCase() || "" 29 | ], 30 | (files, order, query): Seq.Indexed => { 31 | return files 32 | .valueSeq() 33 | .filter(df => df.matchesQuery(query)) 34 | .sort((a, b) => { 35 | return collator.compare(a.fileName.toLowerCase(), b.fileName.toLowerCase()) * (order === SortOrder.Asc ? 1 : -1); 36 | }); 37 | } 38 | ); 39 | 40 | export const getPendingDataFiles = (state: RootStateType): ImmuMap => { 41 | return state.datafiles.pendingFiles; 42 | }; 43 | 44 | export const getPendingDataFileByFilename = createSelector( 45 | [ 46 | getPendingDataFiles, 47 | (state: RootStateType, id: string): string => id 48 | 49 | ], 50 | (files, id): PendingDataFileRecord | undefined => { 51 | return files.get(id) || undefined; 52 | } 53 | ); 54 | -------------------------------------------------------------------------------- /src/selectors/editor.ts: -------------------------------------------------------------------------------- 1 | import { ReactFlowInstance } from "reactflow"; 2 | import { RootStateType } from "../lib/store"; 3 | 4 | export const getGraphEditorInstance = (state: RootStateType): ReactFlowInstance | undefined => state.editor.instance; 5 | export const getGraphEditorLockedState = (state: RootStateType): boolean => state.editor.isLocked; 6 | -------------------------------------------------------------------------------- /src/selectors/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Map as ImmuMap } from "immutable"; 2 | import { RootStateType } from "../lib/store"; 3 | import { NotificationRecord } from "../models/notification"; 4 | import { createSelector } from "reselect"; 5 | 6 | export const getNotifications = (state: RootStateType): ImmuMap => { 7 | return state.nofitications.items; 8 | }; 9 | 10 | export const getNotification = createSelector( 11 | [ 12 | getNotifications, 13 | (state: RootStateType, id: string): string => id 14 | ], 15 | (nofitications, id): NotificationRecord | undefined => { 16 | return nofitications.get(id); 17 | } 18 | ); 19 | -------------------------------------------------------------------------------- /src/selectors/recording.ts: -------------------------------------------------------------------------------- 1 | import { Duration } from "dayjs/plugin/duration"; 2 | import { RootStateType } from "../lib/store"; 3 | import { StreamRecordingState } from "../reducers/recording"; 4 | import { createSelector } from "reselect"; 5 | import { getRunnerConfig } from "./settings"; 6 | import { ConfigKey, ConfigRecord } from "../models/config"; 7 | import { dayjs } from "../lib/util"; 8 | 9 | export const getStreamRecordingState = (state: RootStateType): StreamRecordingState => ({ 10 | active: state.recording.active, 11 | capturedTime: state.recording.capturedTime 12 | }); 13 | 14 | export const getIsStreamRecording = (state: RootStateType): boolean => { 15 | return state.recording.active; 16 | }; 17 | 18 | export const getStreamRecordingCapturedTime = (state: RootStateType): Duration => { 19 | return state.recording.capturedTime; 20 | }; 21 | 22 | export const getStreamRecordingTimeout = createSelector( 23 | [ 24 | (state: RootStateType): ConfigRecord => getRunnerConfig(state, ConfigKey.RecordingTimeout) 25 | ], 26 | (config): Duration | null => { 27 | const val = config?.value || 0; 28 | 29 | if (typeof val !== "number") return null; 30 | if (val <= 0) return null; 31 | 32 | return dayjs.duration(val, "seconds"); 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /src/selectors/settings.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from "reselect"; 2 | import { SettingsTab } from "../lib/constants"; 3 | import { RootStateType } from "../lib/store"; 4 | import { ConfigRecord } from "../models/config"; 5 | import { AppSetting, AppSettingRecord } from "../models/settings"; 6 | import { OrderedMap } from "immutable"; 7 | 8 | 9 | // General 10 | export const getShowSettingsModal = (state: RootStateType): boolean => state.settings.show; 11 | 12 | export const getSettingsAreLoaded = (state: RootStateType): boolean => state.settings.loaded; 13 | 14 | // App Settings 15 | export const getAppSettings = (state: RootStateType): OrderedMap => state.settings.appSettings; 16 | 17 | export const getAppSettingsForTab = createSelector( 18 | [ 19 | getAppSettings, 20 | (state: RootStateType, tab: SettingsTab): SettingsTab => tab 21 | ], 22 | (appSettings, tab: SettingsTab) => appSettings.valueSeq().filter(s => s.tab === tab) 23 | ); 24 | 25 | export const getAppSetting = createSelector( 26 | [ 27 | getAppSettings, 28 | (state: RootStateType, id: AppSetting): AppSetting => id 29 | ], 30 | (settings, id): AppSettingRecord => { 31 | return settings.get(id); 32 | } 33 | ); 34 | 35 | // Runner Config 36 | export const getRunnerOwnsJackServer = (state: RootStateType): boolean => state.settings.ownsJackServer; 37 | export const getRunnerConfigs = (state: RootStateType): OrderedMap => state.settings.runnerConfig; 38 | 39 | export const getRunnerConfig = createSelector( 40 | [ 41 | getRunnerConfigs, 42 | (state: RootStateType, id: ConfigRecord["id"]): ConfigRecord["id"] => id 43 | ], 44 | (runnerConfig, id): ConfigRecord => runnerConfig.get(id) 45 | ); 46 | 47 | export const getRunnerConfigForTab = createSelector( 48 | [ 49 | getRunnerConfigs, 50 | (state: RootStateType, tab: SettingsTab): SettingsTab => tab 51 | ], 52 | (runnerConfig, tab: SettingsTab) => runnerConfig.valueSeq().filter(c => c.tab === tab) 53 | ); 54 | 55 | export const getRunnerConfigByPath = createSelector( 56 | [ 57 | getRunnerConfigs, 58 | (state: RootStateType, path: string): string => path 59 | ], 60 | (runnerConfig, path): ConfigRecord | undefined => runnerConfig.find(rec => rec.path === path) 61 | ); 62 | 63 | export const getSettingsItemsForTab = (state: RootStateType, tab: SettingsTab): Array => { 64 | 65 | return [ 66 | ...getAppSettingsForTab(state, tab).toArray(), 67 | ...getRunnerConfigForTab(state, tab).toArray() 68 | ]; 69 | }; 70 | -------------------------------------------------------------------------------- /src/selectors/transport.ts: -------------------------------------------------------------------------------- 1 | import { RootStateType } from "../lib/store"; 2 | 3 | export const getShowTransportControl = (state: RootStateType): boolean => state.transport.show; 4 | 5 | export const getTransportControlState = (state: RootStateType): Pick => ({ 6 | bpm: state.transport.bpm, 7 | rolling: state.transport.rolling, 8 | sync: state.transport.sync 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "noImplicitAny": true, 21 | "incremental": true 22 | }, 23 | "include": [ 24 | "next-env.d.ts", 25 | "**/*.ts", 26 | "**/*.tsx" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------