From a8f948e0df6dcb0e413285fc323902cd7117b297 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sat, 10 Jan 2026 15:18:50 +0100 Subject: [PATCH 01/57] Move role and sharing functionality to a new footer Signed-off-by: Axel Boberg --- app/bridge.css | 1 - app/components/Footer/index.jsx | 74 +++++++++++++++++++++ app/components/Footer/style.css | 96 +++++++++++++++++++++++++++ app/components/Header/index.jsx | 29 -------- app/components/Modal/index.jsx | 30 ++++++--- app/components/Modal/style.css | 5 ++ app/components/Notification/style.css | 9 +++ app/components/Popover/index.jsx | 19 +++++- app/components/Popover/style.css | 5 -- app/components/Role/index.jsx | 6 +- app/components/Role/style.css | 4 ++ app/components/Sharing/index.jsx | 32 +++++---- app/components/Sharing/style.css | 8 ++- app/views/Workspace.jsx | 2 + app/views/WorkspaceWidget.jsx | 2 + 15 files changed, 257 insertions(+), 65 deletions(-) create mode 100644 app/components/Footer/index.jsx create mode 100644 app/components/Footer/style.css diff --git a/app/bridge.css b/app/bridge.css index 318e602a..85b0ecde 100644 --- a/app/bridge.css +++ b/app/bridge.css @@ -74,7 +74,6 @@ button { .Button--accent, .Button--primary, .Button--secondary, -.Button--tertiary, .Button--ghost { padding: 0.9em 1.5em; diff --git a/app/components/Footer/index.jsx b/app/components/Footer/index.jsx new file mode 100644 index 00000000..a5cc15de --- /dev/null +++ b/app/components/Footer/index.jsx @@ -0,0 +1,74 @@ +import React from 'react' + +import { SharedContext } from '../../sharedContext' +import { LocalContext } from '../../localContext' + +import { Role } from '../Role' +import { Modal } from '../Modal' +import { AppMenu } from '../AppMenu' +import { Palette } from '../Palette' +import { Sharing } from '../Sharing' +import { Preferences } from '../Preferences' + +import { Icon } from '../Icon' + +import * as api from '../../api' +import * as windowUtils from '../../utils/window' + +import './style.css' + +const DEFAULT_TITLE = 'Unnamed' + +function handleReload () { + window.location.reload() +} + +export function Footer ({ title = DEFAULT_TITLE, features }) { + const [shared, applyShared] = React.useContext(SharedContext) + const [local] = React.useContext(LocalContext) + + const [sharingOpen, setSharingOpen] = React.useState(false) + const [roleOpen, setRoleOpen] = React.useState(false) + + const connectionCount = Object.keys(shared?._connections || {}).length + const role = shared?._connections?.[local.id]?.role + + function featureShown (feature) { + if (!Array.isArray(features)) { + return true + } + return features.includes(feature) + } + + return ( + <> +
+
+ { + featureShown('role') && + ( +
+ + setRoleOpen(false)} /> +
+ ) + } + { + featureShown('sharing') && + ( +
+ + setSharingOpen(false)} /> +
+ ) + } +
+
+ + ) +} diff --git a/app/components/Footer/style.css b/app/components/Footer/style.css new file mode 100644 index 00000000..e131ec1f --- /dev/null +++ b/app/components/Footer/style.css @@ -0,0 +1,96 @@ +.Footer { + position: sticky; + display: flex; + bottom: 0; + width: 100%; + height: 24px; + + border-top: 1px solid var(--base-color--shade); + box-sizing: border-box; + + flex-shrink: 0; + + align-items: center; + justify-content: space-between; +} + +.Footer-block { + display: flex; + align-items: center; +} + +.Footer-actionSection { + margin-right: 10px; +} + +.Footer-button.Footer-sharingBtn { + width: 40px; + padding: 0 1.3em 0 1em; + color: var(--base-color); + font-family: var(--base-fontFamily--primary); +} + +.Footer-button.Footer-roleBtn { + width: auto; + padding: 0 7px 0 0; + + white-space: nowrap; + overflow: hidden; +} + +.Footer-button.Footer-roleBtn::before { + content: ''; + height: 100%; + width: 12px; + + margin-right: 7px; + + background: var(--base-color--shade); +} + +.Footer-button.Footer-roleBtn.is-main::before { + background: var(--base-color--accent1); +} + +.Footer-button { + display: flex; + position: relative; + min-width: 32px; + height: 24px; + + border: none; + + color: inherit; + font-size: 0.8em; + font-family: var(--base-fontFamily--primary); + + background: none; + + align-items: center; + justify-content: center; + + -webkit-app-region: no-drag; + + overflow: hidden; +} + +.Footer-button:not(.is-active):hover { + background: var(--base-color--shade); + opacity: 0.7; +} + +.Footer-button.is-active::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + background: var(--base-color--accent1); + opacity: 0.15; +} + +.Footer-button.is-active:hover { + opacity: 0.7; +} \ No newline at end of file diff --git a/app/components/Header/index.jsx b/app/components/Header/index.jsx index 62727865..1f283d87 100644 --- a/app/components/Header/index.jsx +++ b/app/components/Header/index.jsx @@ -3,11 +3,9 @@ import React from 'react' import { SharedContext } from '../../sharedContext' import { LocalContext } from '../../localContext' -import { Role } from '../Role' import { Modal } from '../Modal' import { AppMenu } from '../AppMenu' import { Palette } from '../Palette' -import { Sharing } from '../Sharing' import { Preferences } from '../Preferences' import { Icon } from '../Icon' @@ -28,13 +26,9 @@ export function Header ({ title = DEFAULT_TITLE, features }) { const [local] = React.useContext(LocalContext) const [paletteIsOpen, setPaletteIsOpen] = React.useState(false) - const [sharingOpen, setSharingOpen] = React.useState(false) const [prefsOpen, setPrefsOpen] = React.useState(false) - const [roleOpen, setRoleOpen] = React.useState(false) - const connectionCount = Object.keys(shared?._connections || {}).length const isEditingLayout = shared?._connections?.[local?.id]?.isEditingLayout - const role = shared?._connections?.[local.id]?.role /* Listen for shortcuts @@ -143,29 +137,6 @@ export function Header ({ title = DEFAULT_TITLE, features }) { ) }
- { - featureShown('role') && - ( -
- - setRoleOpen(false)} /> -
- ) - } - { - featureShown('sharing') && - ( -
- - setSharingOpen(false)} /> -
- ) - } { featureShown('palette') && ( diff --git a/app/components/Modal/index.jsx b/app/components/Modal/index.jsx index bb496b8e..b5a05dfe 100644 --- a/app/components/Modal/index.jsx +++ b/app/components/Modal/index.jsx @@ -5,6 +5,7 @@ import { useDraggable } from '../../hooks/useDraggable' import './style.css' import * as modalStack from '../../utils/modals' +import { createPortal } from 'react-dom' export function Modal ({ children, open, size = 'large', onClose = () => {}, draggable = false, shade = true }) { const elRef = React.useRef() @@ -31,15 +32,24 @@ export function Modal ({ children, open, size = 'large', onClose = () => {}, dra }, [open]) return ( -
-
-
- { - draggable &&
•••
- } - {children} -
-
-
+ <> + { + createPortal( + ( +
+
+
+ { + draggable &&
•••
+ } + {children} +
+
+
+ ), + document.body + ) + } + ) } diff --git a/app/components/Modal/style.css b/app/components/Modal/style.css index 6c1365a8..2d2561e2 100644 --- a/app/components/Modal/style.css +++ b/app/components/Modal/style.css @@ -59,6 +59,11 @@ slight box shadow instead max-height: 500px; } +.Modal--auto .Modal-wrapper { + width: auto; + height: auto; +} + .Modal-content { display: block; position: relative; diff --git a/app/components/Notification/style.css b/app/components/Notification/style.css index 1d1429fa..d12986e5 100644 --- a/app/components/Notification/style.css +++ b/app/components/Notification/style.css @@ -82,6 +82,15 @@ --color-text: black; } +.Notification--info { + background-color: var(--base-color--background1); +} + +.Notification--info *, +.Notification--info .Notification-hideBtn { + --color-text: var(--base-color); +} + .Notification-hideBtn { color: var(--color-text); } diff --git a/app/components/Popover/index.jsx b/app/components/Popover/index.jsx index 92c9efb1..a2bfe846 100644 --- a/app/components/Popover/index.jsx +++ b/app/components/Popover/index.jsx @@ -2,7 +2,18 @@ import React from 'react' import './style.css' -export function Popover ({ children, open = false, onClose = () => {} }) { +const ALIGNMENT = { + center: '-50%', + left: '0', + right: '-100%' +} + +const DIRECTION = { + up: 'calc(-100% - 10px)', + down: '0' +} + +export function Popover ({ children, open = false, direction = 'down', alignment = 'center', onClose = () => {} }) { const elRef = React.useRef() React.useEffect(() => { @@ -31,11 +42,13 @@ export function Popover ({ children, open = false, onClose = () => {} }) { }, [onClose]) return ( -
+
{ open && ( -
+
{children}
) diff --git a/app/components/Popover/style.css b/app/components/Popover/style.css index 83cf7cf1..b3a4855b 100644 --- a/app/components/Popover/style.css +++ b/app/components/Popover/style.css @@ -7,9 +7,6 @@ display: block; position: absolute; - left: 50%; - bottom: 0; - text-align: center; border-radius: 15px; @@ -18,8 +15,6 @@ box-sizing: border-box; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); - transform: translate(-50%, 100%); - overflow: hidden; z-index: 1; } diff --git a/app/components/Role/index.jsx b/app/components/Role/index.jsx index 4e492482..43a909c7 100644 --- a/app/components/Role/index.jsx +++ b/app/components/Role/index.jsx @@ -25,15 +25,15 @@ export function Role ({ currentRole = 0, open, onClose = () => {} }) {
Become main
This will turn the current
main client into satellite mode - +
Only the main client's selections can be triggered by the api { - currentRole === 1 + currentRole === 0 ?
This is the main client
: ( - ) diff --git a/app/components/Role/style.css b/app/components/Role/style.css index 85b091dd..b1cb2f29 100644 --- a/app/components/Role/style.css +++ b/app/components/Role/style.css @@ -11,6 +11,10 @@ box-sizing: border-box; } +.Role-content .Button { + margin-top: 10px; +} + .Role-info { margin: 5px; } diff --git a/app/components/Sharing/index.jsx b/app/components/Sharing/index.jsx index 68205778..d27e9247 100644 --- a/app/components/Sharing/index.jsx +++ b/app/components/Sharing/index.jsx @@ -4,6 +4,7 @@ import { SharedContext } from '../../sharedContext' import { Notification } from '../Notification' import { Popover } from '../Popover' +import { Modal } from '../Modal' import CollaborationIcon from '../../assets/icons/collaboration.svg' @@ -53,24 +54,29 @@ export function Sharing ({ open, onClose = () => {} }) { }, [copied]) return ( - +
- { - HOST === 'localhost' && - ( -
- -
- ) - }
-
- Share a link to this workspace and collaborate in real time - +
- + ) } diff --git a/app/components/Sharing/style.css b/app/components/Sharing/style.css index 207e6ea8..80d74081 100644 --- a/app/components/Sharing/style.css +++ b/app/components/Sharing/style.css @@ -11,8 +11,14 @@ box-sizing: border-box; } +.Sharing-text { + padding: 0 5px; + box-sizing: border-box; +} + .Sharing-copyBtn { margin-top: 15px; + margin-bottom: 10px; } .Sharing-icon { @@ -20,5 +26,5 @@ } .Sharing-notification { - padding: 5px; + margin-top: 15px; } \ No newline at end of file diff --git a/app/views/Workspace.jsx b/app/views/Workspace.jsx index 75c50407..12b27f07 100644 --- a/app/views/Workspace.jsx +++ b/app/views/Workspace.jsx @@ -12,6 +12,7 @@ import React from 'react' import { SharedContext } from '../sharedContext' import { Header } from '../components/Header' +import { Footer } from '../components/Footer' import { Onboarding } from '../components/Onboarding' import { MessageContainer } from '../components/MessageContainer' @@ -94,6 +95,7 @@ export const Workspace = () => {
)) } +
) } diff --git a/app/views/WorkspaceWidget.jsx b/app/views/WorkspaceWidget.jsx index e324de35..93a8d0c5 100644 --- a/app/views/WorkspaceWidget.jsx +++ b/app/views/WorkspaceWidget.jsx @@ -10,6 +10,7 @@ import React from 'react' import { Header } from '../components/Header' +import { Footer } from '../components/Footer' import { SharedContext } from '../sharedContext' import { MissingComponent } from '../components/MissingComponent' @@ -83,6 +84,7 @@ export const WorkspaceWidget = () => { : }
+
) } From 67ba23cc93694e0828a52a3f3cd3cd70f7e10f07 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sat, 10 Jan 2026 15:19:43 +0100 Subject: [PATCH 02/57] Remove unused code Signed-off-by: Axel Boberg --- app/components/Header/style.css | 35 --------------------------------- 1 file changed, 35 deletions(-) diff --git a/app/components/Header/style.css b/app/components/Header/style.css index f8568895..b2b40392 100644 --- a/app/components/Header/style.css +++ b/app/components/Header/style.css @@ -55,41 +55,6 @@ Do the same for Windows align-items: center; } -.Header-actionSection { - margin-right: 55px; -} - -.Header-button.Header-sharingBtn { - width: 40px; - padding: 0 1.3em 0 1em; - color: var(--base-color); - font-family: var(--base-fontFamily--primary); -} - -.Header-button.Header-roleBtn { - width: auto; - padding: 0 7px 0 0; - - border: 1px solid var(--base-color--shade); - - white-space: nowrap; - overflow: hidden; -} - -.Header-button.Header-roleBtn::before { - content: ''; - height: 100%; - width: 6px; - - margin-right: 7px; - - background: var(--base-color--shade); -} - -.Header-button.Header-roleBtn.is-main::before { - background: var(--base-color--accent1); -} - .Header-button { display: flex; position: relative; From 2acca938d4c28453fe66e549584cac81227c5cb6 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sat, 10 Jan 2026 15:24:33 +0100 Subject: [PATCH 03/57] Add note regarding the footer to the changelog Signed-off-by: Axel Boberg --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2425bc9..3239945a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - Support for named urls when sharing links to workspaces - Ability to convert items to other types by right-clicking - Ancestor items in context menus now stay tinted when their child menus are opened +### Changed +- Some features have moved to the footer of the app window ### Fixed - An issue where the inspector started to scroll horisontally on overflow - Closing electron windows may cause a loop preventing user defaults from being saved From b6bf07d1dee76f6fd6e4c78dda3bf116850fb613 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sat, 10 Jan 2026 16:58:26 +0100 Subject: [PATCH 04/57] Fix an issue where a comparison for the role determination was inverted Signed-off-by: Axel Boberg --- app/components/Role/index.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/components/Role/index.jsx b/app/components/Role/index.jsx index 43a909c7..ea1ac369 100644 --- a/app/components/Role/index.jsx +++ b/app/components/Role/index.jsx @@ -5,6 +5,8 @@ import * as api from '../../api' import { Popover } from '../Popover' import { PopupConfirm } from '../Popup/confirm' +const MAIN_ROLE_ID = 1 + import './style.css' export function Role ({ currentRole = 0, open, onClose = () => {} }) { @@ -30,7 +32,7 @@ export function Role ({ currentRole = 0, open, onClose = () => {} }) {
Only the main client's selections can be triggered by the api { - currentRole === 0 + currentRole === MAIN_ROLE_ID ?
This is the main client
: ( +
+
+
+ ) +} diff --git a/plugins/timecode/app/components/LTCInput/style.css b/plugins/timecode/app/components/LTCInput/style.css new file mode 100644 index 00000000..b793a86e --- /dev/null +++ b/plugins/timecode/app/components/LTCInput/style.css @@ -0,0 +1,40 @@ +.TargetInput { + margin-bottom: 10px; + padding: 10px 12px; + + border: 1px solid var(--base-color--shade); + border-radius: 10px; +} + +.TargetInput > div, +.TargetInput-flexWrapper > div { + width: 100%; +} + +.TargetInput-flexWrapper { + display: flex; +} + +.TargetInput-flexWrapper > div:last-child { + text-align: right; +} + +.TargetInput-input { + margin-bottom: 10px; +} + +.TargetInput-input:last-child { + margin-bottom: 0; +} + +.TargetInput-input--small { + width: 100px; +} + +.TargetInput-input input { + margin-right: 5px; +} + +.TargetInput-flexInputs { + display: flex; +} diff --git a/plugins/timecode/app/components/SMPTEDisplay/index.jsx b/plugins/timecode/app/components/SMPTEDisplay/index.jsx new file mode 100644 index 00000000..199d3694 --- /dev/null +++ b/plugins/timecode/app/components/SMPTEDisplay/index.jsx @@ -0,0 +1,99 @@ +import bridge from 'bridge' + +import React from 'react' +import './style.css' + +const EASINGS = { + linear: t => t +} + +class Animation { + #from + #to + #durationMs + #easing + #frame = () => {} + + #running = false + #startTime + + constructor (from, to, durationMs = 1000, frame = () => {}, easing = EASINGS.linear,) { + this.#from = from + this.#to = to + this.#durationMs = durationMs + this.#easing = easing + this.#frame = frame + } + + #loop () { + if (!this.#running) { + return + } + + if (!this.#startTime) { + return + } + + if (!this.#durationMs) { + return + } + + const now = Date.now() + const progress = (now - this.#startTime) / this.#durationMs + const eased = this.#easing(progress) + const value = (this.#to - this.#from) * eased + + if (value >= 1) { + this.#frame(this.#to, 1) + this.stop() + return + } + + this.#frame(value, eased) + + window.requestAnimationFrame(() => this.#loop()) + } + + start () { + this.#running = true + this.#startTime = Date.now() + this.#loop() + } + + stop () { + this.#running = false + this.#startTime = undefined + } +} + +export const SMPTEDisplay = ({}) => { + const [smpte, setSmpte] = React.useState() + + React.useEffect(() => { + let anim + function onFrame (frames) { + setSmpte(frames[frames.length - 1].smpte) +/* if (anim) { + anim.stop() + } + + anim = new Animation(0, frames.length, 20, i => { + const j = Math.round(i) + console.log('Rendering', j) + if (frames[j]) { + setSmpte(frames[j].smpte) + } + }) + anim.start() */ + } + + bridge.events.on('timecode.ltc', onFrame) + return () => bridge.events.off('timecode.ltc', onFrame) + }, []) + + return ( +
+ {smpte} +
+ ) +} diff --git a/plugins/timecode/app/components/SMPTEDisplay/style.css b/plugins/timecode/app/components/SMPTEDisplay/style.css new file mode 100644 index 00000000..0bef54bf --- /dev/null +++ b/plugins/timecode/app/components/SMPTEDisplay/style.css @@ -0,0 +1,3 @@ +.SMPTEDisplay { + font-size: 2em; +} \ No newline at end of file diff --git a/plugins/timecode/app/index.jsx b/plugins/timecode/app/index.jsx new file mode 100644 index 00000000..2bedcf9d --- /dev/null +++ b/plugins/timecode/app/index.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom' + +import App from './App' + +import './style.css' + +ReactDOM.render( + + + , + document.getElementById('root') +) diff --git a/plugins/timecode/app/sharedContext.js b/plugins/timecode/app/sharedContext.js new file mode 100644 index 00000000..16d9d14c --- /dev/null +++ b/plugins/timecode/app/sharedContext.js @@ -0,0 +1,56 @@ +/** + * @copyright Copyright © 2021 SVT Design + * @author Axel Boberg + */ + +import React from 'react' +import bridge from 'bridge' + +/** + * A context for being shared + * across active clients + * + * @see {@link ./App.js} + * + * @type { React.Context } + */ +export const SharedContext = React.createContext() + +export const Provider = ({ children }) => { + const [state, setState] = React.useState() + const stateRef = React.useRef() + + React.useEffect(() => { + stateRef.current = state + }, [state]) + + /* + Fetch the state directly + on context load + */ + React.useEffect(() => { + async function initState () { + const state = await bridge.state.get() + setState(state) + } + initState() + }, []) + + /* + Listen for changes to the state + and update the context accordingly + */ + React.useEffect(() => { + function onStateChange (state) { + setState({ ...state }) + } + bridge.events.on('state.change', onStateChange) + return () => bridge.events.off('state.change', onStateChange) + }, []) + + return ( + + { children } + + ) +} diff --git a/plugins/timecode/app/style.css b/plugins/timecode/app/style.css new file mode 100644 index 00000000..e2a5ffed --- /dev/null +++ b/plugins/timecode/app/style.css @@ -0,0 +1,52 @@ +html, body, .View--flex { + position: relative; + width: 100%; + height: 100%; + + padding: 0; + margin: 0; + + overflow: hidden; +} + +#root { + display: contents; +} + +.View--flex { + display: flex; + flex-direction: column; +} + +.View--center { + display: flex; + width: 100%; + height: 100%; + + align-items: center; + justify-content: center; +} + +.View--spread { + display: flex; +} + +.u-width--100pct { + width: 100%; +} + +.u-marginBottom--5px { + margin-bottom: 5px; +} + +.u-scroll--y { + position: relative; + width: 100%; + height: 100%; + overflow-y: scroll; + overflow-x: hidden; +} + +.u-textAlign--center { + text-align: center; +} \ No newline at end of file diff --git a/plugins/timecode/app/views/SMPTE.jsx b/plugins/timecode/app/views/SMPTE.jsx new file mode 100644 index 00000000..429eaf3f --- /dev/null +++ b/plugins/timecode/app/views/SMPTE.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import bridge from 'bridge' + +import { SMPTEDisplay } from '../components/SMPTEDisplay' + +export const SMPTE = () => { + return ( +
+ +
+ ) +} diff --git a/plugins/timecode/app/views/Settings.jsx b/plugins/timecode/app/views/Settings.jsx new file mode 100644 index 00000000..8f359139 --- /dev/null +++ b/plugins/timecode/app/views/Settings.jsx @@ -0,0 +1,33 @@ +import React from 'react' +import bridge from 'bridge' + +import { SharedContext } from '../sharedContext' +import { LTCInput } from '../components/LTCInput' + +export const LTCInputs = () => { + const [state] = React.useContext(SharedContext) + const targets = state?.plugins?.[window.PLUGIN.name]?.targets || [] + + function handleChange (targetId, newData) { + bridge.commands.executeCommand('timecode.editLTCInput', targetId, newData) + } + + function handleDelete (targetId) { + bridge.commands.executeCommand('timecode.removeLTCInput', targetId) + } + + function handleNew () { + bridge.commands.executeCommand('timecode.addLTCInput', {}) + } + + return ( +
+ { + (targets || []).map(target => { + return handleChange(target.id, newData)} onDelete={() => handleDelete(target.id)} /> + }) + } + +
+ ) +} diff --git a/plugins/timecode/index.js b/plugins/timecode/index.js new file mode 100644 index 00000000..3a5a7dd4 --- /dev/null +++ b/plugins/timecode/index.js @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2026 Axel Boberg +// +// SPDX-License-Identifier: MIT + +/** + * @typedef {{ + * host: String, + * port: Number + * }} ConnectionDescription + * + * @typedef {{ + * id: String?, + * name: String, + * host: String, + * port: Number + * }} ServerDescription + */ + +/** + * @type { import('../../api').Api } + */ +const bridge = require('bridge') +const assets = require('../../assets.json') +const manifest = require('./package.json') + +const Logger = require('../../lib/Logger') +const logger = new Logger({ name: 'TimecodePlugin' }) + +const audio = require('./lib/audio') +const ltc = require('./lib/ltc') + +require('./lib/commands') + +async function initWidget () { + const cssPath = `${assets.hash}.${manifest.name}.bundle.css` + const jsPath = `${assets.hash}.${manifest.name}.bundle.js` + + const html = ` + + + + Caspar + + + + + + + +
+ + + ` + return await bridge.server.serveString(html) +} + +function zeroPad (n) { + if (n < 10) { + return `0${n}` + } + return `${n}` +} + +function formatTimecodeFrame (frame) { + return `${zeroPad(frame.hours)}:${zeroPad(frame.minutes)}:${zeroPad(frame.seconds)}.${zeroPad(frame.frames)}` +} + +/* +Activate the plugin and +bootstrap its contributions +*/ +exports.activate = async () => { + logger.debug('Activating timecode plugin') + const htmlPath = await initWidget() + + bridge.widgets.registerWidget({ + id: 'bridge.plugins.timecode.smpte', + name: 'SMPTE display', + uri: `${htmlPath}?path=widget/smpte`, + description: 'Display incoming SMPTE timecode', + supportsFloat: true + }) + + /* + Register the targets setting as + soon as the widget is setup + */ + logger.debug('Registering setting') + bridge.settings.registerSetting({ + title: 'LTC inputs', + group: 'Timecode', + description: 'Configure LTC inputs', + inputs: [ + { type: 'frame', uri: `${htmlPath}?path=settings/ltc-inputs` } + ] + }) + + /* + 1. Find device + */ + const devices = await audio.enumerateInputDevices() + logger.debug('Devices', devices) + const device = devices.find(device => device.label.includes('LTC')) + if (!device) { + logger.warn('No audio device found') + return + } + + logger.debug('Using device', device.label) + + const SAMPLE_RATE = 48000 + const FRAME_RATE = 25 + + /* + 2. Setup context and read buffers + */ + const ctx = await audio.createContext({ + sampleRate: SAMPLE_RATE, + latencyHint: 'interactive' + }) + + const source = await audio.createDeviceStreamSource(ctx, device.deviceId) + + const decoder = ltc.createDecoder(SAMPLE_RATE, FRAME_RATE, 'float') + + const proc = await audio.createLTCDecoder(ctx, decoder.apv) + proc.port.on('message', e => { + const buf = Buffer.from(e?.buffer.buffer) + decoder.write(buf) + + let frame = decoder.read() + while (frame) { + logger.debug('Frame', frame) + + bridge.events.emit('timecode.ltc', [{ + days: frame.days, + hours: frame.hours, + minutes: frame.minutes, + seconds: frame.seconds, + frames: frame.frames, + smpte: formatTimecodeFrame(frame) + }]) + + frame = decoder.read() + } + }) + + source.connect(proc) + proc.connect(ctx.destination) + + logger.debug(`Audio running at ${ctx.sampleRate}Hz`) + logger.debug(`Base Latency: ${ctx.baseLatency}s`) +} diff --git a/plugins/timecode/lib/audio/LTCDecoder.js b/plugins/timecode/lib/audio/LTCDecoder.js new file mode 100644 index 00000000..ea7e0dc6 --- /dev/null +++ b/plugins/timecode/lib/audio/LTCDecoder.js @@ -0,0 +1,24 @@ +class LTCDecoder extends AudioWorkletProcessor { + constructor (opts) { + super() + this._apv = opts?.processorOptions?.apv + this._buffer = new Float32Array(this._apv) + this._index = 0 + } + + process (inputs, outputs, parameters) { + const input = inputs[0] + if (!input || input.length === 0) { + return true + } + + const channelData = input[0] + this.port.postMessage({ + buffer: channelData.slice() + }) + + return true + } +} + +registerProcessor('LTCDecoder', LTCDecoder) diff --git a/plugins/timecode/lib/audio/index.js b/plugins/timecode/lib/audio/index.js new file mode 100644 index 00000000..d4b74a51 --- /dev/null +++ b/plugins/timecode/lib/audio/index.js @@ -0,0 +1,71 @@ +const { + mediaDevices, + AudioContext, + GainNode, + + // eslint-disable-next-line no-unused-vars + MediaStreamAudioSourceNode, + AudioWorkletNode +} = require('node-web-audio-api') + +async function enumerateDevices () { + const devices = await mediaDevices.enumerateDevices() + return devices +} +exports.enumerateDevices = enumerateDevices + +async function enumerateInputDevices () { + const devices = await enumerateDevices() + return devices.filter(device => device?.kind === 'audioinput') +} +exports.enumerateInputDevices = enumerateInputDevices + +async function createDeviceStreamSource (audioContext, deviceId) { + const mediaStream = await mediaDevices.getUserMedia({ + audio: { + deviceId, + echoCancellation: false, + autoGainControl: false, + noiseSuppression: false + } + }) + const source = audioContext.createMediaStreamSource(mediaStream) + return source +} +exports.createDeviceStreamSource = createDeviceStreamSource + +async function createScriptProcessor (audioContext, onData) { + const processor = audioContext.createScriptProcessor() + + if (typeof onData === 'function') { + processor.addEventListener('audioprocess', e => onData(e)) + } + + return processor +} +exports.createScriptProcessor = createScriptProcessor + +async function createContext (opts) { + const context = new AudioContext(opts) + await context.resume() + return context +} +exports.createContext = createContext + +async function createGainNode (audioContext, initialGain = 0) { + const node = new GainNode(audioContext, { gain: initialGain }) + return node +} +exports.createGainNode = createGainNode + +async function createLTCDecoder (ctx, apv) { + await ctx.audioWorklet.addModule('LTCDecoder.js') + const processor = new AudioWorkletNode(ctx, 'LTCDecoder', { + processorOptions: { + apv + } + }) + + return processor +} +exports.createLTCDecoder = createLTCDecoder diff --git a/plugins/timecode/lib/commands.js b/plugins/timecode/lib/commands.js new file mode 100644 index 00000000..3581a443 --- /dev/null +++ b/plugins/timecode/lib/commands.js @@ -0,0 +1,68 @@ +const bridge = require('bridge') + +const uuid = require('uuid') + +const manifest = require('../package.json') +const paths = require('./paths') + +/** + * Add a new target + * from a description object + * @param { TargetDescription } description + * @returns { Promise. } A promise resolving + * to the target's id + */ +async function addLTCInput (description) { + /* + Generate a new id for + referencing the target + */ + description.id = uuid.v4() + + const targetArray = await bridge.state.get(`${paths.STATE_SETTINGS_PATH}.targets`) || [] + if (targetArray.length > 0) { + bridge.state.apply(`plugins.${manifest.name}.targets`, { $push: [description] }) + } else { + bridge.state.apply(`plugins.${manifest.name}.targets`, [description]) + } + + return description.id +} +exports.addLTCInput = addLTCInput +bridge.commands.registerCommand('timecode.addLTCInput', addLTCInput) + +/** + * Update the state from + * a new description + * @param { String } targetId The id of the server to edit + * @param { TargetDescription } description A new target init object + * to apply to the target + */ +async function editTarget (targetId, description) { + const targetArray = await bridge.state.get(`${paths.STATE_SETTINGS_PATH}.targets`) || [] + const newTargetArray = [...targetArray] + .map(target => { + if (target.id !== targetId) { + return target + } + return description + }) + + /* + Return early if there are no targets in the array + as we don't want to set a bad state + */ + if (newTargetArray.length === 0) { + return + } + + bridge.state.apply({ + plugins: { + [manifest.name]: { + targets: { $replace: newTargetArray } + } + } + }) +} +exports.editTarget = editTarget +bridge.commands.registerCommand('osc.editTarget', editTarget) diff --git a/plugins/timecode/lib/ltc/index.js b/plugins/timecode/lib/ltc/index.js new file mode 100644 index 00000000..37faeb2b --- /dev/null +++ b/plugins/timecode/lib/ltc/index.js @@ -0,0 +1,16 @@ +const { LTCDecoder } = require('libltc-wrapper') + +function createDecoder (sampleRate = 48000, frameRate = 25, format = 'u8') { + return new LTCDecoder(sampleRate, frameRate, format) +} +exports.createDecoder = createDecoder + +function writeBuffer (decoder, buffer) { + decoder.write(buffer) +} +exports.writeBuffer = writeBuffer + +function decodeFrames (decoder) { + return decoder.read() +} +exports.decodeFrames = decodeFrames diff --git a/plugins/timecode/lib/paths.js b/plugins/timecode/lib/paths.js new file mode 100644 index 00000000..815f4264 --- /dev/null +++ b/plugins/timecode/lib/paths.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2024 Sveriges Television AB +// +// SPDX-License-Identifier: MIT + +const manifest = require('../package.json') + +exports.STATE_SETTINGS_PATH = `plugins.${manifest.name}` diff --git a/plugins/timecode/package-lock.json b/plugins/timecode/package-lock.json new file mode 100644 index 00000000..a3f5d689 --- /dev/null +++ b/plugins/timecode/package-lock.json @@ -0,0 +1,147 @@ +{ + "name": "bridge-plugin-timecode", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bridge-plugin-timecode", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "libltc-wrapper": "^1.1.2", + "node-web-audio-api": "^1.0.7" + }, + "engines": { + "bridge": "^0.0.1" + } + }, + "node_modules/caller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/caller/-/caller-1.1.0.tgz", + "integrity": "sha512-n+21IZC3j06YpCWaxmUy5AnVqhmCIM2bQtqQyy00HJlmStRt6kwDX5F9Z97pqwAB+G/tgSz6q/kUBbNyQzIubw==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/libltc-wrapper": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/libltc-wrapper/-/libltc-wrapper-1.1.2.tgz", + "integrity": "sha512-WbZocPhZZx8YQsye6zmRSCznEjmmsOmx9KTHt42RoyjLOr+lugkxv0wWyNum9QB17Lj8h9UbHA/27Cmp4FmnJQ==", + "hasInstallScript": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-web-audio-api": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/node-web-audio-api/-/node-web-audio-api-1.0.7.tgz", + "integrity": "sha512-J1Po8THiPdh8ZOc6p8qrZau+DRK2HJkSyjGpaPGdbB5T+lugl2OmzSzc56nmESkWSEww9J30AjeAOdm94E5TBA==", + "license": "BSD-3-Clause", + "dependencies": { + "caller": "^1.1.0", + "node-fetch": "^3.3.2", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/plugins/timecode/package.json b/plugins/timecode/package.json new file mode 100644 index 00000000..9ba8501b --- /dev/null +++ b/plugins/timecode/package.json @@ -0,0 +1,23 @@ +{ + "name": "bridge-plugin-timecode", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "electron:rebuild": "electron-rebuild -f" + }, + "engines": { + "bridge": "^0.0.1" + }, + "keywords": [ + "bridge", + "plugin" + ], + "author": "Axel Boberg (axel.boberg@svt.se)", + "license": "UNLICENSED", + "dependencies": { + "libltc-wrapper": "^1.1.2", + "node-web-audio-api": "^1.0.7" + } +} From ccb1165369b97cc6299e2509414956784901333b Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Tue, 13 Jan 2026 00:04:41 +0100 Subject: [PATCH 09/57] Add pre- and post-build scripts for managing native addons Signed-off-by: Axel Boberg --- package.json | 6 ++-- plugins/timecode/package.json | 3 +- scripts/electron-postbuild-macos.js | 38 +++++++++++++++++++++ scripts/electron-prebuild.js | 52 +++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 scripts/electron-postbuild-macos.js create mode 100644 scripts/electron-prebuild.js diff --git a/package.json b/package.json index ce35aad3..97f63429 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "clean": "node ./scripts/clean-build-folder.js", "electron": "NODE_ENV=development electron --trace-warnings index.js", "electron:rebuild": "electron-rebuild -f", - "electron:build:mac:arm": "npm run build && electron-packager . \"Bridge\" --platform=darwin --arch=arm64 --extend-info extra.plist --icon=media/appicon.icns --overwrite --asar --ignore=\"webpack.*\\.js\" --out ./bin", - "electron:build:mac:intel": "npm run build && electron-packager . \"Bridge\" --platform=darwin --arch=x64 --extend-info extra.plist --icon=media/appicon.icns --overwrite --asar --ignore=\"webpack.*\\.js\" --out ./bin", + "electron:prebuild:arm64": "node scripts/electron-prebuild arm64", + "electron:prebuild:x64": "node scripts/electron-prebuild x64", + "electron:build:mac:arm": "npm run build && npm run electron:prebuild:arm64 && electron-packager . \"Bridge\" --platform=darwin --arch=arm64 --extend-info extra.plist --icon=media/appicon.icns --overwrite --asar --ignore=\"webpack.*\\.js\" --out ./bin && node scripts/electron-postbuild-macos.js bin/Bridge-darwin-arm64/Bridge.app", + "electron:build:mac:intel": "npm run build && npm run electron:prebuild:x64 && electron-packager . \"Bridge\" --platform=darwin --arch=x64 --extend-info extra.plist --icon=media/appicon.icns --overwrite --asar --ignore=\"webpack.*\\.js\" --out ./bin && node scripts/electron-postbuild-macos.js bin/Bridge-darwin-x64/Bridge.app", "electron:build:win": "npm run build && electron-packager . \"Bridge\" --platform=win32 --arch=x64 --extend-info extra.plist --icon=media/appicon.ico --overwrite --asar --ignore=\"webpack.*\\.js\" --out ./bin", "electron:sign:mac": "node scripts/sign-macos.js", "prepare": "husky install", diff --git a/plugins/timecode/package.json b/plugins/timecode/package.json index 9ba8501b..8ae113d5 100644 --- a/plugins/timecode/package.json +++ b/plugins/timecode/package.json @@ -4,8 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "electron:rebuild": "electron-rebuild -f" + "test": "echo \"Error: no test specified\" && exit 1" }, "engines": { "bridge": "^0.0.1" diff --git a/scripts/electron-postbuild-macos.js b/scripts/electron-postbuild-macos.js new file mode 100644 index 00000000..221da44c --- /dev/null +++ b/scripts/electron-postbuild-macos.js @@ -0,0 +1,38 @@ +/** + * @description + * Post build actions for macOS-builds + * Run as `node electron-postbuild-macos.js ` + * + * Actions: + * - Copy dynamically linked libraries into the app bundle + */ + +const fs = require('node:fs') +const path = require('node:path') +const assert = require('node:assert') + +const [,, APP_BUNDLE] = process.argv +assert(APP_BUNDLE, 'Missing required argument \'app bundle\'') + +const CURRENT_DIR = process.cwd() + +const LIBRARIES_TO_COPY = [ + { + path: '../plugins/timecode/node_modules/libltc-wrapper/build/Release', + fileName: 'libltc.11.dylib' + } +] + +const FRAMEWORKS_DIR = path.join(CURRENT_DIR, APP_BUNDLE, '/Contents/Frameworks') + +/* +Copy libraries into the FRAMEWORKS_DIR +within the app bundle +*/ +console.log('Performing post build tasks') +for (const library of LIBRARIES_TO_COPY) { + const from = path.join(__dirname, library.path, library.fileName) + const to = path.join(FRAMEWORKS_DIR, library.fileName) + fs.copyFileSync(from, to) + console.log('%s\x1b[32m%s\x1b[0m', 'Copied framwork: ', library.fileName) +} diff --git a/scripts/electron-prebuild.js b/scripts/electron-prebuild.js new file mode 100644 index 00000000..9ce33945 --- /dev/null +++ b/scripts/electron-prebuild.js @@ -0,0 +1,52 @@ +/** + * @description + * Pre build actions for macOS-builds + * Run as `node electron-prebuild-macos.js ` + * + * Actions: + * - Rebuild native addons for all plugins + */ + +const cp = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') +const assert = require('node:assert') + +const [,, ARCH] = process.argv +assert(ARCH, 'Missing required argument \'arch\'') + +const PLUGINS_DIR = path.join(__dirname, '../plugins') +const MAIN_DIR = path.join(__dirname, '../') + +function forcePackageRebuild (path, arch) { + cp.execSync(`npm run env -- electron-rebuild -f -a ${arch}`, { + cwd: path + }) +} + +console.log('Performing pre build tasks') +fs.readdirSync(PLUGINS_DIR) + .map(pathname => ([path.join(PLUGINS_DIR, pathname), pathname])) + .filter(([pluginPath]) => { + /* + Filter out paths that are not directories, + since we keep a few other files in the plugins + directory as well + */ + return fs.statSync(pluginPath).isDirectory() + }) + .filter(([pluginPath]) => { + /* + Filter out paths that don't + contain a package.json file + */ + const packagePath = path.join(pluginPath, '/package.json') + return fs.existsSync(packagePath) + }) + .forEach(([pluginPath, pluginName]) => { + console.log('%s\x1b[32m%s\x1b[0m', 'Rebuilding addons for plugin: ', pluginName) + forcePackageRebuild(pluginPath, ARCH) + }) + +console.log('%s\x1b[32m%s\x1b[0m', 'Rebuilding addons for main codebase') +forcePackageRebuild(MAIN_DIR, ARCH) From b82d9b5d7de1cc5207793dbe9abb71fa60bfbb5d Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 14 Jan 2026 00:43:55 +0100 Subject: [PATCH 10/57] Add support for lists in settings Signed-off-by: Axel Boberg --- CHANGELOG.md | 1 + app/components/Preferences/preference.jsx | 13 ++- app/components/Preferences/shared.js | 4 +- app/components/PreferencesListInput/index.jsx | 105 ++++++++++++++++++ app/components/PreferencesListInput/style.css | 37 ++++++ lib/schemas/setting.schema.json | 2 +- package.json | 2 +- plugins/timecode/index.js | 14 --- plugins/timecode/package.json | 33 ++++++ 9 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 app/components/PreferencesListInput/index.jsx create mode 100644 app/components/PreferencesListInput/style.css diff --git a/CHANGELOG.md b/CHANGELOG.md index abd086e3..8574c3f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Ability to convert items to other types by right-clicking - Ancestor items in context menus now stay tinted when their child menus are opened - A shortcut to open preferences (CMD/CTRL+,) +- Support for lists in settings ### Changed - Some features have moved to the footer of the app window ### Fixed diff --git a/app/components/Preferences/preference.jsx b/app/components/Preferences/preference.jsx index 23c56a5f..072226a0 100644 --- a/app/components/Preferences/preference.jsx +++ b/app/components/Preferences/preference.jsx @@ -8,7 +8,7 @@ import { inputComponents } from './shared' import objectPath from 'object-path' -export function Preference ({ setting, onChange = () => {} }) { +export function Preference ({ setting, values, onChange = () => {} }) { const [shared] = React.useContext(SharedContext) const [local] = React.useContext(LocalContext) @@ -22,10 +22,15 @@ export function Preference ({ setting, onChange = () => {} }) { */ function valueFromPath (path) { const parts = path.split('.') - const sourceName = parts.shift() + const firstKey = parts.shift() - let source = local - if (sourceName === 'shared') { + let source = values || local + + if (parts.length === 0) { + return source[firstKey] + } + + if (firstKey === 'shared') { source = shared } diff --git a/app/components/Preferences/shared.js b/app/components/Preferences/shared.js index ac6fdb84..3afa1fdb 100644 --- a/app/components/Preferences/shared.js +++ b/app/components/Preferences/shared.js @@ -8,6 +8,7 @@ import { PreferencesNumberInput } from '../PreferencesNumberInput' import { PreferencesSelectInput } from '../PreferencesSelectInput' import { PreferencesThemeInput } from '../PreferencesThemeInput' import { PreferencesFrameInput } from '../PreferencesFrameInput' +import { PreferencesListInput } from '../PreferencesListInput' /** * Map typenames to components @@ -24,5 +25,6 @@ export const inputComponents = { select: PreferencesSelectInput, theme: PreferencesThemeInput, frame: PreferencesFrameInput, - clear: PreferencesClearStateInput + clear: PreferencesClearStateInput, + list: PreferencesListInput } diff --git a/app/components/PreferencesListInput/index.jsx b/app/components/PreferencesListInput/index.jsx new file mode 100644 index 00000000..5b780f83 --- /dev/null +++ b/app/components/PreferencesListInput/index.jsx @@ -0,0 +1,105 @@ +import React from 'react' +import './style.css' + +import * as uuid from 'uuid' + +import { Preference } from '../Preferences/preference' + +function PreferencesListInputItem ({ value = {}, settings = [], onChange = () => {}, onDelete = () => {} }) { + function handleChange (key, newValue) { + onChange({ + ...value, + [key]: newValue + }) + } + + function handleDelete () { + onDelete() + } + + return ( +
+
+
+ { + (Array.isArray(settings) ? settings : []) + .map((setting, i) => { + return ( +
+ +
+ ) + }) + } +
+
+ +
+
+
+ ) +} + +export function PreferencesListInput ({ label, value = [], settings = [], onChange = () => {}, buttonTextCreate = 'New' }) { + const items = Array.isArray(value) ? value : [] + + function handleCreate () { + const id = uuid.v4() + const newItem = { + id + } + onChange({ $replace: [...items, newItem] }) + } + + function handleUpdate (id, newData) { + const index = items.findIndex(item => item.id === id) + if (index === -1) { + return + } + + const newItems = [...items] + newItems[index] = newData + onChange({ $replace: newItems }) + } + + function handleDelete (id) { + const index = items.findIndex(item => item.id === id) + if (index === -1) { + return + } + + const newItems = [...items] + newItems.splice(index, 1) + onChange({ $replace: newItems }) + } + + return ( +
+
+ +
+
+ { + items.map(item => { + return ( +
+ handleUpdate(item.id, newData)} + onDelete={() => handleDelete(item.id)} + /> +
+ ) + }) + } +
+
+ ) +} diff --git a/app/components/PreferencesListInput/style.css b/app/components/PreferencesListInput/style.css new file mode 100644 index 00000000..cd807b70 --- /dev/null +++ b/app/components/PreferencesListInput/style.css @@ -0,0 +1,37 @@ +.PreferencesListInputItem { + margin-top: 10px; + margin-bottom: 10px; + padding: 10px 12px; + + border: 1px solid var(--base-color--shade); + border-radius: 10px; +} + +.PreferencesListInputItem > div, +.PreferencesListInputItem-flexWrapper > div { + width: 100%; +} + +.PreferencesListInputItem-flexWrapper { + display: flex; +} + +.PreferencesListInputItem-flexWrapper > div:last-child { + text-align: right; +} + +.PreferencesListInputItem-input { + margin-bottom: -10px; +} + +.PreferencesListInputItem-input--small { + width: 100px; +} + +.PreferencesListInputItem-input input { + margin-right: 5px; +} + +.PreferencesListInputItem-flexInputs { + display: flex; +} diff --git a/lib/schemas/setting.schema.json b/lib/schemas/setting.schema.json index ba7f2ce1..5f2d3c3a 100644 --- a/lib/schemas/setting.schema.json +++ b/lib/schemas/setting.schema.json @@ -7,7 +7,7 @@ "properties": { "type": { "type": "string", - "enum": ["boolean", "theme", "number", "string", "frame", "select", "segmented"] + "enum": ["boolean", "theme", "number", "string", "frame", "select", "segmented", "list"] }, "bind": { "type": "string" diff --git a/package.json b/package.json index 97f63429..59c58f3b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "electron:rebuild": "electron-rebuild -f", "electron:prebuild:arm64": "node scripts/electron-prebuild arm64", "electron:prebuild:x64": "node scripts/electron-prebuild x64", - "electron:build:mac:arm": "npm run build && npm run electron:prebuild:arm64 && electron-packager . \"Bridge\" --platform=darwin --arch=arm64 --extend-info extra.plist --icon=media/appicon.icns --overwrite --asar --ignore=\"webpack.*\\.js\" --out ./bin && node scripts/electron-postbuild-macos.js bin/Bridge-darwin-arm64/Bridge.app", + "electron:build:mac:arm": "npm run build && npm run electron:prebuild:arm64 && electron-packager . \"Bridge\" --platform=darwin --arch=arm64 --extend-info extra.plist --icon=media/appicon.icns --overwrite --asar --ignore=\"webpack.*\\.js\" --out ./bin && node scripts/electron-postbuild-macos.js bin/Bridge-darwin-arm64/Bridge.app", "electron:build:mac:intel": "npm run build && npm run electron:prebuild:x64 && electron-packager . \"Bridge\" --platform=darwin --arch=x64 --extend-info extra.plist --icon=media/appicon.icns --overwrite --asar --ignore=\"webpack.*\\.js\" --out ./bin && node scripts/electron-postbuild-macos.js bin/Bridge-darwin-x64/Bridge.app", "electron:build:win": "npm run build && electron-packager . \"Bridge\" --platform=win32 --arch=x64 --extend-info extra.plist --icon=media/appicon.ico --overwrite --asar --ignore=\"webpack.*\\.js\" --out ./bin", "electron:sign:mac": "node scripts/sign-macos.js", diff --git a/plugins/timecode/index.js b/plugins/timecode/index.js index 3a5a7dd4..20bac85f 100644 --- a/plugins/timecode/index.js +++ b/plugins/timecode/index.js @@ -87,20 +87,6 @@ exports.activate = async () => { supportsFloat: true }) - /* - Register the targets setting as - soon as the widget is setup - */ - logger.debug('Registering setting') - bridge.settings.registerSetting({ - title: 'LTC inputs', - group: 'Timecode', - description: 'Configure LTC inputs', - inputs: [ - { type: 'frame', uri: `${htmlPath}?path=settings/ltc-inputs` } - ] - }) - /* 1. Find device */ diff --git a/plugins/timecode/package.json b/plugins/timecode/package.json index 8ae113d5..4cf52227 100644 --- a/plugins/timecode/package.json +++ b/plugins/timecode/package.json @@ -18,5 +18,38 @@ "dependencies": { "libltc-wrapper": "^1.1.2", "node-web-audio-api": "^1.0.7" + }, + "contributes": { + "settings": [ + { + "group": "Timecode", + "title": "Input list test title", + "inputs": [ + { + "type": "list", + "label": "List label", + "bind": "shared.plugins.bridge-plugin-timecode.settings.ltc_inputs", + "settings": [ + { + "title": "Test setting", + "inputs": [ + { + "type": "select", + "label": "Test select", + "bind": "select", + "options": ["Do nothing", "Play next sibling", "Select next sibling (main client)"] + }, + { + "type": "string", + "label": "Test string", + "bind": "myString" + } + ] + } + ] + } + ] + } + ] } } From 33af24960260baaef7c009dd26a90bd40096319a Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 14 Jan 2026 00:57:05 +0100 Subject: [PATCH 11/57] Add support for custom ids in select inputs in settings Signed-off-by: Axel Boberg --- CHANGELOG.md | 1 + .../PreferencesSelectInput/index.jsx | 15 +++++++++- plugins/timecode/package.json | 28 +++++++++++++------ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8574c3f7..69b841c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Ancestor items in context menus now stay tinted when their child menus are opened - A shortcut to open preferences (CMD/CTRL+,) - Support for lists in settings +- Support for custom ids in select inputs in settings ### Changed - Some features have moved to the footer of the app window ### Fixed diff --git a/app/components/PreferencesSelectInput/index.jsx b/app/components/PreferencesSelectInput/index.jsx index 73d0bc07..95e89efb 100644 --- a/app/components/PreferencesSelectInput/index.jsx +++ b/app/components/PreferencesSelectInput/index.jsx @@ -11,7 +11,20 @@ export function PreferencesSelectInput ({ label, value, options = [], onChange = diff --git a/plugins/timecode/package.json b/plugins/timecode/package.json index 4cf52227..4aa20700 100644 --- a/plugins/timecode/package.json +++ b/plugins/timecode/package.json @@ -31,18 +31,30 @@ "bind": "shared.plugins.bridge-plugin-timecode.settings.ltc_inputs", "settings": [ { - "title": "Test setting", "inputs": [ { - "type": "select", - "label": "Test select", - "bind": "select", - "options": ["Do nothing", "Play next sibling", "Select next sibling (main client)"] + "type": "string", + "label": "Name", + "bind": "name" }, { - "type": "string", - "label": "Test string", - "bind": "myString" + "type": "select", + "label": "Audio device", + "bind": "device", + "options": [ + { + "id": "none", + "label": "None" + }, + { + "id": "device1", + "label": "Microphone" + }, + { + "id": "device2", + "label": "LTC" + } + ] } ] } From 39a961a15da6cfd3394274a7553fecba3fa4d688 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Fri, 16 Jan 2026 00:15:22 +0100 Subject: [PATCH 12/57] Prevent the main window content from scrolling Signed-off-by: Axel Boberg --- app/index.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/index.css b/app/index.css index b5e8fa02..6e90248f 100644 --- a/app/index.css +++ b/app/index.css @@ -6,6 +6,7 @@ html, body, #root { height: 100%; min-height: 100vh; + overflow-y: hidden; } body { From c9612f3f97c5d49ff46b33d6dc8b601a1f9fc024 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sat, 17 Jan 2026 23:06:18 +0100 Subject: [PATCH 13/57] Clean up the timecode plugin Signed-off-by: Axel Boberg --- api/settings.js | 25 ++- .../PreferencesSelectInput/index.jsx | 31 +-- lib/api/SSettings.js | 60 ++++- plugins/timecode/index copy.js | 198 +++++++++++++++++ plugins/timecode/index.js | 209 ++++++++++++------ plugins/timecode/lib/DIController.js | 2 + plugins/timecode/lib/TimecodeDevice.js | 8 + plugins/timecode/lib/audio/index.js | 60 +---- plugins/timecode/lib/commands.js | 68 ------ .../DataWorkletProcessor.js} | 4 +- plugins/timecode/lib/ltc/LTCDecoder.js | 30 +++ plugins/timecode/lib/ltc/LTCDevice.js | 156 +++++++++++++ plugins/timecode/lib/ltc/index.js | 16 -- plugins/timecode/lib/paths.js | 7 - plugins/timecode/package.json | 45 ---- scripts/sign-macos.js | 10 +- 16 files changed, 642 insertions(+), 287 deletions(-) create mode 100644 plugins/timecode/index copy.js create mode 100644 plugins/timecode/lib/DIController.js create mode 100644 plugins/timecode/lib/TimecodeDevice.js delete mode 100644 plugins/timecode/lib/commands.js rename plugins/timecode/lib/{audio/LTCDecoder.js => ltc/DataWorkletProcessor.js} (77%) create mode 100644 plugins/timecode/lib/ltc/LTCDecoder.js create mode 100644 plugins/timecode/lib/ltc/LTCDevice.js delete mode 100644 plugins/timecode/lib/ltc/index.js delete mode 100644 plugins/timecode/lib/paths.js diff --git a/api/settings.js b/api/settings.js index 1c08d56d..9e12864b 100644 --- a/api/settings.js +++ b/api/settings.js @@ -12,6 +12,9 @@ const DIController = require('../shared/DIController') +const MissingArgumentError = require('./error/MissingArgumentError') +const InvalidArgumentError = require('./error/InvalidArgumentError') + class Settings { #props @@ -23,11 +26,31 @@ class Settings { * Register a setting * by its specification * @param { SettingSpecification } specification A setting specification - * @returns { Promise. } + * @returns { Promise. } */ registerSetting (specification) { return this.#props.Commands.executeCommand('settings.registerSetting', specification) } + + /** + * Apply changes to a registered + * setting in the state + * + * @param { String } id The id of a setting to update + * @param { SettingSpecification } set A setting object to apply + * @returns { Promise. } + */ + async applySetting (id, set = {}) { + if (typeof id !== 'string') { + throw new MissingArgumentError('Invalid value for item id, must be a string') + } + + if (typeof set !== 'object' || Array.isArray(set)) { + throw new InvalidArgumentError('Argument \'set\' must be a valid object that\'s not an array') + } + + return this.#props.Commands.executeCommand('settings.applySetting', id, set) + } } DIController.main.register('Settings', Settings, [ diff --git a/app/components/PreferencesSelectInput/index.jsx b/app/components/PreferencesSelectInput/index.jsx index 95e89efb..9882659e 100644 --- a/app/components/PreferencesSelectInput/index.jsx +++ b/app/components/PreferencesSelectInput/index.jsx @@ -5,27 +5,30 @@ import * as random from '../../utils/random' export function PreferencesSelectInput ({ label, value, options = [], onChange = () => {} }) { const [id] = React.useState(`number-${random.number()}`) + return (

diff --git a/lib/api/SSettings.js b/lib/api/SSettings.js index 556026f7..c9844e90 100644 --- a/lib/api/SSettings.js +++ b/lib/api/SSettings.js @@ -10,6 +10,7 @@ */ const Validator = require('../Validator') +const uuid = require('uuid') const DIController = require('../../shared/DIController') const DIBase = require('../../shared/DIBase') @@ -22,6 +23,7 @@ class SSettings extends DIBase { #setup () { this.props.SCommands.registerAsyncCommand('settings.registerSetting', this.registerSetting.bind(this)) + this.props.SCommands.registerAsyncCommand('settings.applySetting', this.applySetting.bind(this)) } /** @@ -38,24 +40,76 @@ class SSettings extends DIBase { throw Validator.getFirstError(validate) } + /* + Add an id to the spec + for later reference + */ + const _specification = { + ...specification, + id: uuid.v4() + } + /* Make sure that the settings-group actually exists as an array in the state before pushing the specifications */ - if (!this.props.Workspace.state.data._settings?.[specification.group]) { + if (!this.props.Workspace.state.data._settings?.[_specification.group]) { this.props.SState.applyState({ _settings: { - [specification.group]: [specification] + [_specification.group]: [_specification] } }) } else { this.props.SState.applyState({ _settings: { - [specification.group]: { $push: [specification] } + [_specification.group]: { $push: [_specification] } } }) } + return _specification.id + } + + /** + * Apply a registered + * setting in the state + * + * @param { String } id The id of a setting to update + * @param { SettingSpecification } set A setting object to apply + * @returns { Promise. } + */ + applySetting (id, set) { + const groups = Object.entries(this.props.Workspace.state?.data?._settings || {}) + let groupName + let indexInGroup + + for (const [group, settings] of groups) { + if (!Array.isArray(settings)) { + continue + } + + const settingIndex = settings.findIndex(setting => setting.id === id) + if (settingIndex === -1) { + continue + } + + groupName = group + indexInGroup = settingIndex + } + + if (!groupName || indexInGroup == null) { + return + } + + const _set = [] + _set[indexInGroup] = set + + this.props.SState.applyState({ + _settings: { + [groupName]: _set + } + }) + return true } } diff --git a/plugins/timecode/index copy.js b/plugins/timecode/index copy.js new file mode 100644 index 00000000..8df88cbf --- /dev/null +++ b/plugins/timecode/index copy.js @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 Axel Boberg +// +// SPDX-License-Identifier: MIT + +/** + * @typedef {{ + * host: String, + * port: Number + * }} ConnectionDescription + * + * @typedef {{ + * id: String?, + * name: String, + * host: String, + * port: Number + * }} ServerDescription + */ + +/** + * @type { import('../../api').Api } + */ +const bridge = require('bridge') +const assets = require('../../assets.json') +const manifest = require('./package.json') + +const Logger = require('../../lib/Logger') +const logger = new Logger({ name: 'TimecodePlugin' }) + +const audio = require('./lib/audio') +const ltc = require('./lib/ltc') + +require('./lib/commands') + +async function initWidget () { + const cssPath = `${assets.hash}.${manifest.name}.bundle.css` + const jsPath = `${assets.hash}.${manifest.name}.bundle.js` + + const html = ` + + + + Caspar + + + + + + + +
+ + + ` + return await bridge.server.serveString(html) +} + +function zeroPad (n) { + if (n < 10) { + return `0${n}` + } + return `${n}` +} + +function formatTimecodeFrame (frame) { + return `${zeroPad(frame.hours)}:${zeroPad(frame.minutes)}:${zeroPad(frame.seconds)}.${zeroPad(frame.frames)}` +} + +async function makeInputSetting (inputs = []) { + return { + group: 'Timecode', + title: 'Input list test title', + inputs: [ + { + type: 'list', + label: 'List label', + bind: 'shared.plugins.bridge-plugin-timecode.settings.ltc_inputs', + settings: [ + { + inputs: [ + { + type: 'string', + label: 'Name', + bind: 'name' + }, + { + type: 'select', + label: 'Audio device', + bind: 'device', + options: { + $replace: [ + { + id: 'none', + label: 'None' + }, + ...inputs + ] + } + }, + { + type: 'boolean', + label: 'Active', + bind: 'active', + default: true + } + ] + } + ] + } + ] + } +} + +/* +Activate the plugin and +bootstrap its contributions +*/ +exports.activate = async () => { + logger.debug('Activating timecode plugin') + const htmlPath = await initWidget() + + bridge.widgets.registerWidget({ + id: 'bridge.plugins.timecode.smpte', + name: 'SMPTE display', + uri: `${htmlPath}?path=widget/smpte`, + description: 'Display incoming SMPTE timecode', + supportsFloat: true + }) + + const inputSetting = await makeInputSetting() + const settingId = await bridge.settings.registerSetting(inputSetting) + + setInterval(async () => { + const inputSetting = await makeInputSetting() + bridge.settings.applySetting(settingId, inputSetting) + }, 1000) + + /* + 1. Find device + */ + const devices = await audio.enumerateInputDevices() + logger.debug('Devices', devices) + const device = devices.find(device => device.label.includes('LTC')) + if (!device) { + logger.warn('No audio device found') + return + } + + logger.debug('Using device', device.label) + + const SAMPLE_RATE = 48000 + const FRAME_RATE = 25 + + /* + 2. Setup context and read buffers + */ + const ctx = await audio.createContext({ + sampleRate: SAMPLE_RATE, + latencyHint: 'interactive' + }) + + const source = await audio.createDeviceStreamSource(ctx, device.deviceId) + + const decoder = ltc.createDecoder(SAMPLE_RATE, FRAME_RATE, 'float') + + const proc = await audio.createLTCDecoder(ctx, decoder.apv) + proc.port.on('message', e => { + const buf = Buffer.from(e?.buffer.buffer) + decoder.write(buf) + + let frame = decoder.read() + while (frame) { + logger.debug('Frame', frame) + + bridge.events.emit('timecode.ltc', [{ + days: frame.days, + hours: frame.hours, + minutes: frame.minutes, + seconds: frame.seconds, + frames: frame.frames, + smpte: formatTimecodeFrame(frame) + }]) + + frame = decoder.read() + } + }) + + source.connect(proc) + proc.connect(ctx.destination) + + logger.debug(`Audio running at ${ctx.sampleRate}Hz`) + logger.debug(`Base Latency: ${ctx.baseLatency}s`) +} diff --git a/plugins/timecode/index.js b/plugins/timecode/index.js index 20bac85f..486a9617 100644 --- a/plugins/timecode/index.js +++ b/plugins/timecode/index.js @@ -2,34 +2,29 @@ // // SPDX-License-Identifier: MIT -/** - * @typedef {{ - * host: String, - * port: Number - * }} ConnectionDescription - * - * @typedef {{ - * id: String?, - * name: String, - * host: String, - * port: Number - * }} ServerDescription - */ - /** * @type { import('../../api').Api } */ const bridge = require('bridge') -const assets = require('../../assets.json') + const manifest = require('./package.json') +const assets = require('../../assets.json') +const audio = require('./lib/audio') + +const DIController = require('./lib/DIController') + +// eslint-disable-next-line +const LTCDevice = require('./lib/ltc/LTCDevice') const Logger = require('../../lib/Logger') const logger = new Logger({ name: 'TimecodePlugin' }) -const audio = require('./lib/audio') -const ltc = require('./lib/ltc') - -require('./lib/commands') +/** + * Keep an index of all currently + * running LTC devices + * @type { Object. } + */ +const LTC_DEVICES = {} async function initWidget () { const cssPath = `${assets.hash}.${manifest.name}.bundle.css` @@ -60,15 +55,105 @@ async function initWidget () { return await bridge.server.serveString(html) } -function zeroPad (n) { - if (n < 10) { - return `0${n}` +async function makeInputSetting (inputs = []) { + return { + group: 'Timecode', + title: 'Input list test title', + inputs: [ + { + type: 'list', + label: 'List label', + bind: 'shared.plugins.bridge-plugin-timecode.settings.ltc_inputs', + settings: [ + { + inputs: [ + { + type: 'string', + label: 'Name', + bind: 'name' + }, + { + type: 'select', + label: 'Audio device', + bind: 'deviceId', + options: { + $replace: [ + { + id: 'none', + label: 'None' + }, + ...inputs + ] + } + }, + { + type: 'boolean', + label: 'Active', + bind: 'active', + default: true + } + ] + } + ] + } + ] } - return `${n}` } -function formatTimecodeFrame (frame) { - return `${zeroPad(frame.hours)}:${zeroPad(frame.minutes)}:${zeroPad(frame.seconds)}.${zeroPad(frame.frames)}` +async function getAllAudioInputs () { + return (await audio.enumerateInputDevices()) + .map(device => ({ + id: device?.deviceId, + label: device?.label || 'Unnamed device' + })) +} + +function ltcDeviceFactory (deviceId) { + const device = DIController.instantiate('LTCDevice', {}, { + deviceId + }) + device.start() + return device +} + +function syncDevicesWithSpecifiedInputs (inputs = []) { + for (const input of inputs) { + /* + Handle newly added devices + */ + if (!LTC_DEVICES[input.id] && input?.deviceId) { + LTC_DEVICES[input.id] = ltcDeviceFactory(input?.deviceId) + logger.debug('Created LTC device', input.id) + continue + } + + const device = LTC_DEVICES[input.id] + + /* + Handle updated devices + */ + if (device && !device?.compareTo(input)) { + device.close() + if (input?.deviceId) { + LTC_DEVICES[input.id] = ltcDeviceFactory(input?.deviceId) + logger.debug('Updated LTC device', input.id) + } + continue + } + } + + /* + Close and remove devices that are no + longer specified in settings + */ + for (const deviceInputId of Object.keys(LTC_DEVICES)) { + const inputExists = inputs.find(input => input?.id === deviceInputId) + if (!inputExists) { + LTC_DEVICES[deviceInputId].close() + delete LTC_DEVICES[deviceInputId] + logger.debug('Removed LTC device', deviceInputId) + } + } } /* @@ -88,58 +173,40 @@ exports.activate = async () => { }) /* - 1. Find device + Update the list of available audio devices + that's visible in settings */ - const devices = await audio.enumerateInputDevices() - logger.debug('Devices', devices) - const device = devices.find(device => device.label.includes('LTC')) - if (!device) { - logger.warn('No audio device found') - return + { + const inputSetting = await makeInputSetting() + const settingId = await bridge.settings.registerSetting(inputSetting) + + setInterval(async () => { + const inputs = await getAllAudioInputs() + const inputSetting = await makeInputSetting(inputs) + bridge.settings.applySetting(settingId, inputSetting) + }, 2000) } - logger.debug('Using device', device.label) - - const SAMPLE_RATE = 48000 - const FRAME_RATE = 25 - /* - 2. Setup context and read buffers + Update LTC devices whenever + the settings change */ - const ctx = await audio.createContext({ - sampleRate: SAMPLE_RATE, - latencyHint: 'interactive' - }) - - const source = await audio.createDeviceStreamSource(ctx, device.deviceId) - - const decoder = ltc.createDecoder(SAMPLE_RATE, FRAME_RATE, 'float') - - const proc = await audio.createLTCDecoder(ctx, decoder.apv) - proc.port.on('message', e => { - const buf = Buffer.from(e?.buffer.buffer) - decoder.write(buf) - - let frame = decoder.read() - while (frame) { - logger.debug('Frame', frame) - - bridge.events.emit('timecode.ltc', [{ - days: frame.days, - hours: frame.hours, - minutes: frame.minutes, - seconds: frame.seconds, - frames: frame.frames, - smpte: formatTimecodeFrame(frame) - }]) - - frame = decoder.read() + bridge.events.on('state.change', (state, set) => { + if (!set?.plugins?.[manifest?.name]?.settings?.ltc_inputs) { + return } + const inputs = state?.plugins?.[manifest?.name]?.settings?.ltc_inputs || [] + syncDevicesWithSpecifiedInputs(inputs) }) - source.connect(proc) - proc.connect(ctx.destination) - - logger.debug(`Audio running at ${ctx.sampleRate}Hz`) - logger.debug(`Base Latency: ${ctx.baseLatency}s`) + /* + Update LTC devices + on startup + */ + { + const initialInputs = await bridge.state.get(`plugins.${manifest?.name}.settings.ltc_inputs`) + if (initialInputs) { + syncDevicesWithSpecifiedInputs(initialInputs) + } + } } diff --git a/plugins/timecode/lib/DIController.js b/plugins/timecode/lib/DIController.js new file mode 100644 index 00000000..9f55f248 --- /dev/null +++ b/plugins/timecode/lib/DIController.js @@ -0,0 +1,2 @@ +const DIController = require('../../../shared/DIController') +module.exports = new DIController() diff --git a/plugins/timecode/lib/TimecodeDevice.js b/plugins/timecode/lib/TimecodeDevice.js new file mode 100644 index 00000000..cc5f9ba0 --- /dev/null +++ b/plugins/timecode/lib/TimecodeDevice.js @@ -0,0 +1,8 @@ +const DIBase = require('../../../shared/DIBase') + +class TimecodeDevice extends DIBase { + compareTo (spec) { + throw new Error('Subclass has not implemented the compareTo method, this is a requirement for all TimecodeDevice subclasses') + } +} +module.exports = TimecodeDevice diff --git a/plugins/timecode/lib/audio/index.js b/plugins/timecode/lib/audio/index.js index d4b74a51..612463d3 100644 --- a/plugins/timecode/lib/audio/index.js +++ b/plugins/timecode/lib/audio/index.js @@ -1,12 +1,4 @@ -const { - mediaDevices, - AudioContext, - GainNode, - - // eslint-disable-next-line no-unused-vars - MediaStreamAudioSourceNode, - AudioWorkletNode -} = require('node-web-audio-api') +const { mediaDevices } = require('node-web-audio-api') async function enumerateDevices () { const devices = await mediaDevices.enumerateDevices() @@ -19,53 +11,3 @@ async function enumerateInputDevices () { return devices.filter(device => device?.kind === 'audioinput') } exports.enumerateInputDevices = enumerateInputDevices - -async function createDeviceStreamSource (audioContext, deviceId) { - const mediaStream = await mediaDevices.getUserMedia({ - audio: { - deviceId, - echoCancellation: false, - autoGainControl: false, - noiseSuppression: false - } - }) - const source = audioContext.createMediaStreamSource(mediaStream) - return source -} -exports.createDeviceStreamSource = createDeviceStreamSource - -async function createScriptProcessor (audioContext, onData) { - const processor = audioContext.createScriptProcessor() - - if (typeof onData === 'function') { - processor.addEventListener('audioprocess', e => onData(e)) - } - - return processor -} -exports.createScriptProcessor = createScriptProcessor - -async function createContext (opts) { - const context = new AudioContext(opts) - await context.resume() - return context -} -exports.createContext = createContext - -async function createGainNode (audioContext, initialGain = 0) { - const node = new GainNode(audioContext, { gain: initialGain }) - return node -} -exports.createGainNode = createGainNode - -async function createLTCDecoder (ctx, apv) { - await ctx.audioWorklet.addModule('LTCDecoder.js') - const processor = new AudioWorkletNode(ctx, 'LTCDecoder', { - processorOptions: { - apv - } - }) - - return processor -} -exports.createLTCDecoder = createLTCDecoder diff --git a/plugins/timecode/lib/commands.js b/plugins/timecode/lib/commands.js deleted file mode 100644 index 3581a443..00000000 --- a/plugins/timecode/lib/commands.js +++ /dev/null @@ -1,68 +0,0 @@ -const bridge = require('bridge') - -const uuid = require('uuid') - -const manifest = require('../package.json') -const paths = require('./paths') - -/** - * Add a new target - * from a description object - * @param { TargetDescription } description - * @returns { Promise. } A promise resolving - * to the target's id - */ -async function addLTCInput (description) { - /* - Generate a new id for - referencing the target - */ - description.id = uuid.v4() - - const targetArray = await bridge.state.get(`${paths.STATE_SETTINGS_PATH}.targets`) || [] - if (targetArray.length > 0) { - bridge.state.apply(`plugins.${manifest.name}.targets`, { $push: [description] }) - } else { - bridge.state.apply(`plugins.${manifest.name}.targets`, [description]) - } - - return description.id -} -exports.addLTCInput = addLTCInput -bridge.commands.registerCommand('timecode.addLTCInput', addLTCInput) - -/** - * Update the state from - * a new description - * @param { String } targetId The id of the server to edit - * @param { TargetDescription } description A new target init object - * to apply to the target - */ -async function editTarget (targetId, description) { - const targetArray = await bridge.state.get(`${paths.STATE_SETTINGS_PATH}.targets`) || [] - const newTargetArray = [...targetArray] - .map(target => { - if (target.id !== targetId) { - return target - } - return description - }) - - /* - Return early if there are no targets in the array - as we don't want to set a bad state - */ - if (newTargetArray.length === 0) { - return - } - - bridge.state.apply({ - plugins: { - [manifest.name]: { - targets: { $replace: newTargetArray } - } - } - }) -} -exports.editTarget = editTarget -bridge.commands.registerCommand('osc.editTarget', editTarget) diff --git a/plugins/timecode/lib/audio/LTCDecoder.js b/plugins/timecode/lib/ltc/DataWorkletProcessor.js similarity index 77% rename from plugins/timecode/lib/audio/LTCDecoder.js rename to plugins/timecode/lib/ltc/DataWorkletProcessor.js index ea7e0dc6..e5849e1a 100644 --- a/plugins/timecode/lib/audio/LTCDecoder.js +++ b/plugins/timecode/lib/ltc/DataWorkletProcessor.js @@ -1,4 +1,4 @@ -class LTCDecoder extends AudioWorkletProcessor { +class DataWorkletProcessor extends AudioWorkletProcessor { constructor (opts) { super() this._apv = opts?.processorOptions?.apv @@ -21,4 +21,4 @@ class LTCDecoder extends AudioWorkletProcessor { } } -registerProcessor('LTCDecoder', LTCDecoder) +registerProcessor('DataWorkletProcessor', DataWorkletProcessor) diff --git a/plugins/timecode/lib/ltc/LTCDecoder.js b/plugins/timecode/lib/ltc/LTCDecoder.js new file mode 100644 index 00000000..d63f8655 --- /dev/null +++ b/plugins/timecode/lib/ltc/LTCDecoder.js @@ -0,0 +1,30 @@ +const { LTCDecoder: NativeLTCDecoder } = require('libltc-wrapper') +const DIController = require('../DIController') +const DIBase = require('../../../../shared/DIBase') + +const DEFAULT_SAMPLE_RATE_HZ = 48000 +const DEFAULT_FRAME_RATE_HZ = 25 +const DEFAULT_AUDIO_FORMAT = 'float' + +class LTCDecoder extends DIBase { + #nativeDecoder + + get apv () { + return this.#nativeDecoder.apv + } + + constructor (props, sampleRate = DEFAULT_SAMPLE_RATE_HZ, frameRate = DEFAULT_FRAME_RATE_HZ, format = DEFAULT_AUDIO_FORMAT) { + super(props) + this.#nativeDecoder = new NativeLTCDecoder(sampleRate, frameRate, format) + } + + write (buffer) { + this.#nativeDecoder.write(buffer) + } + + read () { + return this.#nativeDecoder.read() + } +} + +DIController.register('LTCDecoder', LTCDecoder) diff --git a/plugins/timecode/lib/ltc/LTCDevice.js b/plugins/timecode/lib/ltc/LTCDevice.js new file mode 100644 index 00000000..62c51e3b --- /dev/null +++ b/plugins/timecode/lib/ltc/LTCDevice.js @@ -0,0 +1,156 @@ +const { + mediaDevices, + AudioContext, + + // eslint-disable-next-line no-unused-vars + MediaStreamAudioSourceNode, + AudioWorkletNode +} = require('node-web-audio-api') + +const DIController = require('../DIController') +const TimecodeDevice = require('../TimecodeDevice') + +const Logger = require('../../../../lib/Logger') +const logger = new Logger({ name: 'LTCDevice' }) + +require('./LTCDecoder') + +const DEFAULT_SAMPLE_RATE_HZ = 48000 + +function zeroPad (n) { + if (n < 10) { + return `0${n}` + } + return `${n}` +} + +function formatTimecodeFrame (frame) { + return `${zeroPad(frame.hours)}:${zeroPad(frame.minutes)}:${zeroPad(frame.seconds)}.${zeroPad(frame.frames)}` +} + +/** + * @typedef {{ + * sampleRate: number, + * frameRate: number, + * deviceId: string + * }} LTCDeviceOptions + */ +class LTCDevice extends TimecodeDevice { + #audioContext + #onFrame + #opts + + #processor + #source + + /** + * Create a new ltc device + * @param { any[] } props + * @param { LTCDeviceOptions } opts + * @param { Function } onFrame + */ + constructor (props, opts = {}, onFrame = () => {}) { + super(props) + this.#opts = opts + this.#onFrame = onFrame + } + + compareTo (spec) { + return this.#opts?.deviceId === spec?.deviceId + } + + #formatFrame (rawFrameData) { + return { + days: rawFrameData.days, + hours: rawFrameData.hours, + minutes: rawFrameData.minutes, + seconds: rawFrameData.seconds, + frames: rawFrameData.frames, + smpte: formatTimecodeFrame(rawFrameData) + } + } + + #handleAudioData (buffer) { + this.props.LTCDecoder.write(buffer) + + let frame = this.props.LTCDecoder.read() + while (frame) { + this.#onFrame(this.#formatFrame(frame)) + frame = this.props.LTCDecoder.read() + } + } + + async #setup () { + if (!this.#opts.deviceId) { + throw new Error('Missing device id as part of the required options object') + } + + this.#audioContext = new AudioContext({ + sampleRate: this.#opts?.sampleRate || DEFAULT_SAMPLE_RATE_HZ, + latencyHint: 'interactive' + }) + const ctx = this.#audioContext + + const mediaStream = await mediaDevices.getUserMedia({ + audio: { + deviceId: this.#opts?.deviceId, + echoCancellation: false, + autoGainControl: false, + noiseSuppression: false + } + }) + + const source = ctx.createMediaStreamSource(mediaStream) + + await ctx.audioWorklet.addModule('DataWorkletProcessor.js') + const processor = new AudioWorkletNode(ctx, 'DataWorkletProcessor', { + processorOptions: { + apv: this.props.LTCDecoder.apv + } + }) + + processor.port.onmessage = e => { + /* + HANDLE MESSAGE + */ + /* const buf = Buffer.from(e?.buffer?.buffer) + this.#handleAudioData(buf) */ + } + + source.connect(processor) + processor.connect(ctx.destination) + + /* + Keep references to the nodes in order to + properly tear them down in the close method + */ + this.#processor = processor + this.#source = source + } + + close () { + if (this.#audioContext) { + this.#audioContext.close() + this.#audioContext = undefined + } + + if (this.#processor?.port) { + this.#processor.port.onmessage = undefined + } + this.#processor?.disconnect() + this.#source?.disconnect() + logger.debug('Closed device') + } + + async start () { + if (!this.#audioContext) { + await this.#setup() + } + await this.#audioContext.resume() + logger.debug('Started device') + } +} + +DIController.register('LTCDevice', LTCDevice, [ + 'LTCDecoder' +]) diff --git a/plugins/timecode/lib/ltc/index.js b/plugins/timecode/lib/ltc/index.js deleted file mode 100644 index 37faeb2b..00000000 --- a/plugins/timecode/lib/ltc/index.js +++ /dev/null @@ -1,16 +0,0 @@ -const { LTCDecoder } = require('libltc-wrapper') - -function createDecoder (sampleRate = 48000, frameRate = 25, format = 'u8') { - return new LTCDecoder(sampleRate, frameRate, format) -} -exports.createDecoder = createDecoder - -function writeBuffer (decoder, buffer) { - decoder.write(buffer) -} -exports.writeBuffer = writeBuffer - -function decodeFrames (decoder) { - return decoder.read() -} -exports.decodeFrames = decodeFrames diff --git a/plugins/timecode/lib/paths.js b/plugins/timecode/lib/paths.js deleted file mode 100644 index 815f4264..00000000 --- a/plugins/timecode/lib/paths.js +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Sveriges Television AB -// -// SPDX-License-Identifier: MIT - -const manifest = require('../package.json') - -exports.STATE_SETTINGS_PATH = `plugins.${manifest.name}` diff --git a/plugins/timecode/package.json b/plugins/timecode/package.json index 4aa20700..8ae113d5 100644 --- a/plugins/timecode/package.json +++ b/plugins/timecode/package.json @@ -18,50 +18,5 @@ "dependencies": { "libltc-wrapper": "^1.1.2", "node-web-audio-api": "^1.0.7" - }, - "contributes": { - "settings": [ - { - "group": "Timecode", - "title": "Input list test title", - "inputs": [ - { - "type": "list", - "label": "List label", - "bind": "shared.plugins.bridge-plugin-timecode.settings.ltc_inputs", - "settings": [ - { - "inputs": [ - { - "type": "string", - "label": "Name", - "bind": "name" - }, - { - "type": "select", - "label": "Audio device", - "bind": "device", - "options": [ - { - "id": "none", - "label": "None" - }, - { - "id": "device1", - "label": "Microphone" - }, - { - "id": "device2", - "label": "LTC" - } - ] - } - ] - } - ] - } - ] - } - ] } } diff --git a/scripts/sign-macos.js b/scripts/sign-macos.js index a1e999f4..32204035 100644 --- a/scripts/sign-macos.js +++ b/scripts/sign-macos.js @@ -3,7 +3,15 @@ const path = require('node:path') function sign (path, label) { const opts = { - app: path + app: path, + binaries: [ + `${path}/Contents/Frameworks/libltc.11.dylib` + ], + optionsForFile: () => { + return { + hardenedRuntime: true + } + } } signAsync(opts) .then(() => { From e7d3559681da1c325eb8338b7cf86aba34ac4538 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sat, 17 Jan 2026 23:46:58 +0100 Subject: [PATCH 14/57] Properly read the audio data buffers and decode them as LTC Signed-off-by: Axel Boberg --- plugins/timecode/index.js | 7 +++++ plugins/timecode/lib/ltc/LTCDevice.js | 38 ++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/plugins/timecode/index.js b/plugins/timecode/index.js index 486a9617..f0e17c1a 100644 --- a/plugins/timecode/index.js +++ b/plugins/timecode/index.js @@ -111,6 +111,13 @@ async function getAllAudioInputs () { function ltcDeviceFactory (deviceId) { const device = DIController.instantiate('LTCDevice', {}, { deviceId + }, frame => { + /** + * @todo + * Submit the frame + * to the time api + */ + logger.debug('Got frame', frame) }) device.start() return device diff --git a/plugins/timecode/lib/ltc/LTCDevice.js b/plugins/timecode/lib/ltc/LTCDevice.js index 62c51e3b..a1f0e52a 100644 --- a/plugins/timecode/lib/ltc/LTCDevice.js +++ b/plugins/timecode/lib/ltc/LTCDevice.js @@ -55,6 +55,17 @@ class LTCDevice extends TimecodeDevice { this.#onFrame = onFrame } + /** + * Compare this device + * to a device spec obtained + * from Bridge settings + * + * Returning true indicates that + * the device matches the spec + * + * @param { any } spec + * @returns { boolean } + */ compareTo (spec) { return this.#opts?.deviceId === spec?.deviceId } @@ -70,7 +81,17 @@ class LTCDevice extends TimecodeDevice { } } - #handleAudioData (buffer) { + /** + * Decode audio data frame + * buffers into LTC timecode, + * + * this.#onFrame will be called + * for every timecode frame that's + * successfully decoded + * + * @param { Buffer } buffer + */ + #decodeAudioData (buffer) { this.props.LTCDecoder.write(buffer) let frame = this.props.LTCDecoder.read() @@ -109,12 +130,17 @@ class LTCDevice extends TimecodeDevice { } }) + /* + Listen for incoming audio data + buffers from the processor and + decode them accordingly + */ processor.port.onmessage = e => { - /* - HANDLE MESSAGE - */ - /* const buf = Buffer.from(e?.buffer?.buffer) - this.#handleAudioData(buf) */ + if (!e?.data?.buffer?.buffer) { + return + } + const buf = Buffer.from(e?.data?.buffer?.buffer) + this.#decodeAudioData(buf) } source.connect(processor) From 6af29cda1d747808b2fe7d62106964b9df384c77 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sun, 18 Jan 2026 00:18:20 +0100 Subject: [PATCH 15/57] Remove unused code Signed-off-by: Axel Boberg --- plugins/timecode/lib/ltc/DataWorkletProcessor.js | 7 ------- plugins/timecode/lib/ltc/LTCDecoder.js | 4 ---- plugins/timecode/lib/ltc/LTCDevice.js | 6 +----- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/plugins/timecode/lib/ltc/DataWorkletProcessor.js b/plugins/timecode/lib/ltc/DataWorkletProcessor.js index e5849e1a..4a5d2028 100644 --- a/plugins/timecode/lib/ltc/DataWorkletProcessor.js +++ b/plugins/timecode/lib/ltc/DataWorkletProcessor.js @@ -1,11 +1,4 @@ class DataWorkletProcessor extends AudioWorkletProcessor { - constructor (opts) { - super() - this._apv = opts?.processorOptions?.apv - this._buffer = new Float32Array(this._apv) - this._index = 0 - } - process (inputs, outputs, parameters) { const input = inputs[0] if (!input || input.length === 0) { diff --git a/plugins/timecode/lib/ltc/LTCDecoder.js b/plugins/timecode/lib/ltc/LTCDecoder.js index d63f8655..88c81560 100644 --- a/plugins/timecode/lib/ltc/LTCDecoder.js +++ b/plugins/timecode/lib/ltc/LTCDecoder.js @@ -9,10 +9,6 @@ const DEFAULT_AUDIO_FORMAT = 'float' class LTCDecoder extends DIBase { #nativeDecoder - get apv () { - return this.#nativeDecoder.apv - } - constructor (props, sampleRate = DEFAULT_SAMPLE_RATE_HZ, frameRate = DEFAULT_FRAME_RATE_HZ, format = DEFAULT_AUDIO_FORMAT) { super(props) this.#nativeDecoder = new NativeLTCDecoder(sampleRate, frameRate, format) diff --git a/plugins/timecode/lib/ltc/LTCDevice.js b/plugins/timecode/lib/ltc/LTCDevice.js index a1f0e52a..3a6df854 100644 --- a/plugins/timecode/lib/ltc/LTCDevice.js +++ b/plugins/timecode/lib/ltc/LTCDevice.js @@ -124,11 +124,7 @@ class LTCDevice extends TimecodeDevice { const source = ctx.createMediaStreamSource(mediaStream) await ctx.audioWorklet.addModule('DataWorkletProcessor.js') - const processor = new AudioWorkletNode(ctx, 'DataWorkletProcessor', { - processorOptions: { - apv: this.props.LTCDecoder.apv - } - }) + const processor = new AudioWorkletNode(ctx, 'DataWorkletProcessor') /* Listen for incoming audio data From 59e3da27a30628726df05eee50acd3fcc1e6ce55 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sun, 18 Jan 2026 21:15:25 +0100 Subject: [PATCH 16/57] Start adding a new time api and work on the timecode plugin Signed-off-by: Axel Boberg --- api/index.js | 3 + api/time.js | 39 +++ .../PreferencesSelectInput/index.jsx | 7 +- .../PreferencesSelectInput/style.css | 5 +- lib/api/STime.js | 104 ++++++++ lib/api/index.js | 3 + plugins/clock/app/App.jsx | 48 +++- plugins/timecode/app/App.jsx | 31 --- .../timecode/app/assets/icons/connected.svg | 8 - .../app/assets/icons/disconnected.svg | 7 - plugins/timecode/app/assets/icons/error.svg | 8 - .../app/components/LTCInput/index.jsx | 36 --- .../app/components/LTCInput/style.css | 40 --- .../app/components/SMPTEDisplay/index.jsx | 99 ------- .../app/components/SMPTEDisplay/style.css | 3 - plugins/timecode/app/index.jsx | 13 - plugins/timecode/app/sharedContext.js | 56 ---- plugins/timecode/app/style.css | 52 ---- plugins/timecode/app/views/SMPTE.jsx | 12 - plugins/timecode/app/views/Settings.jsx | 33 --- plugins/timecode/index copy.js | 198 -------------- plugins/timecode/index.js | 250 ++++++++++++------ plugins/timecode/lib/ltc/LTCDecoder.js | 49 +++- plugins/timecode/lib/ltc/LTCDevice.js | 3 +- 24 files changed, 418 insertions(+), 689 deletions(-) create mode 100644 api/time.js create mode 100644 lib/api/STime.js delete mode 100644 plugins/timecode/app/App.jsx delete mode 100644 plugins/timecode/app/assets/icons/connected.svg delete mode 100644 plugins/timecode/app/assets/icons/disconnected.svg delete mode 100644 plugins/timecode/app/assets/icons/error.svg delete mode 100644 plugins/timecode/app/components/LTCInput/index.jsx delete mode 100644 plugins/timecode/app/components/LTCInput/style.css delete mode 100644 plugins/timecode/app/components/SMPTEDisplay/index.jsx delete mode 100644 plugins/timecode/app/components/SMPTEDisplay/style.css delete mode 100644 plugins/timecode/app/index.jsx delete mode 100644 plugins/timecode/app/sharedContext.js delete mode 100644 plugins/timecode/app/style.css delete mode 100644 plugins/timecode/app/views/SMPTE.jsx delete mode 100644 plugins/timecode/app/views/Settings.jsx delete mode 100644 plugins/timecode/index copy.js diff --git a/api/index.js b/api/index.js index 6b49c5cb..7be21204 100644 --- a/api/index.js +++ b/api/index.js @@ -18,6 +18,7 @@ require('./system') require('./state') require('./types') require('./items') +require('./time') require('./ui') class API { @@ -36,6 +37,7 @@ class API { this.state = props.State this.types = props.Types this.items = props.Items + this.time = props.Time this.ui = props.UI } } @@ -55,6 +57,7 @@ DIController.main.register('API', API, [ 'State', 'Types', 'Items', + 'Time', 'UI' ]) diff --git a/api/time.js b/api/time.js new file mode 100644 index 00000000..15bdd0e8 --- /dev/null +++ b/api/time.js @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 Axel Boberg +// +// SPDX-License-Identifier: MIT + +const DIController = require('../shared/DIController') + +class Time { + #props + + constructor (props) { + this.#props = props + } + + getAllClocks () { + return this.#props.Commands.executeCommand('time.getAllClocks') + } + + registerClock (spec) { + return this.#props.Commands.executeCommand('time.registerClock', spec) + } + + removeClock (id) { + return this.#props.Commands.executeCommand('time.removeClock', id) + } + + applyClock (id, set) { + return this.#props.Commands.executeCommand('time.applyClock', id, set) + } + + tickClock (id, values) { + return this.#props.Commands.executeCommand('time.tickClock', id, values) + } +} + +DIController.main.register('Time', Time, [ + 'State', + 'Events', + 'Commands' +]) diff --git a/app/components/PreferencesSelectInput/index.jsx b/app/components/PreferencesSelectInput/index.jsx index 9882659e..bf9c5559 100644 --- a/app/components/PreferencesSelectInput/index.jsx +++ b/app/components/PreferencesSelectInput/index.jsx @@ -8,7 +8,12 @@ export function PreferencesSelectInput ({ label, value, options = [], onChange = return (
-
+ { + label && + <> + + + } + { + clocks.map(clock => { + return + }) + } + + +
+ { + view === 'latency' + ? {Math.floor(latency * 10) / 10}ms + : + } +
) } diff --git a/plugins/timecode/app/App.jsx b/plugins/timecode/app/App.jsx deleted file mode 100644 index a29baa7a..00000000 --- a/plugins/timecode/app/App.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react' - -import * as SharedContext from './sharedContext' -import * as Settings from './views/Settings' -import { SMPTE } from './views/SMPTE' - -export default function App () { - const [view, setView] = React.useState() - - React.useEffect(() => { - const params = new URLSearchParams(window.location.search) - setView(params.get('path')) - }, []) - - return ( - - { - (function () { - switch (view) { - case 'widget/smpte': - return - case 'settings/ltc-inputs': - return - default: - return <> - } - })() - } - - ) -} diff --git a/plugins/timecode/app/assets/icons/connected.svg b/plugins/timecode/app/assets/icons/connected.svg deleted file mode 100644 index a88f7eb6..00000000 --- a/plugins/timecode/app/assets/icons/connected.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - icons/caspar/connected - - - - - \ No newline at end of file diff --git a/plugins/timecode/app/assets/icons/disconnected.svg b/plugins/timecode/app/assets/icons/disconnected.svg deleted file mode 100644 index a44eb65e..00000000 --- a/plugins/timecode/app/assets/icons/disconnected.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - icons/caspar/disconnected - - - - \ No newline at end of file diff --git a/plugins/timecode/app/assets/icons/error.svg b/plugins/timecode/app/assets/icons/error.svg deleted file mode 100644 index 4189193a..00000000 --- a/plugins/timecode/app/assets/icons/error.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - icons/caspar/error - - - - - \ No newline at end of file diff --git a/plugins/timecode/app/components/LTCInput/index.jsx b/plugins/timecode/app/components/LTCInput/index.jsx deleted file mode 100644 index ebc7a66d..00000000 --- a/plugins/timecode/app/components/LTCInput/index.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react' -import './style.css' - -export const LTCInput = ({ data = {}, onChange = () => {}, onDelete = () => {} }) => { - function handleInput (key, newValue) { - onChange({ - ...data, - [key]: newValue - }) - } - - function handleDelete () { - onDelete() - } - - return ( -
-
-
-
- handleInput('name', e.target.value)}> -
-
- Device input -
-
- Channel input -
-
-
- -
-
-
- ) -} diff --git a/plugins/timecode/app/components/LTCInput/style.css b/plugins/timecode/app/components/LTCInput/style.css deleted file mode 100644 index b793a86e..00000000 --- a/plugins/timecode/app/components/LTCInput/style.css +++ /dev/null @@ -1,40 +0,0 @@ -.TargetInput { - margin-bottom: 10px; - padding: 10px 12px; - - border: 1px solid var(--base-color--shade); - border-radius: 10px; -} - -.TargetInput > div, -.TargetInput-flexWrapper > div { - width: 100%; -} - -.TargetInput-flexWrapper { - display: flex; -} - -.TargetInput-flexWrapper > div:last-child { - text-align: right; -} - -.TargetInput-input { - margin-bottom: 10px; -} - -.TargetInput-input:last-child { - margin-bottom: 0; -} - -.TargetInput-input--small { - width: 100px; -} - -.TargetInput-input input { - margin-right: 5px; -} - -.TargetInput-flexInputs { - display: flex; -} diff --git a/plugins/timecode/app/components/SMPTEDisplay/index.jsx b/plugins/timecode/app/components/SMPTEDisplay/index.jsx deleted file mode 100644 index 199d3694..00000000 --- a/plugins/timecode/app/components/SMPTEDisplay/index.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import bridge from 'bridge' - -import React from 'react' -import './style.css' - -const EASINGS = { - linear: t => t -} - -class Animation { - #from - #to - #durationMs - #easing - #frame = () => {} - - #running = false - #startTime - - constructor (from, to, durationMs = 1000, frame = () => {}, easing = EASINGS.linear,) { - this.#from = from - this.#to = to - this.#durationMs = durationMs - this.#easing = easing - this.#frame = frame - } - - #loop () { - if (!this.#running) { - return - } - - if (!this.#startTime) { - return - } - - if (!this.#durationMs) { - return - } - - const now = Date.now() - const progress = (now - this.#startTime) / this.#durationMs - const eased = this.#easing(progress) - const value = (this.#to - this.#from) * eased - - if (value >= 1) { - this.#frame(this.#to, 1) - this.stop() - return - } - - this.#frame(value, eased) - - window.requestAnimationFrame(() => this.#loop()) - } - - start () { - this.#running = true - this.#startTime = Date.now() - this.#loop() - } - - stop () { - this.#running = false - this.#startTime = undefined - } -} - -export const SMPTEDisplay = ({}) => { - const [smpte, setSmpte] = React.useState() - - React.useEffect(() => { - let anim - function onFrame (frames) { - setSmpte(frames[frames.length - 1].smpte) -/* if (anim) { - anim.stop() - } - - anim = new Animation(0, frames.length, 20, i => { - const j = Math.round(i) - console.log('Rendering', j) - if (frames[j]) { - setSmpte(frames[j].smpte) - } - }) - anim.start() */ - } - - bridge.events.on('timecode.ltc', onFrame) - return () => bridge.events.off('timecode.ltc', onFrame) - }, []) - - return ( -
- {smpte} -
- ) -} diff --git a/plugins/timecode/app/components/SMPTEDisplay/style.css b/plugins/timecode/app/components/SMPTEDisplay/style.css deleted file mode 100644 index 0bef54bf..00000000 --- a/plugins/timecode/app/components/SMPTEDisplay/style.css +++ /dev/null @@ -1,3 +0,0 @@ -.SMPTEDisplay { - font-size: 2em; -} \ No newline at end of file diff --git a/plugins/timecode/app/index.jsx b/plugins/timecode/app/index.jsx deleted file mode 100644 index 2bedcf9d..00000000 --- a/plugins/timecode/app/index.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom' - -import App from './App' - -import './style.css' - -ReactDOM.render( - - - , - document.getElementById('root') -) diff --git a/plugins/timecode/app/sharedContext.js b/plugins/timecode/app/sharedContext.js deleted file mode 100644 index 16d9d14c..00000000 --- a/plugins/timecode/app/sharedContext.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @copyright Copyright © 2021 SVT Design - * @author Axel Boberg - */ - -import React from 'react' -import bridge from 'bridge' - -/** - * A context for being shared - * across active clients - * - * @see {@link ./App.js} - * - * @type { React.Context } - */ -export const SharedContext = React.createContext() - -export const Provider = ({ children }) => { - const [state, setState] = React.useState() - const stateRef = React.useRef() - - React.useEffect(() => { - stateRef.current = state - }, [state]) - - /* - Fetch the state directly - on context load - */ - React.useEffect(() => { - async function initState () { - const state = await bridge.state.get() - setState(state) - } - initState() - }, []) - - /* - Listen for changes to the state - and update the context accordingly - */ - React.useEffect(() => { - function onStateChange (state) { - setState({ ...state }) - } - bridge.events.on('state.change', onStateChange) - return () => bridge.events.off('state.change', onStateChange) - }, []) - - return ( - - { children } - - ) -} diff --git a/plugins/timecode/app/style.css b/plugins/timecode/app/style.css deleted file mode 100644 index e2a5ffed..00000000 --- a/plugins/timecode/app/style.css +++ /dev/null @@ -1,52 +0,0 @@ -html, body, .View--flex { - position: relative; - width: 100%; - height: 100%; - - padding: 0; - margin: 0; - - overflow: hidden; -} - -#root { - display: contents; -} - -.View--flex { - display: flex; - flex-direction: column; -} - -.View--center { - display: flex; - width: 100%; - height: 100%; - - align-items: center; - justify-content: center; -} - -.View--spread { - display: flex; -} - -.u-width--100pct { - width: 100%; -} - -.u-marginBottom--5px { - margin-bottom: 5px; -} - -.u-scroll--y { - position: relative; - width: 100%; - height: 100%; - overflow-y: scroll; - overflow-x: hidden; -} - -.u-textAlign--center { - text-align: center; -} \ No newline at end of file diff --git a/plugins/timecode/app/views/SMPTE.jsx b/plugins/timecode/app/views/SMPTE.jsx deleted file mode 100644 index 429eaf3f..00000000 --- a/plugins/timecode/app/views/SMPTE.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import bridge from 'bridge' - -import { SMPTEDisplay } from '../components/SMPTEDisplay' - -export const SMPTE = () => { - return ( -
- -
- ) -} diff --git a/plugins/timecode/app/views/Settings.jsx b/plugins/timecode/app/views/Settings.jsx deleted file mode 100644 index 8f359139..00000000 --- a/plugins/timecode/app/views/Settings.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' -import bridge from 'bridge' - -import { SharedContext } from '../sharedContext' -import { LTCInput } from '../components/LTCInput' - -export const LTCInputs = () => { - const [state] = React.useContext(SharedContext) - const targets = state?.plugins?.[window.PLUGIN.name]?.targets || [] - - function handleChange (targetId, newData) { - bridge.commands.executeCommand('timecode.editLTCInput', targetId, newData) - } - - function handleDelete (targetId) { - bridge.commands.executeCommand('timecode.removeLTCInput', targetId) - } - - function handleNew () { - bridge.commands.executeCommand('timecode.addLTCInput', {}) - } - - return ( -
- { - (targets || []).map(target => { - return handleChange(target.id, newData)} onDelete={() => handleDelete(target.id)} /> - }) - } - -
- ) -} diff --git a/plugins/timecode/index copy.js b/plugins/timecode/index copy.js deleted file mode 100644 index 8df88cbf..00000000 --- a/plugins/timecode/index copy.js +++ /dev/null @@ -1,198 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Axel Boberg -// -// SPDX-License-Identifier: MIT - -/** - * @typedef {{ - * host: String, - * port: Number - * }} ConnectionDescription - * - * @typedef {{ - * id: String?, - * name: String, - * host: String, - * port: Number - * }} ServerDescription - */ - -/** - * @type { import('../../api').Api } - */ -const bridge = require('bridge') -const assets = require('../../assets.json') -const manifest = require('./package.json') - -const Logger = require('../../lib/Logger') -const logger = new Logger({ name: 'TimecodePlugin' }) - -const audio = require('./lib/audio') -const ltc = require('./lib/ltc') - -require('./lib/commands') - -async function initWidget () { - const cssPath = `${assets.hash}.${manifest.name}.bundle.css` - const jsPath = `${assets.hash}.${manifest.name}.bundle.js` - - const html = ` - - - - Caspar - - - - - - - -
- - - ` - return await bridge.server.serveString(html) -} - -function zeroPad (n) { - if (n < 10) { - return `0${n}` - } - return `${n}` -} - -function formatTimecodeFrame (frame) { - return `${zeroPad(frame.hours)}:${zeroPad(frame.minutes)}:${zeroPad(frame.seconds)}.${zeroPad(frame.frames)}` -} - -async function makeInputSetting (inputs = []) { - return { - group: 'Timecode', - title: 'Input list test title', - inputs: [ - { - type: 'list', - label: 'List label', - bind: 'shared.plugins.bridge-plugin-timecode.settings.ltc_inputs', - settings: [ - { - inputs: [ - { - type: 'string', - label: 'Name', - bind: 'name' - }, - { - type: 'select', - label: 'Audio device', - bind: 'device', - options: { - $replace: [ - { - id: 'none', - label: 'None' - }, - ...inputs - ] - } - }, - { - type: 'boolean', - label: 'Active', - bind: 'active', - default: true - } - ] - } - ] - } - ] - } -} - -/* -Activate the plugin and -bootstrap its contributions -*/ -exports.activate = async () => { - logger.debug('Activating timecode plugin') - const htmlPath = await initWidget() - - bridge.widgets.registerWidget({ - id: 'bridge.plugins.timecode.smpte', - name: 'SMPTE display', - uri: `${htmlPath}?path=widget/smpte`, - description: 'Display incoming SMPTE timecode', - supportsFloat: true - }) - - const inputSetting = await makeInputSetting() - const settingId = await bridge.settings.registerSetting(inputSetting) - - setInterval(async () => { - const inputSetting = await makeInputSetting() - bridge.settings.applySetting(settingId, inputSetting) - }, 1000) - - /* - 1. Find device - */ - const devices = await audio.enumerateInputDevices() - logger.debug('Devices', devices) - const device = devices.find(device => device.label.includes('LTC')) - if (!device) { - logger.warn('No audio device found') - return - } - - logger.debug('Using device', device.label) - - const SAMPLE_RATE = 48000 - const FRAME_RATE = 25 - - /* - 2. Setup context and read buffers - */ - const ctx = await audio.createContext({ - sampleRate: SAMPLE_RATE, - latencyHint: 'interactive' - }) - - const source = await audio.createDeviceStreamSource(ctx, device.deviceId) - - const decoder = ltc.createDecoder(SAMPLE_RATE, FRAME_RATE, 'float') - - const proc = await audio.createLTCDecoder(ctx, decoder.apv) - proc.port.on('message', e => { - const buf = Buffer.from(e?.buffer.buffer) - decoder.write(buf) - - let frame = decoder.read() - while (frame) { - logger.debug('Frame', frame) - - bridge.events.emit('timecode.ltc', [{ - days: frame.days, - hours: frame.hours, - minutes: frame.minutes, - seconds: frame.seconds, - frames: frame.frames, - smpte: formatTimecodeFrame(frame) - }]) - - frame = decoder.read() - } - }) - - source.connect(proc) - proc.connect(ctx.destination) - - logger.debug(`Audio running at ${ctx.sampleRate}Hz`) - logger.debug(`Base Latency: ${ctx.baseLatency}s`) -} diff --git a/plugins/timecode/index.js b/plugins/timecode/index.js index f0e17c1a..f0b9856c 100644 --- a/plugins/timecode/index.js +++ b/plugins/timecode/index.js @@ -8,17 +8,19 @@ const bridge = require('bridge') const manifest = require('./package.json') -const assets = require('../../assets.json') const audio = require('./lib/audio') const DIController = require('./lib/DIController') +const LTCDecoder = require('./lib/ltc/LTCDecoder') // eslint-disable-next-line const LTCDevice = require('./lib/ltc/LTCDevice') const Logger = require('../../lib/Logger') const logger = new Logger({ name: 'TimecodePlugin' }) +const NO_AUDIO_DEVICE_ID = 'none' + /** * Keep an index of all currently * running LTC devices @@ -26,71 +28,50 @@ const logger = new Logger({ name: 'TimecodePlugin' }) */ const LTC_DEVICES = {} -async function initWidget () { - const cssPath = `${assets.hash}.${manifest.name}.bundle.css` - const jsPath = `${assets.hash}.${manifest.name}.bundle.js` - - const html = ` - - - - Caspar - - - - - - - -
- - - ` - return await bridge.server.serveString(html) -} - async function makeInputSetting (inputs = []) { return { group: 'Timecode', - title: 'Input list test title', + title: 'LTC devices', inputs: [ { type: 'list', - label: 'List label', - bind: 'shared.plugins.bridge-plugin-timecode.settings.ltc_inputs', + label: '', + bind: 'shared.plugins.bridge-plugin-timecode.settings.ltc_devices', settings: [ { + title: 'Name', inputs: [ { type: 'string', - label: 'Name', bind: 'name' - }, + } + ] + }, + { + title: 'Audio device', + inputs: [ { type: 'select', - label: 'Audio device', bind: 'deviceId', options: { $replace: [ { - id: 'none', + id: NO_AUDIO_DEVICE_ID, label: 'None' }, ...inputs ] } - }, + } + ] + }, + { + title: 'Frame rate', + inputs: [ { - type: 'boolean', - label: 'Active', - bind: 'active', - default: true + type: 'segmented', + bind: 'frameRateIndex', + segments: LTCDecoder.SUPPORTED_FRAME_RATES } ] } @@ -100,6 +81,41 @@ async function makeInputSetting (inputs = []) { } } +async function registerClockForInput (inputId, label) { + if (typeof inputId !== 'string') { + throw new Error('Missing or invalid input id, must be a string') + } + + const clockAlreadyExistsForInput = await getClockIdForInput(inputId) + if (clockAlreadyExistsForInput) { + return + } + + const clockId = await bridge.time.registerClock({ + label + }) + + bridge.state.apply(`plugins.${manifest.name}.clocks`, { + [inputId]: clockId + }) + + return clockId +} + +function getClockIdForInput (inputId) { + return bridge.state.get(`plugins.${manifest.name}.clocks.${inputId}`) +} + +async function removeClock (inputId, clockId) { + if (!clockId) { + return + } + await bridge.time.removeClock(clockId) + await bridge.state.apply(`plugins.${manifest.name}.clocks`, { + [inputId]: { $delete: true } + }) +} + async function getAllAudioInputs () { return (await audio.enumerateInputDevices()) .map(device => ({ @@ -108,8 +124,19 @@ async function getAllAudioInputs () { })) } -function ltcDeviceFactory (deviceId) { - const device = DIController.instantiate('LTCDevice', {}, { +async function getAudioDeviceWithId (deviceId) { + const devices = await getAllAudioInputs() + return devices.find(device => device.id === deviceId) +} + +function ltcDeviceFactory (deviceId, frameRate = LTCDecoder.DEFAULT_FRAME_RATE_HZ) { + const device = DIController.instantiate('LTCDevice', { + LTCDecoder: DIController.instantiate('LTCDecoder', {}, + LTCDecoder.DEFAULT_SAMPLE_RATE_HZ, + frameRate, + LTCDecoder.DEFAULT_AUDIO_FORMAT + ) + }, { deviceId }, frame => { /** @@ -123,29 +150,105 @@ function ltcDeviceFactory (deviceId) { return device } -function syncDevicesWithSpecifiedInputs (inputs = []) { - for (const input of inputs) { +async function onLTCDeviceCreated (newSpec) { + /* + Make sure the input has registered + a clock in the Bridge API + */ + let clockId = await getClockIdForInput(newSpec?.id) + if (!clockId) { + clockId = await registerClockForInput(newSpec?.id, newSpec?.name) + } + + /* + Set up a new LTC device if there is already a specified device id, + this will be triggered if the workspace is loaded from disk and the + device has already been specified + */ + let device + if (newSpec?.deviceId) { /* - Handle newly added devices + Check if the device exists on this host as the project + file might have been loaded from another host */ - if (!LTC_DEVICES[input.id] && input?.deviceId) { - LTC_DEVICES[input.id] = ltcDeviceFactory(input?.deviceId) - logger.debug('Created LTC device', input.id) - continue + const deviceExists = await getAudioDeviceWithId(newSpec.deviceId) + + const frameRate = LTCDecoder.SUPPORTED_FRAME_RATES[newSpec?.frameRateIndex || 0] + + if (deviceExists) { + logger.debug('Setting upp new LTC device') + device = ltcDeviceFactory(newSpec?.deviceId, frameRate) } + } - const device = LTC_DEVICES[input.id] + LTC_DEVICES[newSpec?.id] = { + clockId, + device + } +} - /* - Handle updated devices - */ - if (device && !device?.compareTo(input)) { - device.close() - if (input?.deviceId) { - LTC_DEVICES[input.id] = ltcDeviceFactory(input?.deviceId) - logger.debug('Updated LTC device', input.id) - } - continue +async function onLTCDeviceChanged (newSpec) { + if (!LTC_DEVICES[newSpec?.id]) { + return + } + + const device = LTC_DEVICES[newSpec?.id]?.device + const frameRate = LTCDecoder.SUPPORTED_FRAME_RATES[newSpec?.frameRateIndex || 0] + + /* + Close and remove the current ltc + device if it is to be replaced + */ + if (device && !device?.compareTo({ ...newSpec, frameRate })) { + await device.close() + LTC_DEVICES[newSpec?.id].device = undefined + } + + /* + Create a new ltc device if none exists + and the spec isn't set to "no device" + */ + if ( + !LTC_DEVICES[newSpec?.id]?.device && + newSpec?.deviceId && + newSpec?.deviceId !== NO_AUDIO_DEVICE_ID + ) { + logger.debug('Setting up new LTC device') + LTC_DEVICES[newSpec?.id].device = ltcDeviceFactory(newSpec?.deviceId, newSpec?.frameRate) + } + + /* + Update the label of the clock feed + */ + const clockId = LTC_DEVICES[newSpec?.id]?.clockId + if (clockId) { + await bridge.time.applyClock(clockId, { + label: newSpec?.name + }) + } +} + +async function onLTCDeviceRemoved (deviceId) { + const spec = LTC_DEVICES[deviceId] + + if (spec?.device) { + spec.device?.close() + } + + if (spec?.clockId) { + await removeClock(spec?.id, spec?.clockId) + } + + delete LTC_DEVICES[deviceId] + logger.debug('Removed LTC device', deviceId) +} + +async function updateDevicesFromSettings (inputs = []) { + for (const input of inputs) { + if (!LTC_DEVICES[input.id]) { + await onLTCDeviceCreated(input) + } else { + await onLTCDeviceChanged(input) } } @@ -156,9 +259,7 @@ function syncDevicesWithSpecifiedInputs (inputs = []) { for (const deviceInputId of Object.keys(LTC_DEVICES)) { const inputExists = inputs.find(input => input?.id === deviceInputId) if (!inputExists) { - LTC_DEVICES[deviceInputId].close() - delete LTC_DEVICES[deviceInputId] - logger.debug('Removed LTC device', deviceInputId) + await onLTCDeviceRemoved(deviceInputId) } } } @@ -169,15 +270,6 @@ bootstrap its contributions */ exports.activate = async () => { logger.debug('Activating timecode plugin') - const htmlPath = await initWidget() - - bridge.widgets.registerWidget({ - id: 'bridge.plugins.timecode.smpte', - name: 'SMPTE display', - uri: `${htmlPath}?path=widget/smpte`, - description: 'Display incoming SMPTE timecode', - supportsFloat: true - }) /* Update the list of available audio devices @@ -199,11 +291,11 @@ exports.activate = async () => { the settings change */ bridge.events.on('state.change', (state, set) => { - if (!set?.plugins?.[manifest?.name]?.settings?.ltc_inputs) { + if (!set?.plugins?.[manifest?.name]?.settings?.ltc_devices) { return } - const inputs = state?.plugins?.[manifest?.name]?.settings?.ltc_inputs || [] - syncDevicesWithSpecifiedInputs(inputs) + const inputs = state?.plugins?.[manifest?.name]?.settings?.ltc_devices || [] + updateDevicesFromSettings(inputs) }) /* @@ -211,9 +303,9 @@ exports.activate = async () => { on startup */ { - const initialInputs = await bridge.state.get(`plugins.${manifest?.name}.settings.ltc_inputs`) + const initialInputs = await bridge.state.get(`plugins.${manifest?.name}.settings.ltc_devices`) if (initialInputs) { - syncDevicesWithSpecifiedInputs(initialInputs) + updateDevicesFromSettings(initialInputs) } } } diff --git a/plugins/timecode/lib/ltc/LTCDecoder.js b/plugins/timecode/lib/ltc/LTCDecoder.js index 88c81560..490d4082 100644 --- a/plugins/timecode/lib/ltc/LTCDecoder.js +++ b/plugins/timecode/lib/ltc/LTCDecoder.js @@ -6,12 +6,58 @@ const DEFAULT_SAMPLE_RATE_HZ = 48000 const DEFAULT_FRAME_RATE_HZ = 25 const DEFAULT_AUDIO_FORMAT = 'float' +const SUPPORTED_FRAME_RATES = [24, 25, 30] + class LTCDecoder extends DIBase { #nativeDecoder + static get DEFAULT_SAMPLE_RATE_HZ () { + return DEFAULT_SAMPLE_RATE_HZ + } + + static get DEFAULT_FRAME_RATE_HZ () { + return DEFAULT_FRAME_RATE_HZ + } + + static get DEFAULT_AUDIO_FORMAT () { + return DEFAULT_AUDIO_FORMAT + } + + static get SUPPORTED_FRAME_RATES () { + return SUPPORTED_FRAME_RATES + } + + /** + * Check if a provided frame rate + * is supported by the decoder + * @param { number } frameRate + * @returns { boolean } + */ + static isSupportedFrameRate (frameRate) { + return SUPPORTED_FRAME_RATES.includes(frameRate) + } + + #frameRate + + /** + * Get the frame rate + * of this decoder + * @type { number } + */ + get frameRate () { + return this.#frameRate + } + constructor (props, sampleRate = DEFAULT_SAMPLE_RATE_HZ, frameRate = DEFAULT_FRAME_RATE_HZ, format = DEFAULT_AUDIO_FORMAT) { super(props) - this.#nativeDecoder = new NativeLTCDecoder(sampleRate, frameRate, format) + + let _frameRate = frameRate + if (!LTCDecoder.isSupportedFrameRate(frameRate)) { + _frameRate = LTCDecoder.DEFAULT_FRAME_RATE_HZ + } + + this.#frameRate = _frameRate + this.#nativeDecoder = new NativeLTCDecoder(sampleRate, _frameRate, format) } write (buffer) { @@ -24,3 +70,4 @@ class LTCDecoder extends DIBase { } DIController.register('LTCDecoder', LTCDecoder) +module.exports = LTCDecoder diff --git a/plugins/timecode/lib/ltc/LTCDevice.js b/plugins/timecode/lib/ltc/LTCDevice.js index 3a6df854..4095d1f9 100644 --- a/plugins/timecode/lib/ltc/LTCDevice.js +++ b/plugins/timecode/lib/ltc/LTCDevice.js @@ -67,7 +67,8 @@ class LTCDevice extends TimecodeDevice { * @returns { boolean } */ compareTo (spec) { - return this.#opts?.deviceId === spec?.deviceId + return this.#opts?.deviceId === spec?.deviceId && + this.props.LTCDecoder.frameRate === spec?.frameRate } #formatFrame (rawFrameData) { From ec0ae09392b1d256cc0a7ac7f9fe0c9d09afd44d Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sun, 18 Jan 2026 21:27:42 +0100 Subject: [PATCH 17/57] Fix an issue where audio devices weren't listed correctly in settings and add an option for placeholders to string inputs Signed-off-by: Axel Boberg --- .../PreferencesStringInput/index.jsx | 4 +-- plugins/timecode/index.js | 32 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/app/components/PreferencesStringInput/index.jsx b/app/components/PreferencesStringInput/index.jsx index 60647452..182d7b38 100644 --- a/app/components/PreferencesStringInput/index.jsx +++ b/app/components/PreferencesStringInput/index.jsx @@ -3,12 +3,12 @@ import './style.css' import * as random from '../../utils/random' -export function PreferencesStringInput ({ label, value = '', onChange = () => {} }) { +export function PreferencesStringInput ({ label, value = '', placeholder, onChange = () => {} }) { const [id] = React.useState(`number-${random.number()}`) return (
- onChange(e.target.value)} /> + onChange(e.target.value)} />
) } diff --git a/plugins/timecode/index.js b/plugins/timecode/index.js index f0b9856c..9e35c4d9 100644 --- a/plugins/timecode/index.js +++ b/plugins/timecode/index.js @@ -28,7 +28,19 @@ const NO_AUDIO_DEVICE_ID = 'none' */ const LTC_DEVICES = {} -async function makeInputSetting (inputs = []) { +async function makeInputSetting (inputs = [], replaceInputs) { + const noInput = { + id: NO_AUDIO_DEVICE_ID, + label: 'None' + } + + let _inputs = [noInput, ...inputs] + if (replaceInputs) { + _inputs = { + $replace: _inputs + } + } + return { group: 'Timecode', title: 'LTC devices', @@ -43,7 +55,8 @@ async function makeInputSetting (inputs = []) { inputs: [ { type: 'string', - bind: 'name' + bind: 'name', + placeholder: 'Name' } ] }, @@ -53,15 +66,7 @@ async function makeInputSetting (inputs = []) { { type: 'select', bind: 'deviceId', - options: { - $replace: [ - { - id: NO_AUDIO_DEVICE_ID, - label: 'None' - }, - ...inputs - ] - } + options: _inputs } ] }, @@ -276,12 +281,13 @@ exports.activate = async () => { that's visible in settings */ { - const inputSetting = await makeInputSetting() + const inputs = await getAllAudioInputs() + const inputSetting = await makeInputSetting(inputs) const settingId = await bridge.settings.registerSetting(inputSetting) setInterval(async () => { const inputs = await getAllAudioInputs() - const inputSetting = await makeInputSetting(inputs) + const inputSetting = await makeInputSetting(inputs, true) bridge.settings.applySetting(settingId, inputSetting) }, 2000) } From 608cf01ed529e1782ed09bd3687ef7d95969e030 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sun, 18 Jan 2026 21:46:30 +0100 Subject: [PATCH 18/57] Setup the state before loading the API as some APIs make use of it Signed-off-by: Axel Boberg --- lib/workspace/Workspace.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/workspace/Workspace.js b/lib/workspace/Workspace.js index 68c0b8c8..a45616bf 100644 --- a/lib/workspace/Workspace.js +++ b/lib/workspace/Workspace.js @@ -84,13 +84,13 @@ class Workspace extends EventEmitter { this.#id = uuid.v4() logger.debug('Initialized workspace', this.#id) + this.state = new SavedState(initialState) + this.api = DIController.main.instantiate('SAPI', { Workspace: this }) this.sockets.setWorkspace(this) - - this.state = new SavedState(initialState) this.plugins = new PluginLoader({ paths: [paths.internalPlugins, paths.plugins], workspace: this }) /** From a80c93b4b7cb25b5a1b5f19808583328569f86e7 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sun, 18 Jan 2026 23:46:46 +0100 Subject: [PATCH 19/57] Minimize widget re-renders as much as possible Signed-off-by: Axel Boberg --- app/components/FrameComponent/index.jsx | 17 ++++++-------- app/components/TabsComponent/index.jsx | 4 ++-- app/components/WidgetRenderer/index.jsx | 31 ++++++++++++++++--------- app/views/Workspace.jsx | 6 +---- app/views/WorkspaceWidget.jsx | 6 +---- 5 files changed, 31 insertions(+), 33 deletions(-) diff --git a/app/components/FrameComponent/index.jsx b/app/components/FrameComponent/index.jsx index e36bc12e..36c820d6 100644 --- a/app/components/FrameComponent/index.jsx +++ b/app/components/FrameComponent/index.jsx @@ -2,7 +2,6 @@ import React from 'react' import { v4 as uuidv4 } from 'uuid' -import { SharedContext } from '../../sharedContext' import { LocalContext } from '../../localContext' import { Icon } from '../Icon' @@ -91,10 +90,8 @@ function copyThemeVariables (iframe, variables = COPY_THEME_VARIABLES) { } } -export function FrameComponent ({ data, onUpdate, enableFloat = true }) { +export function FrameComponent ({ widgetId, uri, widgets, data, onUpdate, enableFloat = true }) { const [caller] = React.useState(uuidv4()) - - const [shared] = React.useContext(SharedContext) const [local] = React.useContext(LocalContext) const [hasFocus, setHasFocus] = React.useState(false) @@ -163,14 +160,14 @@ export function FrameComponent ({ data, onUpdate, enableFloat = true }) { } } - const uri = shared?._widgets?.[data.component]?.uri + console.log('Preparing re-render', data, uri) const snapshot = JSON.stringify([data, uri]) if (snapshot === snapshotRef.current) return snapshotRef.current = snapshot setup() - }, [data, shared, onUpdate]) + }, [uri, data, onUpdate]) /* Clean up all event listeners @@ -183,7 +180,7 @@ export function FrameComponent ({ data, onUpdate, enableFloat = true }) { bridge.events.removeAllListeners(caller) bridge.events.removeAllIntercepts(caller) } - }, [caller, shared?._widgets?.[data.component]?.uri]) + }, [caller, uri]) /* Highligh the component @@ -264,12 +261,12 @@ export function FrameComponent ({ data, onUpdate, enableFloat = true }) {
- {shared?._widgets?.[data.component]?.name} + {widgets?.[data.component]?.name}
{ - enableFloat && shared?._widgets?.[data.component]?.supportsFloat && - } diff --git a/app/components/TabsComponent/index.jsx b/app/components/TabsComponent/index.jsx index 84d5311a..abd4bafe 100644 --- a/app/components/TabsComponent/index.jsx +++ b/app/components/TabsComponent/index.jsx @@ -16,7 +16,7 @@ import { WidgetRenderer } from '../WidgetRenderer' * @param { params } param0 * @returns { React.Component } */ -export function TabsComponent ({ data, onUpdate = () => {} }) { +export function TabsComponent ({ data, widgets, onUpdate = () => {} }) { const [activeTab, setActiveTab] = React.useState() const [tabToRemove, setTabToRemove] = React.useState() @@ -169,7 +169,7 @@ export function TabsComponent ({ data, onUpdate = () => {} }) { if (!data?.children[id]) { return <> } - return handleChildUpdate(activeTab, data)} /> + return handleChildUpdate(activeTab, data)} /> } const tabs = (data?.order || []) diff --git a/app/components/WidgetRenderer/index.jsx b/app/components/WidgetRenderer/index.jsx index 3bca4b9b..27c6b85e 100644 --- a/app/components/WidgetRenderer/index.jsx +++ b/app/components/WidgetRenderer/index.jsx @@ -6,6 +6,7 @@ import { GridItem } from '../GridItem' import { TabsComponent } from '../TabsComponent' import { EmptyComponent } from '../EmptyComponent' import { FrameComponent } from '../FrameComponent' +import { MissingComponent } from '../MissingComponent' /** * Define render functions for the @@ -13,15 +14,17 @@ import { FrameComponent } from '../FrameComponent' * basic layouts */ const INTERNAL_COMPONENTS = { - 'bridge.internals.grid': (data, onUpdate) => { + 'bridge.internals.grid': (widgetId, data, onUpdate, widgets) => { return ( - + { (data.children ? Object.entries(data.children) : []) .map(([id, component]) => ( onUpdate({ children: { [id]: data @@ -34,20 +37,20 @@ const INTERNAL_COMPONENTS = { ) }, - 'bridge.internals.tabs': (data, onUpdate) => { - return + 'bridge.internals.tabs': (widgetId, data, onUpdate, widgets) => { + return }, 'bridge.internals.empty': () => { return } } -export function widgetExists (component, repository = {}) { +export function widgetExists (component, widgets = {}) { if (INTERNAL_COMPONENTS[component]) { return true } - if (repository?.[component]) { + if (widgets?.[component]) { return true } @@ -63,9 +66,15 @@ export function widgetExists (component, repository = {}) { * @param { (arg1: any) => {} } onUpdate * @returns { React.ReactElement } */ -export const WidgetRenderer = ({ data, onUpdate = () => {}, forwardProps = {} }) => { - if (INTERNAL_COMPONENTS[data.component]) { - return INTERNAL_COMPONENTS[data.component](data, onUpdate) +export const WidgetRenderer = ({ widgetId, widgets = {}, data, onUpdate = () => {}, forwardProps = {} }) => { + if (INTERNAL_COMPONENTS[data?.component]) { + return INTERNAL_COMPONENTS[data.component](widgetId, data, onUpdate, widgets) } - return + + const uri = widgets?.[data?.component]?.uri + if (typeof widgets != 'object' || !uri) { + return + } + + return } diff --git a/app/views/Workspace.jsx b/app/views/Workspace.jsx index 12b27f07..f3d86be7 100644 --- a/app/views/Workspace.jsx +++ b/app/views/Workspace.jsx @@ -87,11 +87,7 @@ export const Workspace = () => { (shared.children ? Object.entries(shared.children) : []) .map(([id, component]) => (
- { - widgetExists(component.component, sharedRef.current?._widgets) - ? handleComponentUpdate({ [id]: data })} /> - : - } + handleComponentUpdate({ [id]: data })} />
)) } diff --git a/app/views/WorkspaceWidget.jsx b/app/views/WorkspaceWidget.jsx index 93a8d0c5..0e776aea 100644 --- a/app/views/WorkspaceWidget.jsx +++ b/app/views/WorkspaceWidget.jsx @@ -78,11 +78,7 @@ export const WorkspaceWidget = () => { <>
- { - widgetExists(widget?.component, repository) - ? handleComponentUpdate({ [id]: data })} forwardProps={{ enableFloat: false }} /> - : - } + handleComponentUpdate({ [id]: data })} forwardProps={{ enableFloat: false }} />