From 58261e54ffe87e448b0de80b007ddf7a06dd924b Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 19 Jun 2024 13:46:40 +0200 Subject: [PATCH 01/10] fix: live-status-gateway ignored settings from Core, so logLevel ended up always being the default --- .../live-status-gateway/src/coreHandler.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/live-status-gateway/src/coreHandler.ts b/packages/live-status-gateway/src/coreHandler.ts index 3c5130f7b5..2843fa1014 100644 --- a/packages/live-status-gateway/src/coreHandler.ts +++ b/packages/live-status-gateway/src/coreHandler.ts @@ -137,8 +137,9 @@ export class CoreHandler { } // setup observers const observer = this.core.observe(PeripheralDevicePubSubCollectionsNames.peripheralDeviceForDevice) - observer.added = (id) => this.onDeviceChanged(id) - observer.changed = (id) => this.onDeviceChanged(id) + observer.added = () => this.onDeviceChanged() + observer.changed = () => this.onDeviceChanged() + this.onDeviceChanged() // set initial settings this.setupObserverForPeripheralDeviceCommands(this) } async destroy(): Promise { @@ -178,15 +179,18 @@ export class CoreHandler { this._onConnected = fcn } - onDeviceChanged(id: PeripheralDeviceId): void { - if (id !== this.core.deviceId) return + onDeviceChanged(): void { const col = this.core.getCollection( PeripheralDevicePubSubCollectionsNames.peripheralDeviceForDevice ) if (!col) throw new Error('collection "peripheralDeviceForDevice" not found!') - const device = col.findOne(id) - this.deviceSettings = device?.deviceSettings || {} + const device = col.findOne(this.core.deviceId) + if (!device) { + throw new Error(`No "peripheralDeviceForDevice" with id "${this.core.deviceId}" found!`) + } + + this.deviceSettings = device.deviceSettings || {} const logLevel = this.deviceSettings['debugLogging'] ? 'debug' : 'info' if (logLevel !== this.logger.level) { this.logger.level = logLevel @@ -198,7 +202,7 @@ export class CoreHandler { this.logger.info('Loglevel: ' + this.logger.level) } - const studioId = device?.studioId + const studioId = device.studioId if (studioId === undefined) { throw new Error(`Live status gateway must be attached to a studio`) } From 8682eca8004aedb092a1dca7f7c856c0814eaf66 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 19 Jun 2024 13:52:41 +0100 Subject: [PATCH 02/10] fix: live status gateway including pieces which have been pruned and will never be played SOFIE-3301 (#1206) --- .../src/collections/partInstancesHandler.ts | 10 +++ .../src/collections/pieceInstancesHandler.ts | 71 +++++++++++++++++-- .../src/collections/showStyleBaseHandler.ts | 4 +- .../src/liveStatusServer.ts | 2 + .../topics/__tests__/activePlaylist.spec.ts | 1 + 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/packages/live-status-gateway/src/collections/partInstancesHandler.ts b/packages/live-status-gateway/src/collections/partInstancesHandler.ts index 79ae9464f6..d2b9e7e5f2 100644 --- a/packages/live-status-gateway/src/collections/partInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/partInstancesHandler.ts @@ -11,6 +11,7 @@ import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { PartInstanceId, RundownId, RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' export interface SelectedPartInstances { + previous: DBPartInstance | undefined current: DBPartInstance | undefined next: DBPartInstance | undefined firstInSegmentPlayout: DBPartInstance | undefined @@ -36,6 +37,7 @@ export class PartInstancesHandler super(PartInstancesHandler.name, CollectionName.PartInstances, CorelibPubSub.partInstances, logger, coreHandler) this.observerName = this._name this._collectionData = { + previous: undefined, current: undefined, next: undefined, firstInSegmentPlayout: undefined, @@ -54,6 +56,9 @@ export class PartInstancesHandler if (!this._collectionName || !this._collectionData) return false const collection = this._core.getCollection(this._collectionName) if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) + const previousPartInstance = this._currentPlaylist?.previousPartInfo?.partInstanceId + ? collection.findOne(this._currentPlaylist.previousPartInfo.partInstanceId) + : undefined const currentPartInstance = this._currentPlaylist?.currentPartInfo?.partInstanceId ? collection.findOne(this._currentPlaylist.currentPartInfo.partInstanceId) : undefined @@ -70,6 +75,10 @@ export class PartInstancesHandler ) as DBPartInstance let hasAnythingChanged = false + if (previousPartInstance !== this._collectionData.previous) { + this._collectionData.previous = previousPartInstance + hasAnythingChanged = true + } if (currentPartInstance !== this._collectionData.current) { this._collectionData.current = currentPartInstance hasAnythingChanged = true @@ -91,6 +100,7 @@ export class PartInstancesHandler private clearCollectionData() { if (!this._collectionName || !this._collectionData) return + this._collectionData.previous = undefined this._collectionData.current = undefined this._collectionData.next = undefined this._collectionData.firstInSegmentPlayout = undefined diff --git a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts index c1739c8a6b..1cb7a69af5 100644 --- a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts @@ -10,6 +10,12 @@ import throttleToNextTick from '@sofie-automation/shared-lib/dist/lib/throttleTo import _ = require('underscore') import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { PartInstanceId, PieceInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { processAndPrunePieceInstanceTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' +import { ShowStyleBaseExt, ShowStyleBaseHandler } from './showStyleBaseHandler' +import { PlaylistHandler } from './playlistHandler' +import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { PartInstancesHandler, SelectedPartInstances } from './partInstancesHandler' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' export type PieceInstanceMin = Omit @@ -20,7 +26,7 @@ export interface SelectedPieceInstances { // Pieces present in the current part instance currentPartInstance: PieceInstanceMin[] - // Pieces present in the current part instance + // Pieces present in the next part instance nextPartInstance: PieceInstanceMin[] } @@ -33,6 +39,8 @@ export class PieceInstancesHandler private _partInstanceIds: PartInstanceId[] = [] private _activationId: string | undefined private _subscriptionPending = false + private _sourceLayers: SourceLayers = {} + private _partInstances: SelectedPartInstances | undefined private _throttledUpdateAndNotify = throttleToNextTick(() => { this.updateCollectionData() @@ -61,20 +69,45 @@ export class PieceInstancesHandler this._throttledUpdateAndNotify() } + private processAndPrunePieceInstanceTimings( + partInstance: DBPartInstance | undefined, + pieceInstances: PieceInstance[] + ): PieceInstance[] { + // Approximate when 'now' is in the PartInstance, so that any adlibbed Pieces will be timed roughly correctly + const partStarted = partInstance?.timings?.plannedStartedPlayback + const nowInPart = partStarted === undefined ? 0 : Date.now() - partStarted + + return processAndPrunePieceInstanceTimings(this._sourceLayers, pieceInstances, nowInPart, false, false) + } + private updateCollectionData(): boolean { if (!this._collectionName || !this._collectionData) return false const collection = this._core.getCollection(this._collectionName) if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - const active = this._currentPlaylist?.currentPartInfo?.partInstanceId - ? collection.find((pieceInstance: PieceInstance) => this.isPieceInstanceActive(pieceInstance)) + + const inPreviousPartInstance = this._currentPlaylist?.previousPartInfo?.partInstanceId + ? this.processAndPrunePieceInstanceTimings( + this._partInstances?.previous, + collection.find({ partInstanceId: this._currentPlaylist.previousPartInfo.partInstanceId }) + ) : [] const inCurrentPartInstance = this._currentPlaylist?.currentPartInfo?.partInstanceId - ? collection.find({ partInstanceId: this._currentPlaylist.currentPartInfo.partInstanceId }) + ? this.processAndPrunePieceInstanceTimings( + this._partInstances?.current, + collection.find({ partInstanceId: this._currentPlaylist.currentPartInfo.partInstanceId }) + ) : [] const inNextPartInstance = this._currentPlaylist?.nextPartInfo?.partInstanceId - ? collection.find({ partInstanceId: this._currentPlaylist.nextPartInfo.partInstanceId }) + ? this.processAndPrunePieceInstanceTimings( + undefined, + collection.find({ partInstanceId: this._currentPlaylist.nextPartInfo.partInstanceId }) + ) : [] + const active = [...inPreviousPartInstance, ...inCurrentPartInstance].filter((pieceInstance) => + this.isPieceInstanceActive(pieceInstance) + ) + let hasAnythingChanged = false if (!areElementsShallowEqual(this._collectionData.active, active)) { this._collectionData.active = active @@ -107,7 +140,32 @@ export class PieceInstancesHandler this._collectionData.nextPartInstance = [] } - async update(source: string, data: DBRundownPlaylist | undefined): Promise { + async update( + source: string, + data: DBRundownPlaylist | ShowStyleBaseExt | SelectedPartInstances | undefined + ): Promise { + switch (source) { + case PlaylistHandler.name: + return this.updateRundownPlaylist(source, data as DBRundownPlaylist | undefined) + case ShowStyleBaseHandler.name: { + this.logUpdateReceived('showStyleBase', source) + const showStyleBaseExt = data as ShowStyleBaseExt | undefined + this._sourceLayers = showStyleBaseExt?.sourceLayers ?? {} + this._throttledUpdateAndNotify() + break + } + case PartInstancesHandler.name: { + this.logUpdateReceived('partInstances', source) + this._partInstances = data as SelectedPartInstances + this._throttledUpdateAndNotify() + break + } + default: + throw new Error(`${this._name} received unsupported update from ${source}}`) + } + } + + private async updateRundownPlaylist(source: string, data: DBRundownPlaylist | undefined): Promise { const prevPartInstanceIds = this._partInstanceIds const prevActivationId = this._activationId @@ -188,6 +246,7 @@ export class PieceInstancesHandler return ( pieceInstance.reportedStoppedPlayback == null && pieceInstance.piece.virtual !== true && + pieceInstance.disabled !== true && (pieceInstance.partInstanceId === this._currentPlaylist?.previousPartInfo?.partInstanceId || // a piece from previous part instance may be active during transition pieceInstance.partInstanceId === this._currentPlaylist?.currentPartInfo?.partInstanceId) && (pieceInstance.reportedStartedPlayback != null || // has been reported to have started by the Playout Gateway diff --git a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts index 8a01c78273..e59d39d4c3 100644 --- a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts +++ b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts @@ -12,6 +12,7 @@ import { IOutputLayer, ISourceLayer } from '@sofie-automation/blueprints-integra export interface ShowStyleBaseExt extends DBShowStyleBase { sourceLayerNamesById: ReadonlyMap outputLayerNamesById: ReadonlyMap + sourceLayers: SourceLayers } export class ShowStyleBaseHandler @@ -116,10 +117,11 @@ export class ShowStyleBaseHandler if (outputLayer === undefined || outputLayer === null) continue this._outputLayersMap.set(layerId, outputLayer.name) } - const showStyleBaseExt = { + const showStyleBaseExt: ShowStyleBaseExt = { ...showStyleBase, sourceLayerNamesById: this._sourceLayersMap, outputLayerNamesById: this._outputLayersMap, + sourceLayers, } this._collectionData = showStyleBaseExt } diff --git a/packages/live-status-gateway/src/liveStatusServer.ts b/packages/live-status-gateway/src/liveStatusServer.ts index 5cdda17720..70590e22b0 100644 --- a/packages/live-status-gateway/src/liveStatusServer.ts +++ b/packages/live-status-gateway/src/liveStatusServer.ts @@ -96,6 +96,8 @@ export class LiveStatusServer { await partInstancesHandler.subscribe(globalAdLibActionsHandler) await partInstancesHandler.subscribe(adLibsHandler) await partInstancesHandler.subscribe(globalAdLibsHandler) + await showStyleBaseHandler.subscribe(pieceInstancesHandler) + await partInstancesHandler.subscribe(pieceInstancesHandler) // add observers for websocket topic updates await studioHandler.subscribe(studioTopic) diff --git a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts index cffa88b3d5..09f9220fe6 100644 --- a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts @@ -12,6 +12,7 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' function makeEmptyTestPartInstances(): SelectedPartInstances { return { + previous: undefined, current: undefined, firstInSegmentPlayout: undefined, inCurrentSegment: [], From fb0d2c8ef2e316bfe53cfa7160711101ede29ac4 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 19 Jun 2024 14:55:10 +0200 Subject: [PATCH 03/10] fix: group sends together, for increased performance A local test shows that stringifying messages infividually for each recipient cand easilly take several milliseconds. --- .../src/topics/activePiecesTopic.ts | 4 +--- .../src/topics/activePlaylistTopic.ts | 4 +--- .../src/topics/adLibsTopic.ts | 4 +--- .../src/topics/segmentsTopic.ts | 4 +--- .../src/topics/studioTopic.ts | 5 ++--- packages/live-status-gateway/src/wsHandler.ts | 22 +++++++++++++++---- 6 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/live-status-gateway/src/topics/activePiecesTopic.ts b/packages/live-status-gateway/src/topics/activePiecesTopic.ts index e90d8e71af..fcb53adf4f 100644 --- a/packages/live-status-gateway/src/topics/activePiecesTopic.ts +++ b/packages/live-status-gateway/src/topics/activePiecesTopic.ts @@ -60,9 +60,7 @@ export class ActivePiecesTopic activePieces: [], }) - for (const subscriber of subscribers) { - this.sendMessage(subscriber, message) - } + this.sendMessage(subscribers, message) } async update( diff --git a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts index 98c3e46f1b..f0e6213e63 100644 --- a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -153,9 +153,7 @@ export class ActivePlaylistTopic publicData: undefined, }) - for (const subscriber of subscribers) { - this.sendMessage(subscriber, message) - } + this.sendMessage(subscribers, message) } private isDataInconsistent() { diff --git a/packages/live-status-gateway/src/topics/adLibsTopic.ts b/packages/live-status-gateway/src/topics/adLibsTopic.ts index def55c0bf8..da78725c0b 100644 --- a/packages/live-status-gateway/src/topics/adLibsTopic.ts +++ b/packages/live-status-gateway/src/topics/adLibsTopic.ts @@ -236,9 +236,7 @@ export class AdLibsTopic } : { event: 'adLibs', rundownPlaylistId: null, adLibs: [], globalAdLibs: [] } - for (const subscriber of subscribers) { - this.sendMessage(subscriber, adLibsStatus) - } + this.sendMessage(subscribers, adLibsStatus) } async update( diff --git a/packages/live-status-gateway/src/topics/segmentsTopic.ts b/packages/live-status-gateway/src/topics/segmentsTopic.ts index 5532528058..93dd706d11 100644 --- a/packages/live-status-gateway/src/topics/segmentsTopic.ts +++ b/packages/live-status-gateway/src/topics/segmentsTopic.ts @@ -75,9 +75,7 @@ export class SegmentsTopic }), } - for (const subscriber of subscribers) { - this.sendMessage(subscriber, segmentsStatus) - } + this.sendMessage(subscribers, segmentsStatus) } async update(source: string, data: DBRundownPlaylist | DBSegment[] | DBPart[] | undefined): Promise { diff --git a/packages/live-status-gateway/src/topics/studioTopic.ts b/packages/live-status-gateway/src/topics/studioTopic.ts index 4998a1afd1..945317718d 100644 --- a/packages/live-status-gateway/src/topics/studioTopic.ts +++ b/packages/live-status-gateway/src/topics/studioTopic.ts @@ -54,9 +54,8 @@ export class StudioTopic name: '', playlists: [], } - for (const subscriber of subscribers) { - this.sendMessage(subscriber, studioStatus) - } + + this.sendMessage(subscribers, studioStatus) } async update(source: string, data: DBStudio | DBRundownPlaylist[] | undefined): Promise { diff --git a/packages/live-status-gateway/src/wsHandler.ts b/packages/live-status-gateway/src/wsHandler.ts index 90a43f64e4..072da1ad3e 100644 --- a/packages/live-status-gateway/src/wsHandler.ts +++ b/packages/live-status-gateway/src/wsHandler.ts @@ -34,15 +34,22 @@ export abstract class WebSocketTopicBase { this._logger.error(`Process ${this._name} message not expected '${JSON.stringify(msg)}'`) } - sendMessage(ws: WebSocket, msg: object): void { + sendMessage(recipients: WebSocket | Iterable, msg: object): void { const msgStr = JSON.stringify(msg) - this._logger.debug(`Send ${this._name} message '${msgStr}'`) - ws.send(msgStr) + + recipients = isIterable(recipients) ? recipients : [recipients] + + let count = 0 + for (const ws of recipients) { + count++ + ws.send(msgStr) + } + this._logger.silly(`Send ${this._name} message '${msgStr}' to ${count} recipients`) } sendHeartbeat(ws: WebSocket): void { const msgStr = JSON.stringify({ event: 'heartbeat' }) - this._logger.silly(`Send ${this._name} message '${msgStr}'`) + // this._logger.silly(`Send ${this._name} message '${msgStr}'`) ws.send(msgStr) } @@ -166,3 +173,10 @@ export interface CollectionObserver { observerName: string update(source: string, data: T | undefined): Promise } +function isIterable(obj: T | Iterable): obj is Iterable { + // checks for null and undefined + if (obj == null) { + return false + } + return typeof (obj as any)[Symbol.iterator] === 'function' +} From ae1adeba0c9f992797a59a7a3ec1697897b5bd45 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 20 Jun 2024 13:37:21 +0200 Subject: [PATCH 04/10] fix(LSG): further optimize sending of messages --- packages/live-status-gateway/src/wsHandler.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/live-status-gateway/src/wsHandler.ts b/packages/live-status-gateway/src/wsHandler.ts index 072da1ad3e..6e2926afc8 100644 --- a/packages/live-status-gateway/src/wsHandler.ts +++ b/packages/live-status-gateway/src/wsHandler.ts @@ -35,22 +35,23 @@ export abstract class WebSocketTopicBase { } sendMessage(recipients: WebSocket | Iterable, msg: object): void { - const msgStr = JSON.stringify(msg) - recipients = isIterable(recipients) ? recipients : [recipients] let count = 0 + let msgStr = '' for (const ws of recipients) { + if (!msgStr) msgStr = JSON.stringify(msg) // Optimization: only stringify if there are any recipients count++ ws.send(msgStr) } this._logger.silly(`Send ${this._name} message '${msgStr}' to ${count} recipients`) } - sendHeartbeat(ws: WebSocket): void { + sendHeartbeat(recipients: Set): void { const msgStr = JSON.stringify({ event: 'heartbeat' }) - // this._logger.silly(`Send ${this._name} message '${msgStr}'`) - ws.send(msgStr) + for (const ws of recipients.values()) { + ws.send(msgStr) + } } protected logUpdateReceived(collectionName: string, source: string, extraInfo?: string): void { From 5b38f57e51ebc86b86c66b695b47fc26edad28d7 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 24 Jun 2024 08:41:42 +0200 Subject: [PATCH 05/10] chore: lint --- packages/live-status-gateway/src/coreHandler.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/live-status-gateway/src/coreHandler.ts b/packages/live-status-gateway/src/coreHandler.ts index 2843fa1014..183c9212b0 100644 --- a/packages/live-status-gateway/src/coreHandler.ts +++ b/packages/live-status-gateway/src/coreHandler.ts @@ -15,11 +15,7 @@ import { PeripheralDeviceType, } from '@sofie-automation/shared-lib/dist/peripheralDevice/peripheralDeviceAPI' import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { - PeripheralDeviceCommandId, - PeripheralDeviceId, - StudioId, -} from '@sofie-automation/shared-lib/dist/core/model/Ids' +import { PeripheralDeviceCommandId, StudioId } from '@sofie-automation/shared-lib/dist/core/model/Ids' import { StatusCode } from '@sofie-automation/shared-lib/dist/lib/status' import { PeripheralDeviceCommand } from '@sofie-automation/shared-lib/dist/core/model/PeripheralDeviceCommand' import { LiveStatusGatewayConfig } from './generated/options' From bff9864cb4764770d7512a31ffb6cd1af5a2ca2c Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 24 Jun 2024 08:43:34 +0200 Subject: [PATCH 06/10] chore: fix --- packages/live-status-gateway/src/topics/root.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/live-status-gateway/src/topics/root.ts b/packages/live-status-gateway/src/topics/root.ts index 444ec7fe23..6307aae73a 100644 --- a/packages/live-status-gateway/src/topics/root.ts +++ b/packages/live-status-gateway/src/topics/root.ts @@ -53,7 +53,7 @@ export class RootChannel extends WebSocketTopicBase implements WebSocketTopic { constructor(logger: Logger) { super('Root', logger) - this._heartbeat = setInterval(() => this._subscribers.forEach((ws) => this.sendHeartbeat(ws)), 2000) + this._heartbeat = setInterval(() => this.sendHeartbeat(this._subscribers), 2000) } close(): void { From bf81baf9ff520ad6a9fc9b6378ce6ceeac320645 Mon Sep 17 00:00:00 2001 From: Jan Starzak Date: Mon, 24 Jun 2024 10:57:46 +0200 Subject: [PATCH 07/10] fix: refactor VirtualElement to be a FC --- meteor/client/lib/VirtualElement.tsx | 309 +++++++++++++-------------- meteor/package.json | 2 +- meteor/yarn.lock | 16 +- tsconfig.json | 3 + 4 files changed, 163 insertions(+), 167 deletions(-) create mode 100644 tsconfig.json diff --git a/meteor/client/lib/VirtualElement.tsx b/meteor/client/lib/VirtualElement.tsx index 6c00e4e433..1b825b7292 100644 --- a/meteor/client/lib/VirtualElement.tsx +++ b/meteor/client/lib/VirtualElement.tsx @@ -1,29 +1,6 @@ -import * as React from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { InView } from 'react-intersection-observer' -export interface IProps { - initialShow?: boolean - placeholderHeight?: number - _debug?: boolean - placeholderClassName?: string - width?: string | number - margin?: string - id?: string | undefined - className?: string -} - -declare global { - interface Window { - requestIdleCallback( - callback: Function, - options?: { - timeout: number - } - ): number - cancelIdleCallback(callback: number) - } -} - interface IElementMeasurements { width: string | number clientHeight: number @@ -34,160 +11,172 @@ interface IElementMeasurements { id: string | undefined } -interface IState extends IElementMeasurements { - inView: boolean - isMeasured: boolean -} - const OPTIMIZE_PERIOD = 5000 +const IDLE_CALLBACK_TIMEOUT = 100 + /** * This is a component that allows optimizing the amount of elements present in the DOM through replacing them * with placeholders when they aren't visible in the viewport. * * @export - * @class VirtualElement - * @extends {React.Component} + * @param {(React.PropsWithChildren<{ + * initialShow?: boolean + * placeholderHeight?: number + * _debug?: boolean + * placeholderClassName?: string + * width?: string | number + * margin?: string + * id?: string | undefined + * className?: string + * }>)} { + * initialShow, + * placeholderHeight, + * placeholderClassName, + * width, + * margin, + * id, + * className, + * children, + * } + * @return {*} {(JSX.Element | null)} */ -export class VirtualElement extends React.Component, IState> { - private el: HTMLElement | null = null - private instance: HTMLElement | null = null - private optimizeTimeout: NodeJS.Timer | null = null - private refreshSizingTimeout: NodeJS.Timer | null = null - private styleObj: CSSStyleDeclaration | undefined - - constructor(props: IProps) { - super(props) - this.state = { - inView: props.initialShow || false, - isMeasured: false, - clientHeight: 0, - width: 'auto', - marginBottom: undefined, - marginTop: undefined, - marginLeft: undefined, - marginRight: undefined, - id: undefined, +export function VirtualElement({ + initialShow, + placeholderHeight, + placeholderClassName, + width, + margin, + id, + className, + children, +}: React.PropsWithChildren<{ + initialShow?: boolean + placeholderHeight?: number + _debug?: boolean + placeholderClassName?: string + width?: string | number + margin?: string + id?: string | undefined + className?: string +}>): JSX.Element | null { + const [inView, setInView] = useState(initialShow ?? false) + const [isShowingChildren, setIsShowingChildren] = useState(inView) + const [measurements, setMeasurements] = useState(null) + const [ref, setRef] = useState(null) + const [childRef, setChildRef] = useState(null) + + const isMeasured = !!measurements + + const styleObj = useMemo( + () => ({ + width: width ?? measurements?.width ?? 'auto', + height: (measurements?.clientHeight ?? placeholderHeight ?? '0') + 'px', + marginTop: measurements?.marginTop, + marginLeft: measurements?.marginLeft, + marginRight: measurements?.marginRight, + marginBottom: measurements?.marginBottom, + }), + [width, measurements, placeholderHeight] + ) + + const onVisibleChanged = useCallback((visible: boolean) => { + setInView(visible) + }, []) + + useEffect(() => { + if (inView === true) { + setIsShowingChildren(true) + return } - } - private visibleChanged = (inView: boolean) => { - this.props._debug && console.log(this.props.id, 'Changed', inView) - if (this.optimizeTimeout) { - clearTimeout(this.optimizeTimeout) - this.optimizeTimeout = null - } - if (inView && !this.state.inView) { - this.setState({ - inView, - }) - } else if (!inView && this.state.inView) { - this.optimizeTimeout = setTimeout(() => { - this.optimizeTimeout = null - const measurements = this.measureElement() || undefined - this.setState({ - inView, - - isMeasured: measurements ? true : false, - ...measurements, - } as IState) - }, OPTIMIZE_PERIOD) - } - } + let idleCallback: number | undefined + const optimizeTimeout = window.setTimeout(() => { + idleCallback = window.requestIdleCallback( + () => { + if (childRef) { + setMeasurements(measureElement(childRef)) + } + setIsShowingChildren(false) + }, + { + timeout: IDLE_CALLBACK_TIMEOUT, + } + ) + }, OPTIMIZE_PERIOD) - private measureElement = (): IElementMeasurements | null => { - if (this.el) { - const style = this.styleObj || window.getComputedStyle(this.el) - this.styleObj = style - this.props._debug && console.log(this.props.id, 'Re-measuring child', this.el.clientHeight) - - return { - width: style.width || 'auto', - clientHeight: this.el.clientHeight, - marginTop: style.marginTop || undefined, - marginBottom: style.marginBottom || undefined, - marginLeft: style.marginLeft || undefined, - marginRight: style.marginRight || undefined, - id: this.el.id, + return () => { + if (idleCallback) { + window.cancelIdleCallback(idleCallback) } - } - - return null - } - private refreshSizing = () => { - this.refreshSizingTimeout = null - const measurements = this.measureElement() - if (measurements) { - this.setState({ - isMeasured: true, - ...measurements, - }) + window.clearTimeout(optimizeTimeout) } - } + }, [childRef, inView]) - private findChildElement = () => { - if (!this.el || !this.el.parentElement) { - const el = this.instance ? (this.instance.firstElementChild as HTMLElement) : null - if (el && !el.classList.contains('virtual-element-placeholder')) { - this.el = el - this.styleObj = undefined - this.refreshSizingTimeout = setTimeout(this.refreshSizing, 250) - } - } - } + const showPlaceholder = !isShowingChildren && (!initialShow || isMeasured) - private setRef = (instance: HTMLElement | null) => { - this.instance = instance - this.findChildElement() - } + useLayoutEffect(() => { + if (!ref || showPlaceholder) return - componentDidUpdate(_: IProps, prevState: IState): void { - if (this.state.inView && prevState.inView !== this.state.inView) { - this.findChildElement() - } - } + const el = ref?.firstElementChild + if (!el || el.classList.contains('virtual-element-placeholder') || !(el instanceof HTMLElement)) return - componentWillUnmount(): void { - if (this.optimizeTimeout) clearTimeout(this.optimizeTimeout) - if (this.refreshSizingTimeout) clearTimeout(this.refreshSizingTimeout) - } + setChildRef(el) - render(): JSX.Element { - this.props._debug && - console.log( - this.props.id, - this.state.inView, - this.props.initialShow, - this.state.isMeasured, - !this.state.inView && (!this.props.initialShow || this.state.isMeasured) + let idleCallback: number | undefined + const refreshSizingTimeout = window.setTimeout(() => { + idleCallback = window.requestIdleCallback( + () => { + setMeasurements(measureElement(el)) + }, + { + timeout: IDLE_CALLBACK_TIMEOUT, + } ) - return ( - -
- {!this.state.inView && (!this.props.initialShow || this.state.isMeasured) ? ( -
- ) : ( - this.props.children - )} -
-
- ) + }, 1000) + + return () => { + if (idleCallback) { + window.cancelIdleCallback(idleCallback) + } + window.clearTimeout(refreshSizingTimeout) + } + }, [ref, showPlaceholder]) + + return ( + +
+ {showPlaceholder ? ( +
+ ) : ( + children + )} +
+
+ ) +} + +function measureElement(el: HTMLElement): IElementMeasurements | null { + const style = window.getComputedStyle(el) + const clientRect = el.getBoundingClientRect() + + return { + width: style.width || 'auto', + clientHeight: clientRect.height, + marginTop: style.marginTop || undefined, + marginBottom: style.marginBottom || undefined, + marginLeft: style.marginLeft || undefined, + marginRight: style.marginRight || undefined, + id: el.id, } } diff --git a/meteor/package.json b/meteor/package.json index 4c89f893c4..84850a5b53 100644 --- a/meteor/package.json +++ b/meteor/package.json @@ -93,7 +93,7 @@ "react-dom": "^18.2.0", "react-hotkeys": "^2.0.0", "react-i18next": "^11.18.6", - "react-intersection-observer": "^9.4.3", + "react-intersection-observer": "^9.10.3", "react-moment": "^0.9.7", "react-popper": "^2.2.5", "react-router-dom": "^5.3.3", diff --git a/meteor/yarn.lock b/meteor/yarn.lock index dc218fdee4..a757a7df1e 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -3558,7 +3558,7 @@ __metadata: react-dom: ^18.2.0 react-hotkeys: ^2.0.0 react-i18next: ^11.18.6 - react-intersection-observer: ^9.4.3 + react-intersection-observer: ^9.10.3 react-moment: ^0.9.7 react-popper: ^2.2.5 react-router-dom: ^5.3.3 @@ -10856,12 +10856,16 @@ __metadata: languageName: node linkType: hard -"react-intersection-observer@npm:^9.4.3": - version: 9.4.3 - resolution: "react-intersection-observer@npm:9.4.3" +"react-intersection-observer@npm:^9.10.3": + version: 9.10.3 + resolution: "react-intersection-observer@npm:9.10.3" peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - checksum: ac31c6c76ce72019a1fb50fe6b53ef6429d98b9e9b937f92d16635ef8586392c7058bb61526a8fe6bea6ce36c006015fe2d8893bac43eda6157b9e6b17ad9b68 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + checksum: 482c89a432e582749f3cb3dd696e08638a92e41fbcb81bcb3dc3cadebcf8b40bc47e7a52d2a7e8c4f9eb2a3c1c29b4cb0f21007c1540da05893b5abb11d7a761 languageName: node linkType: hard diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..4086555c7b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./meteor/tsconfig.json", +} \ No newline at end of file From a0ea9b48997fe39e8db5ababef744c103a68509f Mon Sep 17 00:00:00 2001 From: Jan Starzak Date: Wed, 26 Jun 2024 01:26:56 +0200 Subject: [PATCH 08/10] fix(LSG): Token "examples" does not exist when running `yarn gendocs` --- .../live-status-gateway/api/schemas/adLibs.yaml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/live-status-gateway/api/schemas/adLibs.yaml b/packages/live-status-gateway/api/schemas/adLibs.yaml index b98a84e17f..5cb6d6df69 100644 --- a/packages/live-status-gateway/api/schemas/adLibs.yaml +++ b/packages/live-status-gateway/api/schemas/adLibs.yaml @@ -30,7 +30,7 @@ $defs: adLibs: $ref: '#/$defs/adLib/examples' globalAdLibs: - $ref: '#/$defs/adLib/examples' + $ref: '#/$defs/adLibBase/examples' adLib: allOf: - $ref: '#/$defs/adLibBase' @@ -41,6 +41,18 @@ $defs: partId: description: Unique id of the part this adLib belongs to required: [segmentId, partId] + examples: + - id: 'C6K_yIMuGFUk8X_L9A9_jRT6aq4_' + name: Music video clip + sourceLayer: Video Clip + actionType: + - name: pvw + label: Preview + tags: ['music_video'] + segmentId: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_' + partId: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_' + publicData: + fileName: MV000123.mxf globalAdLib: $ref: '#/$defs/adLibBase' adLibBase: From e2ecc7eb48b9ad6c0d95b299d954abac577e70a7 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 2 Jul 2024 08:07:04 +0200 Subject: [PATCH 09/10] fix: make stringifyError handle UserError better --- packages/corelib/src/__tests__/error.spec.ts | 29 +++++++++++++++++++ packages/shared-lib/src/lib/stringifyError.ts | 13 +++++---- 2 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 packages/corelib/src/__tests__/error.spec.ts diff --git a/packages/corelib/src/__tests__/error.spec.ts b/packages/corelib/src/__tests__/error.spec.ts new file mode 100644 index 0000000000..1116728509 --- /dev/null +++ b/packages/corelib/src/__tests__/error.spec.ts @@ -0,0 +1,29 @@ +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { UserError, UserErrorMessage } from '../error' + +describe('UserError', () => { + test('stringifyError', () => { + const rawError = new Error('raw') + rawError.stack = 'mock stack' + const userError = UserError.from(rawError, UserErrorMessage.PartNotFound, { key: 'translatable message' }) + + expect(stringifyError(userError)).toEqual( + 'UserError: ' + + JSON.stringify({ + rawError: 'Error: raw, mock stack', + message: { + key: 'The selected part does not exist', + args: { + key: 'translatable message', + }, + }, + key: 25, + errorCode: 500, + }) + ) + + // serialized and restored + const restored = JSON.parse(userError.toString()) + expect(stringifyError(restored)).toEqual('raw, mock stack') + }) +}) diff --git a/packages/shared-lib/src/lib/stringifyError.ts b/packages/shared-lib/src/lib/stringifyError.ts index 69a5cd7270..abd1ea3d68 100644 --- a/packages/shared-lib/src/lib/stringifyError.ts +++ b/packages/shared-lib/src/lib/stringifyError.ts @@ -11,10 +11,13 @@ export function stringifyError(error: unknown, noStack = false): string { // Has a custom toString() method str = `${(error as any).toString()}` } else { - str = '' - if ((error as Error).message) str += `${(error as Error).message} ` // Is an Error - if ((error as any).reason) str += `${(error as any).reason} ` // Is a Meteor.Error - if ((error as any).details) str += `${(error as any).details} ` + const strings: string[] = [] + if (typeof (error as any).rawError === 'string') strings.push(`${(error as any).rawError}`) // Is an UserError + if (typeof (error as Error).message === 'string') strings.push(`${(error as Error).message}`) // Is an Error + if (typeof (error as any).reason === 'string') strings.push(`${(error as any).reason}`) // Is a Meteor.Error + if (typeof (error as any).details === 'string') strings.push(` ${(error as any).details}`) + + str = strings.join(', ') } if (!str) { @@ -34,7 +37,7 @@ export function stringifyError(error: unknown, noStack = false): string { } if (!noStack) { - if (error && typeof error === 'object' && (error as any).stack) { + if (error && typeof error === 'object' && typeof (error as any).stack === 'string') { str += ', ' + (error as any).stack } } From 02330735fd4f3b03f9bff84e4c41fb9199a4623e Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 2 Jul 2024 09:08:43 +0200 Subject: [PATCH 10/10] fix: further improve stringifyError --- packages/shared-lib/src/lib/stringifyError.ts | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/shared-lib/src/lib/stringifyError.ts b/packages/shared-lib/src/lib/stringifyError.ts index abd1ea3d68..f97659813f 100644 --- a/packages/shared-lib/src/lib/stringifyError.ts +++ b/packages/shared-lib/src/lib/stringifyError.ts @@ -11,13 +11,13 @@ export function stringifyError(error: unknown, noStack = false): string { // Has a custom toString() method str = `${(error as any).toString()}` } else { - const strings: string[] = [] - if (typeof (error as any).rawError === 'string') strings.push(`${(error as any).rawError}`) // Is an UserError - if (typeof (error as Error).message === 'string') strings.push(`${(error as Error).message}`) // Is an Error - if (typeof (error as any).reason === 'string') strings.push(`${(error as any).reason}`) // Is a Meteor.Error - if (typeof (error as any).details === 'string') strings.push(` ${(error as any).details}`) - - str = strings.join(', ') + const strings: (string | undefined)[] = [ + stringify((error as any).rawError), // UserError + stringify((error as Error).message), // Error + stringify((error as any).reason), // Meteor.Error + stringify((error as any).details), + ] + str = strings.filter(Boolean).join(', ') } if (!str) { @@ -46,3 +46,15 @@ export function stringifyError(error: unknown, noStack = false): string { return str } + +function stringify(v: any): string | undefined { + // Tries to stringify objects if they have a toString() that returns something sensible + if (v === undefined) return undefined + if (v === null) return 'null' + + if (typeof v === 'object') { + const str = `${v}` + if (str !== '[object Object]') return str + return undefined + } else return `${v}` +}