From 141672c0b64fd894470184ad9f332a6c67c37daf Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 22 Nov 2023 23:34:19 +0000 Subject: [PATCH] wip --- webui/src/{App.jsx => App.tsx} | 62 ++-- webui/src/Components/Notifications.tsx | 4 +- .../src/{ContextData.jsx => ContextData.tsx} | 120 ++++--- .../{usePagePicker.js => usePagePicker.ts} | 7 +- webui/src/Hooks/usePagesInfoSubscription.ts | 4 +- ...RenderCache.js => useSharedRenderCache.ts} | 17 +- webui/src/Hooks/useUserConfigSubscription.ts | 4 +- webui/src/Surfaces/EditModal.jsx | 291 ----------------- webui/src/Surfaces/EditModal.tsx | 297 ++++++++++++++++++ webui/src/Surfaces/{index.jsx => index.tsx} | 44 +-- webui/src/{index.jsx => index.tsx} | 2 +- webui/src/util.tsx | 3 +- 12 files changed, 466 insertions(+), 389 deletions(-) rename webui/src/{App.jsx => App.tsx} (90%) rename webui/src/{ContextData.jsx => ContextData.tsx} (66%) rename webui/src/Hooks/{usePagePicker.js => usePagePicker.ts} (74%) rename webui/src/Hooks/{useSharedRenderCache.js => useSharedRenderCache.ts} (75%) delete mode 100644 webui/src/Surfaces/EditModal.jsx create mode 100644 webui/src/Surfaces/EditModal.tsx rename webui/src/Surfaces/{index.jsx => index.tsx} (85%) rename webui/src/{index.jsx => index.tsx} (99%) diff --git a/webui/src/App.jsx b/webui/src/App.tsx similarity index 90% rename from webui/src/App.jsx rename to webui/src/App.tsx index 782fb946e3..5786b9918d 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.tsx @@ -39,7 +39,7 @@ import { ConnectionsPage } from './Connections' import { ButtonsPage } from './Buttons' import { ContextData } from './ContextData' import { CloudPage } from './CloudPage' -import { WizardModal, WIZARD_CURRENT_VERSION } from './Wizard' +import { WizardModal, WIZARD_CURRENT_VERSION, WizardModalRef } from './Wizard' import { Navigate, useLocation } from 'react-router-dom' import { useIdleTimer } from 'react-idle-timer' import { ImportExport } from './ImportExport' @@ -58,7 +58,7 @@ export default function App() { const onConnected = () => { setWasConnected((wasConnected0) => { if (wasConnected0) { - window.location.reload(true) + window.location.reload() } else { setConnected(true) } @@ -162,7 +162,14 @@ export default function App() { ) } -function AppMain({ connected, loadingComplete, loadingProgress, buttonGridHotPress }) { +interface AppMainProps { + connected: boolean + loadingComplete: boolean + loadingProgress: number + buttonGridHotPress: boolean +} + +function AppMain({ connected, loadingComplete, loadingProgress, buttonGridHotPress }: AppMainProps) { const config = useContext(UserConfigContext) const [showSidebar, setShowSidebar] = useState(true) @@ -178,10 +185,10 @@ function AppMain({ connected, loadingComplete, loadingProgress, buttonGridHotPre } }, [canLock]) - const wizardModal = useRef() + const wizardModal = useRef(null) const showWizard = useCallback(() => { if (unlocked) { - wizardModal.current.show() + wizardModal.current?.show() } }, [unlocked]) @@ -229,16 +236,21 @@ function AppMain({ connected, loadingComplete, loadingProgress, buttonGridHotPre ) } +interface IdleTimerWrapperProps { + setLocked: () => void + timeoutMinutes: number +} + /** Wrap the idle timer in its own component, as it invalidates every second */ -function IdleTimerWrapper({ setLocked, timeoutMinutes }) { +function IdleTimerWrapper({ setLocked, timeoutMinutes }: IdleTimerWrapperProps) { const notifier = useContext(NotifierContext) - const [, setIdleTimeout] = useState(null) + const [, setIdleTimeout] = useState(null) const TOAST_ID = 'SESSION_TIMEOUT_TOAST' const TOAST_DURATION = 45 * 1000 - const handleOnActive = (event) => { + const handleOnActive = () => { // user is now active, abort the lock setIdleTimeout((v) => { if (v) { @@ -253,15 +265,15 @@ function IdleTimerWrapper({ setLocked, timeoutMinutes }) { return null }) } - const handleAction = (event) => { + const handleAction = () => { // setShouldShowIdleWarning(false) } const handleIdle = () => { - notifier.current.show( + notifier.current?.show( 'Session timeout', 'Your session is about to timeout, and Companion will be locked', - null, + undefined, TOAST_ID ) @@ -305,10 +317,15 @@ function IdleTimerWrapper({ setLocked, timeoutMinutes }) { } }) - return '' + return null +} + +interface AppLoadingProps { + progress: number + connected: boolean } -function AppLoading({ progress, connected }) { +function AppLoading({ progress, connected }: AppLoadingProps) { const message = connected ? 'Syncing' : 'Connecting' return ( @@ -325,7 +342,11 @@ function AppLoading({ progress, connected }) { ) } -function AppAuthWrapper({ setUnlocked }) { +interface AppAuthWrapperProps { + setUnlocked: () => void +} + +function AppAuthWrapper({ setUnlocked }: AppAuthWrapperProps) { const config = useContext(UserConfigContext) const [password, setPassword] = useState('') @@ -341,7 +362,7 @@ function AppAuthWrapper({ setUnlocked }) { e.preventDefault() setPassword((currentPassword) => { - if (currentPassword === config.admin_password) { + if (currentPassword === config?.admin_password) { setShowError(false) setUnlocked() return '' @@ -354,7 +375,7 @@ function AppAuthWrapper({ setUnlocked }) { return false }, - [config.admin_password, setUnlocked] + [config?.admin_password, setUnlocked] ) return ( @@ -370,6 +391,7 @@ function AppAuthWrapper({ setUnlocked }) { value={password} onChange={(e) => passwordChanged(e.currentTarget.value)} invalid={showError} + readOnly={!config} /> Unlock @@ -382,10 +404,14 @@ function AppAuthWrapper({ setUnlocked }) { ) } -function AppContent({ buttonGridHotPress }) { +interface AppContentProps { + buttonGridHotPress: boolean +} + +function AppContent({ buttonGridHotPress }: AppContentProps) { const routerLocation = useLocation() let hasMatchedPane = false - const getClassForPane = (prefix) => { + const getClassForPane = (prefix: string) => { // Require the path to be the same, or to be a prefix with a sub-route if (routerLocation.pathname.startsWith(prefix + '/') || routerLocation.pathname === prefix) { hasMatchedPane = true diff --git a/webui/src/Components/Notifications.tsx b/webui/src/Components/Notifications.tsx index bad9156db5..7083239f00 100644 --- a/webui/src/Components/Notifications.tsx +++ b/webui/src/Components/Notifications.tsx @@ -7,7 +7,9 @@ export interface NotificationsManagerRef { close(messageId: string): void } -type NotificationsManagerProps = Record +interface NotificationsManagerProps { + // Nothing +} interface CurrentToast { id: string diff --git a/webui/src/ContextData.jsx b/webui/src/ContextData.tsx similarity index 66% rename from webui/src/ContextData.jsx rename to webui/src/ContextData.tsx index 4b3f1b0418..a8a1aa4e4d 100644 --- a/webui/src/ContextData.jsx +++ b/webui/src/ContextData.tsx @@ -19,31 +19,49 @@ import { RecentActionsContext, RecentFeedbacksContext, } from './util' -import { NotificationsManager } from './Components/Notifications' +import { NotificationsManager, NotificationsManagerRef } from './Components/Notifications' import { cloneDeep } from 'lodash-es' -import jsonPatch from 'fast-json-patch' +import jsonPatch, { Operation as JsonPatchOperation } from 'fast-json-patch' import { useUserConfigSubscription } from './Hooks/useUserConfigSubscription' import { usePagesInfoSubscription } from './Hooks/usePagesInfoSubscription' +import type { ClientConnectionConfig, ClientEventDefinition, ModuleDisplayInfo } from '@companion/shared/Model/Common' +import { InternalActionDefinition, InternalFeedbackDefinition } from '@companion/shared/Model/Options' +import { AllVariableDefinitions, ModuleVariableDefinitions } from '@companion/shared/Model/Variables' +import { CustomVariablesModel } from '@companion/shared/Model/CustomVariableModel' +import { ClientDevicesList } from '@companion/shared/Model/Surfaces' +import { ClientTriggerData } from '@companion/shared/Model/TriggerModel' + +interface ContextDataProps { + children: (progressPercent: number, loadingComplete: boolean) => React.JSX.Element | React.JSX.Element[] +} -export function ContextData({ children }) { +export function ContextData({ children }: ContextDataProps) { const socket = useContext(SocketContext) - const [eventDefinitions, setEventDefinitions] = useState(null) - const [connections, setConnections] = useState(null) - const [modules, setModules] = useState(null) - const [actionDefinitions, setActionDefinitions] = useState(null) - const [feedbackDefinitions, setFeedbackDefinitions] = useState(null) - const [variableDefinitions, setVariableDefinitions] = useState(null) - const [customVariables, setCustomVariables] = useState(null) - const [surfaces, setSurfaces] = useState(null) - const [triggers, setTriggers] = useState(null) - - const [recentActions, setRecentActions] = useState(() => { + const [eventDefinitions, setEventDefinitions] = useState | null>( + null + ) + const [connections, setConnections] = useState | null>(null) + const [modules, setModules] = useState | null>(null) + const [actionDefinitions, setActionDefinitions] = useState | undefined + > | null>(null) + const [feedbackDefinitions, setFeedbackDefinitions] = useState | undefined + > | null>(null) + const [variableDefinitions, setVariableDefinitions] = useState(null) + const [customVariables, setCustomVariables] = useState(null) + const [surfaces, setSurfaces] = useState(null) + const [triggers, setTriggers] = useState | null>(null) + + const [recentActions, setRecentActions] = useState(() => { const recent = JSON.parse(window.localStorage.getItem('recent_actions') || '[]') return Array.isArray(recent) ? recent : [] }) - const trackRecentAction = useCallback((actionType) => { + const trackRecentAction = useCallback((actionType: string) => { setRecentActions((existing) => { const newActions = [actionType, ...existing.filter((v) => v !== actionType)].slice(0, 20) @@ -60,12 +78,12 @@ export function ContextData({ children }) { [recentActions, trackRecentAction] ) - const [recentFeedbacks, setRecentFeedbacks] = useState(() => { + const [recentFeedbacks, setRecentFeedbacks] = useState(() => { const recent = JSON.parse(window.localStorage.getItem('recent_feedbacks') || '[]') return Array.isArray(recent) ? recent : [] }) - const trackRecentFeedback = useCallback((feedbackType) => { + const trackRecentFeedback = useCallback((feedbackType: string) => { setRecentFeedbacks((existing) => { const newFeedbacks = [feedbackType, ...existing.filter((v) => v !== feedbackType)].slice(0, 20) @@ -82,10 +100,10 @@ export function ContextData({ children }) { [recentFeedbacks, trackRecentFeedback] ) - const completeVariableDefinitions = useMemo(() => { + const completeVariableDefinitions = useMemo(() => { if (variableDefinitions) { // Generate definitions for all the custom variables - const customVariableDefinitions = {} + const customVariableDefinitions: ModuleVariableDefinitions = {} for (const [id, info] of Object.entries(customVariables || {})) { customVariableDefinitions[`custom_${id}`] = { label: info.description, @@ -100,7 +118,7 @@ export function ContextData({ children }) { }, } } else { - return null + return {} } }, [customVariables, variableDefinitions]) @@ -153,22 +171,30 @@ export function ContextData({ children }) { console.error('Failed to load custom values list', e) }) - const updateVariableDefinitions = (label, patch) => { - setVariableDefinitions((oldDefinitions) => applyPatchOrReplaceSubObject(oldDefinitions, label, patch, {})) + const updateVariableDefinitions = (label: string, patch: JsonPatchOperation[]) => { + setVariableDefinitions( + (oldDefinitions) => + oldDefinitions && + applyPatchOrReplaceSubObject(oldDefinitions, label, patch, {}) + ) } - const updateFeedbackDefinitions = (id, patch) => { - setFeedbackDefinitions((oldDefinitions) => applyPatchOrReplaceSubObject(oldDefinitions, id, patch, {})) + const updateFeedbackDefinitions = (id: string, patch: JsonPatchOperation[]) => { + setFeedbackDefinitions( + (oldDefinitions) => oldDefinitions && applyPatchOrReplaceSubObject(oldDefinitions, id, patch, {}) + ) } - const updateActionDefinitions = (id, patch) => { - setActionDefinitions((oldDefinitions) => applyPatchOrReplaceSubObject(oldDefinitions, id, patch, {})) + const updateActionDefinitions = (id: string, patch: JsonPatchOperation[]) => { + setActionDefinitions( + (oldDefinitions) => oldDefinitions && applyPatchOrReplaceSubObject(oldDefinitions, id, patch, {}) + ) } - const updateCustomVariables = (patch) => { - setCustomVariables((oldVariables) => applyPatchOrReplaceObject(oldVariables, patch)) + const updateCustomVariables = (patch: JsonPatchOperation[]) => { + setCustomVariables((oldVariables) => oldVariables && applyPatchOrReplaceObject(oldVariables, patch)) } - const updateTriggers = (controlId, patch) => { + const updateTriggers = (controlId: string, patch: JsonPatchOperation[]) => { console.log('trigger', controlId, patch) - setTriggers((oldTriggers) => applyPatchOrReplaceSubObject(oldTriggers, controlId, patch, {})) + setTriggers((oldTriggers) => oldTriggers && applyPatchOrReplaceSubObject(oldTriggers, controlId, patch, null)) } socketEmitPromise(socket, 'connections:subscribe', []) @@ -180,10 +206,10 @@ export function ContextData({ children }) { setConnections(null) }) - const patchInstances = (patch) => { + const patchInstances = (patch: JsonPatchOperation[] | false) => { setConnections((oldConnections) => { if (patch === false) { - return false + return {} } else { return jsonPatch.applyPatch(cloneDeep(oldConnections) || {}, patch).newDocument } @@ -191,10 +217,10 @@ export function ContextData({ children }) { } socket.on('connections:patch', patchInstances) - const patchModules = (patch) => { + const patchModules = (patch: JsonPatchOperation[] | false) => { setModules((oldModules) => { if (patch === false) { - return false + return {} } else { return jsonPatch.applyPatch(cloneDeep(oldModules) || {}, patch).newDocument } @@ -216,9 +242,9 @@ export function ContextData({ children }) { console.error('Failed to load surfaces', e) }) - const patchSurfaces = (patch) => { + const patchSurfaces = (patch: JsonPatchOperation[]) => { setSurfaces((oldSurfaces) => { - return jsonPatch.applyPatch(cloneDeep(oldSurfaces) || {}, patch).newDocument + return oldSurfaces && jsonPatch.applyPatch(cloneDeep(oldSurfaces) || {}, patch).newDocument }) } socket.on('surfaces:patch', patchSurfaces) @@ -270,10 +296,12 @@ export function ContextData({ children }) { console.error('Failed to unsubscribe from custom variables', e) }) } + } else { + return } }, [socket]) - const notifierRef = useRef() + const notifierRef = useRef(null) const steps = [ eventDefinitions, @@ -295,17 +323,17 @@ export function ContextData({ children }) { return ( - - - - - + + + + + - + - - - + + + diff --git a/webui/src/Hooks/usePagePicker.js b/webui/src/Hooks/usePagePicker.ts similarity index 74% rename from webui/src/Hooks/usePagePicker.js rename to webui/src/Hooks/usePagePicker.ts index fcfd999922..ea5ba01f4a 100644 --- a/webui/src/Hooks/usePagePicker.js +++ b/webui/src/Hooks/usePagePicker.ts @@ -1,9 +1,10 @@ +import { PageModel } from '@companion/shared/Model/PageModel' import { useCallback, useEffect, useRef, useState } from 'react' -export function usePagePicker(pagesObj, initialPage) { +export function usePagePicker(pagesObj: Record, initialPage: number) { const [pageNumber, setPageNumber] = useState(Number(initialPage)) - const pagesRef = useRef() + const pagesRef = useRef>() useEffect(() => { // Avoid binding into callbacks pagesRef.current = pagesObj @@ -19,7 +20,7 @@ export function usePagePicker(pagesObj, initialPage) { if (newIndex < 0) newIndex += pageNumbers.length if (newIndex >= pageNumbers.length) newIndex -= pageNumbers.length - newPage = pageNumbers[newIndex] + newPage = Number(pageNumbers[newIndex]) } return newPage ?? pageNumber diff --git a/webui/src/Hooks/usePagesInfoSubscription.ts b/webui/src/Hooks/usePagesInfoSubscription.ts index 2f2be693b6..1925afb247 100644 --- a/webui/src/Hooks/usePagesInfoSubscription.ts +++ b/webui/src/Hooks/usePagesInfoSubscription.ts @@ -5,8 +5,8 @@ import type { PageModel } from '@companion/shared/Model/PageModel' export function usePagesInfoSubscription( socket: Socket, - setLoadError: ((error: string | null) => void) | undefined, - retryToken: string + setLoadError?: ((error: string | null) => void) | undefined, + retryToken?: string ) { const [pages, setPages] = useState | null>(null) diff --git a/webui/src/Hooks/useSharedRenderCache.js b/webui/src/Hooks/useSharedRenderCache.ts similarity index 75% rename from webui/src/Hooks/useSharedRenderCache.js rename to webui/src/Hooks/useSharedRenderCache.ts index 59f79f937d..68bed9737a 100644 --- a/webui/src/Hooks/useSharedRenderCache.js +++ b/webui/src/Hooks/useSharedRenderCache.ts @@ -1,22 +1,27 @@ import { useContext, useEffect, useMemo, useState } from 'react' import { SocketContext, socketEmitPromise } from '../util' import { nanoid } from 'nanoid' +import { ControlLocation } from '@companion/shared/Model/Common' + +interface ImageState { + image: string | null + isUsed: boolean +} /** * Load and retrieve a page from the shared button render cache - * @param {string} sessionId Unique id of this accessor - * @param {number | undefined} page Page number to load and retrieve - * @param {boolean | undefined} disable Disable loading of this page + * @param location Location of the control to load + * @param disable Disable loading of this page * @returns */ -export function useButtonRenderCache(location, disable = false) { +export function useButtonRenderCache(location: ControlLocation, disable = false) { const socket = useContext(SocketContext) const subId = useMemo(() => nanoid(), []) // TODO - should these be managed a bit more centrally, and batched? It is likely that lots of subscribe/unsubscribe calls will happen at once (changing page/scrolling) - const [imageState, setImageState] = useState({ image: null, isUsed: false }) + const [imageState, setImageState] = useState({ image: null, isUsed: false }) useEffect(() => { if (disable) return @@ -37,7 +42,7 @@ export function useButtonRenderCache(location, disable = false) { console.error(e) }) - const changeHandler = (renderLocation, image, isUsed) => { + const changeHandler = (renderLocation: ControlLocation, image: string | null, isUsed: boolean) => { if (terminated) return if ( diff --git a/webui/src/Hooks/useUserConfigSubscription.ts b/webui/src/Hooks/useUserConfigSubscription.ts index 8296a52087..21a1e17070 100644 --- a/webui/src/Hooks/useUserConfigSubscription.ts +++ b/webui/src/Hooks/useUserConfigSubscription.ts @@ -5,8 +5,8 @@ import { UserConfigModel } from '@companion/shared/Model/UserConfigModel' export function useUserConfigSubscription( socket: Socket, - setLoadError: ((error: string | null) => void) | undefined, - retryToken: string + setLoadError?: ((error: string | null) => void) | undefined, + retryToken?: string ) { const [userConfig, setUserConfig] = useState(null) diff --git a/webui/src/Surfaces/EditModal.jsx b/webui/src/Surfaces/EditModal.jsx deleted file mode 100644 index ff0d686736..0000000000 --- a/webui/src/Surfaces/EditModal.jsx +++ /dev/null @@ -1,291 +0,0 @@ -import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useState } from 'react' -import { - CButton, - CForm, - CFormGroup, - CInput, - CInputCheckbox, - CLabel, - CModal, - CModalBody, - CModalFooter, - CModalHeader, - CSelect, -} from '@coreui/react' -import { LoadingRetryOrError, socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' -import { nanoid } from 'nanoid' - -export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref) { - const socket = useContext(SocketContext) - - const [surfaceInfo, setSurfaceInfo] = useState(null) - const [show, setShow] = useState(false) - - const [surfaceConfig, setSurfaceConfig] = useState(null) - const [surfaceConfigError, setSurfaceConfigError] = useState(null) - const [reloadToken, setReloadToken] = useState(nanoid()) - - const doClose = useCallback(() => setShow(false), []) - const onClosed = useCallback(() => { - setSurfaceInfo(null) - setSurfaceConfig(null) - setSurfaceConfigError(null) - }, []) - - const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) - - useEffect(() => { - setSurfaceConfigError(null) - setSurfaceConfig(null) - - if (surfaceInfo?.id) { - socketEmitPromise(socket, 'surfaces:config-get', [surfaceInfo.id]) - .then((config) => { - console.log(config) - setSurfaceConfig(config) - }) - .catch((err) => { - console.error('Failed to load surface config') - setSurfaceConfigError(`Failed to load surface config`) - }) - } - }, [socket, surfaceInfo?.id, reloadToken]) - - useImperativeHandle( - ref, - () => ({ - show(surface) { - setSurfaceInfo(surface) - setShow(true) - }, - ensureIdIsValid(surfaceIds) { - setSurfaceInfo((oldSurface) => { - if (oldSurface && surfaceIds.indexOf(oldSurface.id) === -1) { - setShow(false) - } - return oldSurface - }) - }, - }), - [] - ) - - const updateConfig = useCallback( - (key, value) => { - console.log('update', key, value) - if (surfaceInfo?.id) { - setSurfaceConfig((oldConfig) => { - const newConfig = { - ...oldConfig, - [key]: value, - } - - socketEmitPromise(socket, 'surfaces:config-set', [surfaceInfo.id, newConfig]) - .then((newConfig) => { - if (typeof newConfig === 'string') { - console.log('Config update failed', newConfig) - } else { - setSurfaceConfig(newConfig) - } - }) - .catch((e) => { - console.log('Config update failed', e) - }) - return newConfig - }) - } - }, - [socket, surfaceInfo?.id] - ) - - return ( - - -
Settings for {surfaceInfo?.type}
-
- - - {surfaceConfig && surfaceInfo && ( - - - Use Last Page At Startup - updateConfig('use_last_page', !!e.currentTarget.checked)} - /> - - - Startup Page - updateConfig('page', parseInt(e.currentTarget.value))} - /> - {surfaceConfig.page} - - {surfaceInfo.configFields?.includes('emulator_size') && ( - <> - - Row count - updateConfig('emulator_rows', parseInt(e.currentTarget.value))} - /> - - - Column count - updateConfig('emulator_columns', parseInt(e.currentTarget.value))} - /> - - - )} - - - Horizontal Offset in grid - updateConfig('xOffset', parseInt(e.currentTarget.value))} - /> - - - Vertical Offset in grid - updateConfig('yOffset', parseInt(e.currentTarget.value))} - /> - - - {surfaceInfo.configFields?.includes('brightness') && ( - - Brightness - updateConfig('brightness', parseInt(e.currentTarget.value))} - /> - - )} - {surfaceInfo.configFields?.includes('illuminate_pressed') && ( - - Illuminate pressed buttons - updateConfig('illuminate_pressed', !!e.currentTarget.checked)} - /> - - )} - - - Button rotation - { - const valueNumber = parseInt(e.currentTarget.value) - updateConfig('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) - }} - > - - - - - - {surfaceInfo.configFields?.includes('legacy_rotation') && ( - <> - - - - - )} - - - {surfaceInfo.configFields?.includes('emulator_control_enable') && ( - - Enable support for Logitech R400/Mastercue/DSan - updateConfig('emulator_control_enable', !!e.currentTarget.checked)} - /> - - )} - {surfaceInfo.configFields?.includes('emulator_prompt_fullscreen') && ( - - Prompt to enter fullscreen - updateConfig('emulator_prompt_fullscreen', !!e.currentTarget.checked)} - /> - - )} - {surfaceInfo.configFields?.includes('videohub_page_count') && ( - - Page Count - updateConfig('videohub_page_count', parseInt(e.currentTarget.value))} - /> - - )} - - Never Pin code lock - updateConfig('never_lock', !!e.currentTarget.checked)} - /> - - - )} - - - - Close - - -
- ) -}) diff --git a/webui/src/Surfaces/EditModal.tsx b/webui/src/Surfaces/EditModal.tsx new file mode 100644 index 0000000000..dc02e1b578 --- /dev/null +++ b/webui/src/Surfaces/EditModal.tsx @@ -0,0 +1,297 @@ +import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useState } from 'react' +import { + CButton, + CForm, + CFormGroup, + CInput, + CInputCheckbox, + CLabel, + CModal, + CModalBody, + CModalFooter, + CModalHeader, + CSelect, +} from '@coreui/react' +import { LoadingRetryOrError, socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' +import { nanoid } from 'nanoid' +import type { AvailableDeviceInfo } from '@companion/shared/Model/Surfaces' + +export interface SurfaceEditModalRef { + show(surface: AvailableDeviceInfo): void + ensureIdIsValid(surfaceIds: string[]): void +} +interface SurfaceEditModalProps { + // Nothing +} + +export const SurfaceEditModal = forwardRef( + function SurfaceEditModal(_props, ref) { + const socket = useContext(SocketContext) + + const [surfaceInfo, setSurfaceInfo] = useState(null) + const [show, setShow] = useState(false) + + const [surfaceConfig, setSurfaceConfig] = useState | null>(null) + const [surfaceConfigError, setSurfaceConfigError] = useState(null) + const [reloadToken, setReloadToken] = useState(nanoid()) + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => { + setSurfaceInfo(null) + setSurfaceConfig(null) + setSurfaceConfigError(null) + }, []) + + const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) + + useEffect(() => { + setSurfaceConfigError(null) + setSurfaceConfig(null) + + if (surfaceInfo?.id) { + socketEmitPromise(socket, 'surfaces:config-get', [surfaceInfo.id]) + .then((config) => { + console.log(config) + setSurfaceConfig(config) + }) + .catch((err) => { + console.error('Failed to load surface config', err) + setSurfaceConfigError(`Failed to load surface config`) + }) + } + }, [socket, surfaceInfo?.id, reloadToken]) + + useImperativeHandle( + ref, + () => ({ + show(surface) { + setSurfaceInfo(surface) + setShow(true) + }, + ensureIdIsValid(surfaceIds) { + setSurfaceInfo((oldSurface) => { + if (oldSurface && surfaceIds.indexOf(oldSurface.id) === -1) { + setShow(false) + } + return oldSurface + }) + }, + }), + [] + ) + + const updateConfig = useCallback( + (key, value) => { + console.log('update', key, value) + if (surfaceInfo?.id) { + setSurfaceConfig((oldConfig) => { + const newConfig = { + ...oldConfig, + [key]: value, + } + + socketEmitPromise(socket, 'surfaces:config-set', [surfaceInfo.id, newConfig]) + .then((newConfig) => { + if (typeof newConfig === 'string') { + console.log('Config update failed', newConfig) + } else { + setSurfaceConfig(newConfig) + } + }) + .catch((e) => { + console.log('Config update failed', e) + }) + return newConfig + }) + } + }, + [socket, surfaceInfo?.id] + ) + + return ( + + +
Settings for {surfaceInfo?.type}
+
+ + + {surfaceConfig && surfaceInfo && ( + + + Use Last Page At Startup + updateConfig('use_last_page', !!e.currentTarget.checked)} + /> + + + Startup Page + updateConfig('page', parseInt(e.currentTarget.value))} + /> + {surfaceConfig.page} + + {surfaceInfo.configFields?.includes('emulator_size') && ( + <> + + Row count + updateConfig('emulator_rows', parseInt(e.currentTarget.value))} + /> + + + Column count + updateConfig('emulator_columns', parseInt(e.currentTarget.value))} + /> + + + )} + + + Horizontal Offset in grid + updateConfig('xOffset', parseInt(e.currentTarget.value))} + /> + + + Vertical Offset in grid + updateConfig('yOffset', parseInt(e.currentTarget.value))} + /> + + + {surfaceInfo.configFields?.includes('brightness') && ( + + Brightness + updateConfig('brightness', parseInt(e.currentTarget.value))} + /> + + )} + {surfaceInfo.configFields?.includes('illuminate_pressed') && ( + + Illuminate pressed buttons + updateConfig('illuminate_pressed', !!e.currentTarget.checked)} + /> + + )} + + + Button rotation + { + const valueNumber = parseInt(e.currentTarget.value) + updateConfig('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) + }} + > + + + + + + {surfaceInfo.configFields?.includes('legacy_rotation') && ( + <> + + + + + )} + + + {surfaceInfo.configFields?.includes('emulator_control_enable') && ( + + Enable support for Logitech R400/Mastercue/DSan + updateConfig('emulator_control_enable', !!e.currentTarget.checked)} + /> + + )} + {surfaceInfo.configFields?.includes('emulator_prompt_fullscreen') && ( + + Prompt to enter fullscreen + updateConfig('emulator_prompt_fullscreen', !!e.currentTarget.checked)} + /> + + )} + {surfaceInfo.configFields?.includes('videohub_page_count') && ( + + Page Count + updateConfig('videohub_page_count', parseInt(e.currentTarget.value))} + /> + + )} + + Never Pin code lock + updateConfig('never_lock', !!e.currentTarget.checked)} + /> + + + )} + + + + Close + + +
+ ) + } +) diff --git a/webui/src/Surfaces/index.jsx b/webui/src/Surfaces/index.tsx similarity index 85% rename from webui/src/Surfaces/index.jsx rename to webui/src/Surfaces/index.tsx index 0fb9e879e0..bd5096b140 100644 --- a/webui/src/Surfaces/index.jsx +++ b/webui/src/Surfaces/index.tsx @@ -5,17 +5,16 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faAdd, faCog, faFolderOpen, faSync, faTrash } from '@fortawesome/free-solid-svg-icons' import { TextInputField } from '../Components/TextInputField' import { useMemo } from 'react' -import { GenericConfirmModal } from '../Components/GenericConfirmModal' -import { SurfaceEditModal } from './EditModal' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal' +import { SurfaceEditModal, SurfaceEditModalRef } from './EditModal' +import { AvailableDeviceInfo, OfflineDeviceInfo } from '@companion/shared/Model/Surfaces' export const SurfacesPage = memo(function SurfacesPage() { const socket = useContext(SocketContext) const surfaces = useContext(SurfacesContext) - const confirmRef = useRef(null) - const surfacesList = useMemo(() => { - const ary = Object.values(surfaces.available) + const ary = Object.values(surfaces.available).filter((s): s is AvailableDeviceInfo => !!s) ary.sort((a, b) => { if (a.index !== b.index) { @@ -29,7 +28,7 @@ export const SurfacesPage = memo(function SurfacesPage() { return ary }, [surfaces.available]) const offlineSurfacesList = useMemo(() => { - const ary = Object.values(surfaces.offline) + const ary = Object.values(surfaces.offline).filter((s): s is OfflineDeviceInfo => !!s) ary.sort((a, b) => { if (a.index !== b.index) { @@ -43,17 +42,15 @@ export const SurfacesPage = memo(function SurfacesPage() { return ary }, [surfaces.offline]) - const editModalRef = useRef() - const confirmModalRef = useRef(null) + const editModalRef = useRef(null) + const confirmRef = useRef(null) const [scanning, setScanning] = useState(false) const [scanError, setScanError] = useState(null) useEffect(() => { // If surface disappears, hide the edit modal - if (editModalRef.current) { - editModalRef.current.ensureIdIsValid(Object.keys(surfaces)) - } + editModalRef.current?.ensureIdIsValid(Object.keys(surfaces)) }, [surfaces]) const refreshUSB = useCallback(() => { @@ -90,12 +87,12 @@ export const SurfacesPage = memo(function SurfacesPage() { ) const configureSurface = useCallback((surface) => { - editModalRef.current.show(surface) + editModalRef.current?.show(surface) }, []) const forgetSurface = useCallback( (surfaceId) => { - confirmModalRef.current.show( + confirmRef.current?.show( 'Forget Surface', 'Are you sure you want to forget this surface? Any settings will be lost', 'Forget', @@ -120,8 +117,6 @@ export const SurfacesPage = memo(function SurfacesPage() { return (
- -

Surfaces

These are the surfaces currently connected to companion. If your streamdeck is missing from this list, you might @@ -153,7 +148,7 @@ export const SurfacesPage = memo(function SurfacesPage() {

 

- +
Connected
@@ -219,7 +214,14 @@ export const SurfacesPage = memo(function SurfacesPage() { ) }) -function AvailableSurfaceRow({ surface, updateName, configureSurface, deleteEmulator }) { +interface AvailableSurfaceRowProps { + surface: AvailableDeviceInfo + updateName: (surfaceId: string, name: string) => void + configureSurface: (surface: AvailableDeviceInfo) => void + deleteEmulator: (surfaceId: string) => void +} + +function AvailableSurfaceRow({ surface, updateName, configureSurface, deleteEmulator }: AvailableSurfaceRowProps) { const updateName2 = useCallback((val) => updateName(surface.id, val), [updateName, surface.id]) const configureSurface2 = useCallback(() => configureSurface(surface), [configureSurface, surface]) const deleteEmulator2 = useCallback(() => deleteEmulator(surface.id), [deleteEmulator, surface.id]) @@ -255,7 +257,13 @@ function AvailableSurfaceRow({ surface, updateName, configureSurface, deleteEmul ) } -function OfflineSuraceRow({ surface, updateName, forgetSurface }) { +interface OfflineSuraceRowProps { + surface: OfflineDeviceInfo + updateName: (surfaceId: string, name: string) => void + forgetSurface: (surfaceId: string) => void +} + +function OfflineSuraceRow({ surface, updateName, forgetSurface }: OfflineSuraceRowProps) { const updateName2 = useCallback((val) => updateName(surface.id, val), [updateName, surface.id]) const forgetSurface2 = useCallback(() => forgetSurface(surface.id), [forgetSurface, surface.id]) diff --git a/webui/src/index.jsx b/webui/src/index.tsx similarity index 99% rename from webui/src/index.jsx rename to webui/src/index.tsx index 9a9cab654b..d34d1bca30 100644 --- a/webui/src/index.jsx +++ b/webui/src/index.tsx @@ -46,7 +46,7 @@ import { ConnectionDebug } from './ConnectionDebug' // }, // }) -const socket = new io() +const socket = io() if (window.location.hash && window.location.hash.includes('debug_socket')) { socket.onAny(function (name, ...data) { console.log('received event', name, data) diff --git a/webui/src/util.tsx b/webui/src/util.tsx index 70c9337aa2..8b17cc35bc 100644 --- a/webui/src/util.tsx +++ b/webui/src/util.tsx @@ -266,10 +266,11 @@ export function applyPatchOrReplaceSubObject( oldDefinitions: Record, key: string, patch: JsonPatchOperation[], - defVal: T + defVal: T | null ) { if (oldDefinitions) { const oldEntry = oldDefinitions[key] ?? defVal + if (!oldEntry) return oldDefinitions const newDefinitions = { ...oldDefinitions } if (!patch) {