diff --git a/lib/Data/ImportExport.js b/lib/Data/ImportExport.js index 0bdd0c23b7..a58070566d 100644 --- a/lib/Data/ImportExport.js +++ b/lib/Data/ImportExport.js @@ -363,6 +363,7 @@ class DataImportExport extends CoreBase { if (!config || !isFalsey(config.surfaces)) { exp.surfaces = this.surfaces.exportAll(false) + exp.surfaceGroups = this.surfaces.exportAllGroups(false) } return exp @@ -790,9 +791,7 @@ class DataImportExport extends CoreBase { } if (!config || config.surfaces) { - for (const [id, surface] of Object.entries(data.surfaces || {})) { - this.surfaces.importSurface(id, surface) - } + this.surfaces.importSurfaces(data.surfaceGroups || {}, data.surfaces || {}) } if (!config || config.triggers) { diff --git a/lib/Data/Metrics.js b/lib/Data/Metrics.js index 81676c2060..44db4442a1 100644 --- a/lib/Data/Metrics.js +++ b/lib/Data/Metrics.js @@ -33,23 +33,26 @@ class DataMetrics extends CoreBase { #cycle() { this.logger.silly('cycle') - const devices = this.surfaces.getDevicesList().available - /** * @type {string[]} */ const relevantDevices = [] try { - Object.values(devices).forEach((device) => { - if (device.id !== undefined && !device.id.startsWith('emulator:')) { - // remove leading "satellite-" from satellite device serial numbers. - const serialNumber = device.id.replace('satellite-', '') - // normalize serialNumber by md5 hashing it, we don't want/need the specific serialNumber anyways. - const deviceHash = crypto.createHash('md5').update(serialNumber).digest('hex') - if (deviceHash && deviceHash.length === 32) relevantDevices.push(deviceHash) + const surfaceGroups = this.surfaces.getDevicesList() + for (const surfaceGroup of surfaceGroups) { + if (!surfaceGroup.surfaces) continue + + for (const surface of surfaceGroup.surfaces) { + if (surface.id && surface.isConnected && !surface.id.startsWith('emulator:')) { + // remove leading "satellite-" from satellite device serial numbers. + const serialNumber = surface.id.replace('satellite-', '') + // normalize serialnumber by md5 hashing it, we don't want/need the specific serialnumber anyways. + const deviceHash = crypto.createHash('md5').update(serialNumber).digest('hex') + if (deviceHash && deviceHash.length === 32) relevantDevices.push(deviceHash) + } } - }) + } } catch (e) { // don't care } diff --git a/lib/Data/Model/ExportModel.ts b/lib/Data/Model/ExportModel.ts index 0227358e5e..d53ddaf656 100644 --- a/lib/Data/Model/ExportModel.ts +++ b/lib/Data/Model/ExportModel.ts @@ -14,6 +14,7 @@ export interface ExportFullv4 extends ExportBase<'full'> { custom_variables?: CustomVariablesModel instances?: ExportInstancesv4 surfaces?: unknown + surfaceGroups?: unknown } export interface ExportPageModelv4 extends ExportBase<'page'> { diff --git a/lib/Internal/Surface.js b/lib/Internal/Surface.js index 9cc52930c3..85763933a0 100644 --- a/lib/Internal/Surface.js +++ b/lib/Internal/Surface.js @@ -18,17 +18,34 @@ import { combineRgb } from '@companion-module/base' import LogController from '../Log/Controller.js' -/** @type {import('./Types.js').InternalActionInputField} */ -const CHOICES_CONTROLLER = { - type: 'internal:surface_serial', - label: 'Surface / controller', - id: 'controller', - default: 'self', - includeSelf: true, -} +/** @type {import('./Types.js').InternalActionInputField[]} */ +const CHOICES_SURFACE_GROUP_WITH_VARIABLES = [ + { + type: 'checkbox', + label: 'Use variables for surface', + id: 'controller_from_variable', + default: false, + }, + { + type: 'internal:surface_serial', + label: 'Surface / group', + id: 'controller', + default: 'self', + includeSelf: true, + isVisible: (options) => !options.controller_from_variable, + }, + { + type: 'textinput', + label: 'Surface / group', + id: 'controller_variable', + default: 'self', + isVisible: (options) => !!options.controller_from_variable, + useVariables: true, + }, +] /** @type {import('./Types.js').InternalActionInputField[]} */ -const CHOICES_CONTROLLER_WITH_VARIABLES = [ +const CHOICES_SURFACE_ID_WITH_VARIABLES = [ { type: 'checkbox', label: 'Use variables for surface', @@ -36,12 +53,17 @@ const CHOICES_CONTROLLER_WITH_VARIABLES = [ default: false, }, { - ...CHOICES_CONTROLLER, + type: 'internal:surface_serial', + label: 'Surface / group', + id: 'controller', + default: 'self', + includeSelf: true, + useRawSurfaces: true, isVisible: (options) => !options.controller_from_variable, }, { type: 'textinput', - label: 'Surface / controller', + label: 'Surface / group', id: 'controller_variable', default: 'self', isVisible: (options) => !!options.controller_from_variable, @@ -215,9 +237,9 @@ export default class Surface { getActionDefinitions() { return { set_brightness: { - label: 'Surface: Set serialNumber to brightness', + label: 'Surface: Set to brightness', options: [ - ...CHOICES_CONTROLLER_WITH_VARIABLES, + ...CHOICES_SURFACE_ID_WITH_VARIABLES, { type: 'number', @@ -233,8 +255,8 @@ export default class Surface { }, set_page: { - label: 'Surface: Set serialNumber to page', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES, ...CHOICES_PAGE_WITH_VARIABLES], + label: 'Surface: Set to page', + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES, ...CHOICES_PAGE_WITH_VARIABLES], }, set_page_byindex: { label: 'Surface: Set by index to page', @@ -255,20 +277,20 @@ export default class Surface { inc_page: { label: 'Surface: Increment page number', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, dec_page: { label: 'Surface: Decrement page number', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, lockout_device: { label: 'Surface: Lockout specified surface immediately.', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, unlockout_device: { label: 'Surface: Unlock specified surface immediately.', - options: [...CHOICES_CONTROLLER_WITH_VARIABLES], + options: [...CHOICES_SURFACE_GROUP_WITH_VARIABLES], }, lockout_all: { @@ -346,7 +368,7 @@ export default class Surface { } setImmediate(() => { - this.#surfaceController.setDeviceLocked(theController, true, true) + this.#surfaceController.setSurfaceOrGroupLocked(theController, true, true) }) } return true @@ -355,7 +377,7 @@ export default class Surface { if (!theController) return true setImmediate(() => { - this.#surfaceController.setDeviceLocked(theController, false, true) + this.#surfaceController.setSurfaceOrGroupLocked(theController, false, true) }) return true @@ -467,7 +489,7 @@ export default class Surface { options: [ { type: 'internal:surface_serial', - label: 'Surface / controller', + label: 'Surface / group', id: 'controller', }, { diff --git a/lib/Internal/Types.ts b/lib/Internal/Types.ts index 1a2e239208..0023bb2e8d 100644 --- a/lib/Internal/Types.ts +++ b/lib/Internal/Types.ts @@ -40,6 +40,7 @@ export type InternalInputField = ( type: 'internal:surface_serial' includeSelf: boolean default: string + useRawSurfaces?: boolean } | { type: 'internal:page' diff --git a/lib/Surface/Controller.js b/lib/Surface/Controller.js index c97230c503..1dc7393621 100644 --- a/lib/Surface/Controller.js +++ b/lib/Surface/Controller.js @@ -1,3 +1,4 @@ +// @ts-check /* * This file is part of the Companion project * Copyright (c) 2018 Bitfocus AS @@ -26,21 +27,19 @@ import { usb } from 'usb' // @ts-ignore import shuttleControlUSB from 'shuttle-control-usb' import { listLoupedecks, LoupedeckModelId } from '@loupedeck/node' - -import SurfaceHandler from './Handler.js' +import SurfaceHandler, { getSurfaceName } from './Handler.js' import SurfaceIPElgatoEmulator, { EmulatorRoom } from './IP/ElgatoEmulator.js' import SurfaceIPElgatoPlugin from './IP/ElgatoPlugin.js' import SurfaceIPSatellite from './IP/Satellite.js' - import ElgatoStreamDeckDriver from './USB/ElgatoStreamDeck.js' import InfinittonDriver from './USB/Infinitton.js' import XKeysDriver from './USB/XKeys.js' import LoupedeckLiveDriver from './USB/LoupedeckLive.js' import SurfaceUSBLoupedeckCt from './USB/LoupedeckCt.js' import ContourShuttleDriver from './USB/ContourShuttle.js' - -import CoreBase from '../Core/Base.js' import SurfaceIPVideohubPanel from './IP/VideohubPanel.js' +import CoreBase from '../Core/Base.js' +import { SurfaceGroup } from './Group.js' // Force it to load the hidraw driver just in case HID.setDriverType('hidraw') @@ -51,7 +50,7 @@ const SurfacesRoom = 'surfaces' class SurfaceController extends CoreBase { /** * The last sent json object - * @type {ClientDevicesList | null} + * @type {ClientDevicesListItem[] | null} * @access private */ #lastSentJson = null @@ -63,6 +62,13 @@ class SurfaceController extends CoreBase { */ #surfaceHandlers = new Map() + /** + * The surface groups wrapping the surface handlers + * @type {Map} + * @access private + */ + #surfaceGroups = new Map() + /** * Last time each surface was interacted with, for lockouts * The values get cleared when a surface is locked, and remains while unlocked @@ -107,8 +113,15 @@ class SurfaceController extends CoreBase { this.#surfacesAllLocked = !!this.userconfig.getKey('link_lockouts') - // Setup defined emulators - { + setImmediate(() => { + // Setup groups + const groupsConfigs = this.db.getKey('surface-groups', {}) + for (const groupId of Object.keys(groupsConfigs)) { + const newGroup = new SurfaceGroup(this.registry, groupId, null, this.isPinLockEnabled()) + this.#surfaceGroups.set(groupId, newGroup) + } + + // Setup defined emulators const instances = this.db.getKey('deviceconfig', {}) || {} for (const id of Object.keys(instances)) { // If the id starts with 'emulator:' then re-add it @@ -116,14 +129,12 @@ class SurfaceController extends CoreBase { this.addEmulator(id.substring(9)) } } - } - // Initial search for USB devices - this.#refreshDevices().catch(() => { - this.logger.warn('Initial USB scan failed') - }) + // Initial search for USB devices + this.#refreshDevices().catch(() => { + this.logger.warn('Initial USB scan failed') + }) - setImmediate(() => { this.updateDevicesList() this.#startStopLockoutTimer() @@ -189,10 +200,10 @@ class SurfaceController extends CoreBase { if (this.#surfacesAllLocked) return let doLockout = false - for (const device of this.#surfaceHandlers.values()) { - if (this.#isSurfaceTimedOut(device.surfaceId, timeout)) { + for (const surfaceGroup of this.#surfaceGroups.values()) { + if (this.#isSurfaceGroupTimedOut(surfaceGroup.groupId, timeout)) { doLockout = true - this.#surfacesLastInteraction.delete(device.surfaceId) + this.#surfacesLastInteraction.delete(surfaceGroup.groupId) } } @@ -200,10 +211,10 @@ class SurfaceController extends CoreBase { this.setAllLocked(true) } } else { - for (const device of this.#surfaceHandlers.values()) { - if (this.#isSurfaceTimedOut(device.surfaceId, timeout)) { - this.#surfacesLastInteraction.delete(device.surfaceId) - this.setDeviceLocked(device.surfaceId, true) + for (const surfaceGroup of this.#surfaceGroups.values()) { + if (this.#isSurfaceGroupTimedOut(surfaceGroup.groupId, timeout)) { + this.#surfacesLastInteraction.delete(surfaceGroup.groupId) + this.setSurfaceOrGroupLocked(surfaceGroup.groupId, true) } } } @@ -213,14 +224,14 @@ class SurfaceController extends CoreBase { /** * Check if a surface should be timed out - * @param {string} surfaceId + * @param {string} groupId * @param {number} timeout * @returns {boolean} */ - #isSurfaceTimedOut(surfaceId, timeout) { + #isSurfaceGroupTimedOut(groupId, timeout) { if (!this.isPinLockEnabled()) return false - const lastInteraction = this.#surfacesLastInteraction.get(surfaceId) || 0 + const lastInteraction = this.#surfacesLastInteraction.get(groupId) || 0 return lastInteraction + timeout < Date.now() } @@ -254,20 +265,6 @@ class SurfaceController extends CoreBase { * @returns {void} */ #createSurfaceHandler(surfaceId, integrationType, panel) { - const panelSurfaceId = panel.info.deviceId - - let isLocked = false - if (this.isPinLockEnabled()) { - const timeout = Number(this.userconfig.getKey('pin_timeout')) * 1000 - if (this.userconfig.getKey('link_lockouts')) { - isLocked = this.#surfacesAllLocked - } else if (timeout && !isNaN(timeout)) { - isLocked = this.#isSurfaceTimedOut(panelSurfaceId, timeout) - } else { - isLocked = !this.#surfacesLastInteraction.has(panelSurfaceId) - } - } - const surfaceConfig = this.getDeviceConfig(panel.info.deviceId) if (!surfaceConfig) { this.logger.silly(`Creating config for newly discovered device ${panel.info.deviceId}`) @@ -275,26 +272,29 @@ class SurfaceController extends CoreBase { this.logger.silly(`Reusing config for device ${panel.info.deviceId}`) } - const handler = new SurfaceHandler(this.registry, integrationType, panel, isLocked, surfaceConfig) + const handler = new SurfaceHandler(this.registry, integrationType, panel, surfaceConfig) handler.on('interaction', () => { - this.#surfacesLastInteraction.set(panelSurfaceId, Date.now()) + const groupId = handler.getGroupId() || handler.surfaceId + this.#surfacesLastInteraction.set(groupId, Date.now()) }) handler.on('configUpdated', (newConfig) => { this.setDeviceConfig(handler.surfaceId, newConfig) }) handler.on('unlocked', () => { - this.#surfacesLastInteraction.set(panelSurfaceId, Date.now()) + const groupId = handler.getGroupId() || handler.surfaceId + this.#surfacesLastInteraction.set(groupId, Date.now()) if (this.userconfig.getKey('link_lockouts')) { this.setAllLocked(false) + } else { + this.setSurfaceOrGroupLocked(groupId, false) } }) this.#surfaceHandlers.set(surfaceId, handler) - if (!isLocked) { - // If not already locked, keep it unlocked for the full timeout - this.#surfacesLastInteraction.set(panelSurfaceId, Date.now()) - } + + // Update the group to have the new surface + this.#attachSurfaceToGroup(handler) } /** @@ -312,15 +312,15 @@ class SurfaceController extends CoreBase { (id) => { const fullId = EmulatorRoom(id) - const instance = this.#surfaceHandlers.get(fullId) - if (!instance || !(instance.panel instanceof SurfaceIPElgatoEmulator)) { + const surface = this.#surfaceHandlers.get(fullId) + if (!surface || !(surface.panel instanceof SurfaceIPElgatoEmulator)) { throw new Error(`Emulator "${id}" does not exist!`) } // Subscribe to the bitmaps client.join(fullId) - return instance.panel.setupClient(client) + return surface.panel.setupClient(client) } ) @@ -335,12 +335,12 @@ class SurfaceController extends CoreBase { (id, x, y) => { const fullId = EmulatorRoom(id) - const instance = this.#surfaceHandlers.get(fullId) - if (!instance) { + const surface = this.#surfaceHandlers.get(fullId) + if (!surface) { throw new Error(`Emulator "${id}" does not exist!`) } - instance.panel.emit('click', x, y, true) + surface.panel.emit('click', x, y, true) } ) @@ -355,12 +355,12 @@ class SurfaceController extends CoreBase { (id, x, y) => { const fullId = EmulatorRoom(id) - const instance = this.#surfaceHandlers.get(fullId) - if (!instance) { + const surface = this.#surfaceHandlers.get(fullId) + if (!surface) { throw new Error(`Emulator "${id}" does not exist!`) } - instance.panel.emit('click', x, y, false) + surface.panel.emit('click', x, y, false) } ) @@ -402,12 +402,33 @@ class SurfaceController extends CoreBase { * @returns {void} */ (id, name) => { - for (let instance of this.#surfaceHandlers.values()) { - if (instance.surfaceId == id) { - instance.setPanelName(name) + // Find a matching group + const group = this.#surfaceGroups.get(id) + if (group && !group.isAutoGroup) { + group.setName(name) + this.updateDevicesList() + return + } + + // Find a connected surface + for (let surface of this.#surfaceHandlers.values()) { + if (surface && surface.surfaceId == id) { + surface.setPanelName(name) this.updateDevicesList() + return } } + + // Find a disconnected surface + const configs = this.db.getKey('deviceconfig', {}) + if (configs[id]) { + configs[id].name = name + this.db.setKey('deviceconfig', configs) + this.updateDevicesList() + return + } + + throw new Error('not found') } ) @@ -418,9 +439,9 @@ class SurfaceController extends CoreBase { * @returns {[config: unknown, info: unknown] | null} */ (id) => { - for (let instance of this.#surfaceHandlers.values()) { - if (instance.surfaceId == id) { - return instance.getPanelConfig() + for (const surface of this.#surfaceHandlers.values()) { + if (surface && surface.surfaceId == id) { + return surface.getPanelConfig() } } return null @@ -435,10 +456,10 @@ class SurfaceController extends CoreBase { * @returns {string | undefined} */ (id, config) => { - for (let instance of this.#surfaceHandlers.values()) { - if (instance.surfaceId == id) { - instance.setPanelConfig(config) - return instance.getPanelConfig() + for (let surface of this.#surfaceHandlers.values()) { + if (surface && surface.surfaceId == id) { + surface.setPanelConfig(config) + return surface.getPanelConfig() } } return 'device not found' @@ -483,8 +504,8 @@ class SurfaceController extends CoreBase { * @returns {string | true} */ (id) => { - for (let instance of this.#surfaceHandlers.values()) { - if (instance.surfaceId == id) { + for (let surface of this.#surfaceHandlers.values()) { + if (surface.surfaceId == id) { return 'device is active' } } @@ -498,6 +519,172 @@ class SurfaceController extends CoreBase { return 'device not found' } ) + + client.onPromise( + 'surfaces:group-add', + /** + * @param {string} name + * @returns {string} + */ + (name) => { + if (!name || typeof name !== 'string') throw new Error('Invalid name') + + // TODO - should this do friendlier ids? + const groupId = `group:${nanoid()}` + + const newGroup = new SurfaceGroup(this.registry, groupId, null, this.isPinLockEnabled()) + newGroup.setName(name) + this.#surfaceGroups.set(groupId, newGroup) + + this.updateDevicesList() + + return groupId + } + ) + + client.onPromise( + 'surfaces:group-remove', + /** + * @param {string} groupId + * @returns {string} + */ + (groupId) => { + const group = this.#surfaceGroups.get(groupId) + if (!group || group.isAutoGroup) throw new Error(`Group does not exist`) + + // Clear the group for all surfaces + for (const surfaceHandler of group.surfaceHandlers) { + surfaceHandler.setGroupId(null) + this.#attachSurfaceToGroup(surfaceHandler) + } + + group.dispose() + group.forgetConfig() + this.#surfaceGroups.delete(groupId) + + this.updateDevicesList() + + return groupId + } + ) + + client.onPromise( + 'surfaces:add-to-group', + /** + * @param {string} groupId + * @param {string} surfaceId + * @returns {void} + */ + (groupId, surfaceId) => { + const group = groupId ? this.#surfaceGroups.get(groupId) : null + if (groupId && !group) throw new Error(`Group does not exist: ${groupId}`) + + const surfaceHandler = Array.from(this.#surfaceHandlers.values()).find( + (surface) => surface.surfaceId === surfaceId + ) + if (!surfaceHandler) throw new Error(`Surface does not exist or is not connected: ${surfaceId}`) + // TODO - we can handle this if it is still in the config + + this.#detachSurfaceFromGroup(surfaceHandler) + + surfaceHandler.setGroupId(groupId) + + this.#attachSurfaceToGroup(surfaceHandler) + + this.updateDevicesList() + } + ) + + client.onPromise( + 'surfaces:group-config-get', + /** + * @param {string} groupId + * @returns {any} + */ + (groupId) => { + const group = this.#surfaceGroups.get(groupId) + if (!group) throw new Error(`Group does not exist: ${groupId}`) + + return group.groupConfig + } + ) + + client.onPromise( + 'surfaces:group-config-set', + /** + * @param {string} groupId + * @param {string} key + * @param {any} value + * @returns {any} + */ + (groupId, key, value) => { + const group = this.#surfaceGroups.get(groupId) + if (!group) throw new Error(`Group does not exist: ${groupId}`) + + const err = group.setGroupConfigValue(key, value) + if (err) return err + + return group.groupConfig + } + ) + } + + /** + * Attach a `SurfaceHandler` to its `SurfaceGroup` + * @param {SurfaceHandler} surfaceHandler + * @returns {void} + */ + #attachSurfaceToGroup(surfaceHandler) { + const rawSurfaceGroupId = surfaceHandler.getGroupId() + const surfaceGroupId = rawSurfaceGroupId || surfaceHandler.surfaceId + const existingGroup = this.#surfaceGroups.get(surfaceGroupId) + if (existingGroup) { + existingGroup.attachSurface(surfaceHandler) + } else { + let isLocked = false + if (this.isPinLockEnabled()) { + const timeout = Number(this.userconfig.getKey('pin_timeout')) * 1000 + if (this.userconfig.getKey('link_lockouts')) { + isLocked = this.#surfacesAllLocked + } else if (timeout && !isNaN(timeout)) { + isLocked = this.#isSurfaceGroupTimedOut(surfaceGroupId, timeout) + } else { + isLocked = !this.#surfacesLastInteraction.has(surfaceGroupId) + } + } + + if (!isLocked) { + // If not already locked, keep it unlocked for the full timeout + this.#surfacesLastInteraction.set(surfaceGroupId, Date.now()) + } + + const newGroup = new SurfaceGroup( + this.registry, + surfaceGroupId, + !rawSurfaceGroupId ? surfaceHandler : null, + isLocked + ) + this.#surfaceGroups.set(surfaceGroupId, newGroup) + } + } + + /** + * Detach a `SurfaceHandler` from its `SurfaceGroup` + * @param {SurfaceHandler} surfaceHandler + * @returns {void} + */ + #detachSurfaceFromGroup(surfaceHandler) { + const existingGroupId = surfaceHandler.getGroupId() || surfaceHandler.surfaceId + const existingGroup = existingGroupId ? this.#surfaceGroups.get(existingGroupId) : null + if (!existingGroup) return + + existingGroup.detachSurface(surfaceHandler) + + // Cleanup an auto surface group + if (existingGroup.isAutoGroup) { + existingGroup.dispose() + this.#surfaceGroups.delete(existingGroupId) + } } /** @@ -532,100 +719,128 @@ class SurfaceController extends CoreBase { /** * - * @returns {ClientDevicesList} + * @returns {ClientDevicesListItem[]} */ getDevicesList() { - /** @type {AvailableDeviceInfo[]} */ - const availableDevicesInfo = [] - /** @type {OfflineDeviceInfo[]} */ - const offlineDevicesInfo = [] - - const config = this.db.getKey('deviceconfig', {}) - - const instanceMap = new Map() - for (const instance of this.#surfaceHandlers.values()) { - instanceMap.set(instance.surfaceId, instance) - } - - const surfaceIds = Array.from(new Set([...Object.keys(config), ...instanceMap.keys()])) - for (const id of surfaceIds) { - const instance = instanceMap.get(id) - const conf = config[id] - - /** @type {BaseDeviceInfo} */ - const commonInfo = { + /** + * + * @param {string} id + * @param {Record} config + * @param {SurfaceHandler | null} surfaceHandler + * @returns {ClientSurfaceItem} + */ + function translateSurfaceConfig(id, config, surfaceHandler) { + /** @type {ClientSurfaceItem} */ + const surfaceInfo = { id: id, - type: conf?.type || 'Unknown', - integrationType: conf?.integrationType || '', - name: conf?.name || '', - index: 0, // Fixed later + type: config?.type || 'Unknown', + integrationType: config?.integrationType || '', + name: config?.name || '', + // location: 'Offline', + configFields: [], + isConnected: !!surfaceHandler, + displayName: getSurfaceName(config, id), + location: null, } - if (instance) { - let location = instance.panel.info.location + if (surfaceHandler) { + let location = surfaceHandler.panel.info.location if (location && location.startsWith('::ffff:')) location = location.substring(7) - availableDevicesInfo.push({ - ...commonInfo, - location: location || 'Local', - configFields: instance.panel.info.configFields || [], - }) - } else { - offlineDevicesInfo.push({ - ...commonInfo, - }) + surfaceInfo.location = location || null + surfaceInfo.configFields = surfaceHandler.panel.info.configFields || [] } + + return surfaceInfo } - /** - * @param {BaseDeviceInfo} a - * @param {BaseDeviceInfo} b - * @returns -1 | 0 | 1 - */ - function sortDevices(a, b) { - // emulator must be first - if (a.id === 'emulator') { - return -1 - } else if (b.id === 'emulator') { - return 1 + /** @type {ClientDevicesListItem[]} */ + const result = [] + + const surfaceGroups = Array.from(this.#surfaceGroups.values()) + surfaceGroups.sort( + /** + * @param {SurfaceGroup} a + * @param {SurfaceGroup} b + * @returns -1 | 0 | 1 + */ + (a, b) => { + // manual groups must be first + if (!a.isAutoGroup && b.isAutoGroup) { + return -1 + } else if (!b.isAutoGroup && a.isAutoGroup) { + return 1 + } + + const aIsEmulator = a.groupId.startsWith('emulator:') + const bIsEmulator = b.groupId.startsWith('emulator:') + + // emulator must be first + if (aIsEmulator && !bIsEmulator) { + return -1 + } else if (bIsEmulator && !aIsEmulator) { + return 1 + } + + // then by id + return a.groupId.localeCompare(b.groupId) } + ) - // sort by type first - const type = a.type.localeCompare(b.type) - if (type !== 0) { - return type + const groupsMap = new Map() + surfaceGroups.forEach((group, index) => { + /** @type {ClientDevicesListItem} */ + const groupResult = { + id: group.groupId, + index: index, + displayName: group.displayName, + isAutoGroup: group.isAutoGroup, + surfaces: group.surfaceHandlers.map((handler) => + translateSurfaceConfig(handler.surfaceId, handler.getFullConfig(), handler) + ), } + result.push(groupResult) + groupsMap.set(group.groupId, groupResult) + }) - // then by serial - return a.id.localeCompare(b.id) + const mappedSurfaceId = new Set() + for (const group of result) { + for (const surface of group.surfaces) { + mappedSurfaceId.add(surface.id) + } } - availableDevicesInfo.sort(sortDevices) - offlineDevicesInfo.sort(sortDevices) - /** @type {ClientDevicesList} */ - const res = { - available: {}, - offline: {}, - } - availableDevicesInfo.forEach((info, index) => { - res.available[info.id] = { - ...info, - index, - } - }) - offlineDevicesInfo.forEach((info, index) => { - res.offline[info.id] = { - ...info, - index, + // Add any automatic groups for offline surfaces + const config = this.db.getKey('deviceconfig', {}) + for (const [surfaceId, surface] of Object.entries(config)) { + if (mappedSurfaceId.has(surfaceId)) continue + + const groupId = surface.groupId || surfaceId + + const existingGroup = groupsMap.get(groupId) + if (existingGroup) { + existingGroup.surfaces.push(translateSurfaceConfig(surfaceId, surface, null)) + } else { + /** @type {ClientDevicesListItem} */ + const groupResult = { + id: groupId, + index: result.length, + displayName: `${surface.name || surface.type} (${surfaceId}) - Offline`, + isAutoGroup: true, + surfaces: [translateSurfaceConfig(surfaceId, surface, null)], + } + result.push(groupResult) + groupsMap.set(groupId, groupResult) } - }) + } - return res + return result } reset() { // Each active handler will re-add itself when doing the save as part of its own reset this.db.setKey('deviceconfig', {}) + this.db.setKey('surface-groups', {}) this.#resetAllDevices() this.updateDevicesList() } @@ -744,7 +959,7 @@ class SurfaceController extends CoreBase { ? listLoupedecks().then((deviceInfos) => Promise.allSettled( deviceInfos.map(async (deviceInfo) => { - this.logger.log('found loupedeck', deviceInfo) + this.logger.info('found loupedeck', deviceInfo) if (!this.#surfaceHandlers.has(deviceInfo.path)) { if ( deviceInfo.model === LoupedeckModelId.LoupedeckLive || @@ -898,20 +1113,53 @@ class SurfaceController extends CoreBase { return clone ? cloneDeep(obj) : obj } + exportAllGroups(clone = true) { + const obj = this.db.getKey('surface-groups', {}) || {} + return clone ? cloneDeep(obj) : obj + } + /** * Import a surface configuration - * @param {string} surfaceId - * @param {*} config + * @param {Record} surfaceGroups + * @param {Record} surfaces * @returns {void} */ - importSurface(surfaceId, config) { - const device = this.#getSurfaceHandlerForId(surfaceId, true) - if (device) { - // Device is currently loaded - device.setPanelConfig(config) - } else { - // Device is not loaded - this.setDeviceConfig(surfaceId, config) + importSurfaces(surfaceGroups, surfaces) { + for (const [id, surfaceGroup] of Object.entries(surfaceGroups)) { + let group = this.#getGroupForId(id, true) + if (!group) { + // Group does not exist + group = new SurfaceGroup(this.registry, id, null, this.isPinLockEnabled()) + this.#surfaceGroups.set(id, group) + } + + // Sync config + group.setName(surfaceGroup.name ?? '') + for (const [key, value] of Object.entries(surfaceGroup)) { + if (key === 'name') continue + group.setGroupConfigValue(key, value) + } + } + + for (const [surfaceId, surfaceConfig] of Object.entries(surfaces)) { + const surface = this.#getSurfaceHandlerForId(surfaceId, true) + if (surface) { + // Device is currently loaded + surface.setPanelConfig(surfaceConfig.config) + surface.saveGroupConfig(surfaceConfig.groupConfig) + surface.setPanelName(surfaceConfig.name) + + // Update the groupId + const newGroupId = surfaceConfig.groupId ?? null + if (surface.getGroupId() !== newGroupId && this.#getGroupForId(newGroupId)) { + this.#detachSurfaceFromGroup(surface) + surface.setGroupId(newGroupId) + this.#attachSurfaceToGroup(surface) + } + } else { + // Device is not loaded + this.setDeviceConfig(surfaceId, surfaceConfig) + } } this.updateDevicesList() @@ -924,17 +1172,20 @@ class SurfaceController extends CoreBase { * @returns {void} */ removeDevice(devicePath, purge) { - const existingSurface = this.#surfaceHandlers.get(devicePath) - if (existingSurface) { + const surfaceHandler = this.#surfaceHandlers.get(devicePath) + if (surfaceHandler) { this.logger.silly('remove device ' + devicePath) + // Detach surface from any group + this.#detachSurfaceFromGroup(surfaceHandler) + try { - existingSurface.unload(purge) + surfaceHandler.unload(purge) } catch (e) { // Ignore for now } - existingSurface.removeAllListeners() + surfaceHandler.removeAllListeners() this.#surfaceHandlers.delete(devicePath) } @@ -943,9 +1194,10 @@ class SurfaceController extends CoreBase { } quit() { - for (const device of this.#surfaceHandlers.values()) { + for (const surface of this.#surfaceHandlers.values()) { + if (!surface) continue try { - device.unload() + surface.unload() } catch (e) { // Ignore for now } @@ -961,7 +1213,7 @@ class SurfaceController extends CoreBase { * @returns {string | undefined} */ getDeviceIdFromIndex(index) { - for (const dev of Object.values(this.getDevicesList().available)) { + for (const dev of this.getDevicesList()) { if (dev.index === index) { return dev.id } @@ -971,63 +1223,75 @@ class SurfaceController extends CoreBase { /** * Perform page-up for a surface - * @param {string} surfaceId - * @param {boolean} looseIdMatching + * @param {string} surfaceOrGroupId + * @param {boolean=} looseIdMatching * @returns {void} */ - devicePageUp(surfaceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - device.doPageUp() + devicePageUp(surfaceOrGroupId, looseIdMatching) { + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + surfaceGroup.doPageUp() } } /** * Perform page-down for a surface - * @param {string} surfaceId + * @param {string} surfaceOrGroupId * @param {boolean=} looseIdMatching * @returns {void} */ - devicePageDown(surfaceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - device.doPageDown() + devicePageDown(surfaceOrGroupId, looseIdMatching) { + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + surfaceGroup.doPageDown() } } /** * Set the page number for a surface - * @param {string} surfaceId + * @param {string} surfaceOrGroupId * @param {number} page * @param {boolean=} looseIdMatching * @param {boolean=} defer Defer the drawing to the next tick * @returns {void} */ - devicePageSet(surfaceId, page, looseIdMatching = false, defer = false) { - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - device.setCurrentPage(page, defer) + devicePageSet(surfaceOrGroupId, page, looseIdMatching, defer = false) { + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + surfaceGroup.setCurrentPage(page, defer) } } /** * Get the page number of a surface - * @param {string} surfaceId + * @param {string} surfaceOrGroupId * @param {boolean=} looseIdMatching * @returns {number | undefined} */ - devicePageGet(surfaceId, looseIdMatching = false) { - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - return device.getCurrentPage() + devicePageGet(surfaceOrGroupId, looseIdMatching = false) { + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + return surfaceGroup.getCurrentPage() } else { return undefined } } #resetAllDevices() { - for (const device of this.#surfaceHandlers.values()) { + // Destroy any groups and detach their contents + for (const surfaceGroup of this.#surfaceGroups.values()) { + for (const surface of surfaceGroup.surfaceHandlers) { + surfaceGroup.detachSurface(surface) + } + surfaceGroup.dispose() + } + this.#surfaceGroups.clear() + + // Re-attach in auto-groups + for (const surface of this.#surfaceHandlers.values()) { try { - device.resetConfig() + surface.resetConfig() + + this.#attachSurfaceToGroup(surface) } catch (e) { - this.logger.warn('Could not reset a device') + this.logger.warn('Could not reattach a surface') } } } @@ -1055,21 +1319,21 @@ class SurfaceController extends CoreBase { this.#surfacesAllLocked = !!locked - for (const device of this.#surfaceHandlers.values()) { - this.#surfacesLastInteraction.set(device.surfaceId, Date.now()) + for (const surfaceGroup of this.#surfaceGroups.values()) { + this.#surfacesLastInteraction.set(surfaceGroup.groupId, Date.now()) - device.setLocked(!!locked) + surfaceGroup.setLocked(!!locked) } } /** * Set all surfaces as locked - * @param {string} surfaceId + * @param {string} surfaceOrGroupId * @param {boolean} locked * @param {boolean} looseIdMatching * @returns {void} */ - setDeviceLocked(surfaceId, locked, looseIdMatching = false) { + setSurfaceOrGroupLocked(surfaceOrGroupId, locked, looseIdMatching = false) { if (!this.isPinLockEnabled()) return if (this.userconfig.getKey('link_lockouts')) { @@ -1079,14 +1343,14 @@ class SurfaceController extends CoreBase { // Track the lock/unlock state, even if the device isn't online if (locked) { - this.#surfacesLastInteraction.delete(surfaceId) + this.#surfacesLastInteraction.delete(surfaceOrGroupId) } else { - this.#surfacesLastInteraction.set(surfaceId, Date.now()) + this.#surfacesLastInteraction.set(surfaceOrGroupId, Date.now()) } - const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - device.setLocked(!!locked) + const surfaceGroup = this.#getGroupForId(surfaceOrGroupId, looseIdMatching) + if (surfaceGroup) { + surfaceGroup.setLocked(!!locked) } } } @@ -1105,6 +1369,25 @@ class SurfaceController extends CoreBase { } } + /** + * Get the `SurfaceGroup` for a surfaceId or groupId + * @param {string} surfaceOrGroupId + * @param {boolean} looseIdMatching + * @returns {SurfaceGroup | undefined} + */ + #getGroupForId(surfaceOrGroupId, looseIdMatching = false) { + const matchingGroup = this.#surfaceGroups.get(surfaceOrGroupId) + if (matchingGroup) return matchingGroup + + const surface = this.#getSurfaceHandlerForId(surfaceOrGroupId, looseIdMatching) + if (surface) { + const groupId = surface.getGroupId() || surface.surfaceId + return this.#surfaceGroups.get(groupId) + } + + return undefined + } + /** * Get the `SurfaceHandler` for a surfaceId * @param {string} surfaceId @@ -1114,28 +1397,28 @@ class SurfaceController extends CoreBase { #getSurfaceHandlerForId(surfaceId, looseIdMatching) { if (surfaceId === 'emulator') surfaceId = 'emulator:emulator' - const instances = Array.from(this.#surfaceHandlers.values()) + const surfaces = Array.from(this.#surfaceHandlers.values()) // try and find exact match - let device = instances.find((d) => d.surfaceId === surfaceId) - if (device) return device + let surface = surfaces.find((d) => d.surfaceId === surfaceId) + if (surface) return surface // only try more variations if the id isnt new format if (!looseIdMatching || surfaceId.includes(':')) return undefined // try the most likely streamdeck prefix let surfaceId2 = `streamdeck:${surfaceId}` - device = instances.find((d) => d.surfaceId === surfaceId2) - if (device) return device + surface = surfaces.find((d) => d.surfaceId === surfaceId2) + if (surface) return surface // it is unlikely, but it could be a loupedeck surfaceId2 = `loupedeck:${surfaceId}` - device = instances.find((d) => d.surfaceId === surfaceId2) - if (device) return device + surface = surfaces.find((d) => d.surfaceId === surfaceId2) + if (surface) return surface // or maybe a satellite? surfaceId2 = `satellite-${surfaceId}` - return instances.find((d) => d.surfaceId === surfaceId2) + return surfaces.find((d) => d.surfaceId === surfaceId2) } } @@ -1158,9 +1441,23 @@ export default SurfaceController * } & BaseDeviceInfo} AvailableDeviceInfo * * @typedef {{ - * available: Record - * offline: Record - * }} ClientDevicesList + * id: string + * type: string + * integrationType: string + * name: string + * configFields: string[] + * isConnected: boolean + * displayName: string + * location: string | null + * }} ClientSurfaceItem + * + * @typedef {{ + * id: string + * index: number + * displayName: string + * isAutoGroup: boolean + * surfaces: ClientSurfaceItem[] + * }} ClientDevicesListItem */ /** diff --git a/lib/Surface/Group.js b/lib/Surface/Group.js new file mode 100644 index 0000000000..9b1d4adc32 --- /dev/null +++ b/lib/Surface/Group.js @@ -0,0 +1,366 @@ +// @ts-check +/* + * This file is part of the Companion project + * Copyright (c) 2018 Bitfocus AS + * Authors: William Viker , Håkon Nessjøen + * + * This program is free software. + * You should have received a copy of the MIT licence as well as the Bitfocus + * Individual Contributor License Agreement for companion along with + * this program. + * + * You can be released from the requirements of the license by purchasing + * a commercial license. Buying such a license is mandatory as soon as you + * develop commercial activities involving the Companion software without + * disclosing the source code of your own applications. + * + */ + +import { cloneDeep } from 'lodash-es' +import CoreBase from '../Core/Base.js' + +/** + * @typedef {import('./Handler.js').default} SurfaceHandler + * + * @typedef {{ + * name: string + * last_page: number + * startup_page: number + * use_last_page: boolean + * }} SurfaceGroupConfig + */ + +export class SurfaceGroup extends CoreBase { + /** + * The defaults config for a group + * @type {SurfaceGroupConfig} + * @access public + * @static + */ + static DefaultOptions = { + name: 'Unnamed group', + last_page: 1, + startup_page: 1, + use_last_page: true, + } + + /** + * Id of this group + * @type {string} + * @access public + */ + groupId + + /** + * The current page of this surface group + * @type {number} + * @access private + */ + #currentPage = 1 + + /** + * The surfaces belonging to this group + * @type {SurfaceHandler[]} + * @access private + */ + surfaceHandlers = [] + + /** + * Whether this is an auto-group to wrap a single surface handler + * @type {boolean} + * @access private + */ + #isAutoGroup = false + + /** + * Whether surfaces in this group should be locked + * @type {boolean} + * @access private + */ + #isLocked = false + + /** + * Configuration of this surface group + * @type {SurfaceGroupConfig} + * @access public + */ + groupConfig + + /** + * + * @param {import('../Registry.js').default} registry + * @param {string} groupId + * @param {SurfaceHandler | null} soleHandler + * @param {boolean} isLocked + */ + constructor(registry, groupId, soleHandler, isLocked) { + super(registry, `group(${groupId})`, `Surface/Group/${groupId}`) + + this.groupId = groupId + this.#isLocked = isLocked + + // Load the appropriate config + if (soleHandler) { + this.groupConfig = soleHandler.getGroupConfig() ?? {} + if (!this.groupConfig.name) this.groupConfig.name = 'Auto group' + + this.#isAutoGroup = true + } else { + this.groupConfig = this.db.getKey('surface-groups', {})[this.groupId] || {} + } + // Apply missing defaults + this.groupConfig = { + ...cloneDeep(SurfaceGroup.DefaultOptions), + ...this.groupConfig, + } + + // Determine the correct page to use + if (this.groupConfig.use_last_page) { + this.#currentPage = this.groupConfig.last_page ?? 1 + } else { + this.#currentPage = this.groupConfig.last_page = this.groupConfig.startup_page ?? 1 + } + + // Now attach and setup the surface + if (soleHandler) this.attachSurface(soleHandler) + + this.#saveConfig() + } + + /** + * Stop anything processing this group, it is being marked as inactive + */ + dispose() { + // Nothing to do (yet) + } + + /** + * Delete this group from the config + */ + forgetConfig() { + const groupsConfig = this.db.getKey('surface-groups', {}) + delete groupsConfig[this.groupId] + this.db.setKey('surface-groups', groupsConfig) + } + + /** + * Check if this SurfaceGroup is an automatically generated group for a standalone surface + */ + get isAutoGroup() { + return this.#isAutoGroup + } + + /** + * Get the displayname of this surface group + */ + get displayName() { + const firstHandler = this.surfaceHandlers[0] + if (this.#isAutoGroup && firstHandler) { + return firstHandler.displayName + } else { + return this.groupConfig.name + } + } + + /** + * Add a surface to be run by this group + * @param {SurfaceHandler} surfaceHandler + * @returns {void} + */ + attachSurface(surfaceHandler) { + if (this.#isAutoGroup && this.surfaceHandlers.length) + throw new Error(`Cannot add surfaces to group: "${this.groupId}"`) + + this.surfaceHandlers.push(surfaceHandler) + + surfaceHandler.setLocked(this.#isLocked, true) + surfaceHandler.storeNewDevicePage(this.#currentPage, true) + } + + /** + * Detach a surface from this group + * @param {SurfaceHandler} surfaceHandler + * @returns {void} + */ + detachSurface(surfaceHandler) { + const surfaceId = surfaceHandler.surfaceId + this.surfaceHandlers = this.surfaceHandlers.filter((handler) => handler.surfaceId !== surfaceId) + } + + /** + * Perform page-down for this surface group + * @returns {void} + */ + doPageDown() { + if (this.userconfig.getKey('page_direction_flipped') === true) { + this.#increasePage() + } else { + this.#decreasePage() + } + } + + /** + * Set the current page of this surface group + * @param {number} newPage + * @param {boolean} defer + * @returns {void} + */ + setCurrentPage(newPage, defer = false) { + if (newPage == 100) { + newPage = 1 + } + if (newPage == 0) { + newPage = 99 + } + this.#storeNewPage(newPage, defer) + } + + /** + * Get the current page of this surface group + * @returns {number} + */ + getCurrentPage() { + return this.#currentPage + } + + /** + * Perform page-up for this surface group + * @returns {void} + */ + doPageUp() { + if (this.userconfig.getKey('page_direction_flipped') === true) { + this.#decreasePage() + } else { + this.#increasePage() + } + } + + #increasePage() { + let newPage = this.#currentPage + 1 + if (newPage >= 100) { + newPage = 1 + } + if (newPage <= 0) { + newPage = 99 + } + + this.#storeNewPage(newPage) + } + + #decreasePage() { + let newPage = this.#currentPage - 1 + if (newPage >= 100) { + newPage = 1 + } + if (newPage <= 0) { + newPage = 99 + } + + this.#storeNewPage(newPage) + } + + /** + * Update to a new page number + * @param {number} newPage + * @param {boolean} defer + * @returns {void} + */ + #storeNewPage(newPage, defer = false) { + // TODO - variables? + this.#currentPage = this.groupConfig.last_page = newPage + this.#saveConfig() + + for (const surfaceHandler of this.surfaceHandlers) { + surfaceHandler.storeNewDevicePage(newPage, defer) + } + } + + /** + * Update the config for this SurfaceGroup + * @param {string} key Config field to change + * @param {any} value New value for the field + * @returns + */ + setGroupConfigValue(key, value) { + this.logger.debug(`Set config "${key}" to "${value}"`) + switch (key) { + case 'use_last_page': { + value = Boolean(value) + + this.groupConfig.use_last_page = value + this.#saveConfig() + + return + } + case 'startup_page': { + value = Number(value) + if (isNaN(value)) { + this.logger.warn(`Invalid startup_page "${value}"`) + return 'invalid value' + } + + this.groupConfig.startup_page = value + this.#saveConfig() + + return + } + case 'last_page': { + value = Number(value) + if (isNaN(value)) { + this.logger.warn(`Invalid current_page "${value}"`) + return 'invalid value' + } + + this.#storeNewPage(value) + + return + } + default: + this.logger.warn(`Cannot set unknown config field "${key}"`) + return 'invalid key' + } + } + + /** + * Set the surface as locked + * @param {boolean} locked + * @returns {void} + */ + setLocked(locked) { + // // skip if surface can't be locked + // if (this.#surfaceConfig.config.never_lock) return + + // Track the locked status + this.#isLocked = !!locked + + // If it changed, redraw + for (const surface of this.surfaceHandlers) { + surface.setLocked(locked) + } + } + + /** + * Set the name of this surface group + * @param {string} name + * @returns {void} + */ + setName(name) { + this.groupConfig.name = name || 'Unnamed group' + this.#saveConfig() + } + + /** + * Save the configuration of this surface group + */ + #saveConfig() { + if (this.#isAutoGroup) { + // TODO: this does not feel great.. + const surface = this.surfaceHandlers[0] + surface.saveGroupConfig(this.groupConfig) + } else { + const groupsConfig = this.db.getKey('surface-groups', {}) + groupsConfig[this.groupId] = this.groupConfig + this.db.setKey('surface-groups', groupsConfig) + } + } +} diff --git a/lib/Surface/Handler.js b/lib/Surface/Handler.js index fe4454c28f..77cdab3557 100644 --- a/lib/Surface/Handler.js +++ b/lib/Surface/Handler.js @@ -20,6 +20,7 @@ import { oldBankIndexToXY } from '../Shared/ControlId.js' import { cloneDeep } from 'lodash-es' import { LEGACY_MAX_BUTTONS } from '../Util/Constants.js' import { rotateXYForPanel, unrotateXYForPanel } from './Util.js' +import { SurfaceGroup } from './Group.js' import { EventEmitter } from 'events' import { ImageResult } from '../Graphics/ImageResult.js' @@ -72,6 +73,8 @@ const PINCODE_NUMBER_POSITIONS_SKIP_FIRST_COL = [ * deviceId: string * devicePath: string * type: string + * configFields: string[] + * location?: string * }} SurfacePanelInfo * @typedef {{ * info: SurfacePanelInfo @@ -85,6 +88,16 @@ const PINCODE_NUMBER_POSITIONS_SKIP_FIRST_COL = [ * } & EventEmitter} SurfacePanel */ +/** + * Get the display name of a surface + * @param {Record} config + * @param {string} surfaceId + * @returns {string} + */ +export function getSurfaceName(config, surfaceId) { + return `${config?.name || config?.type || 'Unknown'} (${surfaceId})` +} + class SurfaceHandler extends CoreBase { static PanelDefaults = { // defaults from the panel - TODO properly @@ -92,11 +105,10 @@ class SurfaceHandler extends CoreBase { rotation: 0, // companion owned defaults - use_last_page: true, never_lock: false, - page: 1, xOffset: 0, yOffset: 0, + groupId: null, } /** @@ -111,7 +123,7 @@ class SurfaceHandler extends CoreBase { * @type {number} * @access private */ - #currentPage + #currentPage = 1 /** * Current pincode entry if locked @@ -179,15 +191,13 @@ class SurfaceHandler extends CoreBase { * @param {import('../Registry.js').default} registry * @param {string} integrationType * @param {SurfacePanel} panel - * @param {boolean} isLocked * @param {any | undefined} surfaceConfig */ - constructor(registry, integrationType, panel, isLocked, surfaceConfig) { + constructor(registry, integrationType, panel, surfaceConfig) { super(registry, `surface(${panel.info.deviceId})`, `Surface/Handler/${panel.info.deviceId}`) this.logger.silly('loading for ' + panel.info.devicePath) this.panel = panel - this.#isSurfaceLocked = isLocked this.#surfaceConfig = surfaceConfig ?? {} this.#pincodeNumberPositions = PINCODE_NUMBER_POSITIONS @@ -208,8 +218,6 @@ class SurfaceHandler extends CoreBase { this.#pincodeCodePosition = [3, 4] } - this.#currentPage = 1 // The current page of the surface - // Persist the type in the db for use when it is disconnected this.#surfaceConfig.type = this.panel.info.type || 'Unknown' this.#surfaceConfig.integrationType = integrationType @@ -227,23 +235,20 @@ class SurfaceHandler extends CoreBase { this.#surfaceConfig.config.yOffset = 0 } - if (this.#surfaceConfig.config.use_last_page === undefined) { + if (!this.#surfaceConfig.groupConfig) { // Fill in the new field based on previous behaviour: // If a page had been chosen, then it would start on that - this.#surfaceConfig.config.use_last_page = this.#surfaceConfig.config.page === undefined - } - - if (this.#surfaceConfig.config.use_last_page) { - if (this.#surfaceConfig.page !== undefined) { - // use last page if defined - this.#currentPage = this.#surfaceConfig.page - } - } else { - if (this.#surfaceConfig.config.page !== undefined) { - // use startup page if defined - this.#currentPage = this.#surfaceConfig.page = this.#surfaceConfig.config.page + const use_last_page = this.#surfaceConfig.config.use_last_page ?? this.#surfaceConfig.config.page === undefined + this.#surfaceConfig.groupConfig = { + page: this.#surfaceConfig.page, + startup_page: this.#surfaceConfig.config.page, + use_last_page: use_last_page, } } + // Forget old values + delete this.#surfaceConfig.config.use_last_page + delete this.#surfaceConfig.config.page + delete this.#surfaceConfig.page if (this.#surfaceConfig.config.never_lock) { // if device can't be locked, then make sure it isnt already locked @@ -269,12 +274,27 @@ class SurfaceHandler extends CoreBase { this.panel.setConfig(config, true) } - this.surfaces.emit('surface_page', this.surfaceId, this.#currentPage) - this.#drawPage() }) } + /** + * Get the current groupId this surface belongs to + * @returns {string | null} + */ + getGroupId() { + return this.#surfaceConfig.groupId + } + /** + * Set the current groupId of this surface + * @param {string | null} groupId + * @returns {void} + */ + setGroupId(groupId) { + this.#surfaceConfig.groupId = groupId + this.#saveConfig() + } + #getCurrentOffset() { return { xOffset: this.#surfaceConfig.config.xOffset, @@ -286,28 +306,8 @@ class SurfaceHandler extends CoreBase { return this.panel.info.deviceId } - #surfaceIncreasePage() { - this.#currentPage++ - if (this.#currentPage >= 100) { - this.#currentPage = 1 - } - if (this.#currentPage <= 0) { - this.#currentPage = 99 - } - - this.#storeNewDevicePage(this.#currentPage) - } - - #surfaceDecreasePage() { - this.#currentPage-- - if (this.#currentPage >= 100) { - this.#currentPage = 1 - } - if (this.#currentPage <= 0) { - this.#currentPage = 99 - } - - this.#storeNewDevicePage(this.#currentPage) + get displayName() { + return getSurfaceName(this.#surfaceConfig, this.surfaceId) } #drawPage() { @@ -368,9 +368,10 @@ class SurfaceHandler extends CoreBase { /** * Set the surface as locked * @param {boolean} locked + * @param {skipDraw=} locked * @returns {void} */ - setLocked(locked) { + setLocked(locked, skipDraw = false) { // skip if surface can't be locked if (this.#surfaceConfig.config.never_lock) return @@ -378,7 +379,9 @@ class SurfaceHandler extends CoreBase { if (this.#isSurfaceLocked != locked) { this.#isSurfaceLocked = !!locked - this.#drawPage() + if (!skipDraw) { + this.#drawPage() + } } } @@ -434,7 +437,12 @@ class SurfaceHandler extends CoreBase { #onDeviceRemove() { if (!this.panel) return - this.surfaces.removeDevice(this.panel.info.devicePath) + + try { + this.surfaces.removeDevice(this.panel.info.devicePath) + } catch (e) { + this.logger.error(`Remove failed: ${e}`) + } } #onDeviceResized() { @@ -452,7 +460,8 @@ class SurfaceHandler extends CoreBase { * @returns {void} */ #onDeviceClick(x, y, pressed, pageOffset) { - if (this.panel) { + if (!this.panel) return + try { if (!this.#isSurfaceLocked) { this.emit('interaction') @@ -496,12 +505,9 @@ class SurfaceHandler extends CoreBase { } if (this.#currentPincodeEntry == this.userconfig.getKey('pin').toString()) { - this.#isSurfaceLocked = false this.#currentPincodeEntry = '' this.emit('unlocked') - - this.#drawPage() } else if (this.#currentPincodeEntry.length >= this.userconfig.getKey('pin').toString().length) { this.#currentPincodeEntry = '' } @@ -513,6 +519,8 @@ class SurfaceHandler extends CoreBase { this.panel.draw(this.#pincodeCodePosition[0], this.#pincodeCodePosition[1], datap.code) } } + } catch (e) { + this.logger.error(`Click failed: ${e}`) } } @@ -525,7 +533,8 @@ class SurfaceHandler extends CoreBase { * @returns {void} */ #onDeviceRotate(x, y, direction, pageOffset) { - if (this.panel) { + if (!this.panel) return + try { if (!this.#isSurfaceLocked) { this.emit('interaction') @@ -553,6 +562,8 @@ class SurfaceHandler extends CoreBase { } else { // Ignore when locked out } + } catch (e) { + this.logger.error(`Click failed: ${e}`) } } @@ -608,49 +619,50 @@ class SurfaceHandler extends CoreBase { } } - doPageDown() { - if (this.userconfig.getKey('page_direction_flipped') === true) { - this.#surfaceIncreasePage() - } else { - this.#surfaceDecreasePage() - } + /** + * Reset the config of this surface to defaults + */ + resetConfig() { + this.#surfaceConfig.groupConfig = cloneDeep(SurfaceGroup.DefaultOptions) + this.#surfaceConfig.groupId = null + this.setPanelConfig(cloneDeep(SurfaceHandler.PanelDefaults)) } /** - * - * @param {number} page - * @param {boolean} defer - * @returns {void} + * Trigger a save of the config */ - setCurrentPage(page, defer = false) { - this.#currentPage = page - if (this.#currentPage == 100) { - this.#currentPage = 1 - } - if (this.#currentPage == 0) { - this.#currentPage = 99 - } - this.#storeNewDevicePage(this.#currentPage, defer) + #saveConfig() { + this.emit('configUpdated', this.#surfaceConfig) } - getCurrentPage() { - return this.#currentPage - } + /** + * Get the 'SurfaceGroup' config for this surface, when run as an auto group + * @returns {import('./Group.js').SurfaceGroupConfig} + */ + getGroupConfig() { + if (this.getGroupId()) throw new Error('Cannot retrieve the config from a non-auto surface') - doPageUp() { - if (this.userconfig.getKey('page_direction_flipped') === true) { - this.#surfaceDecreasePage() - } else { - this.#surfaceIncreasePage() - } + return this.#surfaceConfig.groupConfig } - resetConfig() { - this.setPanelConfig(cloneDeep(SurfaceHandler.PanelDefaults)) + /** + * Get the full config blob for this surface + * @returns {any} + */ + getFullConfig() { + return this.#surfaceConfig } - #saveConfig() { - this.emit('configUpdated', this.#surfaceConfig) + /** + * Set and save the 'SurfaceGroup' config for this surface, when run as an auto group + * @param {import('./Group.js').SurfaceGroupConfig} groupConfig + * @returns {void} + */ + saveGroupConfig(groupConfig) { + if (this.getGroupId()) throw new Error('Cannot save the config for a non-auto surface') + + this.#surfaceConfig.groupConfig = groupConfig + this.#saveConfig() } /** @@ -659,15 +671,6 @@ class SurfaceHandler extends CoreBase { * @returns {void} */ setPanelConfig(newconfig) { - if ( - !newconfig.use_last_page && - newconfig.page !== undefined && - newconfig.page !== this.#surfaceConfig.config.page - ) { - // Startup page has changed, so change over to it - this.#storeNewDevicePage(newconfig.page) - } - let redraw = false if ( newconfig.xOffset != this.#surfaceConfig.config.xOffset || @@ -713,9 +716,8 @@ class SurfaceHandler extends CoreBase { * @param {boolean} defer * @returns {void} */ - #storeNewDevicePage(newpage, defer = false) { - this.#surfaceConfig.page = this.#currentPage = newpage - this.#saveConfig() + storeNewDevicePage(newpage, defer = false) { + this.#currentPage = newpage this.surfaces.emit('surface_page', this.surfaceId, newpage) diff --git a/webui/src/Controls/InternalInstanceFields.jsx b/webui/src/Controls/InternalInstanceFields.jsx index d243182c36..7b7080dad9 100644 --- a/webui/src/Controls/InternalInstanceFields.jsx +++ b/webui/src/Controls/InternalInstanceFields.jsx @@ -52,6 +52,7 @@ export function InternalInstanceField(option, isOnControl, readonly, value, setV value={value} setValue={setValue} includeSelf={option.includeSelf} + useRawSurfaces={option.useRawSurfaces} /> ) case 'internal:trigger': @@ -181,8 +182,8 @@ function InternalVariableDropdown({ value, setValue, disabled }) { ) } -function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disabled, includeSelf }) { - const context = useContext(SurfacesContext) +function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disabled, includeSelf, useRawSurfaces }) { + const surfacesContext = useContext(SurfacesContext) const choices = useMemo(() => { const choices = [] @@ -190,21 +191,26 @@ function InternalSurfaceBySerialDropdown({ isOnControl, value, setValue, disable choices.push({ id: 'self', label: 'Current surface' }) } - for (const surface of Object.values(context?.available ?? {})) { - choices.push({ - label: `${surface.name || surface.type} (${surface.id})`, - id: surface.id, - }) + if (!useRawSurfaces) { + for (const group of surfacesContext ?? []) { + choices.push({ + label: group.displayName, + id: group.id, + }) + } + } else { + for (const group of surfacesContext ?? []) { + for (const surface of group.surfaces) { + choices.push({ + label: surface.displayName, + id: surface.id, + }) + } + } } - for (const surface of Object.values(context?.offline ?? {})) { - choices.push({ - label: `${surface.name || surface.type} (${surface.id}) - Offline`, - id: surface.id, - }) - } return choices - }, [context, isOnControl, includeSelf]) + }, [surfacesContext, isOnControl, includeSelf, useRawSurfaces]) return } diff --git a/webui/src/Surfaces/AddGroupModal.jsx b/webui/src/Surfaces/AddGroupModal.jsx new file mode 100644 index 0000000000..1d9de27890 --- /dev/null +++ b/webui/src/Surfaces/AddGroupModal.jsx @@ -0,0 +1,79 @@ +import React, { forwardRef, useCallback, useContext, useImperativeHandle, useState } from 'react' +import { + CButton, + CForm, + CFormGroup, + CInput, + CLabel, + CModal, + CModalBody, + CModalFooter, + CModalHeader, +} from '@coreui/react' +import { socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' + +export const AddSurfaceGroupModal = forwardRef(function SurfaceEditModal(_props, ref) { + const socket = useContext(SocketContext) + + const [show, setShow] = useState(false) + + const [groupName, setGroupName] = useState(null) + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => { + setGroupName(null) + }, []) + + const doAction = useCallback( + (e) => { + if (e) e.preventDefault() + + if (!groupName) return + + setShow(false) + setGroupName(null) + + socketEmitPromise(socket, 'surfaces:group-add', [groupName]).catch((err) => { + console.error('Group add failed', err) + }) + }, + [groupName] + ) + + useImperativeHandle( + ref, + () => ({ + show() { + setShow(true) + setGroupName('My group') + }, + }), + [] + ) + + const onNameChange = useCallback((e) => setGroupName(e.target.value), []) + + return ( + + +
Add Surface Group
+
+ + + + Name + + + + + + + Cancel + + + Save + + +
+ ) +}) diff --git a/webui/src/Surfaces/EditModal.jsx b/webui/src/Surfaces/EditModal.jsx index ff0d686736..be17288a04 100644 --- a/webui/src/Surfaces/EditModal.jsx +++ b/webui/src/Surfaces/EditModal.jsx @@ -12,75 +12,135 @@ import { CModalHeader, CSelect, } from '@coreui/react' -import { LoadingRetryOrError, socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util' +import { LoadingRetryOrError, socketEmitPromise, SocketContext, PreventDefaultHandler, SurfacesContext } from '../util' import { nanoid } from 'nanoid' +import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { InternalInstanceField } from '../Controls/InternalInstanceFields' +import { MenuPortalContext } from '../Components/DropdownInputField' + +const PAGE_FIELD_SPEC = { type: 'internal:page', includeDirection: false } export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref) { const socket = useContext(SocketContext) + const surfacesContext = useContext(SurfacesContext) - const [surfaceInfo, setSurfaceInfo] = useState(null) + const [rawGroupId, setGroupId] = useState(null) + const [surfaceId, setSurfaceId] = useState(null) const [show, setShow] = useState(false) + let surfaceInfo = null + if (surfaceId) { + for (const group of surfacesContext) { + if (surfaceInfo) break + + for (const surface of group.surfaces) { + if (surface.id === surfaceId) { + surfaceInfo = { + ...surface, + groupId: group.isAutoGroup ? null : group.id, + } + break + } + } + } + } + + const groupId = surfaceInfo && !surfaceInfo.groupId ? surfaceId : rawGroupId + let groupInfo = null + if (groupId) { + for (const group of surfacesContext) { + if (group.id === groupId) { + groupInfo = group + break + } + } + } + const [surfaceConfig, setSurfaceConfig] = useState(null) - const [surfaceConfigError, setSurfaceConfigError] = useState(null) + const [groupConfig, setGroupConfig] = useState(null) + const [configLoadError, setConfigLoadError] = useState(null) const [reloadToken, setReloadToken] = useState(nanoid()) const doClose = useCallback(() => setShow(false), []) const onClosed = useCallback(() => { - setSurfaceInfo(null) + setSurfaceId(null) setSurfaceConfig(null) - setSurfaceConfigError(null) + setConfigLoadError(null) }, []) const doRetryConfigLoad = useCallback(() => setReloadToken(nanoid()), []) useEffect(() => { - setSurfaceConfigError(null) + setConfigLoadError(null) setSurfaceConfig(null) + setGroupConfig(null) - if (surfaceInfo?.id) { - socketEmitPromise(socket, 'surfaces:config-get', [surfaceInfo.id]) + if (surfaceId) { + socketEmitPromise(socket, 'surfaces:config-get', [surfaceId]) .then((config) => { - console.log(config) setSurfaceConfig(config) }) .catch((err) => { console.error('Failed to load surface config') - setSurfaceConfigError(`Failed to load surface config`) + setConfigLoadError(`Failed to load surface config`) }) } - }, [socket, surfaceInfo?.id, reloadToken]) + if (groupId) { + socketEmitPromise(socket, 'surfaces:group-config-get', [groupId]) + .then((config) => { + setGroupConfig(config) + }) + .catch((err) => { + console.error('Failed to load group config') + setConfigLoadError(`Failed to load surface group config`) + }) + } + }, [socket, surfaceId, groupId, reloadToken]) useImperativeHandle( ref, () => ({ - show(surface) { - setSurfaceInfo(surface) + show(surfaceId, groupId) { + setSurfaceId(surfaceId) + setGroupId(groupId) setShow(true) }, - ensureIdIsValid(surfaceIds) { - setSurfaceInfo((oldSurface) => { - if (oldSurface && surfaceIds.indexOf(oldSurface.id) === -1) { - setShow(false) - } - return oldSurface - }) - }, }), [] ) - const updateConfig = useCallback( + useEffect(() => { + // If surface disappears/disconnects, hide this + + const onlineSurfaceIds = new Set() + for (const group of surfacesContext) { + for (const surface of group.surfaces) { + if (surface.isConnected) { + onlineSurfaceIds.add(surface.id) + } + } + } + + setSurfaceId((oldSurfaceId) => { + if (oldSurfaceId && !onlineSurfaceIds.has(oldSurfaceId)) { + setShow(false) + } + return oldSurfaceId + }) + }, [surfacesContext]) + + const setSurfaceConfigValue = useCallback( (key, value) => { - console.log('update', key, value) - if (surfaceInfo?.id) { + console.log('update surface', key, value) + if (surfaceId) { setSurfaceConfig((oldConfig) => { const newConfig = { ...oldConfig, [key]: value, } - socketEmitPromise(socket, 'surfaces:config-set', [surfaceInfo.id, newConfig]) + socketEmitPromise(socket, 'surfaces:config-set', [surfaceId, newConfig]) .then((newConfig) => { if (typeof newConfig === 'string') { console.log('Config update failed', newConfig) @@ -95,197 +155,280 @@ export const SurfaceEditModal = forwardRef(function SurfaceEditModal(_props, ref }) } }, - [socket, surfaceInfo?.id] + [socket, surfaceId] + ) + const setGroupConfigValue = useCallback( + (key, value) => { + console.log('update group', key, value) + if (groupId) { + socketEmitPromise(socket, 'surfaces:group-config-set', [groupId, key, value]) + .then((newConfig) => { + if (typeof newConfig === 'string') { + console.log('group config update failed', newConfig) + } else { + setGroupConfig(newConfig) + } + }) + .catch((e) => { + console.log('group config update failed', e) + }) + + setGroupConfig((oldConfig) => { + return { + ...oldConfig, + [key]: value, + } + }) + } + }, + [socket, groupId] + ) + + const setSurfaceGroupId = useCallback( + (groupId) => { + if (!groupId || groupId === 'null') groupId = null + socketEmitPromise(socket, 'surfaces:add-to-group', [groupId, surfaceId]).catch((e) => { + console.log('Config update failed', e) + }) + }, + [socket, surfaceId] ) + const [modalRef, setModalRef] = useState(null) + return ( - - -
Settings for {surfaceInfo?.type}
-
- - - {surfaceConfig && surfaceInfo && ( + + + +
Settings for {surfaceInfo?.displayName ?? surfaceInfo?.type ?? groupInfo?.displayName}
+
+ + + - - 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') && ( + {surfaceInfo && ( + + + Surface Group  + + + setSurfaceGroupId(e.currentTarget.value)} + > + + + {surfacesContext + .filter((group) => !group.isAutoGroup) + .map((group) => ( + + ))} + + + )} + + {groupConfig && ( + <> + + Use Last Page At Startup + setGroupConfigValue('use_last_page', !!e.currentTarget.checked)} + /> + + + Startup Page + + {InternalInstanceField( + PAGE_FIELD_SPEC, + false, + !!groupConfig.use_last_page, + groupConfig.startup_page, + (val) => setGroupConfigValue('startup_page', val) + )} + + + Current Page + + {InternalInstanceField(PAGE_FIELD_SPEC, false, false, groupConfig.last_page, (val) => + setGroupConfigValue('last_page', val) + )} + + + )} + + {surfaceConfig && surfaceInfo && ( <> + {surfaceInfo.configFields?.includes('emulator_size') && ( + <> + + Row count + setSurfaceConfigValue('emulator_rows', parseInt(e.currentTarget.value))} + /> + + + Column count + setSurfaceConfigValue('emulator_columns', parseInt(e.currentTarget.value))} + /> + + + )} + - Row count + Horizontal Offset in grid updateConfig('emulator_rows', parseInt(e.currentTarget.value))} + value={surfaceConfig.xOffset} + onChange={(e) => setSurfaceConfigValue('xOffset', parseInt(e.currentTarget.value))} /> - Column count + Vertical Offset in grid updateConfig('emulator_columns', parseInt(e.currentTarget.value))} + value={surfaceConfig.yOffset} + onChange={(e) => setSurfaceConfigValue('yOffset', 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)} - /> - - )} + {surfaceInfo.configFields?.includes('brightness') && ( + + Brightness + setSurfaceConfigValue('brightness', parseInt(e.currentTarget.value))} + /> + + )} + {surfaceInfo.configFields?.includes('illuminate_pressed') && ( + + Illuminate pressed buttons + setSurfaceConfigValue('illuminate_pressed', !!e.currentTarget.checked)} + /> + + )} - - Button rotation - { - const valueNumber = parseInt(e.currentTarget.value) - updateConfig('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) - }} - > - - - - + + Button rotation + { + const valueNumber = parseInt(e.currentTarget.value) + setSurfaceConfigValue('rotation', isNaN(valueNumber) ? e.currentTarget.value : valueNumber) + }} + > + + + + - {surfaceInfo.configFields?.includes('legacy_rotation') && ( - <> - - - - + {surfaceInfo.configFields?.includes('legacy_rotation') && ( + <> + + + + + )} + + + {surfaceInfo.configFields?.includes('emulator_control_enable') && ( + + Enable support for Logitech R400/Mastercue/DSan + setSurfaceConfigValue('emulator_control_enable', !!e.currentTarget.checked)} + /> + )} - - - {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))} - /> - + {surfaceInfo.configFields?.includes('emulator_prompt_fullscreen') && ( + + Prompt to enter fullscreen + setSurfaceConfigValue('emulator_prompt_fullscreen', !!e.currentTarget.checked)} + /> + + )} + {surfaceInfo.configFields?.includes('videohub_page_count') && ( + + Page Count + setSurfaceConfigValue('videohub_page_count', parseInt(e.currentTarget.value))} + /> + + )} + + Never Pin code lock + setSurfaceConfigValue('never_lock', !!e.currentTarget.checked)} + /> + + )} - - Never Pin code lock - updateConfig('never_lock', !!e.currentTarget.checked)} - /> - - )} - - - - Close - - +
+ + + Close + + +
) }) diff --git a/webui/src/Surfaces/index.jsx b/webui/src/Surfaces/index.jsx index 0fb9e879e0..c543809c58 100644 --- a/webui/src/Surfaces/index.jsx +++ b/webui/src/Surfaces/index.jsx @@ -1,61 +1,27 @@ -import React, { memo, useCallback, useContext, useEffect, useRef, useState } from 'react' +import React, { memo, useCallback, useContext, useRef, useState } from 'react' import { CAlert, CButton, CButtonGroup } from '@coreui/react' import { SurfacesContext, socketEmitPromise, SocketContext } from '../util' 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 { AddSurfaceGroupModal } from './AddGroupModal' +import classNames from 'classnames' export const SurfacesPage = memo(function SurfacesPage() { const socket = useContext(SocketContext) - const surfaces = useContext(SurfacesContext) + const surfacesContext = useContext(SurfacesContext) const confirmRef = useRef(null) - const surfacesList = useMemo(() => { - const ary = Object.values(surfaces.available) - - ary.sort((a, b) => { - if (a.index !== b.index) { - return a.index - b.index - } - - // fallback to serial - return a.id.localeCompare(b.id) - }) - - return ary - }, [surfaces.available]) - const offlineSurfacesList = useMemo(() => { - const ary = Object.values(surfaces.offline) - - ary.sort((a, b) => { - if (a.index !== b.index) { - return a.index - b.index - } - - // fallback to serial - return a.id.localeCompare(b.id) - }) - - return ary - }, [surfaces.offline]) - - const editModalRef = useRef() + const editModalRef = useRef(null) + const addGroupModalRef = useRef(null) const confirmModalRef = 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)) - } - }, [surfaces]) - const refreshUSB = useCallback(() => { setScanning(true) setScanError(null) @@ -89,8 +55,27 @@ export const SurfacesPage = memo(function SurfacesPage() { [socket] ) - const configureSurface = useCallback((surface) => { - editModalRef.current.show(surface) + const addGroup = useCallback(() => { + addGroupModalRef.current.show() + }, [socket]) + + const deleteGroup = useCallback( + (groupId) => { + confirmRef?.current?.show('Remove Group', 'Are you sure?', 'Remove', () => { + socketEmitPromise(socket, 'surfaces:group-remove', [groupId]).catch((err) => { + console.error('Group remove failed', err) + }) + }) + }, + [socket] + ) + + const configureSurface = useCallback((surfaceId) => { + editModalRef.current.show(surfaceId, null) + }, []) + + const configureGroup = useCallback((groupId) => { + editModalRef.current.show(null, groupId) }, []) const forgetSurface = useCallback( @@ -148,16 +133,16 @@ export const SurfacesPage = memo(function SurfacesPage() { Add Emulator + + Add Group + -

 

- + -
Connected
- - +
@@ -169,48 +154,34 @@ export const SurfacesPage = memo(function SurfacesPage() { - {surfacesList.map((surface) => ( - - ))} - - {surfacesList.length === 0 && ( - - - + {surfacesContext.map((group) => + group.isAutoGroup && (group.surfaces || []).length === 1 ? ( + + ) : ( + + ) )} - -
NO
No control surfaces have been detected
-
Disconnected
- - - - - - - - - - - - {offlineSurfacesList.map((surface) => ( - - ))} - - {offlineSurfacesList.length === 0 && ( + {surfacesContext.length === 0 && ( - + )} @@ -219,57 +190,98 @@ export const SurfacesPage = memo(function SurfacesPage() { ) }) -function AvailableSurfaceRow({ surface, updateName, configureSurface, deleteEmulator }) { - 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]) +function ManualGroupRow({ + group, + configureGroup, + deleteGroup, + updateName, + configureSurface, + deleteEmulator, + forgetSurface, +}) { + const configureGroup2 = useCallback(() => configureGroup(group.id), [configureGroup, group.id]) + const deleteGroup2 = useCallback(() => deleteGroup(group.id), [deleteGroup, group.id]) + const updateName2 = useCallback((val) => updateName(group.id, val), [updateName, group.id]) return ( - - - - - - - - + <> + + + + + + + + + {(group.surfaces || []).map((surface) => ( + + ))} + ) } -function OfflineSuraceRow({ surface, updateName, forgetSurface }) { +function SurfaceRow({ surface, index, updateName, configureSurface, deleteEmulator, forgetSurface, noBorder }) { const updateName2 = useCallback((val) => updateName(surface.id, val), [updateName, surface.id]) + const configureSurface2 = useCallback(() => configureSurface(surface.id), [configureSurface, surface.id]) + const deleteEmulator2 = useCallback(() => deleteEmulator(surface.id), [deleteEmulator, surface.id]) const forgetSurface2 = useCallback(() => forgetSurface(surface.id), [forgetSurface, surface.id]) return ( - + + + ) diff --git a/webui/src/scss/_common.scss b/webui/src/scss/_common.scss index 6eb683d0cc..aab0651486 100644 --- a/webui/src/scss/_common.scss +++ b/webui/src/scss/_common.scss @@ -102,6 +102,10 @@ code { } .table { color: #111; + + &.table-margin-top { + margin-top: 1rem; + } } .modal { @@ -147,3 +151,7 @@ code { .c-switch-input { display: none; } + +.noBorder td { + border: none; +}
IDNameType 
No itemsNo control surfaces have been detected
#{surface.index}{surface.id} - - {surface.type}{surface.location} - - - Settings - - - {surface.integrationType === 'emulator' && ( - <> - - - - - - - - )} - -
#{group.index}{group.id} + + Group- + + + Settings + + + + Delete + + +
{index !== undefined ? `#${index}` : ''} {surface.id} {surface.type}{surface.isConnected ? surface.location || 'Local' : 'Offline'} - - Forget - + {surface.isConnected ? ( + + + Settings + + + {surface.integrationType === 'emulator' && ( + <> + + + + + + + + )} + + ) : ( + + Forget + + )}