├── .dockerignore ├── .env.example ├── .env.test ├── .eslintrc.json ├── .fleet └── run.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .reuse └── dep5 ├── .vscode └── launch.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── LICENSES ├── CC-BY-NC-ND-4.0.txt ├── CC-BY-SA-4.0.txt ├── CC0-1.0.txt └── MIT.txt ├── README.md ├── api ├── README.md ├── browser │ ├── client.js │ └── transport.js ├── classes │ ├── Cache.js │ ├── Cache.unit.test.js │ ├── LazyValue.js │ └── LazyValue.unit.test.js ├── client.js ├── commands.js ├── dummy │ └── transport.js ├── error │ ├── ApiError.js │ ├── InvalidArgumentError.js │ ├── MissingArgumentError.js │ ├── MissingIdentityError.js │ └── NoLocalHandlerError.js ├── events.js ├── events.unit.test.js ├── index.js ├── items.js ├── messages.js ├── messages.unit.test.js ├── node │ ├── client.js │ └── transport.js ├── random.js ├── server.js ├── settings.js ├── shortcuts.js ├── state.js ├── system.js ├── system.unit.test.js ├── transport.js ├── types.js ├── variables.js ├── variables.unit.test.js └── widgets.js ├── app ├── App.jsx ├── api.js ├── assets │ ├── fonts │ │ ├── InterVariable-Italic.woff2 │ │ ├── InterVariable.woff2 │ │ ├── bridge-glyphs.css │ │ ├── bridge-glyphs.woff │ │ └── inter.css │ └── icons │ │ ├── add.svg │ │ ├── arrow-down.svg │ │ ├── arrow-right.svg │ │ ├── close.svg │ │ ├── collaboration.svg │ │ ├── color-success.svg │ │ ├── color-warning.svg │ │ ├── edit-detail.svg │ │ ├── edit.svg │ │ ├── index.js │ │ ├── inspector.svg │ │ ├── person.svg │ │ ├── placeholder.svg │ │ ├── preferences.svg │ │ ├── rundown.svg │ │ ├── selector-white.svg │ │ ├── selector.svg │ │ ├── spinner.svg │ │ ├── warning.svg │ │ └── widget.svg ├── bridge.css ├── components │ ├── ContextMenu │ │ ├── index.jsx │ │ └── style.css │ ├── ContextMenuDivider │ │ ├── index.jsx │ │ └── style.css │ ├── ContextMenuItem │ │ ├── index.jsx │ │ └── style.css │ ├── EmptyComponent │ │ ├── index.jsx │ │ └── style.css │ ├── Frame │ │ ├── index.jsx │ │ └── style.css │ ├── FrameComponent │ │ ├── index.jsx │ │ └── style.css │ ├── Grid │ │ ├── background.css │ │ ├── background.jsx │ │ ├── index.jsx │ │ └── style.css │ ├── GridEmptyContent │ │ ├── index.jsx │ │ └── style.css │ ├── GridItem │ │ ├── index.jsx │ │ └── style.css │ ├── Header │ │ ├── index.jsx │ │ └── style.css │ ├── Icon │ │ ├── index.jsx │ │ └── style.css │ ├── Layout │ │ ├── index.jsx │ │ └── style.css │ ├── Message │ │ ├── index.jsx │ │ └── style.css │ ├── MessageContainer │ │ ├── index.jsx │ │ └── style.css │ ├── MissingComponent │ │ ├── index.jsx │ │ └── style.css │ ├── Modal │ │ ├── index.jsx │ │ └── style.css │ ├── Notification │ │ ├── index.jsx │ │ └── style.css │ ├── Onboarding │ │ ├── index.jsx │ │ ├── onboarding.json │ │ └── style.css │ ├── Palette │ │ ├── index.jsx │ │ ├── integrations │ │ │ ├── index.js │ │ │ └── items.jsx │ │ └── style.css │ ├── Popover │ │ ├── index.jsx │ │ └── style.css │ ├── Popup │ │ ├── confirm.css │ │ ├── confirm.jsx │ │ ├── index.jsx │ │ ├── shortcut.css │ │ ├── shortcut.jsx │ │ └── style.css │ ├── Preferences │ │ ├── index.jsx │ │ ├── preference.css │ │ ├── preference.jsx │ │ ├── sections │ │ │ ├── appearance.json │ │ │ ├── general.json │ │ │ ├── shortcuts.json │ │ │ └── state.json │ │ ├── shared.js │ │ └── style.css │ ├── PreferencesBooleanInput │ │ ├── index.jsx │ │ └── style.css │ ├── PreferencesClearStateInput │ │ ├── index.jsx │ │ └── style.css │ ├── PreferencesFrameInput │ │ ├── index.jsx │ │ └── style.css │ ├── PreferencesNumberInput │ │ ├── index.jsx │ │ └── style.css │ ├── PreferencesSegmentedInput │ │ ├── index.jsx │ │ └── style.css │ ├── PreferencesSelectInput │ │ ├── index.jsx │ │ └── style.css │ ├── PreferencesShortcutsInput │ │ ├── index.jsx │ │ └── style.css │ ├── PreferencesStringInput │ │ ├── index.jsx │ │ └── style.css │ ├── PreferencesThemeInput │ │ ├── index.jsx │ │ └── style.css │ ├── PreferencesVersionInput │ │ ├── index.jsx │ │ └── style.css │ ├── Role │ │ ├── index.jsx │ │ └── style.css │ ├── Router │ │ ├── index.jsx │ │ ├── router.js │ │ └── router.unit.test.js │ ├── SegmentedControl │ │ ├── index.jsx │ │ └── style.css │ ├── Sharing │ │ ├── index.jsx │ │ └── style.css │ ├── Tabs │ │ ├── index.jsx │ │ └── style.css │ ├── TabsComponent │ │ └── index.jsx │ ├── VerticalNavigation │ │ ├── index.jsx │ │ └── style.css │ └── WidgetSelector │ │ ├── index.jsx │ │ └── style.css ├── fonts.css ├── hooks │ ├── useDraggable.js │ ├── useJson.js │ └── useWebsocket.js ├── index.css ├── index.jsx ├── localContext.js ├── sharedContext.js ├── socketContext.js ├── template.js ├── theme.css ├── utils │ ├── browser.js │ ├── clipboard.js │ ├── console.js │ ├── fetch.js │ ├── random.js │ └── shortcuts.js └── views │ ├── Start.jsx │ └── Workspace.jsx ├── docker-compose.yml ├── docs ├── README.md ├── api │ └── README.md ├── architecture.md ├── build.md ├── plugins │ ├── README.md │ ├── installation.md │ └── styling.md ├── structure.md └── types.md ├── examples ├── plugin-custom-types │ ├── README.md │ ├── index.js │ └── package.json └── plugin-hello-world │ ├── README.md │ ├── index.js │ └── package.json ├── extra.plist ├── index.js ├── lib ├── Logger.js ├── ProjectFile.js ├── SocketHandler.js ├── State.js ├── State.unit.test.js ├── StaticFileRegistry.js ├── StaticFileRegistry.unit.test.js ├── UserDefaults.js ├── Validator.js ├── Workspace.js ├── WorkspaceRegistry.js ├── api │ ├── CommandHandler.js │ ├── SClient.js │ ├── SCommands.js │ ├── SCommands.unit.test.js │ ├── SEvents.js │ ├── SEvents.unit.test.js │ ├── SItems.js │ ├── SServer.js │ ├── SSettings.js │ ├── SShortcuts.js │ ├── SState.js │ ├── SSystem.js │ ├── STypes.js │ ├── SVariables.js │ ├── SWindow.js │ └── index.js ├── config.js ├── electron │ ├── electron.js │ └── windowManagement.js ├── error │ ├── ApiError.js │ ├── ContextError.js │ ├── HttpError.js │ ├── MissingTypeError.js │ ├── PluginMissingMainScriptError.js │ └── ValidationError.js ├── init-common.js ├── init-electron.js ├── init-node.js ├── network.js ├── paths.js ├── platform.js ├── plugin │ ├── ContextStore.js │ ├── ContributionLoader.js │ ├── ContributionLoader.unit.test.js │ ├── PluginLoader.js │ ├── PluginLoader.unit.test.js │ ├── PluginManifest.js │ ├── context.js │ └── worker.js ├── routes │ └── index.js ├── schemas │ ├── plugin.schema.json │ ├── setting.schema.json │ ├── shortcuts.schema.json │ └── type.schema.json ├── server.js ├── template.json └── utils.js ├── media ├── appicon.icns ├── appicon.ico ├── appicon.png ├── docs │ ├── architecture │ │ ├── client-server.png │ │ └── methodology.png │ └── plugins │ │ ├── directory.png │ │ ├── loader.png │ │ └── manage-plugins.png ├── screenshot.png └── ui.png ├── package-lock.json ├── package.json ├── plugins ├── README.md ├── button │ ├── app │ │ ├── App.jsx │ │ ├── components │ │ │ ├── ItemButton │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── ItemDropArea │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ └── QueryPath │ │ │ │ └── index.jsx │ │ ├── index.jsx │ │ └── style.css │ ├── index.js │ ├── package-lock.json │ └── package.json ├── caspar │ ├── README.md │ ├── app │ │ ├── App.jsx │ │ ├── assets │ │ │ └── icons │ │ │ │ ├── connected.svg │ │ │ │ ├── disconnected.svg │ │ │ │ └── error.svg │ │ ├── components │ │ │ ├── EasingPreview │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── LibraryHeader │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── LibraryList │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── LibraryListItem │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── LiveSwitchControl │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── Monaco │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── Select │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── ServerInput │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── ServerSelector │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── ServerStatus │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── ServerStatusBadge │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── TemplateDataHeader │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ └── ThumbnailImage │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ ├── index.jsx │ │ ├── sharedContext.js │ │ ├── style.css │ │ ├── utils │ │ │ ├── asset.cjs │ │ │ ├── asset.unit.test.js │ │ │ └── easings.js │ │ └── views │ │ │ ├── InspectorServer.jsx │ │ │ ├── InspectorTemplate.jsx │ │ │ ├── InspectorTransition.jsx │ │ │ ├── Library.jsx │ │ │ ├── LiveSwitch.jsx │ │ │ ├── Settings.jsx │ │ │ ├── Status.jsx │ │ │ └── Thumbnail.jsx │ ├── index.js │ ├── lib │ │ ├── AMCP.js │ │ ├── Cache.js │ │ ├── Caspar.js │ │ ├── CasparManager.js │ │ ├── TcpSocket.js │ │ ├── commands.js │ │ ├── error │ │ │ ├── CasparError.js │ │ │ ├── CommandError.js │ │ │ └── TcpSocketError.js │ │ ├── handlers.js │ │ ├── paths.js │ │ └── types.js │ ├── package-lock.json │ ├── package.json │ └── webpack.config.js ├── clock │ ├── app │ │ ├── App.jsx │ │ ├── Average.js │ │ ├── components │ │ │ └── CurrentTime │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ ├── index.jsx │ │ └── style.css │ ├── index.js │ ├── package-lock.json │ └── package.json ├── http │ ├── README.md │ ├── index.js │ ├── lib │ │ ├── RequestManager.js │ │ ├── RequestManager.unit.test.js │ │ └── random.js │ ├── package-lock.json │ └── package.json ├── inspector │ ├── README.md │ ├── app │ │ ├── App.jsx │ │ ├── assets │ │ │ └── icons │ │ │ │ ├── arrow-down.svg │ │ │ │ ├── bucket.svg │ │ │ │ └── index.js │ │ ├── components │ │ │ ├── Accordion │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── BooleanInput │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── ColorInput │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── Form │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── Icon │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── NoSelection │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── SelectInput │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── StringInput │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── TextInput │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── VariableHint │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ └── VariableStringInput │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ ├── index.css │ │ ├── index.jsx │ │ ├── sharedContext.js │ │ ├── storeContext.js │ │ └── views │ │ │ └── Inspector.jsx │ ├── index.js │ ├── package-lock.json │ └── package.json ├── osc │ ├── README.md │ ├── app │ │ ├── App.jsx │ │ ├── components │ │ │ ├── LogHeader │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── LogItem │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── TargetInput │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ └── TargetSelector │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ ├── index.jsx │ │ ├── sharedContext.js │ │ ├── style.css │ │ └── views │ │ │ ├── InspectorTarget.jsx │ │ │ ├── Settings.jsx │ │ │ └── WidgetLog.jsx │ ├── index.js │ ├── lib │ │ ├── Server.js │ │ ├── TCPTransport.js │ │ ├── Transport.js │ │ ├── UDPClient.js │ │ ├── UDPTransport.js │ │ ├── commands.js │ │ ├── handlers.js │ │ ├── log.js │ │ ├── paths.js │ │ └── types.js │ ├── package-lock.json │ └── package.json ├── rundown │ ├── README.md │ ├── app │ │ ├── App.jsx │ │ ├── assets │ │ │ └── icons │ │ │ │ ├── arrow-down-play.svg │ │ │ │ ├── arrow-down-secondary.svg │ │ │ │ ├── arrow-down.svg │ │ │ │ ├── empty.svg │ │ │ │ ├── index.js │ │ │ │ └── warning.svg │ │ ├── components │ │ │ ├── ContextAddMenu │ │ │ │ └── index.jsx │ │ │ ├── Header │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── Icon │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── Layout │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── RundownDividerItem │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── RundownGroupItem │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── RundownItem │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── RundownItemIndicatorsSection │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── RundownItemProgress │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── RundownItemTimeSection │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── RundownList │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ ├── RundownListItem │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ │ └── RundownVariableItem │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ ├── config.js │ │ ├── hooks │ │ │ └── useAsyncValue.js │ │ ├── index.css │ │ ├── index.jsx │ │ ├── sharedContext.js │ │ ├── utils │ │ │ ├── clipboard.js │ │ │ ├── keyboard.js │ │ │ └── selection.js │ │ └── views │ │ │ └── Rundown.jsx │ ├── index.js │ ├── lib │ │ ├── Accumulator.js │ │ ├── commands.js │ │ └── handlers.js │ ├── package-lock.json │ └── package.json ├── scheduler │ ├── index.js │ ├── lib │ │ └── Interval.js │ ├── package-lock.json │ └── package.json ├── shortcuts │ ├── package-lock.json │ └── package.json ├── state │ ├── README.md │ ├── app │ │ ├── App.jsx │ │ ├── components │ │ │ └── TreeView │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ ├── index.css │ │ └── index.jsx │ ├── index.js │ ├── package-lock.json │ └── package.json ├── types │ ├── app │ │ ├── App.jsx │ │ ├── components │ │ │ └── ReferenceButton │ │ │ │ ├── index.jsx │ │ │ │ └── style.css │ │ ├── index.jsx │ │ ├── style.css │ │ └── views │ │ │ └── InspectorReferenceButton.jsx │ ├── index.js │ ├── lib │ │ ├── types.js │ │ └── utils.js │ ├── package-lock.json │ └── package.json ├── variables │ ├── index.js │ ├── package-lock.json │ └── package.json └── webpack.config.js ├── public └── robots.txt ├── scripts ├── clean-build-folder.js ├── install-plugin-dependencies.js └── sign-macos.js ├── shared ├── DIBase.js ├── DIController.js ├── DIController.unit.test.js ├── DIControllerError.js ├── index.js ├── merge.js └── merge.unit.test.js ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT= 2 | NODE_ENV= 3 | LOG_LEVEL=debug|info|warn|error 4 | APP_DATA_BASE_PATH= -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | # used for testing 3 | LOG_LEVEL=info -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": [ 3 | "**/*.test.js", 4 | "**/node_modules/**/*.js", 5 | "/bin/**/*.js", 6 | "/dist/**/*.js" 7 | ], 8 | "env": { 9 | "browser": true, 10 | "es2021": true, 11 | "node": true 12 | }, 13 | "extends": [ 14 | "standard" 15 | ], 16 | "overrides": [ 17 | ], 18 | "parserOptions": { 19 | "ecmaVersion": "latest" 20 | }, 21 | "plugins": [ 22 | ], 23 | "rules": { 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.fleet/run.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "docker-compose-up", 5 | "name": "Run with Docker compose", 6 | "files": [] 7 | }, 8 | { 9 | "name": "start electron", 10 | "type": "command", 11 | "program": "$WORKSPACE_DIR$/node_modules/.bin/electron", 12 | "args": [ 13 | "$WORKSPACE_DIR$/index.js", 14 | "--remote-debugging-port=9222" 15 | ] 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [16.x, 18.x, 20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - name: Install dependencies 24 | run: npm ci 25 | - name: Test 26 | run: npm test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macos 2 | *.DS_Store 3 | 4 | # npm 5 | node_modules 6 | 7 | # build 8 | assets.json 9 | dist 10 | bin 11 | 12 | # temporary files 13 | data -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | 5 | # Does the commit have a signoff? 6 | if [ "1" != "$(grep -c '^Signed-off-by: ' "$1")" ]; then 7 | printf >&2 "%sMissing Signed-off-by line, commit with --signoff or -s. %s\n" 8 | exit 1 9 | fi 10 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | proxy=null -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "name": "Docker: Attach to Node", 6 | "request": "attach", 7 | "localRoot": "${workspaceFolder}", 8 | "remoteRoot": "/app", 9 | "port": 9229, 10 | "address": "127.0.0.1", 11 | "trace": true, 12 | "restart": true, 13 | "sourceMaps": true 14 | }, 15 | { 16 | "type": "node", 17 | "request": "launch", 18 | "name": "Electron: Launch main process", 19 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 20 | "program": "${workspaceRoot}/index.js", 21 | "runtimeArgs": [ 22 | ".", 23 | "--remote-debugging-port=9222" 24 | ], 25 | "windows": { 26 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # The first stage will 2 | # build the app into /app 3 | FROM node:14.16.0-alpine3.10 4 | WORKDIR /app 5 | 6 | COPY package*.json ./ 7 | 8 | RUN npm ci 9 | 10 | COPY . ./ 11 | RUN npm run build 12 | 13 | CMD ["npm", "start"] 14 | 15 | # Create a second image 16 | # to force-squash the history 17 | # and prevent any tokens 18 | # from leaking out 19 | FROM node:14.16.0-alpine3.10 20 | WORKDIR /app 21 | 22 | COPY --from=0 /app /app 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sveriges Television AB 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # API 2 | The extension api available for plugins 3 | 4 | [Full api documentation](/docs/api/README.md) -------------------------------------------------------------------------------- /api/classes/Cache.unit.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const Cache = require('./Cache') 6 | const cache = new Cache(5) 7 | 8 | test('cache a provider', () => { 9 | cache.cache('myKey', () => 10) 10 | expect(cache.get('myKey')).resolves.toBe(10) 11 | }) 12 | 13 | test('respect the max entry count', () => { 14 | cache.cache('myKey1', () => 10) 15 | cache.cache('myKey2', () => 10) 16 | cache.cache('myKey3', () => 10) 17 | cache.cache('myKey4', () => 10) 18 | cache.cache('myKey5', () => 10) 19 | cache.cache('myKey6', () => 10) 20 | expect(cache.get('myKey1')).resolves.toBe(undefined) 21 | }) 22 | -------------------------------------------------------------------------------- /api/classes/LazyValue.unit.test.js: -------------------------------------------------------------------------------- 1 | const LazyValue = require('./LazyValue') 2 | 3 | test('set a lazy value', () => { 4 | const value = new LazyValue() 5 | expect(value.get()).toBeUndefined() 6 | value.set('foo') 7 | expect(value.get()).toBe('foo') 8 | }) 9 | 10 | test('await a lazy value one time', () => { 11 | const value = new LazyValue() 12 | expect(value.getLazy()).resolves.toBe('bar') 13 | value.set('bar') 14 | }) 15 | 16 | test('await a lazy value multiple times', () => { 17 | const value = new LazyValue() 18 | expect(value.getLazy()).resolves.toBe('baz') 19 | expect(value.getLazy()).resolves.toBe('baz') 20 | value.set('baz') 21 | expect(value.getLazy()).resolves.toBe('baz') 22 | }) -------------------------------------------------------------------------------- /api/client.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | ;(function () { 6 | if (module.parent) { 7 | require('./node/client') 8 | return 9 | } 10 | if (typeof window !== 'undefined') { 11 | require('./browser/client') 12 | } 13 | })() 14 | -------------------------------------------------------------------------------- /api/dummy/transport.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | /** 6 | * @type { import('../transport').Communicator } 7 | */ 8 | module.exports = { 9 | onMessage: handler => {}, 10 | send: msg => {} 11 | } 12 | -------------------------------------------------------------------------------- /api/error/ApiError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class ApiError extends Error { 6 | constructor (msg = 'Api error') { 7 | super(msg) 8 | } 9 | } 10 | module.exports = ApiError 11 | -------------------------------------------------------------------------------- /api/error/InvalidArgumentError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const ApiError = require('./ApiError') 6 | 7 | class InvalidArgumentError extends ApiError { 8 | constructor (msg = 'Invalid argument') { 9 | super(msg) 10 | } 11 | } 12 | module.exports = InvalidArgumentError 13 | -------------------------------------------------------------------------------- /api/error/MissingArgumentError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const ApiError = require('./ApiError') 6 | 7 | class MissingArgumentError extends ApiError { 8 | constructor (msg = 'Missing argument') { 9 | super(msg) 10 | } 11 | } 12 | module.exports = MissingArgumentError 13 | -------------------------------------------------------------------------------- /api/error/MissingIdentityError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const ApiError = require('./ApiError') 6 | 7 | class MissingIdentityError extends ApiError { 8 | constructor (msg = 'Unknown client identity') { 9 | super(msg) 10 | } 11 | } 12 | module.exports = MissingIdentityError 13 | -------------------------------------------------------------------------------- /api/error/NoLocalHandlerError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const ApiError = require('./ApiError') 6 | 7 | class NoLocalHandlerError extends ApiError { 8 | constructor (msg = 'Missing local handler') { 9 | super(msg) 10 | } 11 | } 12 | module.exports = NoLocalHandlerError 13 | -------------------------------------------------------------------------------- /api/events.unit.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | require('./events') 6 | 7 | const DIController = require('../shared/DIController') 8 | 9 | let events 10 | beforeAll(() => { 11 | events = DIController.main.instantiate('Events', { 12 | Commands: { 13 | registerCommand: () => {}, 14 | executeCommand: () => {} 15 | } 16 | }) 17 | }) 18 | 19 | test('create a new caller scope', () => { 20 | const scope = events.createScope('mycaller') 21 | expect(scope.id).toEqual('mycaller') 22 | }) 23 | 24 | test('remove all listeners for a caller', () => { 25 | const scope = events.createScope('mySecondcaller') 26 | scope.on('test', () => {}) 27 | expect(events.removeAllListeners('mySecondcaller')).toEqual(1) 28 | }) 29 | 30 | test('remove all intercepts for a caller', () => { 31 | const scope = events.createScope('myThirdcaller') 32 | scope.intercept('test', () => {}) 33 | expect(events.removeAllIntercepts('myThirdcaller')).toEqual(1) 34 | }) 35 | -------------------------------------------------------------------------------- /api/node/transport.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const { parentPort } = require('worker_threads') 6 | 7 | const DIController = require('../../shared/DIController') 8 | 9 | class Transport { 10 | onMessage (handler) { 11 | parentPort.on('message', handler) 12 | } 13 | 14 | send (msg) { 15 | parentPort.postMessage(msg) 16 | } 17 | } 18 | 19 | DIController.main.register('Transport', Transport) 20 | -------------------------------------------------------------------------------- /api/random.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const DEFAULT_CHARACTER_MAP = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' 6 | 7 | /** 8 | * Generate a random string of 9 | * characters in the map 10 | * 11 | * This function is NOT 12 | * cryptographically strong 13 | * 14 | * @param { Number } length The length of the returned string 15 | * @param { String } map A string of characters to include 16 | * in the returned string 17 | * @returns { String } 18 | *//** 19 | * Generate a random string 20 | * 21 | * This function is NOT 22 | * cryptographically strong 23 | * 24 | * @param { Number } length The length of the returned string 25 | * @returns { String } 26 | */ 27 | function string (length = 10, map = DEFAULT_CHARACTER_MAP) { 28 | return Array 29 | .from({ length }, () => Math.round(Math.random() * map.length)) 30 | .map(val => map[val % map.length]) 31 | .join('') 32 | } 33 | exports.string = string 34 | -------------------------------------------------------------------------------- /api/settings.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | /** 6 | * @typedef {{ 7 | * $id: String, 8 | * group: String, 9 | * name: String 10 | * }} SettingSpecification See lib/schemas/setting.schema.json for complete spec 11 | */ 12 | 13 | const DIController = require('../shared/DIController') 14 | 15 | class Settings { 16 | #props 17 | 18 | constructor (props) { 19 | this.#props = props 20 | } 21 | 22 | /** 23 | * Register a setting 24 | * by its specification 25 | * @param { SettingSpecification } specification A setting specification 26 | * @returns { Promise. } 27 | */ 28 | registerSetting (specification) { 29 | return this.#props.Commands.executeCommand('settings.registerSetting', specification) 30 | } 31 | } 32 | 33 | DIController.main.register('Settings', Settings, [ 34 | 'Commands' 35 | ]) 36 | -------------------------------------------------------------------------------- /api/system.js: -------------------------------------------------------------------------------- 1 | const DIController = require('../shared/DIController') 2 | 3 | class System { 4 | #props 5 | 6 | /** 7 | * The version string as 8 | * returned by getVersion 9 | * 10 | * This is only used as a cache 11 | * and should not be accessed directly 12 | * 13 | * @type { String | undefined } 14 | */ 15 | #version 16 | 17 | constructor (props) { 18 | this.#props = props 19 | } 20 | 21 | /** 22 | * Get the system version 23 | * @returns { Promise. } 24 | */ 25 | getVersion () { 26 | /* 27 | Return the stored version as it 28 | only has to be fetched once 29 | */ 30 | if (this.#version) { 31 | return this.#version 32 | } 33 | 34 | return this.#props.Commands.executeCommand('system.getVersion') 35 | .then(res => { 36 | this.#version = res 37 | return res 38 | }) 39 | } 40 | } 41 | 42 | DIController.main.register('System', System, [ 43 | 'Commands' 44 | ]) 45 | -------------------------------------------------------------------------------- /api/system.unit.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | require('./system') 6 | 7 | const DIController = require('../shared/DIController') 8 | 9 | let system 10 | beforeAll(() => { 11 | system = DIController.main.instantiate('System', { 12 | Commands: { 13 | executeCommand: cmd => { 14 | switch (cmd) { 15 | case 'system.getVersion': 16 | return Promise.resolve('1.0.0') 17 | } 18 | } 19 | } 20 | }) 21 | }) 22 | 23 | test('get the system version', async () => { 24 | const version = await system.getVersion() 25 | expect(version).toEqual('1.0.0') 26 | }) 27 | -------------------------------------------------------------------------------- /api/transport.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | ;(function () { 6 | /* 7 | Use a dummy transport 8 | for unit-tests 9 | */ 10 | if (typeof process !== 'undefined' && process.env.JEST_WORKER_ID) { 11 | require('./dummy/transport') 12 | return 13 | } 14 | if (module.parent) { 15 | console.log('[API] Using node transport') 16 | require('./node/transport') 17 | return 18 | } 19 | if (typeof window !== 'undefined') { 20 | console.log('[API] Using browser transport') 21 | require('./browser/transport') 22 | } 23 | })() 24 | -------------------------------------------------------------------------------- /api/widgets.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const DIController = require('../shared/DIController') 6 | 7 | class Widgets { 8 | #props 9 | 10 | constructor (props) { 11 | this.#props = props 12 | } 13 | 14 | /** 15 | * Make a widget available 16 | * to the application 17 | * @param { 18 | * id: String, 19 | * name: String, 20 | * uri: String, 21 | * description: String 22 | * } spec 23 | */ 24 | registerWidget (spec) { 25 | this.#props.State.apply({ 26 | _widgets: { 27 | [spec.id]: spec 28 | } 29 | }) 30 | } 31 | } 32 | 33 | DIController.main.register('Widgets', Widgets, [ 34 | 'State' 35 | ]) 36 | -------------------------------------------------------------------------------- /app/api.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | /** 6 | * Load the api 7 | * @returns { Promise. } The api 8 | */ 9 | export const load = (function () { 10 | let bridge = window.bridge 11 | let resolvers = [] 12 | 13 | if (!bridge) { 14 | /* 15 | Observe window.bridge and resolve all 16 | pending promises as soon as it's set 17 | */ 18 | Object.defineProperty(window, 'bridge', { 19 | configurable: true, 20 | set: value => { 21 | bridge = value 22 | resolvers.forEach(resolve => resolve(bridge)) 23 | resolvers = undefined 24 | }, 25 | get: () => bridge 26 | }) 27 | } 28 | 29 | return () => { 30 | if (bridge) { 31 | return Promise.resolve(bridge) 32 | } 33 | return new Promise(resolve => { 34 | resolvers.push(resolve) 35 | }) 36 | } 37 | })() 38 | -------------------------------------------------------------------------------- /app/assets/fonts/InterVariable-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/app/assets/fonts/InterVariable-Italic.woff2 -------------------------------------------------------------------------------- /app/assets/fonts/InterVariable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/app/assets/fonts/InterVariable.woff2 -------------------------------------------------------------------------------- /app/assets/fonts/bridge-glyphs.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: bridge-glyphs; 3 | src: url("./bridge-glyphs.woff") format("woff"); 4 | } -------------------------------------------------------------------------------- /app/assets/fonts/bridge-glyphs.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/app/assets/fonts/bridge-glyphs.woff -------------------------------------------------------------------------------- /app/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/add 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/assets/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/arrow-down 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/assets/icons/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/arrow-right 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/close 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/assets/icons/color-success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/message/success 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/assets/icons/color-warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/message/warning 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/assets/icons/edit-detail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/edit-detail 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/assets/icons/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/edit 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/assets/icons/index.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | import add from './add.svg' 6 | import edit from './edit.svg' 7 | import close from './close.svg' 8 | import person from './person.svg' 9 | import widget from './widget.svg' 10 | import rundown from './rundown.svg' 11 | import warning from './warning.svg' 12 | import selector from './selector.svg' 13 | import inspector from './inspector.svg' 14 | import arrowRight from './arrow-right.svg' 15 | import editDetail from './edit-detail.svg' 16 | import placeholder from './placeholder.svg' 17 | import preferences from './preferences.svg' 18 | 19 | import colorSuccess from './color-success.svg' 20 | import colorWarning from './color-warning.svg' 21 | 22 | export default { 23 | add, 24 | edit, 25 | close, 26 | person, 27 | widget, 28 | rundown, 29 | warning, 30 | selector, 31 | inspector, 32 | arrowRight, 33 | editDetail, 34 | placeholder, 35 | preferences, 36 | colorSuccess, 37 | colorWarning 38 | } 39 | -------------------------------------------------------------------------------- /app/assets/icons/inspector.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/inspector 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/assets/icons/person.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/person 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/assets/icons/placeholder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/placeholder 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/assets/icons/preferences.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/preferences 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/assets/icons/rundown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/rundown 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/assets/icons/selector-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/selector-white 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/assets/icons/selector.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/selector 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/assets/icons/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/spinner 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/assets/icons/warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/warning 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/assets/icons/widget.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/widget 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/components/ContextMenu/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .ContextMenu { 4 | position: fixed; 5 | width: 150px; 6 | 7 | background: white; 8 | color: black; 9 | 10 | border-radius: 5px; 11 | 12 | box-sizing: border-box; 13 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); 14 | 15 | margin-top: -3px; 16 | 17 | animation-name: context-menu-in; 18 | animation-duration: 200ms; 19 | animation-fill-mode: forwards; 20 | 21 | overflow: hidden; 22 | z-index: 100; 23 | } 24 | 25 | @keyframes context-menu-in { 26 | 0% { 27 | margin-top: -3px; 28 | } 29 | 100% { 30 | margin-top: 0; 31 | } 32 | } 33 | 34 | .ContextMenu--up { 35 | transform: translate(0, -100%); 36 | } -------------------------------------------------------------------------------- /app/components/ContextMenuDivider/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | export const ContextMenuDivider = () => { 5 | return ( 6 |
7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /app/components/ContextMenuDivider/style.css: -------------------------------------------------------------------------------- 1 | .ContextMenuDivider { 2 | width: 100%; 3 | height: 1px; 4 | 5 | background: black; 6 | opacity: 0.1; 7 | } 8 | -------------------------------------------------------------------------------- /app/components/ContextMenuItem/style.css: -------------------------------------------------------------------------------- 1 | .ContextMenuItem { 2 | position: relative; 3 | padding: 0.2em; 4 | } 5 | 6 | .ContextMenuItem-text { 7 | padding: 0.5em; 8 | border-radius: 4px; 9 | } 10 | 11 | .ContextMenuItem .Icon { 12 | position: absolute; 13 | top: 50%; 14 | right: 7px; 15 | 16 | transform: translate(0, -50%); 17 | 18 | --base-color: black; 19 | } 20 | 21 | .ContextMenuItem:hover > .ContextMenuItem-text { 22 | background: rgb(228, 228, 228); 23 | } 24 | 25 | .ContextMenuItem:active > .ContextMenuItem-text { 26 | background: rgb(211, 211, 211); 27 | } -------------------------------------------------------------------------------- /app/components/EmptyComponent/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { SharedContext } from '../../sharedContext' 3 | import { LocalContext } from '../../localContext' 4 | 5 | import './style.css' 6 | 7 | export const EmptyComponent = () => { 8 | const [shared] = React.useContext(SharedContext) 9 | const [local] = React.useContext(LocalContext) 10 | 11 | const isEditingLayout = shared?._connections?.[local?.id]?.isEditingLayout 12 | 13 | return ( 14 |
15 |
16 |
No widget selected
17 | { isEditingLayout && 'Right click for options' } 18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/components/EmptyComponent/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .EmptyComponent { 4 | display: flex; 5 | width: 100%; 6 | height: 100%; 7 | 8 | text-align: center; 9 | align-items: center; 10 | } 11 | 12 | .EmptyComponent-container { 13 | width: 100%; 14 | } 15 | 16 | .EmptyComponent-heading { 17 | margin: 10px 0 3px; 18 | font-weight: 600; 19 | } -------------------------------------------------------------------------------- /app/components/Frame/style.css: -------------------------------------------------------------------------------- 1 | .Frame { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | .Frame-frame { 8 | position: relative; 9 | width: 100%; 10 | height: 0; 11 | border: none; 12 | } 13 | -------------------------------------------------------------------------------- /app/components/FrameComponent/style.css: -------------------------------------------------------------------------------- 1 | .FrameComponent { 2 | display: flex; 3 | position: relative; 4 | padding: 0; 5 | margin: 0; 6 | 7 | width: 100%; 8 | height: 100%; 9 | 10 | flex-direction: column; 11 | 12 | overflow: hidden; 13 | } 14 | 15 | .FrameComponent.is-focused::before { 16 | content: ''; 17 | position: absolute; 18 | width: 100%; 19 | height: 100%; 20 | 21 | border-radius: 5px; 22 | box-shadow: inset 0 0 0 1px var(--base-color--accent4); 23 | 24 | pointer-events: none; 25 | z-index: 1; 26 | } 27 | 28 | .FrameComponent-header { 29 | position: relative; 30 | display: flex; 31 | width: 100%; 32 | height: 30px; 33 | 34 | padding: 0 10px; 35 | 36 | font-size: 0.9em; 37 | opacity: 0.7; 38 | 39 | flex-shrink: 0; 40 | 41 | align-items: center; 42 | box-sizing: border-box; 43 | } 44 | 45 | .FrameComponent-wrapper { 46 | position: relative; 47 | width: 100%; 48 | height: 100%; 49 | } 50 | 51 | .FrameComponent-frame { 52 | position: absolute; 53 | width: 100%; 54 | height: 100%; 55 | border: none; 56 | } 57 | -------------------------------------------------------------------------------- /app/components/Grid/background.css: -------------------------------------------------------------------------------- 1 | .Grid-background { 2 | position: absolute; 3 | display: flex; 4 | top: 0; 5 | left: 0; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | 10 | .Grid-backgroundCol { 11 | position: relative; 12 | display: flex; 13 | width: 100%; 14 | height: 100%; 15 | 16 | flex-direction: column; 17 | } 18 | 19 | .Grid-backgroundRow { 20 | width: 100%; 21 | height: 100%; 22 | 23 | border: 0.5px solid var(--base-color--shade-3pct); 24 | } -------------------------------------------------------------------------------- /app/components/Grid/background.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './background.css' 3 | 4 | export function GridBackground ({ cols = 1, rows = 1}) { 5 | return ( 6 |
7 | { 8 | Array(cols).fill(undefined).map((_, i) => { 9 | return ( 10 |
11 | { 12 | Array(rows).fill(undefined).map((__, j) => { 13 | return
14 | }) 15 | } 16 |
17 | ) 18 | }) 19 | } 20 |
21 | ) 22 | } -------------------------------------------------------------------------------- /app/components/Grid/style.css: -------------------------------------------------------------------------------- 1 | .Grid { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | 6 | padding: 3px; 7 | box-sizing: border-box; 8 | 9 | flex-grow: 1; 10 | } 11 | 12 | .Grid-layout { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | 17 | /* 18 | Modify the color of the resize 19 | handles to be more clearly visible 20 | 21 | The class is otherwise controlled 22 | by react-grid-layout 23 | */ 24 | .Grid .react-resizable-handle::after { 25 | border-color: var(--base-color); 26 | } 27 | 28 | /* 29 | Disable transitions for 30 | a more responsive app 31 | */ 32 | .Grid .react-grid-item { 33 | transition: none; 34 | } 35 | 36 | /* 37 | Style the placeholder to be coherent 38 | with the rest of the app 39 | */ 40 | .Grid .react-grid-item.react-grid-placeholder { 41 | background: var(--base-color--accent1); 42 | } 43 | 44 | .Grid-item.is-changing { 45 | animation-name: Grid-item-is-changing; 46 | animation-duration: 1.5s; 47 | animation-iteration-count: infinite; 48 | } 49 | 50 | @keyframes Grid-item-is-changing { 51 | 0% { 52 | opacity: 1; 53 | } 54 | 50% { 55 | opacity: 0.3; 56 | } 57 | 100% { 58 | opacity: 1; 59 | } 60 | } -------------------------------------------------------------------------------- /app/components/GridEmptyContent/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { SharedContext } from '../../sharedContext' 4 | import { LocalContext } from '../../localContext' 5 | 6 | import './style.css' 7 | 8 | export function GridEmptyContent () { 9 | const [, applyShared] = React.useContext(SharedContext) 10 | const [local] = React.useContext(LocalContext) 11 | 12 | function handleEnterEditMode () { 13 | applyShared({ 14 | _connections: { 15 | [local.id]: { 16 | isEditingLayout: true 17 | } 18 | } 19 | }) 20 | } 21 | 22 | return ( 23 |
24 |
25 |

This tab is empty

26 | Add widgets in the edit mode 27 |
28 | 29 |
30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/components/GridEmptyContent/style.css: -------------------------------------------------------------------------------- 1 | .GridEmptyContent { 2 | position: absolute; 3 | display: flex; 4 | 5 | top: 0; 6 | right: 0; 7 | bottom: 0; 8 | left: 0; 9 | 10 | text-align: center; 11 | 12 | align-items: center; 13 | justify-content: center; 14 | 15 | z-index: 1; 16 | } 17 | 18 | .GridEmptyContent-actions { 19 | margin-top: 20px; 20 | } -------------------------------------------------------------------------------- /app/components/GridItem/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import { SharedContext } from '../../sharedContext' 5 | import { LocalContext } from '../../localContext' 6 | 7 | export const GridItem = ({ children }) => { 8 | const [shared] = React.useContext(SharedContext) 9 | const [local] = React.useContext(LocalContext) 10 | 11 | /** 12 | * Indicating whether or not the user 13 | * is currently in layout edit mode 14 | * @type { Boolean } 15 | */ 16 | const userIsEditingLayout = shared?._connections[local.id]?.isEditingLayout 17 | 18 | return ( 19 |
20 |
21 | {children} 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/components/GridItem/style.css: -------------------------------------------------------------------------------- 1 | .GridItem { 2 | width: 100%; 3 | height: 100%; 4 | background: var(--base-color--shade-3pct); 5 | border-radius: 5px; 6 | 7 | box-shadow: inset 0 0 0 1px var(--base-color--shade-3pct); 8 | overflow: hidden; 9 | 10 | transition: 0.1s box-shadow; 11 | } 12 | 13 | .GridItem.is-editing { 14 | box-shadow: inset 0 0 0 1px var(--base-color--shade3); 15 | } 16 | 17 | .GridItem .GridItem-content { 18 | opacity: 1; 19 | transition: 0.2s opacity; 20 | } 21 | 22 | .GridItem.is-editing .GridItem-content { 23 | opacity: 0.3; 24 | pointer-events: none; 25 | } 26 | 27 | .GridItem-content { 28 | position: relative; 29 | width: 100%; 30 | height: 100%; 31 | } -------------------------------------------------------------------------------- /app/components/Icon/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import icons from '../../assets/icons' 3 | 4 | import './style.css' 5 | 6 | export function Icon ({ name = 'placeholder', color = 'var(--base-color)', originalColors = false }) { 7 | return ( 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /app/components/Icon/style.css: -------------------------------------------------------------------------------- 1 | .Icon { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | 6 | --Icon-color: var(--base-color); 7 | } 8 | 9 | .Icon:not(.Icon--originalColors) svg [stroke]:not([stroke='none']) { 10 | stroke: var(--Icon-color); 11 | } 12 | 13 | .Icon:not(.Icon--originalColors) svg [fill]:not([fill='none']) { 14 | fill: var(--Icon-color); 15 | } -------------------------------------------------------------------------------- /app/components/Layout/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import './style.css' 4 | 5 | export function Master ({ children, sidebar }) { 6 | return ( 7 |
8 |
9 | {sidebar} 10 |
11 |
12 | {children} 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/components/Layout/style.css: -------------------------------------------------------------------------------- 1 | .Layout--master { 2 | display: flex; 3 | width: 100%; 4 | } 5 | 6 | .Layout--master-main, 7 | .Layout--master-sidebar { 8 | overflow-y: scroll; 9 | } 10 | 11 | .Layout--master-sidebar { 12 | width: 250px; 13 | padding: 20px; 14 | 15 | border-right: 1px solid var(--base-color--shade); 16 | 17 | text-align: left; 18 | } 19 | 20 | .Layout--master-main { 21 | width: 100%; 22 | padding: 20px; 23 | 24 | flex: 1; 25 | } -------------------------------------------------------------------------------- /app/components/MessageContainer/style.css: -------------------------------------------------------------------------------- 1 | .MessageContainer { 2 | position: fixed; 3 | bottom: 10px; 4 | right: 10px; 5 | z-index: 1; 6 | } -------------------------------------------------------------------------------- /app/components/MissingComponent/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import icon from '../../assets/icons/warning.svg' 5 | 6 | export const MissingComponent = ({ data = {} }) => { 7 | return ( 8 |
9 |
10 |
11 |
Missing or crashing widget
12 | {data.component} 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/components/MissingComponent/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .MissingComponent { 4 | display: flex; 5 | width: 100%; 6 | height: 100%; 7 | 8 | text-align: center; 9 | align-items: center; 10 | } 11 | 12 | .MissingComponent-container { 13 | width: 100%; 14 | } 15 | 16 | .MissingComponent-heading { 17 | margin: 10px 0 3px; 18 | font-weight: 600; 19 | } -------------------------------------------------------------------------------- /app/components/Onboarding/onboarding.json: -------------------------------------------------------------------------------- 1 | { 2 | "updatedAt": "2024-05-16T12:22:00", 3 | "heading": "Welcome to Bridge", 4 | "paragraphs": [ 5 | { 6 | "icon": "edit", 7 | "heading": "Customize the workspace", 8 | "body": "Enter the edit mode to change the layout of your workspace, this mode is always available next to settings in the top right" 9 | }, 10 | { 11 | "icon": "widget", 12 | "heading": "Widgets", 13 | "body": "Use widgets to add functionality to your workspace" 14 | }, 15 | { 16 | "icon": "rundown", 17 | "heading": "Rundown", 18 | "body": "Add items to your rundown by right-clicking or dragging them in" 19 | }, 20 | { 21 | "icon": "inspector", 22 | "heading": "Inspector", 23 | "body": "The inspector lets you edit items" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /app/components/Onboarding/style.css: -------------------------------------------------------------------------------- 1 | .Onboarding { 2 | display: flex; 3 | padding: 30px; 4 | height: 100%; 5 | 6 | flex-direction: column; 7 | box-sizing: border-box; 8 | } 9 | 10 | .Onboarding-paragraphs { 11 | height: 100%; 12 | margin: 15px 20px 0; 13 | text-align: left; 14 | } 15 | 16 | .Onboarding-paragraph { 17 | display: flex; 18 | margin-bottom: 15px; 19 | 20 | align-items: center; 21 | } 22 | 23 | .Onboarding-paragraphIcon { 24 | margin-right: 15px; 25 | } -------------------------------------------------------------------------------- /app/components/Palette/integrations/index.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | import items from './items' 6 | 7 | /** 8 | * Export all available integrations 9 | * 10 | * All integrations must export a definition object, 11 | * see the 'items' integration for an example 12 | */ 13 | export default [ 14 | items 15 | ] 16 | -------------------------------------------------------------------------------- /app/components/Popover/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import './style.css' 4 | 5 | export function Popover ({ children, open = false, onClose = () => {} }) { 6 | const elRef = React.useRef() 7 | 8 | React.useEffect(() => { 9 | function close (e) { 10 | onClose() 11 | } 12 | window.addEventListener('blur', close) 13 | return () => { 14 | window.removeEventListener('blur', close) 15 | } 16 | }, [onClose]) 17 | 18 | React.useEffect(() => { 19 | function close (e) { 20 | if (e.composedPath().includes(elRef.current)) { 21 | return 22 | } 23 | onClose() 24 | } 25 | window.addEventListener('click', close, true) 26 | window.addEventListener('contextmenu', close, true) 27 | return () => { 28 | window.removeEventListener('click', close) 29 | window.removeEventListener('contextmenu', close) 30 | } 31 | }, [onClose]) 32 | 33 | return ( 34 |
35 | { 36 | open && 37 | ( 38 |
39 | {children} 40 |
41 | ) 42 | } 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /app/components/Popover/style.css: -------------------------------------------------------------------------------- 1 | .Popover, 2 | .Popover-trigger { 3 | position: relative; 4 | } 5 | 6 | .Popover-content { 7 | display: block; 8 | position: absolute; 9 | 10 | left: 50%; 11 | bottom: 0; 12 | 13 | text-align: center; 14 | 15 | border-radius: 15px; 16 | background: var(--base-color--background); 17 | 18 | box-sizing: border-box; 19 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 20 | 21 | transform: translate(-50%, 100%); 22 | 23 | overflow: hidden; 24 | z-index: 1; 25 | } 26 | -------------------------------------------------------------------------------- /app/components/Popup/confirm.css: -------------------------------------------------------------------------------- 1 | .PopupConfirm-actions { 2 | display: flex; 3 | margin-top: 20px; 4 | justify-content: space-around; 5 | } 6 | 7 | .PopupConfirm-confirmAction { 8 | font-weight: 500; 9 | } -------------------------------------------------------------------------------- /app/components/Popup/confirm.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Popup } from '.' 3 | 4 | import './confirm.css' 5 | 6 | export function PopupConfirm ({ children, open, confirmText = 'Confirm', abortText = 'Abort', onChange = () => {} }) { 7 | return ( 8 | 9 | {children} 10 |
11 | 12 | 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/components/Popup/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import './style.css' 4 | 5 | export function Popup ({ children, open }) { 6 | return ( 7 |
8 |
9 | {children} 10 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /app/components/Popup/shortcut.css: -------------------------------------------------------------------------------- 1 | .PopupShortcut-actions { 2 | display: flex; 3 | margin-top: 20px; 4 | justify-content: space-around; 5 | } 6 | 7 | .PopupShortcut-confirmAction { 8 | font-weight: 500; 9 | } 10 | 11 | .PopupShortcut-preview { 12 | display: flex; 13 | height: 2em; 14 | 15 | padding: 0 10px; 16 | 17 | align-items: center; 18 | justify-content: center; 19 | 20 | animation-name: PopupShortcut-preview-fade; 21 | animation-duration: 1s; 22 | animation-iteration-count: infinite; 23 | } 24 | 25 | @keyframes PopupShortcut-preview-fade { 26 | 0% { 27 | opacity: 1; 28 | } 29 | 30 | 50% { 31 | opacity: 0.5; 32 | } 33 | 34 | 100% { 35 | opacity: 1; 36 | } 37 | } 38 | 39 | .PopupShortcut-description { 40 | width: 100%; 41 | margin-top: 5px; 42 | margin-bottom: 10px; 43 | 44 | text-align: center; 45 | opacity: 0.5; 46 | } 47 | -------------------------------------------------------------------------------- /app/components/Popup/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .Popup { 4 | display: flex; 5 | position: fixed; 6 | top: 0; 7 | right: 0; 8 | bottom: 0; 9 | left: 0; 10 | 11 | pointer-events: none; 12 | 13 | background: rgba(0, 0, 0, 0.6); 14 | color: var(--base-color); 15 | 16 | z-index: 10; 17 | 18 | align-items: center; 19 | justify-content: center; 20 | 21 | opacity: 0; 22 | 23 | transition: 0.1s; 24 | } 25 | 26 | .Popup.is-open { 27 | opacity: 1; 28 | pointer-events: all; 29 | } 30 | 31 | .Popup-content { 32 | display: block; 33 | width: calc(100% - 20px); 34 | max-width: 320px; 35 | 36 | height: auto; 37 | padding: 20px 15px; 38 | 39 | text-align: center; 40 | 41 | border-radius: 15px; 42 | background: var(--base-color--background); 43 | 44 | box-sizing: border-box; 45 | 46 | transform: translate(0, 10px); 47 | transition: 0.2s; 48 | } 49 | 50 | .Popup.is-open .Popup-content { 51 | transform: translate(0, 0); 52 | } 53 | 54 | .Popup h1 { 55 | font-size: 1.5em; 56 | } -------------------------------------------------------------------------------- /app/components/Preferences/preference.css: -------------------------------------------------------------------------------- 1 | .Preferences-preference { 2 | margin-bottom: 20px; 3 | text-align: left; 4 | } 5 | 6 | .Preferences-preferenceTitle, 7 | .Preferences-preferenceDescription { 8 | display: block; 9 | } 10 | 11 | .Preferences-preferenceDescription { 12 | max-width: 500px; 13 | } -------------------------------------------------------------------------------- /app/components/Preferences/sections/appearance.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Theme", 4 | "description": "The application's color theme", 5 | "inputs": [ 6 | { 7 | "type": "theme", 8 | "bind": "local.theme", 9 | "items": [ 10 | { "label": "Dark", "color": "#232427", "value": "dark" }, 11 | { "label": "Light", "color": "#EBEBEB", "value": "light" }, 12 | { "label": "Purple", "color": "#641E78", "value": "purple" } 13 | ] 14 | } 15 | ] 16 | } 17 | ] -------------------------------------------------------------------------------- /app/components/Preferences/sections/general.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Version", 4 | "inputs": [ 5 | { "type": "version" } 6 | ] 7 | }, 8 | { 9 | "title": "HTTP port", 10 | "description": "This setting requires a full restart", 11 | "inputs": [ 12 | { "type": "number", "bind": "shared._userDefaults.httpPort", "min": 3000, "max": 65535 } 13 | ] 14 | }, 15 | { 16 | "title": "HTTP bind to all ip adresses", 17 | "description": "This setting requires a full restart", 18 | "inputs": [ 19 | { "type": "boolean", "bind": "shared._userDefaults.httpBindToAll", "label": "Bind to 0.0.0.0" } 20 | ] 21 | }, 22 | { 23 | "title": "Hide messages", 24 | "description": "Hide all status messages that are normally shown in the bottom right of the app", 25 | "inputs": [ 26 | { "type": "boolean", "bind": "shared._userDefaults.hideMessages", "label": "Hide messages" } 27 | ] 28 | } 29 | ] -------------------------------------------------------------------------------- /app/components/Preferences/sections/shortcuts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Shortcuts", 4 | "description": "Customize registered keyboard shortcuts", 5 | "inputs": [ 6 | { 7 | "type": "shortcuts" 8 | } 9 | ] 10 | } 11 | ] -------------------------------------------------------------------------------- /app/components/Preferences/sections/state.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Clear items", 4 | "description": "Will remove all items from the current workspace without emitting the remove event", 5 | "inputs": [ 6 | { "type": "clear", "bind": "shared.items", "label": "Clear all items" } 7 | ] 8 | } 9 | ] -------------------------------------------------------------------------------- /app/components/Preferences/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .Preferences { 4 | display: flex; 5 | position: relative; 6 | 7 | height: 100%; 8 | 9 | flex-direction: column; 10 | } 11 | 12 | .Preferences > * { 13 | display: flex; 14 | } 15 | 16 | .Preferences-content { 17 | /* 18 | Setting the height to 1px 19 | lets the pane grow to fit 20 | the parent rather than shrink, 21 | which would result in issues 22 | with the scrolling views 23 | */ 24 | height: 1px; 25 | flex: 1; 26 | } 27 | 28 | .Preferences-footer { 29 | display: flex; 30 | width: 100%; 31 | 32 | padding: 10px 10px 10px 30px; 33 | 34 | border-top: 1px solid var(--base-color--shade); 35 | 36 | align-items: center; 37 | justify-content: space-between; 38 | 39 | box-sizing: border-box; 40 | } -------------------------------------------------------------------------------- /app/components/PreferencesBooleanInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import * as random from '../../utils/random' 5 | 6 | export function PreferencesBooleanInput ({ label, value = false, onChange = () => {} }) { 7 | const [id] = React.useState(`number-${random.number()}`) 8 | return ( 9 |
10 | onChange(e.target.checked)} /> 11 | 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/components/PreferencesBooleanInput/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .PreferencesBooleanInput { 4 | margin: 5px 0; 5 | } -------------------------------------------------------------------------------- /app/components/PreferencesClearStateInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import { PopupConfirm } from '../Popup/confirm' 5 | 6 | export function PreferencesClearStateInput ({ label, onChange = () => {} }) { 7 | const [confirmIsOpen, setConfirmIsOpen] = React.useState(false) 8 | 9 | function handleCloseConfirm (confirm) { 10 | if (confirm) { 11 | onChange({ $delete: true }) 12 | } 13 | setConfirmIsOpen(false) 14 | } 15 | 16 | return ( 17 | <> 18 | handleCloseConfirm(confirm)}> 19 |
{label}
20 | This action is irreversible 21 |
22 |
23 | 26 |
27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /app/components/PreferencesClearStateInput/style.css: -------------------------------------------------------------------------------- 1 | .PreferencesClearStateInput { 2 | margin: 10px 0 0; 3 | } -------------------------------------------------------------------------------- /app/components/PreferencesFrameInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import { Frame } from '../Frame' 5 | 6 | import * as api from '../../api' 7 | 8 | export function PreferencesFrameInput ({ label, uri }) { 9 | const [bridge, setBridge] = React.useState() 10 | 11 | React.useEffect(() => { 12 | async function setup () { 13 | setBridge(await api.load()) 14 | } 15 | setup() 16 | }, []) 17 | 18 | return ( 19 |
20 | { 21 | bridge && 22 | } 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/components/PreferencesFrameInput/style.css: -------------------------------------------------------------------------------- 1 | .PreferencesFrameInput { 2 | padding: 15px 0; 3 | } -------------------------------------------------------------------------------- /app/components/PreferencesNumberInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import * as random from '../../utils/random' 5 | 6 | export function PreferencesNumberInput ({ label, value = '', min = 0, max = 10, onChange = () => {} }) { 7 | const [id] = React.useState(`number-${random.number()}`) 8 | const [error, setError] = React.useState() 9 | 10 | React.useEffect(() => { 11 | if (value < min) { 12 | setError(`Cannot be less than ${min}`) 13 | return 14 | } 15 | 16 | if (value > max) { 17 | setError(`Cannot be more than ${max}`) 18 | return 19 | } 20 | 21 | setError(undefined) 22 | }, [value]) 23 | 24 | return ( 25 |
26 | onChange(e.target.value)} /> 27 | 28 | { 29 | error && 30 |
{error}
31 | } 32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/components/PreferencesNumberInput/style.css: -------------------------------------------------------------------------------- 1 | .PreferencesNumberInput { 2 | display: flex; 3 | margin-top: 10px; 4 | 5 | align-items: center; 6 | } 7 | 8 | .PreferencesNumberInput-input { 9 | margin-right: 10px; 10 | } 11 | 12 | .PreferencesNumberInput-error { 13 | display: inline-block; 14 | position: relative; 15 | margin-left: 10px; 16 | padding: 5px 10px; 17 | 18 | color: var(--base-color--alert); 19 | border-radius: 6px; 20 | 21 | overflow: hidden; 22 | } 23 | 24 | .PreferencesNumberInput-error::after { 25 | content: ''; 26 | position: absolute; 27 | 28 | top: 0; 29 | left: 0; 30 | width: 100%; 31 | height: 100%; 32 | 33 | background: var(--base-color--alert); 34 | opacity: 0.2; 35 | } -------------------------------------------------------------------------------- /app/components/PreferencesSegmentedInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import { SegmentedControl } from '../SegmentedControl' 5 | 6 | export function PreferencesSegmentedInput ({ label, value, segments = [], onChange = () => {} }) { 7 | return ( 8 |
9 | 10 |
11 | onChange(segments.indexOf(newValue))} 15 | /> 16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /app/components/PreferencesSegmentedInput/style.css: -------------------------------------------------------------------------------- 1 | .PreferencesSegmentedInput-controlWrapper { 2 | margin-top: 10px; 3 | } -------------------------------------------------------------------------------- /app/components/PreferencesSelectInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import * as random from '../../utils/random' 5 | 6 | export function PreferencesSelectInput ({ label, value, options = [], onChange = () => {} }) { 7 | const [id] = React.useState(`number-${random.number()}`) 8 | return ( 9 |
10 | 11 | 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/components/PreferencesSelectInput/style.css: -------------------------------------------------------------------------------- 1 | .PreferencesSelectInput select { 2 | margin-top: 10px; 3 | } -------------------------------------------------------------------------------- /app/components/PreferencesShortcutsInput/style.css: -------------------------------------------------------------------------------- 1 | .PreferencesShortcutsInput-list { 2 | padding: 0; 3 | list-style: none; 4 | } 5 | 6 | .PreferencesShortcutsInput-listItem { 7 | display: flex; 8 | padding: 0.5em; 9 | border-radius: 6px; 10 | box-sizing: border-box; 11 | } 12 | 13 | .PreferencesShortcutsInput-listItem:nth-child(odd) { 14 | background: var(--base-color--shade1); 15 | } 16 | 17 | .PreferencesShortcutsInput-description { 18 | width: 100%; 19 | } 20 | 21 | .PreferencesShortcutsInput-description.is-empty { 22 | opacity: 0.5; 23 | } 24 | 25 | .PreferencesShortcutsInput-trigger { 26 | display: flex; 27 | 28 | text-align: right; 29 | 30 | flex-shrink: 0; 31 | opacity: 0.5; 32 | } 33 | 34 | .PreferencesShortcutsInput-listItem.has-changed .PreferencesShortcutsInput-trigger { 35 | font-weight: 500; 36 | opacity: 1; 37 | } 38 | 39 | .PreferencesShortcutsInput-icon { 40 | margin-left: 5px; 41 | } -------------------------------------------------------------------------------- /app/components/PreferencesStringInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import * as random from '../../utils/random' 5 | 6 | export function PreferencesStringInput ({ label, value = '', onChange = () => {} }) { 7 | const [id] = React.useState(`number-${random.number()}`) 8 | return ( 9 |
10 | 11 | onChange(e.target.value)} /> 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/components/PreferencesStringInput/style.css: -------------------------------------------------------------------------------- 1 | .PreferencesStringInput-input { 2 | display: block; 3 | margin-top: 5px; 4 | } -------------------------------------------------------------------------------- /app/components/PreferencesThemeInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | /** 5 | * @example 6 | * "items": [ 7 | * { "label": "Dark", "color": "#232427", "value": "dark" }, 8 | * { "label": "Light", "color": "#EBEBEB", "value": "light" } 9 | * ] 10 | */ 11 | export function PreferencesThemeInput ({ items = [], value, onChange = () => {} }) { 12 | return ( 13 |
14 | { 15 | items 16 | .map((item, i) => { 17 | const isActive = value === item.value 18 | return ( 19 |
onChange(item.value)} 23 | > 24 |
25 |
26 | {item.label} 27 |
28 |
29 | ) 30 | }) 31 | } 32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/components/PreferencesThemeInput/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .PreferencesThemeInput-item { 4 | display: inline-block; 5 | margin: 15px 15px 10px 0; 6 | width: 80px; 7 | text-align: center; 8 | } 9 | 10 | .PreferencesThemeInput-itemColor { 11 | width: 100%; 12 | height: 50px; 13 | 14 | border-radius: 8px; 15 | } 16 | 17 | .PreferencesThemeInput-item.is-active .PreferencesThemeInput-itemColor { 18 | box-shadow: 0 0 0 3px var(--base-color--shade); 19 | } 20 | 21 | .PreferencesThemeInput-itemLabel { 22 | margin-top: 5px; 23 | } -------------------------------------------------------------------------------- /app/components/PreferencesVersionInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | function getEnvironment () { 5 | if (window.navigator.userAgent.includes('Bridge')) { 6 | return 'electron' 7 | } 8 | return 'web' 9 | } 10 | 11 | export function PreferencesVersionInput () { 12 | return ( 13 |
14 | {window.APP.version || 'Unknown'} ({getEnvironment()}) 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/components/PreferencesVersionInput/style.css: -------------------------------------------------------------------------------- 1 | .PreferencesVersionInput { 2 | padding: 1em; 3 | 4 | background: var(--base-color--shade1); 5 | border-radius: 6px; 6 | } -------------------------------------------------------------------------------- /app/components/Role/style.css: -------------------------------------------------------------------------------- 1 | .Role { 2 | width: 300px; 3 | 4 | text-align: left; 5 | font-size: 1em; 6 | color: var(--base-color); 7 | } 8 | 9 | .Role-content { 10 | padding: 10px; 11 | box-sizing: border-box; 12 | } 13 | 14 | .Role-info { 15 | margin: 5px; 16 | } 17 | 18 | .Role-status { 19 | margin-top: 10px; 20 | opacity: 0.7; 21 | } -------------------------------------------------------------------------------- /app/components/Router/router.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {{ 3 | * path: String | RegExp, 4 | * render: Function. 5 | * }} Route 6 | */ 7 | 8 | /** 9 | * Try to find the first matching 10 | * route to a specified pathname 11 | * @param { String } pathname 12 | * @param { Route[] } routes 13 | * @returns { Route | undefined } 14 | */ 15 | function findRoute (pathname, routes) { 16 | for (const route of routes) { 17 | if (typeof route.path === 'string' && route.path === pathname) { 18 | return route 19 | } 20 | if (route.path instanceof RegExp && route.path.test(pathname)) { 21 | return route 22 | } 23 | } 24 | } 25 | exports.findRoute = findRoute 26 | -------------------------------------------------------------------------------- /app/components/Router/router.unit.test.js: -------------------------------------------------------------------------------- 1 | const router = require('./router.js') 2 | 3 | /** 4 | * @type { import('./router.js').Route[] } 5 | */ 6 | const ROUTES = [ 7 | { 8 | path: '/', 9 | render: () => 'root' 10 | }, 11 | { 12 | path: '/foo', 13 | render: () => 'foo' 14 | }, 15 | { 16 | path: '/foo/bar', 17 | render: () => 'foobar' 18 | }, 19 | { 20 | path: /^\/qux\/.+$/, 21 | render: () => 'qux' 22 | } 23 | ] 24 | 25 | test('find routes with string paths', () => { 26 | expect(router.findRoute('/', ROUTES)?.render()).toBe('root') 27 | expect(router.findRoute('/foo', ROUTES)?.render()).toBe('foo') 28 | expect(router.findRoute('/foo/bar', ROUTES)?.render()).toBe('foobar') 29 | expect(router.findRoute('/baz', ROUTES)?.render()).toBe(undefined) 30 | }) 31 | 32 | test('find routes with regex paths', () => { 33 | expect(router.findRoute('/qux', ROUTES)?.render()).toBe(undefined) 34 | expect(router.findRoute('/qux/foo', ROUTES)?.render()).toBe('qux') 35 | expect(router.findRoute('/qux/foo/bar', ROUTES)?.render()).toBe('qux') 36 | }) 37 | -------------------------------------------------------------------------------- /app/components/SegmentedControl/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | export function SegmentedControl ({ className = '', value: _value, values = [], onChange = () => {} }) { 5 | return ( 6 |
7 | { 8 | (Array.isArray(values) ? values : []) 9 | .map(value => { 10 | const isActive = value === _value 11 | return ( 12 |
!isActive && onChange(value)} 16 | > 17 | {value} 18 |
19 | ) 20 | }) 21 | } 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/components/SegmentedControl/style.css: -------------------------------------------------------------------------------- 1 | .SegmentedControl { 2 | display: inline-block; 3 | padding: 4px; 4 | 5 | border-radius: 8px; 6 | background: var(--base-color--shade1); 7 | white-space: nowrap; 8 | } 9 | 10 | .SegmentedControl-segment { 11 | display: inline-block; 12 | padding: 0.75em 1em; 13 | color: var(--base-color); 14 | 15 | border-radius: 5px; 16 | } 17 | 18 | .SegmentedControl-segment.is-active { 19 | background: var(--base-color--background); 20 | box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1); 21 | } -------------------------------------------------------------------------------- /app/components/Sharing/style.css: -------------------------------------------------------------------------------- 1 | .Sharing { 2 | width: 300px; 3 | 4 | text-align: left; 5 | font-size: 1em; 6 | color: var(--base-color); 7 | } 8 | 9 | .Sharing-content { 10 | padding: 10px; 11 | box-sizing: border-box; 12 | } 13 | 14 | .Sharing-copyBtn { 15 | margin-top: 15px; 16 | } 17 | 18 | .Sharing-icon { 19 | margin: 5px 0 10px; 20 | } 21 | 22 | .Sharing-notification { 23 | padding: 5px; 24 | } -------------------------------------------------------------------------------- /app/components/VerticalNavigation/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .VerticalNavigation-section { 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | .VerticalNavigation-section:not(:first-child) { 9 | margin-top: 2em; 10 | } 11 | 12 | .VerticalNavigation-section .VerticalNavigation-sectionLabel { 13 | margin-left: 8px; 14 | margin-bottom: 10px; 15 | } 16 | 17 | .VerticalNavigation-item { 18 | display: block; 19 | padding: 0.55em 0.5em; 20 | border-radius: 6px; 21 | 22 | text-decoration: none; 23 | } 24 | 25 | .VerticalNavigation-item:hover { 26 | background: var(--base-color--shade1); 27 | } 28 | 29 | .VerticalNavigation-item.is-active { 30 | font-weight: 500; 31 | background: var(--base-color--shade); 32 | } 33 | -------------------------------------------------------------------------------- /app/components/WidgetSelector/style.css: -------------------------------------------------------------------------------- 1 | .WidgetSelector { 2 | display: flex; 3 | position: relative; 4 | 5 | height: 100%; 6 | 7 | flex-direction: column; 8 | } 9 | 10 | .WidgetSelector-header { 11 | padding: 0.75em; 12 | border-bottom: 1px solid var(--base-color--shade); 13 | } 14 | 15 | .WidgetSelector-search { 16 | width: 100%; 17 | } 18 | 19 | .WidgetSelector-footer { 20 | display: flex; 21 | width: 100%; 22 | 23 | padding: 10px; 24 | 25 | border-top: 1px solid var(--base-color--shade); 26 | 27 | align-items: center; 28 | justify-content: center; 29 | 30 | box-sizing: border-box; 31 | } 32 | 33 | .WidgetSelector-list { 34 | height: 100%; 35 | text-align: left; 36 | overflow-y: scroll; 37 | } 38 | 39 | .WidgetSelector-listItem { 40 | display: flex; 41 | padding: 0.75em; 42 | padding-bottom: 1.2em; 43 | } 44 | 45 | .WidgetSelector-listItemCheck { 46 | display: flex; 47 | margin-right: 10px; 48 | } 49 | 50 | .WidgetSelector-listItem:nth-child(even) { 51 | background: var(--base-color--grey1); 52 | } 53 | 54 | .WidgetSelector-widgetId { 55 | font-size: 0.9em; 56 | opacity: 0.7; 57 | } 58 | 59 | .WidgetSelector-widgetDescription { 60 | margin-top: 7px; 61 | } -------------------------------------------------------------------------------- /app/fonts.css: -------------------------------------------------------------------------------- 1 | @import "./assets/fonts/inter.css"; 2 | @import "./assets/fonts/bridge-glyphs.css"; -------------------------------------------------------------------------------- /app/hooks/useJson.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | import React from 'react' 6 | 7 | export const useJson = url => { 8 | const [data, setData] = React.useState({}) 9 | 10 | React.useEffect(() => { 11 | async function get (url) { 12 | const res = await window.fetch(url) 13 | .then(res => res.json()) 14 | setData(res) 15 | } 16 | get(url) 17 | 18 | return () => setData({}) 19 | }, [url]) 20 | 21 | return [data] 22 | } 23 | -------------------------------------------------------------------------------- /app/index.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | @import "./theme.css"; 4 | @import "./bridge.css"; 5 | 6 | html, body, #root { 7 | height: 100%; 8 | min-height: 100vh; 9 | background: var(--base-color--background); 10 | } 11 | 12 | #root { 13 | display: flex; 14 | flex-direction: column; 15 | } 16 | 17 | .View-component { 18 | display: contents; 19 | } 20 | 21 | /* Text */ 22 | 23 | .u-textAlign--center { 24 | text-align: center; 25 | } 26 | 27 | /* Size */ 28 | 29 | .u-width--100pct { 30 | width: 100%; 31 | } 32 | -------------------------------------------------------------------------------- /app/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import App from './App' 5 | 6 | import * as _console from './utils/console' 7 | _console.init() 8 | 9 | import './index.css' 10 | 11 | ReactDOM.render( 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ) -------------------------------------------------------------------------------- /app/localContext.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | import React from 'react' 6 | 7 | /** 8 | * A context for holding data meant 9 | * to only be accessible locally 10 | * 11 | * @see {@link ./App.js} 12 | * 13 | * @type { React.Context } 14 | */ 15 | export const LocalContext = React.createContext() 16 | -------------------------------------------------------------------------------- /app/sharedContext.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | import React from 'react' 6 | 7 | /** 8 | * A context for being shared 9 | * across active clients 10 | * 11 | * @see {@link ./App.js} 12 | * 13 | * @type { React.Context } 14 | */ 15 | export const SharedContext = React.createContext() 16 | -------------------------------------------------------------------------------- /app/socketContext.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | import React from 'react' 6 | 7 | /** 8 | * A context for exposing the 9 | * websocket responsible for ipc 10 | * 11 | * @see {@link ./App.js} 12 | * 13 | * @type { React.Context } 14 | */ 15 | export const SocketContext = React.createContext() 16 | -------------------------------------------------------------------------------- /app/utils/browser.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | /** 6 | * Returns whether or not the current 7 | * window is running as an electron app 8 | * by checking the user agent 9 | * @returns { Boolean } 10 | */ 11 | export function isElectron () { 12 | return window.navigator.userAgent.includes('Bridge') 13 | } 14 | 15 | /** 16 | * Get the current platform as a string 17 | * @return { String } 18 | */ 19 | export function platform () { 20 | const alternatives = [ 21 | { 22 | if: value => value.indexOf('mac') >= 0, 23 | newValue: 'darwin' 24 | }, 25 | { 26 | if: value => value.indexOf('linux') >= 0, 27 | newValue: 'linux' 28 | }, 29 | { 30 | if: value => value.indexOf('win') >= 0, 31 | newValue: 'windows' 32 | } 33 | ] 34 | const nav = window.navigator.platform 35 | 36 | for (const match of alternatives) { 37 | if (match.if(nav.toLowerCase())) { 38 | return match.newValue 39 | } 40 | } 41 | return nav 42 | } 43 | -------------------------------------------------------------------------------- /app/utils/clipboard.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | /** 6 | * Copy a string into the clipboard 7 | * @param { String } str A string to copy 8 | * @returns { Promise. } 9 | */ 10 | export function copyText (str) { 11 | return navigator.clipboard.writeText(str) 12 | } 13 | 14 | /** 15 | * Read the string stored in the clipboard, 16 | * will return an empty string if the clipboard is empty 17 | * @returns { Promise. } 18 | */ 19 | export function readText () { 20 | return navigator.clipboard.readText() 21 | } 22 | -------------------------------------------------------------------------------- /app/utils/console.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Init the development console 3 | * with a welcome message 4 | */ 5 | export function init () { 6 | console.log('%c[APP] Bridge development console', 'font-weight: 600; color: #E543FF') 7 | } 8 | -------------------------------------------------------------------------------- /app/utils/fetch.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const API_HOST = (function () { 6 | if (window.APP.apiHost) { 7 | return window.APP.apiHost 8 | } 9 | return '' 10 | })() 11 | 12 | export const host = API_HOST 13 | 14 | export function api (path) { 15 | return window.fetch(`${API_HOST}${path}`) 16 | } 17 | -------------------------------------------------------------------------------- /app/utils/random.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | /** 6 | * Generate a random number 7 | * @param { Number } length The length of the number to generate 8 | * @returns { Number } 9 | */ 10 | export function number (length = 5) { 11 | return Math.floor(Math.random() * Math.pow(10, length)) 12 | } 13 | -------------------------------------------------------------------------------- /app/views/Start.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Axel Boberg 3 | */ 4 | 5 | import React from 'react' 6 | 7 | export const Start = () => { 8 | return ( 9 | <> 10 |
11 |

Welcome

12 | New workspace 13 |
14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | version: '3.9' 6 | services: 7 | app: 8 | build: . 9 | command: npm run nodemon 10 | volumes: 11 | - .:/app 12 | environment: 13 | - NODE_ENV=development 14 | - PORT=3000 15 | - APP_DATA_BASE_PATH=../data 16 | ports: 17 | - 3000:3000 18 | - 9229:9229 -------------------------------------------------------------------------------- /docs/build.md: -------------------------------------------------------------------------------- 1 | # Build instructions 2 | 3 | As Bridge was created with multiple platforms in mind from the start making builds should be a rather painless process and not depend on any specific tooling. 4 | 5 | Unless specified the guidelines below apply to all supported platforms. 6 | 7 | ## Creating an Electron production build 8 | 9 | 1. **Make sure a compatible version of Node is installed on your system** 10 | Most often the preferred version is the latest LTS release. 11 | 12 | 2. **Install dependencies** 13 | Run `npm ci` in the project root. This will automatically install dependencies for the core software and any bundled plugins. 14 | 15 | 3. **Create a build** 16 | Run one of the build commands specified in `package.json`. These differ based on the platform used. A binary will be created in the `bin` directory. 17 | `npm run electron:build:mac:arm` 18 | `npm run electron:build:mac:intel` 19 | `npm run electron:build:win` -------------------------------------------------------------------------------- /docs/plugins/installation.md: -------------------------------------------------------------------------------- 1 | # Installing plugins 2 | 3 | Installing plugins to Bridge is as simple as placing the plugin source in the plugins directory on your computer. 4 | 5 | ## 1. Find the plugin directory 6 | The easiest way of finding the plugin directory is opening Bridge and going to "Manage Plugins..." in the application menu. This will open the correct path in Finder / Explorer. 7 | 8 | ![Manage Plugins](/media/docs/plugins/manage-plugins.png) 9 | 10 | ## 2. Copy the plugin source 11 | Place the plugin's source directory into Bridge's plugins directory. This is a simple matter of copy/paste. 12 | 13 | ![Plugins directory](/media/docs/plugins/directory.png) 14 | 15 | ## 3. Restart Bridge 16 | Restart Bridge to enjoy your newly installed plugin. -------------------------------------------------------------------------------- /docs/structure.md: -------------------------------------------------------------------------------- 1 | # Project structure 2 | 3 | This repository contains the code needed for the node process (Electron main), the frontend (Electron renderer), the extension api and documentation. 4 | 5 | The file tree looks like the following: 6 | ```sh 7 | bridge 8 | |- api # The plugin api, shared between processes 9 | |- browser # Browser specific code 10 | |- node # Node specific code 11 | |- app # Frontend code 12 | |- assets # Bundled assets 13 | |- components # React components 14 | |- hooks # React hooks 15 | |- utils # Utility files 16 | |- views # Views 17 | |- docs # Documentation 18 | |- examples # Plugin examples 19 | |- lib # Node/backend 20 | |- media # Static media for the documentation 21 | |- plugins # Bundled plugins 22 | |- public # Static files served by the web server 23 | |- shared # Code shared between processes 24 | |- scripts # Helper scripts used by the build process e.t.c. 25 | 26 | These are created during the build process: 27 | 28 | |- dist # Bundled js and css files, webpack output 29 | |- bin # The built electron app 30 | ``` -------------------------------------------------------------------------------- /docs/types.md: -------------------------------------------------------------------------------- 1 | # Types 2 | All items have a type. It's a blueprint for the data it contains and in some cases how it should be rendered in the UI. Bridge comes with a set of server agnostic bundled types. 3 | 4 | ## Bundled types 5 | 6 | ### Groups 7 | The group type is a container for multiple items that belong together. Groups can be nested and provides alternatives for playing their children. 8 | 9 | ### Divider 10 | Items of this type cannot be played. They are simply dividers with a name, color and note. 11 | 12 | ### Variable 13 | Variables set a value to the state when played. Their values can be used throughout the application. 14 | 15 | ### Reference 16 | A reference is a pointer to another item. It's useful when an item needs to be present as more than one instance but without duplicating its data. -------------------------------------------------------------------------------- /examples/plugin-custom-types/README.md: -------------------------------------------------------------------------------- 1 | # Custom types example plugin 2 | This is an example plugin for showcasing how to create custom types that can be run within Bridge. 3 | The type definition is located within [package.json](./package.json) while the type-specific logic resides in [index.js](./index.js). -------------------------------------------------------------------------------- /examples/plugin-custom-types/index.js: -------------------------------------------------------------------------------- 1 | const bridge = require('bridge') 2 | 3 | /* 4 | The exported 'activate' function is the plugin's initialization function, 5 | it will be called when the plugin is loaded and every time a workspace 6 | is opened 7 | */ 8 | exports.activate = async () => { 9 | /* 10 | Listen for the item.play event to react to an item being played, 11 | the item's type-property can be used to perform the correct action 12 | 13 | The type 'plugin-custom-types.my-type' is in this case 14 | defined within the plugin's package.json file 15 | */ 16 | bridge.events.on('item.play', item => { 17 | if (item?.type === 'plugin-custom-types.my-type') { 18 | // Perform action 19 | console.log('MY CUSTOM TYPE PLAYED') 20 | } 21 | }) 22 | 23 | bridge.events.on('item.stop', item => { 24 | if (item?.type === 'plugin-custom-types.my-type') { 25 | // Perform action 26 | console.log('MY CUSTOM TYPE STOPPED') 27 | } 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /examples/plugin-custom-types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin-custom-types", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "engines": { 10 | "bridge": "^0.0.1" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "contributes": { 15 | "types": [ 16 | { 17 | "id": "plugin-custom-types.my-type", 18 | "name": "My custom type", 19 | "inherits": "bridge.types.delayable", 20 | "category": "Custom types", 21 | "properties": { 22 | "myValue": { 23 | "name": "My custom value", 24 | "type": "string", 25 | "default": "Default value", 26 | "ui.group": "My custom type plugin" 27 | } 28 | } 29 | } 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/plugin-hello-world/README.md: -------------------------------------------------------------------------------- 1 | # Hello World example plugin 2 | This is an example plugin registering a 'Hello World widget' in the Bridge UI 3 | Copy the entire directory to Bridge's plugin path and restart Bridge to make it appear in the widget selection menu in the edit mode. 4 | 5 | **Tip: find the plugin path via the application's menu bar after you've started Bridge. Plugins > Manage Plugins...** -------------------------------------------------------------------------------- /examples/plugin-hello-world/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin-hello-world", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "engines": { 10 | "bridge": "^0.0.1" 11 | }, 12 | "author": "", 13 | "license": "ISC" 14 | } 15 | -------------------------------------------------------------------------------- /extra.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeExtensions 9 | 10 | bridge 11 | 12 | CFBundleTypeIconFile 13 | /media/appicon.icns 14 | CFBundleTypeName 15 | Bridge workspace 16 | CFBundleTypeOSTypes 17 | 18 | **** 19 | 20 | CFBundleTypeRole 21 | Editor 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const platform = require('./lib/platform') 6 | 7 | /* 8 | Do required initialization 9 | */ 10 | require('./lib/init-common') 11 | require('./lib/server') 12 | 13 | if (platform.isElectron()) { 14 | require('./lib/init-electron') 15 | } else { 16 | require('./lib/init-node') 17 | } 18 | -------------------------------------------------------------------------------- /lib/StaticFileRegistry.unit.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const StaticFileRegistry = require('./StaticFileRegistry') 6 | 7 | test('to create file hash', () => { 8 | const hash = 'f5e44c40330ce54abdac1c1a7c54b403bbc339ae25779a16d5d7d3e45c1e77dc' 9 | expect(StaticFileRegistry.getInstance().serve('/path/to/file')).toEqual(hash) 10 | }) 11 | 12 | test('to store file reference', () => { 13 | const hash = 'f5e44c40330ce54abdac1c1a7c54b403bbc339ae25779a16d5d7d3e45c1e77dc' 14 | StaticFileRegistry.getInstance().serve('/path/to/file') 15 | expect(StaticFileRegistry.getInstance()._map.has(hash)).toBeTruthy() 16 | }) 17 | 18 | test('should stop servng file', () => { 19 | const hash = '7d5de2a2f389ae55218d993ca620eddd589a8fcd72b4d848c3ef5770db46c08b' 20 | StaticFileRegistry.getInstance().serve('/myFile') 21 | StaticFileRegistry.getInstance().remove(hash) 22 | expect(StaticFileRegistry.getInstance()._map.has(hash)).toBeFalsy() 23 | }) 24 | -------------------------------------------------------------------------------- /lib/UserDefaults.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const State = require('./State') 6 | 7 | /** 8 | * A state representing user defaults, 9 | * that is settings for the current local 10 | * machine that is shared between workspaces 11 | * @type { State } 12 | */ 13 | module.exports = new State() 14 | -------------------------------------------------------------------------------- /lib/api/CommandHandler.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class CommandHandler { 6 | /** 7 | * Create a new handler 8 | * @param { HandlerFunction } handlerFn The handler's function 9 | * @param { String } owner The owner of the handler 10 | */ 11 | constructor (handlerFn, owner) { 12 | /** 13 | * @readonly 14 | * @type { HandlerFunction } 15 | */ 16 | this.call = handlerFn 17 | 18 | /** 19 | * @readonly 20 | * @type { String } 21 | */ 22 | this.owner = owner 23 | } 24 | } 25 | module.exports = CommandHandler 26 | -------------------------------------------------------------------------------- /lib/api/SClient.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const DIController = require('../../shared/DIController') 6 | const DIBase = require('../../shared/DIBase') 7 | 8 | class SClient extends DIBase { 9 | constructor (...args) { 10 | super(...args) 11 | this.#setup() 12 | } 13 | 14 | #setup () { 15 | this.props.SCommands.registerCommand('client.heartbeat', this.heartbeat.bind(this)) 16 | } 17 | 18 | heartbeat (connectionId) { 19 | this.props.SState.applyState({ 20 | _connections: { 21 | [connectionId]: { heartbeat: Date.now() } 22 | } 23 | }) 24 | } 25 | } 26 | 27 | DIController.main.register('SClient', SClient, [ 28 | 'SCommands', 29 | 'SState' 30 | ]) 31 | -------------------------------------------------------------------------------- /lib/api/SEvents.unit.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Axel Boberg 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | require('./SEvents') 6 | require('./SCommands') 7 | 8 | const DIController = require('../../shared/DIController') 9 | 10 | let events 11 | beforeAll(() => { 12 | events = DIController.main.instantiate('SEvents') 13 | }) 14 | 15 | test('register and execute an event handler', () => { 16 | events.on('myEvent', data => { 17 | expect(data).toBe('foo') 18 | }) 19 | events.emit('myEvent', 'foo') 20 | }) 21 | 22 | test('remove all handlers by owner', () => { 23 | events.on('mySecondEvent', () => {}, '1') 24 | expect(events.hasHandlersForEvent('mySecondEvent')).toBe(true) 25 | events.removeAllByOwner('1') 26 | expect(events.hasHandlersForEvent('mySecondEvent')).toBe(false) 27 | }) 28 | 29 | test('off', () => { 30 | const id = events.on('myThirdEvent', () => {}) 31 | expect(events.hasHandlersForEvent('myThirdEvent')).toBe(true) 32 | events.off('myThirdEvent', id) 33 | expect(events.hasHandlersForEvent('myThirdEvent')).toBe(false) 34 | }) 35 | -------------------------------------------------------------------------------- /lib/api/SSystem.js: -------------------------------------------------------------------------------- 1 | const pkg = require('../../package.json') 2 | 3 | const DIController = require('../../shared/DIController') 4 | const DIBase = require('../../shared/DIBase') 5 | 6 | class SSystem extends DIBase { 7 | constructor (...args) { 8 | super(...args) 9 | this.#setup() 10 | } 11 | 12 | #setup () { 13 | this.props.SCommands.registerAsyncCommand('system.getVersion', this.getVersion.bind(this)) 14 | } 15 | 16 | /** 17 | * Get the system version 18 | * @returns { String } 19 | */ 20 | getVersion () { 21 | return pkg.version || 'unknown' 22 | } 23 | } 24 | 25 | DIController.main.register('SSystem', SSystem, [ 26 | 'SCommands' 27 | ]) 28 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | exports.defaults = { 6 | HTTP_PORT: 5544 7 | } 8 | -------------------------------------------------------------------------------- /lib/error/ApiError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class ApiError extends Error { 6 | constructor (message, code) { 7 | super(message) 8 | this.name = 'ApiError' 9 | this.code = code 10 | } 11 | } 12 | 13 | module.exports = ApiError 14 | -------------------------------------------------------------------------------- /lib/error/ContextError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class ContextError extends Error { 6 | constructor (message, code, status) { 7 | super(message) 8 | this.name = 'ContextError' 9 | this.code = code 10 | this.status = status 11 | } 12 | } 13 | 14 | module.exports = ContextError 15 | -------------------------------------------------------------------------------- /lib/error/HttpError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class HttpError extends Error { 6 | constructor (message, code, status) { 7 | super(message) 8 | this.name = 'HttpError' 9 | this.code = code 10 | this.status = status 11 | } 12 | } 13 | 14 | module.exports = HttpError 15 | -------------------------------------------------------------------------------- /lib/error/MissingTypeError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class MissingTypeError extends Error { 6 | constructor () { 7 | super('No such type exists') 8 | this.name = 'MissingTypeError' 9 | this.code = 'ERR_TYPE_MISSING_TYPE' 10 | } 11 | } 12 | 13 | module.exports = MissingTypeError 14 | -------------------------------------------------------------------------------- /lib/error/PluginMissingMainScriptError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class PluginMissingMainScriptError extends Error { 6 | constructor () { 7 | super('Plugin has no main script') 8 | this.name = 'PluginMissingMainScriptError' 9 | this.code = 'ERR_PLUGIN_MISSING_MAIN_SCRIPT' 10 | } 11 | } 12 | 13 | module.exports = PluginMissingMainScriptError 14 | -------------------------------------------------------------------------------- /lib/error/ValidationError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class ValidationError extends Error { 6 | constructor (message, code, params) { 7 | super(message) 8 | this.name = 'ValidationError' 9 | this.code = code 10 | this.params = params 11 | } 12 | } 13 | 14 | module.exports = ValidationError 15 | -------------------------------------------------------------------------------- /lib/network.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | 3 | /** 4 | * Get the first IPv4 5 | * address of the server 6 | * @returns { String } 7 | */ 8 | function getFirstIPv4Address () { 9 | const nets = os.networkInterfaces() 10 | 11 | for (const name of Object.keys(nets)) { 12 | for (const net of nets[name]) { 13 | const familyV4Value = typeof net.family === 'string' ? 'IPv4' : 4 14 | if (net.family !== familyV4Value || net.internal) { 15 | continue 16 | } 17 | return net.address 18 | } 19 | } 20 | } 21 | exports.getFirstIPv4Address = getFirstIPv4Address 22 | -------------------------------------------------------------------------------- /lib/platform.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | /** 6 | * Check if the process is running 7 | * in an environment that is 8 | * compatible with Electron 9 | * @returns { Boolean } True if the process is 10 | * compatible with Electron 11 | */ 12 | function isElectron () { 13 | return process.versions.electron != null 14 | } 15 | exports.isElectron = isElectron 16 | -------------------------------------------------------------------------------- /lib/plugin/PluginLoader.unit.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const PluginLoader = require('./PluginLoader') 6 | const ValidationError = require('../error/ValidationError') 7 | 8 | test('validate a valid manifest', async () => { 9 | const loader = new PluginLoader({ paths: ['/my/path'] }) 10 | const manifest = { 11 | name: 'my-great-plugin', 12 | version: '1.0.0', 13 | engines: { 14 | bridge: '^1.0.0' 15 | } 16 | } 17 | await expect(loader.validateManifest(manifest)).resolves.toBe(true) 18 | }) 19 | 20 | test('validate an invalid manifest', async () => { 21 | const loader = new PluginLoader({ paths: ['/my/path'] }) 22 | const manifest = { 23 | version: '1.0.0', 24 | engines: { 25 | bridge: '^1.0.0' 26 | } 27 | } 28 | await expect(loader.validateManifest(manifest)).rejects.toThrow(ValidationError) 29 | }) 30 | -------------------------------------------------------------------------------- /lib/plugin/PluginManifest.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | /** 6 | * @description A common type definition 7 | * for a PluginManifest 8 | * 9 | * @typedef {{ 10 | * id: String 11 | * }} PluginType 12 | * 13 | * @typedef {{ 14 | * id: String, 15 | * description: String, 16 | * trigger: String[] 17 | * }} PluginSchortcut 18 | * 19 | * @typedef {{ 20 | * name: String, 21 | * version: String, 22 | * _path: String, 23 | * contributions?: PluginContributions 24 | * }} PluginManifest 25 | */ 26 | 27 | /** 28 | * @type { PluginManifest } 29 | */ 30 | module.exports = {} 31 | -------------------------------------------------------------------------------- /lib/plugin/worker.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const Module = require('module') 6 | const { workerData } = require('worker_threads') 7 | 8 | const api = require('../../api') 9 | 10 | /* 11 | Shim require to return the api 12 | whenever it's requesting 'bridge' 13 | 14 | If using webpack or other bundlers 15 | to bundle code this means that 16 | bridge should be marked as an external 17 | commonjs module. 18 | */ 19 | ;(function () { 20 | const _require = Module.prototype.require 21 | 22 | Module.prototype.require = function (path) { 23 | if (path === 'bridge') { 24 | return api 25 | } 26 | return _require.call(this, path) 27 | } 28 | })() 29 | 30 | /* 31 | Require the plugin and call 32 | its initializer function 33 | */ 34 | ;(function () { 35 | const plugin = require(workerData?.manifest?._path) 36 | plugin.activate() 37 | })() 38 | -------------------------------------------------------------------------------- /lib/routes/index.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const { Router } = require('express') 6 | const router = new Router() 7 | 8 | const HttpError = require('../error/HttpError') 9 | const StaticFileRegistry = require('../StaticFileRegistry') 10 | 11 | router.get('/serve/:id', (req, res, next) => { 12 | const stream = StaticFileRegistry.getInstance().createReadStream(req.params.id) 13 | if (!stream) { 14 | const err = new HttpError('File not found', 'ERR_NOT_FOUND', '404') 15 | return next(err) 16 | } 17 | stream.pipe(res) 18 | }) 19 | 20 | module.exports = router 21 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | /** 6 | * Returns a promise that resolves 7 | * after the given delay 8 | * @param { Number } delayMs 9 | * @returns { Promise. } 10 | */ 11 | function wait (delayMs) { 12 | return new Promise(resolve => { 13 | setTimeout(() => resolve(), delayMs) 14 | }) 15 | } 16 | exports.wait = wait 17 | -------------------------------------------------------------------------------- /media/appicon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/appicon.icns -------------------------------------------------------------------------------- /media/appicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/appicon.ico -------------------------------------------------------------------------------- /media/appicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/appicon.png -------------------------------------------------------------------------------- /media/docs/architecture/client-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/docs/architecture/client-server.png -------------------------------------------------------------------------------- /media/docs/architecture/methodology.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/docs/architecture/methodology.png -------------------------------------------------------------------------------- /media/docs/plugins/directory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/docs/plugins/directory.png -------------------------------------------------------------------------------- /media/docs/plugins/loader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/docs/plugins/loader.png -------------------------------------------------------------------------------- /media/docs/plugins/manage-plugins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/docs/plugins/manage-plugins.png -------------------------------------------------------------------------------- /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/screenshot.png -------------------------------------------------------------------------------- /media/ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/bridge/b211e1f8275fa2f9a988207d40e3358468e08b42/media/ui.png -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # Internal plugins 2 | This directory holds the plugins that are bundled with Bridge and should not include external plugins. 3 | 4 | ## Building 5 | These plugins are built together with Bridge and triggered with the main `build` command defined in [package.json](/package.json). -------------------------------------------------------------------------------- /plugins/button/app/components/ItemButton/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | export const ItemButton = ({ label, color = 'transparent', onClick = () => {} }) => { 5 | return ( 6 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /plugins/button/app/components/ItemButton/style.css: -------------------------------------------------------------------------------- 1 | .ItemButton { 2 | display: flex; 3 | width: calc(100% - 10px); 4 | height: calc(100% - 5px); 5 | 6 | margin: 0 5px 5px; 7 | padding: 10px; 8 | 9 | color: var(--base-color); 10 | font-size: 1em; 11 | font-family: var(--base-fontFamily--primary); 12 | 13 | border: none; 14 | border-radius: 6px; 15 | 16 | background: none; 17 | 18 | box-sizing: border-box; 19 | 20 | align-items: center; 21 | justify-content: center; 22 | } 23 | 24 | .ItemButton:hover { 25 | box-shadow: inset 0 0 0 2px var(--base-color--shade1); 26 | } 27 | 28 | .ItemButton:active { 29 | opacity: 0.7; 30 | } -------------------------------------------------------------------------------- /plugins/button/app/components/ItemDropArea/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | export const ItemDropArea = ({ children, onDrop = () => {} }) => { 5 | const [isDraggedOver, setIsDraggedOver] = React.useState() 6 | 7 | function handleDragOver (e) { 8 | e.preventDefault() 9 | setIsDraggedOver(true) 10 | } 11 | 12 | function handleDragLeave (e) { 13 | e.stopPropagation() 14 | setIsDraggedOver(false) 15 | } 16 | 17 | function handleDrop (e) { 18 | e.stopPropagation() 19 | setIsDraggedOver(false) 20 | 21 | const itemId = e.dataTransfer.getData('text/plain') 22 | const item = e.dataTransfer.getData('bridge/item') 23 | if (!itemId && !item?.id) { 24 | return 25 | } 26 | 27 | onDrop(itemId || item?.id) 28 | } 29 | 30 | return ( 31 |
35 | { 36 | isDraggedOver && 37 | ( 38 |
43 | ) 44 | } 45 | {children} 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /plugins/button/app/components/ItemDropArea/style.css: -------------------------------------------------------------------------------- 1 | .ItemDropArea { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | .ItemDropArea-dropArea { 8 | position: absolute; 9 | width: 100%; 10 | height: 100%; 11 | 12 | background: var(--base-color--shade1); 13 | border: 2px solid var(--base-color--shade2); 14 | 15 | border-radius: 6px; 16 | 17 | z-index: 1; 18 | } -------------------------------------------------------------------------------- /plugins/button/app/components/QueryPath/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function getQueryParam (path, param) { 4 | const params = new URLSearchParams(path) 5 | return params.get(param) 6 | } 7 | 8 | export const QueryPath = ({ path, children }) => { 9 | const [currentPath, setCurrentPath] = React.useState() 10 | 11 | React.useEffect(() => { 12 | function onPopState () { 13 | setCurrentPath(getQueryParam(window.location.search, 'path')) 14 | } 15 | 16 | onPopState() 17 | 18 | window.addEventListener('popstate', onPopState) 19 | return window.removeEventListener('popstate', onPopState) 20 | }, []) 21 | 22 | if (currentPath !== path) { 23 | return <> 24 | } 25 | 26 | return ( 27 | <> 28 | {children} 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /plugins/button/app/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import App from './App' 5 | 6 | import './style.css' 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ) -------------------------------------------------------------------------------- /plugins/button/app/style.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | 6 | padding: 0; 7 | margin: 0; 8 | 9 | font-size: min(10vw, 30vh); 10 | } 11 | -------------------------------------------------------------------------------- /plugins/button/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bridge-plugin-button", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "bridge-plugin-button", 9 | "version": "1.0.0", 10 | "license": "UNLICENSED", 11 | "engines": { 12 | "bridge": "^0.0.1" 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /plugins/button/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bridge-plugin-button", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "engines": { 10 | "bridge": "^0.0.1" 11 | }, 12 | "keywords": [ 13 | "bridge", 14 | "plugin" 15 | ], 16 | "author": "Axel Boberg (axel.boberg@svt.se)", 17 | "license": "UNLICENSED" 18 | } 19 | -------------------------------------------------------------------------------- /plugins/caspar/app/assets/icons/connected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/caspar/connected 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /plugins/caspar/app/assets/icons/disconnected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/caspar/disconnected 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /plugins/caspar/app/assets/icons/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/caspar/error 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/EasingPreview/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import * as easings from '../../utils/easings' 5 | 6 | export const EasingPreview = ({ easingName = 'linear' }) => { 7 | const canvasRef = React.useRef() 8 | 9 | /* 10 | Draw the easing function as a series of lines 11 | whenever the canvas or easing changes 12 | */ 13 | React.useEffect(() => { 14 | if (!canvasRef.current) { 15 | return 16 | } 17 | const canvas = canvasRef.current 18 | const ctx = canvas.getContext('2d') 19 | 20 | ctx.clearRect(0, 0, canvas.width, canvas.height) 21 | /** 22 | * @todo 23 | * Parameterize these 24 | */ 25 | ctx.strokeStyle = 'red' 26 | ctx.lineWidth = 3 27 | 28 | ctx.beginPath() 29 | ctx.moveTo(0, canvas.height) 30 | for (let i = 0; i < canvas.width; i++) { 31 | ctx.lineTo(i, (canvas.height - easings[easingName](i / canvas.width) * canvas.height)) 32 | } 33 | ctx.stroke() 34 | }, [canvasRef.current, easingName]) 35 | 36 | return ( 37 |
38 | 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/EasingPreview/style.css: -------------------------------------------------------------------------------- 1 | .EasingPreview { 2 | width: 50px; 3 | height: 50px; 4 | padding: 5px; 5 | margin-right: 10px; 6 | 7 | border: 1px solid var(--base-color--shade); 8 | border-radius: 10px; 9 | } 10 | 11 | .EasingPreview canvas { 12 | transform: scale(0.5); 13 | transform-origin: top left; 14 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/LibraryHeader/style.css: -------------------------------------------------------------------------------- 1 | .LibraryHeader { 2 | border-bottom: 1px solid var(--base-color--shade); 3 | } 4 | 5 | .LibraryHeader-serverSelector { 6 | width: 100%; 7 | padding: 0 5px 5px; 8 | box-sizing: border-box; 9 | } 10 | 11 | .LibraryHeader-refresh { 12 | margin: 0 5px 5px; 13 | } 14 | 15 | @media screen and (min-width: 350px) { 16 | .LibraryHeader { 17 | display: flex; 18 | align-items: center; 19 | } 20 | 21 | .LibraryHeader-serverSelector { 22 | width: 45%; 23 | } 24 | 25 | .LibraryHeader-refresh { 26 | margin: 0 5px 5px 0; 27 | } 28 | } 29 | 30 | .LibraryHeader-search { 31 | width: 100%; 32 | padding: 0 5px 5px; 33 | box-sizing: border-box; 34 | } 35 | 36 | .LibraryHeader-search input { 37 | width: 100%; 38 | } 39 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/LibraryList/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import { LibraryListItem } from '../LibraryListItem' 5 | 6 | export const LibraryList = ({ items = [] }) => { 7 | return ( 8 |
    9 | { 10 | (items || []).map((item, i) => { 11 | return 12 | }) 13 | } 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/LibraryList/style.css: -------------------------------------------------------------------------------- 1 | .LibraryList { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | padding: 0; 6 | margin: 0; 7 | 8 | list-style: none; 9 | overflow-y: scroll; 10 | overflow-x: hidden; 11 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/LibraryListItem/style.css: -------------------------------------------------------------------------------- 1 | .LibraryListItem { 2 | display: flex; 3 | padding: 0.4em 0.5em; 4 | font-size: 0.9em; 5 | 6 | border-radius: 3px; 7 | justify-content: space-between; 8 | } 9 | 10 | .LibraryListItem:active { 11 | box-shadow: inset 0 0 1px 1px var(--base-color); 12 | } 13 | 14 | .LibraryListItem:nth-child(odd) { 15 | background: var(--base-color--shade); 16 | } 17 | 18 | .LibraryListItem > div:last-child { 19 | flex-shrink: 0; 20 | text-align: right; 21 | } 22 | 23 | .LibraryListItem-col { 24 | margin-right: 10px; 25 | text-overflow: ellipsis; 26 | 27 | white-space: nowrap; 28 | overflow: hidden; 29 | } 30 | 31 | .LibraryListItem > div:last-child > .LibraryListItem-col:last-child { 32 | margin-right: 0; 33 | } 34 | 35 | .LibraryListItem-metadata { 36 | opacity: 0.5; 37 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/LiveSwitchControl/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | export const LiveSwitchControl = ({ value, onChange = () => {} }) => { 5 | return ( 6 |
7 |
Caspar is { value ? 'live' : 'not live' }
8 |
9 | onChange(e.target.checked)} /> 10 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/LiveSwitchControl/style.css: -------------------------------------------------------------------------------- 1 | .LiveSwitchControl { 2 | display: flex; 3 | 4 | width: 100%; 5 | height: 100%; 6 | 7 | align-items: center; 8 | justify-content: center; 9 | 10 | flex-direction: column; 11 | } 12 | 13 | .LiveSwitchControl.is-live::before { 14 | content: ''; 15 | position: absolute; 16 | 17 | top: 0; 18 | left: 0; 19 | right: 0; 20 | bottom: 0; 21 | 22 | background: radial-gradient(var(--base-color--alert), transparent 170%); 23 | opacity: 0.7; 24 | } 25 | 26 | .LiveSwitchControl-title { 27 | margin-bottom: 7px; 28 | z-index: 1; 29 | } 30 | 31 | .LiveSwitchControl-control { 32 | margin-bottom: 5px; 33 | transform: scale(1.3); 34 | } 35 | 36 | .LiveSwitchControl .LiveSwitchControl-control input::before { 37 | background: var(--base-color--alert); 38 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/Monaco/style.css: -------------------------------------------------------------------------------- 1 | .Monaco { 2 | width: 100%; 3 | height: 350px; 4 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/Select/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | export const Select = ({ children, values = [], defaultValue, onChange = () => {} }) => { 5 | const isMultipleSelected = React.useMemo(() => { 6 | const set = new Set() 7 | for (const value of values) { 8 | set.add(value) 9 | } 10 | return set.size > 1 11 | }, [values]) 12 | 13 | return ( 14 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/Select/style.css: -------------------------------------------------------------------------------- 1 | .Select { 2 | width: 100%; 3 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/ServerInput/style.css: -------------------------------------------------------------------------------- 1 | .ServerInput { 2 | margin-bottom: 10px; 3 | padding: 10px 12px; 4 | 5 | border: 1px solid var(--base-color--shade); 6 | border-radius: 10px; 7 | } 8 | 9 | .ServerInput > div, 10 | .ServerInput-flexWrapper > div { 11 | width: 100%; 12 | } 13 | 14 | .ServerInput-flexWrapper { 15 | display: flex; 16 | } 17 | 18 | .ServerInput-flexWrapper > div:last-child { 19 | text-align: right; 20 | } 21 | 22 | .ServerInput-space { 23 | height: 7px; 24 | } 25 | 26 | .ServerInput-input { 27 | margin-bottom: 10px; 28 | } 29 | 30 | .ServerInput-input:last-child { 31 | margin-bottom: 0; 32 | } 33 | 34 | .ServerInput-input--small { 35 | width: 100px; 36 | } 37 | 38 | .ServerInput-input input { 39 | margin-right: 5px; 40 | } 41 | 42 | .ServerInput-input--switch { 43 | margin-top: 7px; 44 | } 45 | 46 | .ServerInput-flexInputs { 47 | display: flex; 48 | } 49 | 50 | .ServerInput-status { 51 | padding-top: 10px; 52 | margin-top: 10px; 53 | border-top: 1px solid var(--base-color--shade1); 54 | } 55 | 56 | .ServerInput-actions .Button { 57 | margin-bottom: 7px; 58 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/ServerSelector/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import { SharedContext } from '../../sharedContext' 5 | 6 | const SERVER_GROUPS = [ 7 | { 8 | id: 'group:0', 9 | name: 'Group: Primary' 10 | }, 11 | { 12 | id: 'group:1', 13 | name: 'Group: Secondary' 14 | } 15 | ] 16 | 17 | export const ServerSelector = ({ value = '__none', multipleValuesSelected = false, onChange = () => {} }) => { 18 | const [state] = React.useContext(SharedContext) 19 | const servers = state?.plugins?.[window.PLUGIN.name]?.servers || [] 20 | 21 | return ( 22 |
23 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/ServerSelector/style.css: -------------------------------------------------------------------------------- 1 | .ServerSelector { 2 | width: 100%; 3 | box-sizing: border-box; 4 | } 5 | 6 | .ServerSelector select { 7 | width: 100%; 8 | } 9 | 10 | .ServerSelector-noServers { 11 | padding: 0.5em 0; 12 | width: 100%; 13 | text-align: center; 14 | 15 | opacity: 0.5; 16 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/ServerStatus/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | import { ServerStatusBadge } from '../ServerStatusBadge' 5 | 6 | export const ServerStatus = ({ server = {} }) => { 7 | return ( 8 |
9 |
10 | {server?.name} 11 |
12 |
13 | 14 |
15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/ServerStatus/style.css: -------------------------------------------------------------------------------- 1 | .ServerStatus { 2 | display: flex; 3 | padding: 5px 10px; 4 | width: 100%; 5 | 6 | box-sizing: border-box; 7 | } 8 | 9 | .ServerStatus:nth-child(odd) { 10 | background: var(--base-color--shade); 11 | } 12 | 13 | .ServerStatus > div { 14 | width: 100%; 15 | } 16 | 17 | .ServerStatus > div:last-child { 18 | text-align: right; 19 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/ServerStatusBadge/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { SharedContext } from '../../sharedContext' 4 | 5 | import './style.css' 6 | 7 | import disconnectedIcon from '../../assets/icons/disconnected.svg' 8 | import connectedIcon from '../../assets/icons/connected.svg' 9 | import errorIcon from '../../assets/icons/error.svg' 10 | 11 | const ICON_MAPPING = { 12 | ERROR: errorIcon, 13 | CONNECTED: connectedIcon, 14 | CONNECTING: disconnectedIcon, 15 | DISCONNECTED: disconnectedIcon 16 | } 17 | 18 | const TEXT_MAPPING = { 19 | ERROR: 'Error', 20 | CONNECTED: 'Connected', 21 | CONNECTING: 'Connecting...', 22 | DISCONNECTED: 'Disconnected' 23 | } 24 | 25 | export const ServerStatusBadge = ({ serverId }) => { 26 | const [state] = React.useContext(SharedContext) 27 | const status = state?._tmp?.[window.PLUGIN.name]?.servers?.[serverId]?.status || 'DISCONNECTED' 28 | 29 | return ( 30 |
31 | 32 | {TEXT_MAPPING[status]} 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/ServerStatusBadge/style.css: -------------------------------------------------------------------------------- 1 | .ServerStatus { 2 | display: flex; 3 | padding: 5px 10px; 4 | width: 100%; 5 | 6 | box-sizing: border-box; 7 | } 8 | 9 | .ServerStatus:nth-child(odd) { 10 | background: var(--base-color--shade); 11 | } 12 | 13 | .ServerStatus > div { 14 | width: 100%; 15 | } 16 | 17 | .ServerStatus > div:last-child { 18 | text-align: right; 19 | } 20 | 21 | .ServerStatusBadge-icon { 22 | vertical-align: middle; 23 | margin-right: 7px; 24 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/TemplateDataHeader/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | export const TemplateDataHeader = ({ hasUnsavedChanges, onSave = () => {} }) => { 5 | if (!hasUnsavedChanges) { 6 | return <> 7 | } 8 | 9 | return ( 10 |
11 |
12 | Save changes 13 |
14 |
15 | 16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/TemplateDataHeader/style.css: -------------------------------------------------------------------------------- 1 | .TemplateDataHeader { 2 | display: flex; 3 | 4 | width: 100%; 5 | padding: 5px 5px 5px 10px; 6 | 7 | border-radius: 6px; 8 | background: var(--base-color--accent5); 9 | 10 | align-items: center; 11 | justify-content: space-between; 12 | } 13 | 14 | .TemplateDataHeader-section { 15 | display: flex; 16 | align-items: center; 17 | } -------------------------------------------------------------------------------- /plugins/caspar/app/components/ThumbnailImage/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | export const ThumbnailImage = ({ src, alt = 'Thumbnail image' }) => { 5 | return ( 6 |
7 | { 8 | src 9 | ? {alt} 10 | :
Thumbnail is not available
11 | } 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /plugins/caspar/app/components/ThumbnailImage/style.css: -------------------------------------------------------------------------------- 1 | .ThumbnailImage { 2 | display: flex; 3 | position: relative; 4 | 5 | width: 100%; 6 | height: 100%; 7 | 8 | align-items: center; 9 | justify-content: center; 10 | } 11 | 12 | .ThumbnailImage-img { 13 | width: 100%; 14 | height: 100%; 15 | 16 | object-fit: contain; 17 | } 18 | 19 | .ThumbnailImage-text { 20 | text-align: center; 21 | } -------------------------------------------------------------------------------- /plugins/caspar/app/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import App from './App' 5 | 6 | import './style.css' 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ) 14 | -------------------------------------------------------------------------------- /plugins/caspar/app/style.css: -------------------------------------------------------------------------------- 1 | html, body, .View--flex { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | 6 | padding: 0; 7 | margin: 0; 8 | 9 | overflow: hidden; 10 | } 11 | 12 | #root { 13 | display: contents; 14 | } 15 | 16 | .View--flex { 17 | display: flex; 18 | flex-direction: column; 19 | } 20 | 21 | .View--center { 22 | display: flex; 23 | width: 100%; 24 | height: 100%; 25 | 26 | align-items: center; 27 | justify-content: center; 28 | } 29 | 30 | .View--spread { 31 | display: flex; 32 | } 33 | 34 | .u-width--100pct { 35 | width: 100%; 36 | } 37 | 38 | .u-marginBottom--5px { 39 | margin-bottom: 5px; 40 | } 41 | 42 | .u-scroll--y { 43 | position: relative; 44 | width: 100%; 45 | height: 100%; 46 | overflow-y: scroll; 47 | overflow-x: hidden; 48 | } 49 | 50 | .u-textAlign--center { 51 | text-align: center; 52 | } -------------------------------------------------------------------------------- /plugins/caspar/app/views/LiveSwitch.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import bridge from 'bridge' 3 | 4 | import { SharedContext } from '../sharedContext' 5 | import { LiveSwitchControl } from '../components/LiveSwitchControl' 6 | 7 | export const LiveSwitch = () => { 8 | const [state] = React.useContext(SharedContext) 9 | const isLive = state?.plugins?.[window.PLUGIN.name]?.isLive ?? true 10 | 11 | function handleNewValue (newValue) { 12 | bridge.state.apply({ 13 | plugins: { 14 | [window.PLUGIN.name]: { 15 | isLive: newValue 16 | } 17 | } 18 | }) 19 | } 20 | 21 | return ( 22 | handleNewValue(newValue)} /> 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /plugins/caspar/app/views/Settings.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import bridge from 'bridge' 3 | 4 | import { SharedContext } from '../sharedContext' 5 | import { ServerInput } from '../components/ServerInput' 6 | 7 | export const Servers = () => { 8 | const [state] = React.useContext(SharedContext) 9 | const servers = state?.plugins?.[window.PLUGIN.name]?.servers || [] 10 | 11 | function handleChange (serverId, newData) { 12 | bridge.commands.executeCommand('caspar.editServer', serverId, newData) 13 | } 14 | 15 | function handleDelete (serverId) { 16 | bridge.commands.executeCommand('caspar.removeServer', serverId) 17 | } 18 | 19 | function handleNew () { 20 | bridge.commands.executeCommand('caspar.addServer', {}) 21 | } 22 | 23 | return ( 24 |
25 | { 26 | (servers || []).map((server, i) => { 27 | return handleChange(server.id, newData)} onDelete={() => handleDelete(server.id)} /> 28 | }) 29 | } 30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /plugins/caspar/app/views/Status.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { SharedContext } from '../sharedContext' 4 | import { ServerStatus } from '../components/ServerStatus' 5 | 6 | export const Status = () => { 7 | const [state] = React.useContext(SharedContext) 8 | const servers = state?.plugins?.[window.PLUGIN.name]?.servers || [] 9 | 10 | return ( 11 |
12 | { 13 | (servers || []).map((server, i) => { 14 | return 15 | }) 16 | } 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /plugins/caspar/lib/CasparManager.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class CasparManager { 6 | constructor () { 7 | /** 8 | * @private 9 | * @type { Map. } 10 | */ 11 | this._index = new Map() 12 | } 13 | 14 | /** 15 | * Add a new Caspar server 16 | * to this manager 17 | * @param { String } id 18 | * @param { Caspar } caspar 19 | */ 20 | add (id, caspar) { 21 | this._index.set(id, caspar) 22 | } 23 | 24 | /** 25 | * Remove a server from the 26 | * manager by its id 27 | * @param { String } id 28 | */ 29 | remove (id) { 30 | this._index.delete(id) 31 | } 32 | 33 | /** 34 | * Get a server from the 35 | * manager by its id 36 | * @param { String } id 37 | * @returns { Caspar? } 38 | */ 39 | get (id) { 40 | return this._index.get(id) 41 | } 42 | } 43 | module.exports = CasparManager 44 | -------------------------------------------------------------------------------- /plugins/caspar/lib/error/CasparError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class CasparError extends Error { 6 | constructor (msg, code) { 7 | super(msg) 8 | this.name = 'CasparError' 9 | this.code = code 10 | } 11 | } 12 | module.exports = CasparError 13 | -------------------------------------------------------------------------------- /plugins/caspar/lib/error/CommandError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class CommandError extends Error { 6 | constructor (msg, code) { 7 | super(msg) 8 | this.name = 'CommandError' 9 | this.code = code 10 | } 11 | } 12 | module.exports = CommandError 13 | -------------------------------------------------------------------------------- /plugins/caspar/lib/error/TcpSocketError.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | class TcpSocketError extends Error { 6 | constructor (msg, code) { 7 | super(msg) 8 | this.code = code 9 | this.name = 'TcpSocketError' 10 | } 11 | } 12 | module.exports = TcpSocketError 13 | -------------------------------------------------------------------------------- /plugins/caspar/lib/paths.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | const manifest = require('../package.json') 6 | 7 | exports.STATE_SETTINGS_PATH = `plugins.${manifest.name}` 8 | -------------------------------------------------------------------------------- /plugins/caspar/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bridge-plugin-caspar", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "engines": { 10 | "bridge": "^0.0.1" 11 | }, 12 | "keywords": [ 13 | "bridge", 14 | "plugin" 15 | ], 16 | "author": "Axel Boberg (axel.boberg@svt.se)", 17 | "license": "UNLICENSED", 18 | "dependencies": { 19 | "monaco-editor": "^0.45.0", 20 | "tinycolor2": "^1.6.0" 21 | }, 22 | "devDependencies": { 23 | "monaco-editor-webpack-plugin": "^7.1.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /plugins/caspar/webpack.config.js: -------------------------------------------------------------------------------- 1 | const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin') 2 | 3 | module.exports = { 4 | plugins: [ 5 | new MonacoWebpackPlugin({ 6 | languages: ['json'], 7 | features: [ 8 | 'comment', 9 | 'colorPicker', 10 | 'smartSelect', 11 | 'multicursor', 12 | 'inlineCompletions', 13 | 'clipboard' 14 | ] 15 | }) 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /plugins/clock/app/components/CurrentTime/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .CurrentTime-char { 4 | display: inline-block; 5 | width: 0.6em; 6 | } 7 | 8 | .CurrentTime-faded { 9 | opacity: 0.4; 10 | } -------------------------------------------------------------------------------- /plugins/clock/app/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import App from './App' 5 | 6 | import './style.css' 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ) -------------------------------------------------------------------------------- /plugins/clock/app/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | html, body, #root { 4 | position: relative; 5 | width: 100%; 6 | height: 100%; 7 | 8 | padding: 0; 9 | margin: 0; 10 | 11 | font-size: min(8vw, 30vh); 12 | } 13 | 14 | .Clock-wrapper { 15 | display: flex; 16 | width: 100%; 17 | height: 100%; 18 | 19 | align-items: center; 20 | justify-content: center; 21 | } 22 | 23 | .Clock-digits { 24 | font-size: 2em; 25 | } 26 | -------------------------------------------------------------------------------- /plugins/clock/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bridge-plugin-clock", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "bridge-plugin-clock", 9 | "version": "1.0.0", 10 | "license": "UNLICENSED", 11 | "engines": { 12 | "bridge": "^0.0.1" 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /plugins/clock/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bridge-plugin-clock", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "engines": { 10 | "bridge": "^0.0.1" 11 | }, 12 | "keywords": [ 13 | "bridge", 14 | "plugin" 15 | ], 16 | "author": "Axel Boberg (axel.boberg@svt.se)", 17 | "license": "UNLICENSED" 18 | } 19 | -------------------------------------------------------------------------------- /plugins/http/README.md: -------------------------------------------------------------------------------- 1 | # HTTP plugin 2 | This plugin brings basic HTTP functionality to Bridge, such as items that make requests. 3 | 4 | Stopping an HTTP item immediately aborts any requests started by playing it. Playing an item multiple times while requests are still running will result in multiple requests being made. 5 | 6 | ## User agent 7 | All requests will be tagged by Bridge's user agent string, formatted as `Bridge/` for easier identification. 8 | -------------------------------------------------------------------------------- /plugins/http/lib/random.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate an unsafe random string of the 3 | * specified length, containing only characters 4 | * from the provided map 5 | * @param { Number } length 6 | * @param { String } map 7 | * @returns { String } 8 | */ 9 | function randomString (length = 5, map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') { 10 | let out = '' 11 | for (let i = 0; i < length; i++) { 12 | out += map[Math.round(Math.random() * map.length)] 13 | } 14 | return out 15 | } 16 | module.randomString = randomString 17 | 18 | /** 19 | * Make a unique string id that isn't 20 | * in the provided list of existing ids 21 | * 22 | * Will recursively call itself if a proposed 23 | * id is colliding with an already existing one 24 | * 25 | * @param { Number } length 26 | * @param { String[] } existingIds 27 | * @returns { String } 28 | */ 29 | function makeUniqueId (length, existingIds = []) { 30 | const proposal = randomString(length) 31 | if (existingIds.includes(proposal)) { 32 | return makeUniqueId(existingIds) 33 | } 34 | return proposal 35 | } 36 | exports.makeUniqueId = makeUniqueId 37 | -------------------------------------------------------------------------------- /plugins/http/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bridge-plugin-http", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "bridge-plugin-http", 9 | "version": "1.0.0", 10 | "license": "UNLICENSED", 11 | "engines": { 12 | "bridge": "^0.0.1" 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /plugins/http/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bridge-plugin-http", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "description": "", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "engines": { 10 | "bridge": "^0.0.1" 11 | }, 12 | "keywords": [ 13 | "bridge", 14 | "plugin" 15 | ], 16 | "contributes": { 17 | "types": [ 18 | { 19 | "id": "bridge.http.request", 20 | "inherits": "bridge.types.delayable", 21 | "properties": { 22 | "http.url": { 23 | "name": "URL", 24 | "type": "string", 25 | "default": "https://", 26 | "allowsVariables": true, 27 | "ui.group": "HTTP" 28 | } 29 | } 30 | }, 31 | { 32 | "id": "bridge.http.get", 33 | "name": "GET", 34 | "category": "HTTP", 35 | "inherits": "bridge.http.request", 36 | "properties": {} 37 | } 38 | ] 39 | }, 40 | "author": "Axel Boberg (git@axelboberg.se)", 41 | "license": "UNLICENSED" 42 | } 43 | -------------------------------------------------------------------------------- /plugins/inspector/README.md: -------------------------------------------------------------------------------- 1 | # Inspector plugin 2 | Bridge's default inspector plugin 3 | 4 | ## Description 5 | A widget allowing item data to be edited 6 | 7 | ## Table of contents 8 | - [Description](#description) -------------------------------------------------------------------------------- /plugins/inspector/app/assets/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/arrow-down 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /plugins/inspector/app/assets/icons/index.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | import arrowDown from './arrow-down.svg' 6 | import bucket from './bucket.svg' 7 | 8 | export default { 9 | 'arrow-down': arrowDown, 10 | bucket 11 | } 12 | -------------------------------------------------------------------------------- /plugins/inspector/app/components/Accordion/index.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | import React from 'react' 8 | import './style.css' 9 | 10 | import { Icon } from '../Icon' 11 | 12 | export function Accordion ({ title, children, open: _open = true }) { 13 | const [open, setOpen] = React.useState(_open) 14 | 15 | function handleClick () { 16 | setOpen(!open) 17 | } 18 | 19 | return ( 20 |
21 |
handleClick()}> 22 |
23 | 24 |
25 |
26 | {title} 27 |
28 |
29 | { 30 | open 31 | ? children 32 | : <> 33 | } 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /plugins/inspector/app/components/Accordion/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | .Accordion-header { 8 | display: flex; 9 | padding: 3px; 10 | 11 | align-items: center; 12 | box-sizing: border-box; 13 | } 14 | 15 | .Accordion .Accordion-icon { 16 | margin-right: 5px; 17 | transform: rotate(-90deg); 18 | } 19 | 20 | .Accordion.is-open .Accordion-icon { 21 | transform: rotate(0deg); 22 | } 23 | -------------------------------------------------------------------------------- /plugins/inspector/app/components/BooleanInput/index.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | import React from 'react' 8 | import './style.css' 9 | 10 | export function BooleanInput ({ 11 | htmlFor, 12 | value = '', 13 | onChange = () => {}, 14 | large 15 | }) { 16 | return ( 17 | onChange(e.target.checked)} 23 | /> 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /plugins/inspector/app/components/BooleanInput/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ -------------------------------------------------------------------------------- /plugins/inspector/app/components/Icon/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import icons from '../../assets/icons' 3 | 4 | import './style.css' 5 | 6 | export function Icon ({ name = 'placeholder', color = 'var(--base-color)' }) { 7 | return ( 8 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /plugins/inspector/app/components/Icon/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .Icon svg [stroke]:not([stroke='none']) { 4 | stroke: var(--Icon-color); 5 | } 6 | 7 | .Icon svg [fill]:not([fill='none']) { 8 | fill: var(--Icon-color); 9 | } -------------------------------------------------------------------------------- /plugins/inspector/app/components/NoSelection/index.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | import React from 'react' 8 | import './style.css' 9 | 10 | export function NoSelection () { 11 | return ( 12 |
13 | No item selected 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /plugins/inspector/app/components/NoSelection/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | .NoSelection { 8 | position: relative; 9 | display: flex; 10 | 11 | width: 100%; 12 | height: 100%; 13 | 14 | align-items: center; 15 | justify-content: center; 16 | } 17 | -------------------------------------------------------------------------------- /plugins/inspector/app/components/SelectInput/index.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | import React from 'react' 8 | import './style.css' 9 | 10 | export function SelectInput ({ 11 | value = '', 12 | data = {}, 13 | onChange = () => {} 14 | }) { 15 | return ( 16 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /plugins/inspector/app/components/SelectInput/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | .SelectInput { 8 | width: 100%; 9 | } -------------------------------------------------------------------------------- /plugins/inspector/app/components/StringInput/index.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | import React from 'react' 8 | import './style.css' 9 | 10 | export function StringInput ({ 11 | htmlFor, 12 | value = '', 13 | onChange = () => {}, 14 | onKeyDown = () => {}, 15 | large 16 | }) { 17 | return ( 18 | onChange(e.target.value)} 24 | onKeyDown={e => onKeyDown(e)} 25 | /> 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /plugins/inspector/app/components/StringInput/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | .StringInput { 8 | width: 100%; 9 | padding: 0.5em; 10 | 11 | font-size: 1em; 12 | font-family: var(--base-fontFamily--primary); 13 | 14 | border: none; 15 | border-radius: 8px; 16 | 17 | color: var(--base-color); 18 | background: var(--base-color--shade2); 19 | 20 | box-sizing: border-box; 21 | } 22 | 23 | .StringInput--large { 24 | font-size: 1.1em; 25 | } -------------------------------------------------------------------------------- /plugins/inspector/app/components/TextInput/index.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Sveriges Television AB 3 | * 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | 7 | import React from 'react' 8 | import './style.css' 9 | 10 | export function TextInput ({ 11 | htmlFor, 12 | value = '', 13 | onChange = () => {} 14 | }) { 15 | return ( 16 |