From fe1c159ebff48a2873d9e662bb46be5fbd8d17b7 Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 16 Dec 2024 12:05:23 +0100 Subject: [PATCH 001/293] feat: UI - presenter timing counter remaing part/segment --- .../webui/src/client/styles/countdown/presenter.scss | 4 ++++ .../webui/src/client/ui/ClockView/PresenterScreen.tsx | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/styles/countdown/presenter.scss b/packages/webui/src/client/styles/countdown/presenter.scss index 03aab547eb..0f2a939f43 100644 --- a/packages/webui/src/client/styles/countdown/presenter.scss +++ b/packages/webui/src/client/styles/countdown/presenter.scss @@ -118,6 +118,10 @@ $hold-status-color: $liveline-timecode-color; padding: 0 0.2em; line-height: 1em; + > .overtime { + color: $general-late-color; + } + > img.freeze-icon { width: 0.9em; height: 0.9em; diff --git a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx index 6f3469d7bc..4cba09eab1 100644 --- a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx @@ -41,6 +41,7 @@ import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { useSetDocumentClass } from '../util/useSetDocumentClass' import { useRundownAndShowStyleIdsForPlaylist } from '../util/useRundownAndShowStyleIdsForPlaylist' import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' +import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining' interface SegmentUi extends DBSegment { items: Array @@ -460,7 +461,11 @@ function PresenterScreenContentDefaultLayout({ />
- + {/* + /> */}
From e217db10bd7b835a761bf8c0f570bbc1772cb896 Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 16 Dec 2024 12:38:36 +0100 Subject: [PATCH 002/293] feat: UI - presenter timing only use PartOrSegmentRemaining if type is SEGMENT_BUDGET_DURATION --- .../client/ui/ClockView/PresenterScreen.tsx | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx index 4cba09eab1..835e5a6b31 100644 --- a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx @@ -13,7 +13,7 @@ import { PieceIconContainer } from '../PieceIcons/PieceIcon' import { PieceNameContainer } from '../PieceIcons/PieceName' import { Timediff } from './Timediff' import { RundownUtils } from '../../lib/rundown' -import { PieceLifespan } from '@sofie-automation/blueprints-integration' +import { CountdownType, PieceLifespan } from '@sofie-automation/blueprints-integration' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { PieceCountdownContainer } from '../PieceIcons/PieceCountdown' import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' @@ -461,21 +461,24 @@ function PresenterScreenContentDefaultLayout({ />
- - {/* */} + {currentSegment?.segmentTiming?.countdownType === CountdownType.SEGMENT_BUDGET_DURATION ? ( + + ) : ( + + )}
From 32525311e6d3586eafadf8a50b3000dd0549b0ed Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 7 Jan 2025 12:09:00 +0100 Subject: [PATCH 003/293] refactor(EAV-450): get rid of async in handlers; extract shared logic to base classes --- .../src/collections/adLibActionsHandler.ts | 91 +++---- .../src/collections/adLibsHandler.ts | 87 +++--- .../collections/globalAdLibActionsHandler.ts | 78 ++---- .../src/collections/globalAdLibsHandler.ts | 85 ++---- .../src/collections/partHandler.ts | 117 +++----- .../src/collections/partInstancesHandler.ts | 102 ++++--- .../src/collections/partsHandler.ts | 25 +- .../src/collections/pieceInstancesHandler.ts | 236 +++++++--------- .../src/collections/playlistHandler.ts | 86 ++---- .../src/collections/rundownHandler.ts | 98 +++---- .../src/collections/rundownsHandler.ts | 21 +- .../src/collections/segmentHandler.ts | 115 ++++---- .../src/collections/segmentsHandler.ts | 22 +- .../src/collections/showStyleBaseHandler.ts | 56 ++-- .../src/collections/studioHandler.ts | 46 +--- .../src/helpers/equality.ts | 75 ++++++ .../src/liveStatusServer.ts | 125 ++++----- .../src/topics/__tests__/activePieces.spec.ts | 16 +- .../topics/__tests__/activePlaylist.spec.ts | 45 ++-- .../src/topics/__tests__/adLibs.spec.ts | 30 ++- .../topics/__tests__/segmentsTopic.spec.ts | 92 +++---- .../src/topics/__tests__/utils.ts | 39 +++ .../src/topics/activePiecesTopic.ts | 105 +++----- .../src/topics/activePlaylistTopic.ts | 222 +++++++-------- .../src/topics/adLibsTopic.ts | 200 ++++++-------- .../src/topics/helpers/segmentTiming.ts | 15 +- .../live-status-gateway/src/topics/root.ts | 4 + .../src/topics/segmentsTopic.ts | 108 +++----- .../src/topics/studioTopic.ts | 82 +++--- packages/live-status-gateway/src/wsHandler.ts | 252 ++++++++++++++---- 30 files changed, 1227 insertions(+), 1448 deletions(-) create mode 100644 packages/live-status-gateway/src/helpers/equality.ts diff --git a/packages/live-status-gateway/src/collections/adLibActionsHandler.ts b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts index 256d1fb571..8411660e64 100644 --- a/packages/live-status-gateway/src/collections/adLibActionsHandler.ts +++ b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts @@ -1,79 +1,56 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { AdLibActionId, RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { SelectedPartInstances } from './partInstancesHandler' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { CollectionHandlers } from '../liveStatusServer' + +const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const +type Playlist = PickArr export class AdLibActionsHandler - extends CollectionBase - implements Collection, CollectionObserver + extends PublicationCollection + implements Collection { - public observerName: string - private _curRundownId: RundownId | undefined - private _curPartInstance: DBPartInstance | undefined + private _currentRundownId: RundownId | undefined constructor(logger: Logger, coreHandler: CoreHandler) { - super(AdLibActionsHandler.name, CollectionName.AdLibActions, CorelibPubSub.adLibActions, logger, coreHandler) - this.observerName = this._name + super(CollectionName.AdLibActions, CorelibPubSub.adLibActions, logger, coreHandler) + } + + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) } - async changed(id: AdLibActionId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName) return - const col = this._core.getCollection(this._collectionName) - if (!col) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = col.find({ rundownId: this._curRundownId }) - await this.notify(this._collectionData) + protected changed(): void { + this.updateAndNotify() } - async update(source: string, data: SelectedPartInstances | undefined): Promise { - this.logUpdateReceived('partInstances', source) - const prevRundownId = this._curRundownId - this._curPartInstance = data ? data.current ?? data.next : undefined - this._curRundownId = this._curPartInstance ? this._curPartInstance.rundownId : undefined + private onPlaylistUpdate = (data: Playlist | undefined): void => { + this.logUpdateReceived('playlist') + const prevRundownId = this._currentRundownId - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return - if (prevRundownId !== this._curRundownId) { - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - if (this._dbObserver) this._dbObserver.stop() - if (this._curRundownId && this._curPartInstance) { - this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, [ - this._curRundownId, - ]) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - this._dbObserver.removed = (id) => { - void this.changed(id, 'removed').catch(this._logger.error) - } + const rundownPlaylist = data - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = collection.find({ - rundownId: this._curRundownId, - }) - await this.notify(this._collectionData) + this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId + + if (prevRundownId !== this._currentRundownId) { + this.stopSubscription() + if (this._currentRundownId) { + this.setupSubscription([this._currentRundownId]) } + // no need to trigger updateAndNotify() because the subscription will take care of this } } - // override notify to implement empty array handling - async notify(data: AdLibAction[] | undefined): Promise { - this.logNotifyingUpdate(data?.length) - if (data !== undefined) { - for (const observer of this._observers) { - await observer.update(this._name, data) - } - } + protected updateAndNotify(): void { + const col = this.getCollectionOrFail() + this._collectionData = col.find({ rundownId: this._currentRundownId }) + this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/adLibsHandler.ts b/packages/live-status-gateway/src/collections/adLibsHandler.ts index e34fbcb11f..15ae8ce348 100644 --- a/packages/live-status-gateway/src/collections/adLibsHandler.ts +++ b/packages/live-status-gateway/src/collections/adLibsHandler.ts @@ -1,80 +1,55 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { PieceId, RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { SelectedPartInstances } from './partInstancesHandler' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { CollectionHandlers } from '../liveStatusServer' + +const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const +type Playlist = PickArr export class AdLibsHandler - extends CollectionBase - implements Collection, CollectionObserver + extends PublicationCollection + implements Collection { - public observerName: string - // private _core: CoreConnection private _currentRundownId: RundownId | undefined - private _currentPartInstance: DBPartInstance | undefined constructor(logger: Logger, coreHandler: CoreHandler) { - super(AdLibsHandler.name, CollectionName.AdLibPieces, CorelibPubSub.adLibPieces, logger, coreHandler) - this.observerName = this._name + super(CollectionName.AdLibPieces, CorelibPubSub.adLibPieces, logger, coreHandler) + } + + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) } - async changed(id: PieceId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName) return - const col = this._core.getCollection(this._collectionName) - if (!col) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = col.find({ rundownId: this._currentRundownId }) - await this.notify(this._collectionData) + changed(): void { + this.updateAndNotify() } - async update(source: string, data: SelectedPartInstances | undefined): Promise { - this.logUpdateReceived('partInstances', source) + private onPlaylistUpdate = (data: Playlist | undefined): void => { + this.logUpdateReceived('playlist') const prevRundownId = this._currentRundownId - this._currentPartInstance = data ? data.current ?? data.next : undefined - this._currentRundownId = this._currentPartInstance?.rundownId + const rundownPlaylist = data - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return - if (prevRundownId !== this._currentRundownId) { - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - if (this._dbObserver) this._dbObserver.stop() - if (this._currentRundownId && this._currentPartInstance) { - this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, [ - this._currentRundownId, - ]) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - this._dbObserver.removed = (id) => { - void this.changed(id, 'removed').catch(this._logger.error) - } + this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = collection.find({ - rundownId: this._currentRundownId, - }) - await this.notify(this._collectionData) + if (prevRundownId !== this._currentRundownId) { + this.stopSubscription() + if (this._currentRundownId) { + this.setupSubscription([this._currentRundownId]) } + // no need to trigger updateAndNotify() because the subscription will take care of this } } - // override notify to implement empty array handling - async notify(data: AdLibPiece[] | undefined): Promise { - this.logNotifyingUpdate(data?.length) - if (data !== undefined) { - for (const observer of this._observers) { - await observer.update(this._name, data) - } - } + protected updateAndNotify(): void { + const collection = this.getCollectionOrFail() + this._collectionData = collection.find({ rundownId: this._currentRundownId }) + this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts index 4ec34285b6..349ec2cbca 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts @@ -1,85 +1,63 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { RundownBaselineAdLibActionId, RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { SelectedPartInstances } from './partInstancesHandler' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { CollectionHandlers } from '../liveStatusServer' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const +type Playlist = PickArr export class GlobalAdLibActionsHandler - extends CollectionBase< + extends PublicationCollection< RundownBaselineAdLibAction[], CorelibPubSub.rundownBaselineAdLibActions, CollectionName.RundownBaselineAdLibActions > - implements Collection, CollectionObserver + implements Collection { - public observerName: string private _currentRundownId: RundownId | undefined constructor(logger: Logger, coreHandler: CoreHandler) { super( - GlobalAdLibActionsHandler.name, CollectionName.RundownBaselineAdLibActions, CorelibPubSub.rundownBaselineAdLibActions, logger, coreHandler ) - this.observerName = this._name } - async changed(id: RundownBaselineAdLibActionId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName) return - const col = this._core.getCollection(this._collectionName) - if (!col) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = col.find({ rundownId: this._currentRundownId }) - await this.notify(this._collectionData) + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + } + + changed(): void { + this.updateAndNotify() } - async update(source: string, data: SelectedPartInstances | undefined): Promise { - this.logUpdateReceived('partInstances', source) + private onPlaylistUpdate = (data: Playlist | undefined): void => { + this.logUpdateReceived('playlist') const prevRundownId = this._currentRundownId - const partInstance = data ? data.current ?? data.next : undefined - this._currentRundownId = partInstance?.rundownId + const rundownPlaylist = data + + this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return if (prevRundownId !== this._currentRundownId) { - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - if (this._dbObserver) this._dbObserver.stop() + this.stopSubscription() if (this._currentRundownId) { - this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, [ - this._currentRundownId, - ]) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - this._dbObserver.removed = (id) => { - void this.changed(id, 'removed').catch(this._logger.error) - } - - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = collection.find({ rundownId: this._currentRundownId }) - await this.notify(this._collectionData) + this.setupSubscription([this._currentRundownId]) } } } - // override notify to implement empty array handling - async notify(data: RundownBaselineAdLibAction[] | undefined): Promise { - this.logNotifyingUpdate(data?.length) - if (data !== undefined) { - for (const observer of this._observers) { - await observer.update(this._name, data) - } - } + protected updateAndNotify(): void { + const collection = this.getCollectionOrFail() + this._collectionData = collection.find({ rundownId: this._currentRundownId }) + this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts index 2f8f8fb662..fd449dd25e 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts @@ -1,85 +1,58 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { PieceId, RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { SelectedPartInstances } from './partInstancesHandler' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { CollectionHandlers } from '../liveStatusServer' + +const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const +type Playlist = PickArr export class GlobalAdLibsHandler - extends CollectionBase< + extends PublicationCollection< RundownBaselineAdLibItem[], CorelibPubSub.rundownBaselineAdLibPieces, CollectionName.RundownBaselineAdLibPieces > - implements Collection, CollectionObserver + implements Collection { - public observerName: string private _currentRundownId: RundownId | undefined constructor(logger: Logger, coreHandler: CoreHandler) { - super( - GlobalAdLibsHandler.name, - CollectionName.RundownBaselineAdLibPieces, - CorelibPubSub.rundownBaselineAdLibPieces, - logger, - coreHandler - ) - this.observerName = this._name + super(CollectionName.RundownBaselineAdLibPieces, CorelibPubSub.rundownBaselineAdLibPieces, logger, coreHandler) } - async changed(id: PieceId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName) return - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = collection.find({ rundownId: this._currentRundownId }) - await this.notify(this._collectionData) + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + } + + changed(): void { + this.updateAndNotify() } - async update(source: string, data: SelectedPartInstances | undefined): Promise { - this.logUpdateReceived('globalAdLibs', source) + private onPlaylistUpdate = (data: Playlist | undefined): void => { + this.logUpdateReceived('playlist') const prevRundownId = this._currentRundownId - const partInstance = data ? data.current ?? data.next : undefined - this._currentRundownId = partInstance?.rundownId + const rundownPlaylist = data + + this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return if (prevRundownId !== this._currentRundownId) { - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - if (this._dbObserver) this._dbObserver.stop() + this.stopSubscription() if (this._currentRundownId) { - this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, [ - this._currentRundownId, - ]) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - this._dbObserver.removed = (id) => { - void this.changed(id, 'removed').catch(this._logger.error) - } - - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = collection.find({ rundownId: this._currentRundownId }) - await this.notify(this._collectionData) + this.setupSubscription([this._currentRundownId]) } } } - // override notify to implement empty array handling - async notify(data: RundownBaselineAdLibItem[] | undefined): Promise { - this.logNotifyingUpdate(data?.length) - if (data !== undefined) { - for (const observer of this._observers) { - await observer.update(this._name, data) - } - } + protected updateAndNotify(): void { + const collection = this.getCollectionOrFail() + this._collectionData = collection.find({ rundownId: this._currentRundownId }) + this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/partHandler.ts b/packages/live-status-gateway/src/collections/partHandler.ts index c2df5416da..0af5bb9ee7 100644 --- a/packages/live-status-gateway/src/collections/partHandler.ts +++ b/packages/live-status-gateway/src/collections/partHandler.ts @@ -1,102 +1,71 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { PartInstancesHandler, SelectedPartInstances } from './partInstancesHandler' -import { PlaylistHandler } from './playlistHandler' +import { SelectedPartInstances } from './partInstancesHandler' import { PartsHandler } from './partsHandler' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' +import { CollectionHandlers } from '../liveStatusServer' + +const PLAYLIST_KEYS = ['_id', 'rundownIdsInOrder'] as const +type Playlist = PickArr + +const PART_INSTANCES_KEYS = ['current'] as const +type PartInstances = PickArr export class PartHandler - extends CollectionBase - implements Collection, CollectionObserver, CollectionObserver + extends PublicationCollection + implements Collection { - public observerName: string - private _activePlaylist: DBRundownPlaylist | undefined + private _activePlaylist: Playlist | undefined private _currentPartInstance: DBPartInstance | undefined constructor(logger: Logger, coreHandler: CoreHandler, private _partsHandler: PartsHandler) { - super(PartHandler.name, CollectionName.Parts, CorelibPubSub.parts, logger, coreHandler) - this.observerName = this._name + super(CollectionName.Parts, CorelibPubSub.parts, logger, coreHandler) + } + + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + handlers.partInstancesHandler.subscribe(this.onPartInstanceUpdate, PART_INSTANCES_KEYS) } - async changed(id: PartId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName) return - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) + changed(): void { + const collection = this.getCollectionOrFail() const allParts = collection.find(undefined) - await this._partsHandler.setParts(allParts) + this._partsHandler.setParts(allParts) if (this._collectionData) { this._collectionData = collection.findOne(this._collectionData._id) - await this.notify(this._collectionData) + this.notify(this._collectionData) } } - async update(source: string, data: DBRundownPlaylist | SelectedPartInstances | undefined): Promise { - const prevRundownIds = this._activePlaylist?.rundownIdsInOrder ?? [] - const prevCurPartInstance = this._currentPartInstance + private onPlaylistUpdate = (rundownPlaylist: Playlist | undefined): void => { + this.logUpdateReceived('playlist', `rundownPlaylistId ${rundownPlaylist?._id}`) + this._activePlaylist = rundownPlaylist - const rundownPlaylist = data ? (data as DBRundownPlaylist) : undefined - const partInstances = data as SelectedPartInstances - switch (source) { - case PlaylistHandler.name: - this.logUpdateReceived('playlist', source, `rundownPlaylistId ${rundownPlaylist?._id}`) - this._activePlaylist = rundownPlaylist - break - case PartInstancesHandler.name: - this.logUpdateReceived('partInstances', source) - this._currentPartInstance = partInstances.current - break - default: - throw new Error(`${this._name} received unsupported update from ${source}}`) + this.stopSubscription() + if (this._activePlaylist) { + const rundownIds = this._activePlaylist.rundownIdsInOrder + this.setupSubscription(rundownIds, null) } + } - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return - const rundownsChanged = !areElementsShallowEqual(this._activePlaylist?.rundownIdsInOrder ?? [], prevRundownIds) - if (rundownsChanged) { - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - if (this._dbObserver) this._dbObserver.stop() - if (this._activePlaylist) { - const rundownIds = this._activePlaylist.rundownIdsInOrder - this._subscriptionId = await this._coreHandler.setupSubscription( - this._publicationName, - rundownIds, - null - ) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - this._dbObserver.removed = (id) => { - void this.changed(id, 'removed').catch(this._logger.error) - } - } - } - const collection = this._core.getCollection(this._collectionName) - if (rundownsChanged) { - const allParts = collection.find(undefined) - await this._partsHandler.setParts(allParts) - } - if (prevCurPartInstance !== this._currentPartInstance) { - this._logger.debug( - `${this._name} found updated partInstances with current part ${this._activePlaylist?.currentPartInfo?.partInstanceId}` - ) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - if (this._currentPartInstance) { - this._collectionData = collection.findOne(this._currentPartInstance.part._id) - await this.notify(this._collectionData) - } + private onPartInstanceUpdate = (partInstances: PartInstances | SelectedPartInstances | undefined): void => { + if (!partInstances) return + + this.logUpdateReceived('partInstances') + this._currentPartInstance = partInstances.current + + const collection = this.getCollectionOrFail() + + if (this._currentPartInstance) { + this._collectionData = collection.findOne(this._currentPartInstance.part._id) + this.notify(this._collectionData) } } } diff --git a/packages/live-status-gateway/src/collections/partInstancesHandler.ts b/packages/live-status-gateway/src/collections/partInstancesHandler.ts index acca11e154..0597800745 100644 --- a/packages/live-status-gateway/src/collections/partInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/partInstancesHandler.ts @@ -1,6 +1,6 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' @@ -8,7 +8,8 @@ import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isSha import _ = require('underscore') import throttleToNextTick from '@sofie-automation/shared-lib/dist/lib/throttleToNextTick' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { PartInstanceId, RundownId, RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { RundownId, RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { CollectionHandlers } from '../liveStatusServer' export interface SelectedPartInstances { previous: DBPartInstance | undefined @@ -18,24 +19,31 @@ export interface SelectedPartInstances { inCurrentSegment: DBPartInstance[] } +const PLAYLIST_KEYS = [ + '_id', + 'activationId', + 'previousPartInfo', + 'currentPartInfo', + 'nextPartInfo', + 'rundownIdsInOrder', +] as const +type Playlist = PickArr + export class PartInstancesHandler - extends CollectionBase - implements Collection, CollectionObserver + extends PublicationCollection + implements Collection { - public observerName: string - private _currentPlaylist: DBRundownPlaylist | undefined + private _currentPlaylist: Playlist | undefined private _rundownIds: RundownId[] = [] private _activationId: RundownPlaylistActivationId | undefined - private _subscriptionPending = false private _throttledUpdateAndNotify = throttleToNextTick(() => { this.updateCollectionData() - this.notify(this._collectionData).catch(this._logger.error) + this.notify(this._collectionData) }) constructor(logger: Logger, coreHandler: CoreHandler) { - super(PartInstancesHandler.name, CollectionName.PartInstances, CorelibPubSub.partInstances, logger, coreHandler) - this.observerName = this._name + super(CollectionName.PartInstances, CorelibPubSub.partInstances, logger, coreHandler) this._collectionData = { previous: undefined, current: undefined, @@ -45,17 +53,19 @@ export class PartInstancesHandler } } - async changed(id: PartInstanceId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName || this._subscriptionPending) return + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + } + changed(): void { this._throttledUpdateAndNotify() } 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!`) + if (!this._collectionData) return false + const collection = this.getCollectionOrFail() const previousPartInstance = this._currentPlaylist?.previousPartInfo?.partInstanceId ? collection.findOne(this._currentPlaylist.previousPartInfo.partInstanceId) : undefined @@ -99,25 +109,25 @@ 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 - this._collectionData.inCurrentSegment = [] + if (!this._collectionData) return + this._collectionData = { + previous: undefined, + current: undefined, + next: undefined, + firstInSegmentPlayout: undefined, + inCurrentSegment: [], + } } - async update(source: string, data: DBRundownPlaylist | undefined): Promise { - const prevRundownIds = this._rundownIds.map((rid) => rid) + private onPlaylistUpdate = (data: Playlist | undefined): void => { + const prevRundownIds = [...this._rundownIds] const prevActivationId = this._activationId this.logUpdateReceived( 'playlist', - source, `rundownPlaylistId ${data?._id}, active ${data?.activationId ? true : false}` ) this._currentPlaylist = data - if (!this._collectionName) return this._rundownIds = this._currentPlaylist ? this._currentPlaylist.rundownIdsInOrder : [] this._activationId = this._currentPlaylist?.activationId @@ -125,49 +135,27 @@ export class PartInstancesHandler const sameSubscription = areElementsShallowEqual(this._rundownIds, prevRundownIds) && prevActivationId === this._activationId if (!sameSubscription) { - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return - if (!this._currentPlaylist) return - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - this._subscriptionPending = true - this._subscriptionId = await this._coreHandler.setupSubscription( - this._publicationName, - this._rundownIds, - this._activationId - ) - this._subscriptionPending = false - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - this._dbObserver.removed = (id) => { - void this.changed(id, 'removed').catch(this._logger.error) - } - - await this.updateAndNotify() + this.stopSubscription() + this.setupSubscription(this._rundownIds, this._activationId) } else if (this._subscriptionId) { - await this.updateAndNotify() + this.updateAndNotify() } else { - await this.clearAndNotify() + this.clearAndNotify() } } else { - await this.clearAndNotify() + this.clearAndNotify() } } - private async clearAndNotify() { + private clearAndNotify() { this.clearCollectionData() - await this.notify(this._collectionData) + this.notify(this._collectionData) } - private async updateAndNotify() { + private updateAndNotify() { const hasAnythingChanged = this.updateCollectionData() if (hasAnythingChanged) { - await this.notify(this._collectionData) + this.notify(this._collectionData) } } } diff --git a/packages/live-status-gateway/src/collections/partsHandler.ts b/packages/live-status-gateway/src/collections/partsHandler.ts index aece1f9c6d..1d12956527 100644 --- a/packages/live-status-gateway/src/collections/partsHandler.ts +++ b/packages/live-status-gateway/src/collections/partsHandler.ts @@ -3,36 +3,21 @@ import { CoreHandler } from '../coreHandler' import { CollectionBase, Collection } from '../wsHandler' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import _ = require('underscore') -import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' const THROTTLE_PERIOD_MS = 200 -export class PartsHandler - extends CollectionBase - implements Collection -{ - public observerName: string - private throttledNotify: (data: DBPart[]) => Promise +export class PartsHandler extends CollectionBase implements Collection { + private throttledNotify: (data: DBPart[]) => void constructor(logger: Logger, coreHandler: CoreHandler) { - super(PartsHandler.name, CollectionName.Parts, CorelibPubSub.parts, logger, coreHandler) - this.observerName = this._name + super(CollectionName.Parts, logger, coreHandler) this.throttledNotify = _.throttle(this.notify.bind(this), THROTTLE_PERIOD_MS, { leading: true, trailing: true }) } - async setParts(parts: DBPart[]): Promise { + setParts(parts: DBPart[]): void { this.logUpdateReceived('parts', parts.length) this._collectionData = parts - await this.throttledNotify(this._collectionData) - } - - async notify(data: DBPart[] | undefined): Promise { - this.logNotifyingUpdate(this._collectionData?.length) - if (data !== undefined) { - for (const observer of this._observers) { - await observer.update(this._name, data) - } - } + this.throttledNotify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts index 74bff309e2..30fdf1f280 100644 --- a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts @@ -1,26 +1,42 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' -import throttleToNextTick from '@sofie-automation/shared-lib/dist/lib/throttleToNextTick' import _ = require('underscore') import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { PartInstanceId, PieceInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { + PieceInstanceWithTimings, processAndPrunePieceInstanceTimings, resolvePrunedPieceInstance, } from '@sofie-automation/corelib/dist/playout/processAndPrune' -import { ShowStyleBaseExt, ShowStyleBaseHandler } from './showStyleBaseHandler' -import { PlaylistHandler } from './playlistHandler' +import { ShowStyleBaseExt } from './showStyleBaseHandler' import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' -import { PartInstancesHandler, SelectedPartInstances } from './partInstancesHandler' +import { SelectedPartInstances } from './partInstancesHandler' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { arePropertiesDeepEqual } from '../helpers/equality' +import { CollectionHandlers } from '../liveStatusServer' import { ReadonlyDeep } from 'type-fest' +const PLAYLIST_KEYS = [ + '_id', + 'activationId', + 'currentPartInfo', + 'nextPartInfo', + 'previousPartInfo', + 'rundownIdsInOrder', +] as const +type Playlist = PickArr + +const PART_INSTANCES_KEYS = ['previous', 'current'] as const +type PartInstances = PickArr + +const SHOW_STYLE_BASE_KEYS = ['sourceLayers'] as const +type ShowStyle = PickArr + export type PieceInstanceMin = Omit, 'reportedStartedPlayback' | 'reportedStoppedPlayback'> export interface SelectedPieceInstances { @@ -35,30 +51,16 @@ export interface SelectedPieceInstances { } export class PieceInstancesHandler - extends CollectionBase - implements Collection, CollectionObserver + extends PublicationCollection + implements Collection { - public observerName: string - private _currentPlaylist: DBRundownPlaylist | undefined + private _currentPlaylist: Playlist | undefined private _partInstanceIds: PartInstanceId[] = [] - private _activationId: string | undefined - private _subscriptionPending = false private _sourceLayers: SourceLayers = {} - private _partInstances: SelectedPartInstances | undefined - - private _throttledUpdateAndNotify = throttleToNextTick(() => { - this.updateAndNotify().catch(this._logger.error) - }) + private _partInstances: PartInstances | undefined constructor(logger: Logger, coreHandler: CoreHandler) { - super( - PieceInstancesHandler.name, - CollectionName.PieceInstances, - CorelibPubSub.pieceInstances, - logger, - coreHandler - ) - this.observerName = this._name + super(CollectionName.PieceInstances, CorelibPubSub.pieceInstances, logger, coreHandler) this._collectionData = { active: [], currentPartInstance: [], @@ -66,17 +68,23 @@ export class PieceInstancesHandler } } - async changed(id: PieceInstanceId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName || this._subscriptionPending) return - this._throttledUpdateAndNotify() + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + handlers.partInstancesHandler.subscribe(this.onPartInstancesUpdate, PART_INSTANCES_KEYS) + handlers.showStyleBaseHandler.subscribe(this.onShowStyleBaseUpdate, SHOW_STYLE_BASE_KEYS) + } + + changed(): void { + this.updateAndNotify() } private processAndPrunePieceInstanceTimings( partInstance: DBPartInstance | undefined, pieceInstances: PieceInstance[], filterActive: boolean - ): ReadonlyDeep[] { + ): PieceInstanceWithTimings[] { // 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 @@ -104,9 +112,8 @@ export class PieceInstancesHandler } 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!`) + if (!this._collectionData) return false + const collection = this.getCollectionOrFail() const inPreviousPartInstance = this._currentPlaylist?.previousPartInfo?.partInstanceId ? this.processAndPrunePieceInstanceTimings( @@ -145,20 +152,35 @@ export class PieceInstancesHandler hasAnythingChanged = true } if ( - !_.isEqual(this._collectionData.currentPartInstance, inCurrentPartInstance) && - (this._collectionData.currentPartInstance.length !== inCurrentPartInstance.length || - this._collectionData.currentPartInstance.some((pieceInstance, index) => { - return !arePropertiesDeepEqual>( - inCurrentPartInstance[index], - pieceInstance, - ['reportedStartedPlayback', 'reportedStoppedPlayback'] - ) - })) + this._collectionData.currentPartInstance.length !== inCurrentPartInstance.length || + this._collectionData.currentPartInstance.some((pieceInstance, index) => { + return !arePropertiesDeepEqual(inCurrentPartInstance[index], pieceInstance, [ + 'plannedStartedPlayback', + 'plannedStoppedPlayback', + 'reportedStartedPlayback', + 'reportedStoppedPlayback', + 'resolvedEndCap', + 'priority', + ]) + }) ) { + this._logger.debug('xcur', { prev: this._collectionData.currentPartInstance, cur: inCurrentPartInstance }) this._collectionData.currentPartInstance = inCurrentPartInstance hasAnythingChanged = true } - if (!_.isEqual(this._collectionData.nextPartInstance, inNextPartInstance)) { + if ( + this._collectionData.nextPartInstance.length !== inNextPartInstance.length || + this._collectionData.nextPartInstance.some((pieceInstance, index) => { + return !arePropertiesDeepEqual(inNextPartInstance[index], pieceInstance, [ + 'plannedStartedPlayback', + 'plannedStoppedPlayback', + 'reportedStartedPlayback', + 'reportedStoppedPlayback', + 'resolvedEndCap', + 'priority', + ]) + }) + ) { this._collectionData.nextPartInstance = inNextPartInstance hasAnythingChanged = true } @@ -166,122 +188,70 @@ export class PieceInstancesHandler } private clearCollectionData() { - if (!this._collectionName || !this._collectionData) return + if (!this._collectionData) return this._collectionData.active = [] this._collectionData.currentPartInstance = [] this._collectionData.nextPartInstance = [] } - 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 onShowStyleBaseUpdate = (showStyleBase: ShowStyle | undefined): void => { + this.logUpdateReceived('showStyleBase') + this._sourceLayers = showStyleBase?.sourceLayers ?? {} + this.updateAndNotify() } - private async updateRundownPlaylist(source: string, data: DBRundownPlaylist | undefined): Promise { + private onPartInstancesUpdate = (partInstances: PartInstances | undefined): void => { + this.logUpdateReceived('partInstances') + this._partInstances = partInstances + this.updateAndNotify() + } + + private onPlaylistUpdate = (playlist: Playlist | undefined): void => { + this.logUpdateReceived('playlist', `rundownPlaylistId ${playlist?._id}, active ${!!playlist?.activationId}`) + const prevPartInstanceIds = this._partInstanceIds - const prevActivationId = this._activationId + const prevPlaylist = this._currentPlaylist - this.logUpdateReceived('playlist', source, `rundownPlaylistId ${data?._id}, active ${!!data?.activationId}`) - this._currentPlaylist = data - if (!this._collectionName) return + this._currentPlaylist = playlist this._partInstanceIds = this._currentPlaylist - ? _.compact([ - this._currentPlaylist.previousPartInfo?.partInstanceId, - this._currentPlaylist.nextPartInfo?.partInstanceId, - this._currentPlaylist.currentPartInfo?.partInstanceId, - ]) + ? _.compact( + [ + this._currentPlaylist.previousPartInfo?.partInstanceId, + this._currentPlaylist.nextPartInfo?.partInstanceId, + this._currentPlaylist.currentPartInfo?.partInstanceId, + ].sort() + ) : [] - this._activationId = unprotectString(this._currentPlaylist?.activationId) - if (this._currentPlaylist && this._partInstanceIds.length && this._activationId) { + if (this._currentPlaylist && this._partInstanceIds.length && this._currentPlaylist?.activationId) { const sameSubscription = areElementsShallowEqual(this._partInstanceIds, prevPartInstanceIds) && - prevActivationId === this._activationId + areElementsShallowEqual( + prevPlaylist?.rundownIdsInOrder ?? [], + this._currentPlaylist.rundownIdsInOrder + ) && + prevPlaylist?.activationId === this._currentPlaylist?.activationId if (!sameSubscription) { - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return - if (!this._currentPlaylist) return - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - this._subscriptionPending = true - this._subscriptionId = await this._coreHandler.setupSubscription( - this._publicationName, - this._currentPlaylist.rundownIdsInOrder, - this._partInstanceIds, - {} - ) - this._subscriptionPending = false - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - this._dbObserver.removed = (id) => { - void this.changed(id, 'removed').catch(this._logger.error) - } - - await this.updateAndNotify() + this.setupSubscription(this._currentPlaylist.rundownIdsInOrder, this._partInstanceIds, {}) } else if (this._subscriptionId) { - await this.updateAndNotify() + this.updateAndNotify() } else { - await this.clearAndNotify() + this.clearAndNotify() } } else { - this.clearCollectionData() - await this.notify(this._collectionData) + this.clearAndNotify() } } - private async clearAndNotify() { + private clearAndNotify() { this.clearCollectionData() - await this.notify(this._collectionData) + this.notify(this._collectionData) } - private async updateAndNotify() { + private updateAndNotify() { const hasAnythingChanged = this.updateCollectionData() if (hasAnythingChanged) { - await this.notify(this._collectionData) + this.notify(this._collectionData) } } } - -function arePropertiesDeepEqual>(a: T, b: T, omitProperties: Array): boolean { - if (typeof a !== 'object' || a == null || typeof b !== 'object' || b == null) { - return false - } - - const keysA = Object.keys(a).filter((key) => !omitProperties.includes(key)) - const keysB = Object.keys(b).filter((key) => !omitProperties.includes(key)) - - if (keysA.length !== keysB.length) return false - - for (const key of keysA) { - if (!keysB.includes(key) || !_.isEqual(a[key], b[key])) { - return false - } - } - - return true -} diff --git a/packages/live-status-gateway/src/collections/playlistHandler.ts b/packages/live-status-gateway/src/collections/playlistHandler.ts index 3c81b0818c..4251cc874f 100644 --- a/packages/live-status-gateway/src/collections/playlistHandler.ts +++ b/packages/live-status-gateway/src/collections/playlistHandler.ts @@ -1,89 +1,61 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection } from '../wsHandler' +import { CollectionBase, Collection, PublicationCollection } from '../wsHandler' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { CollectionHandlers } from '../liveStatusServer' export class PlaylistsHandler - extends CollectionBase + extends CollectionBase implements Collection { - public observerName: string - constructor(logger: Logger, coreHandler: CoreHandler) { - super(PlaylistsHandler.name, CollectionName.RundownPlaylists, undefined, logger, coreHandler) - this.observerName = this._name + super(CollectionName.RundownPlaylists, logger, coreHandler) } - async setPlaylists(playlists: DBRundownPlaylist[]): Promise { + setPlaylists(playlists: DBRundownPlaylist[]): void { this.logUpdateReceived('playlists', playlists.length) this._collectionData = playlists - await this.notify(this._collectionData) - } - - // override notify to implement empty array handling - async notify(data: DBRundownPlaylist[] | undefined): Promise { - this.logNotifyingUpdate(this._collectionData?.length) - if (data !== undefined) { - for (const observer of this._observers) { - await observer.update(this._name, data) - } - } + this.notify(this._collectionData) } } export class PlaylistHandler - extends CollectionBase + extends PublicationCollection implements Collection { - public observerName: string private _playlistsHandler: PlaylistsHandler constructor(logger: Logger, coreHandler: CoreHandler) { - super( - PlaylistHandler.name, - CollectionName.RundownPlaylists, - CorelibPubSub.rundownPlaylists, - logger, - coreHandler - ) - this.observerName = this._name + super(CollectionName.RundownPlaylists, CorelibPubSub.rundownPlaylists, logger, coreHandler) this._playlistsHandler = new PlaylistsHandler(this._logger, this._coreHandler) } - async init(): Promise { - await super.init() - if (!this._studioId) return - if (!this._collectionName) return - if (!this._publicationName) return - this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, null, [this._studioId]) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - if (this._collectionName) { - const col = this._core.getCollection(this._collectionName) - if (!col) throw new Error(`collection '${this._collectionName}' not found!`) - const playlists = col.find(undefined) - this._collectionData = playlists.find((p) => p.activationId) - await this._playlistsHandler.setPlaylists(playlists) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - } + init(handlers: CollectionHandlers): void { + super.init(handlers) + this.setupSubscription(null, [this._studioId]) } - async changed(id: RundownPlaylistId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName) return - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) + changed(): void { + this.updateAndNotify() + } + + protected updateAndNotify(): void { + const collection = this.getCollectionOrFail() const playlists = collection.find(undefined) - await this._playlistsHandler.setPlaylists(playlists) - this._collectionData = playlists.find((p) => p.activationId) - await this.notify(this._collectionData) + this._playlistsHandler.setPlaylists(playlists) + + this.updateAndNotifyActivePlaylist(playlists) + } + + private updateAndNotifyActivePlaylist(playlists: DBRundownPlaylist[]) { + const prevActivePlaylist = this._collectionData + const activePlaylist = playlists.find((p) => p.activationId) + this._collectionData = activePlaylist + if (prevActivePlaylist !== activePlaylist) { + this.notify(this._collectionData) + } } get playlistsHandler(): PlaylistsHandler { diff --git a/packages/live-status-gateway/src/collections/rundownHandler.ts b/packages/live-status-gateway/src/collections/rundownHandler.ts index c59f077874..a29bd994e9 100644 --- a/packages/live-status-gateway/src/collections/rundownHandler.ts +++ b/packages/live-status-gateway/src/collections/rundownHandler.ts @@ -1,89 +1,69 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { PartInstancesHandler, SelectedPartInstances } from './partInstancesHandler' import { RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' -import { PlaylistHandler } from './playlistHandler' import { RundownsHandler } from './rundownsHandler' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { unprotectString } from '@sofie-automation/server-core-integration' +import { CollectionHandlers } from '../liveStatusServer' + +const PLAYLIST_KEYS = ['_id', 'currentPartInfo', 'nextPartInfo'] as const +type Playlist = PickArr export class RundownHandler - extends CollectionBase - implements Collection, CollectionObserver, CollectionObserver + extends PublicationCollection + implements Collection { - public observerName: string private _currentPlaylistId: RundownPlaylistId | undefined private _currentRundownId: RundownId | undefined constructor(logger: Logger, coreHandler: CoreHandler, private _rundownsHandler?: RundownsHandler) { - super(RundownHandler.name, CollectionName.Rundowns, CorelibPubSub.rundownsInPlaylists, logger, coreHandler) - this.observerName = this._name + super(CollectionName.Rundowns, CorelibPubSub.rundownsInPlaylists, logger, coreHandler) } - async changed(id: RundownId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (id !== this._currentRundownId) - throw new Error(`${this._name} received change with unexpected id ${id} !== ${this._currentRundownId}`) - if (!this._collectionName) return - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - await this._rundownsHandler?.setRundowns(collection.find(undefined)) - if (this._collectionData) this._collectionData = collection.findOne(this._collectionData._id) - await this.notify(this._collectionData) + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) } - async update(source: string, data: DBRundownPlaylist | SelectedPartInstances | undefined): Promise { + changed(): void { + this.updateAndNotify() + } + + protected updateAndNotify(): void { + const collection = this.getCollectionOrFail() + this._rundownsHandler?.setRundowns(collection.find(undefined)) + if (this._currentRundownId) { + this._collectionData = collection.findOne(this._currentRundownId) + } else { + this._collectionData = undefined + } + this.notify(this._collectionData) + } + + private onPlaylistUpdate = (data: Playlist | undefined): void => { const prevPlaylistId = this._currentPlaylistId const prevCurRundownId = this._currentRundownId - const rundownPlaylist = data ? (data as DBRundownPlaylist) : undefined - const partInstances = data as SelectedPartInstances - switch (source) { - case PlaylistHandler.name: - this.logUpdateReceived('playlist', source, unprotectString(rundownPlaylist?._id)) - this._currentPlaylistId = rundownPlaylist?._id - break - case PartInstancesHandler.name: - this.logUpdateReceived('partInstances', source) - this._currentRundownId = partInstances.current?.rundownId ?? partInstances.next?.rundownId - break - default: - throw new Error(`${this._name} received unsupported update from ${source}}`) - } + const rundownPlaylist = data + + this.logUpdateReceived('playlist', unprotectString(rundownPlaylist?._id)) + this._currentPlaylistId = rundownPlaylist?._id + this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return if (prevPlaylistId !== this._currentPlaylistId) { - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - if (this._dbObserver) this._dbObserver.stop() + this.stopSubscription() if (this._currentPlaylistId) { - this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, [ - this._currentPlaylistId, - ]) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } + this.setupSubscription([this._currentPlaylistId]) } + return } - if (prevCurRundownId !== this._currentPlaylistId) { - const currentPlaylistId = this._currentRundownId - if (currentPlaylistId) { - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - const rundown = collection.findOne(currentPlaylistId) - if (!rundown) throw new Error(`rundown '${currentPlaylistId}' not found!`) - this._collectionData = rundown - } else this._collectionData = undefined - await this.notify(this._collectionData) + if (prevCurRundownId !== this._currentRundownId) { + this.updateAndNotify() } } } diff --git a/packages/live-status-gateway/src/collections/rundownsHandler.ts b/packages/live-status-gateway/src/collections/rundownsHandler.ts index 0ffb07422c..539c4c87df 100644 --- a/packages/live-status-gateway/src/collections/rundownsHandler.ts +++ b/packages/live-status-gateway/src/collections/rundownsHandler.ts @@ -5,29 +5,16 @@ import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' export class RundownsHandler - extends CollectionBase + extends CollectionBase implements Collection { - public observerName: string - constructor(logger: Logger, coreHandler: CoreHandler) { - super(RundownsHandler.name, CollectionName.Rundowns, undefined, logger, coreHandler) - this.observerName = this._name + super(CollectionName.Rundowns, logger, coreHandler) } - async setRundowns(rundowns: DBRundown[]): Promise { + setRundowns(rundowns: DBRundown[]): void { this.logUpdateReceived('rundowns', rundowns.length) this._collectionData = rundowns - await this.notify(this._collectionData) - } - - // override notify to implement empty array handling - async notify(data: DBRundown[] | undefined): Promise { - this.logNotifyingUpdate(this._collectionData?.length) - if (data !== undefined) { - for (const observer of this._observers) { - await observer.update(this._name, data) - } - } + this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/segmentHandler.ts b/packages/live-status-gateway/src/collections/segmentHandler.ts index 830af41e0b..773c6126a2 100644 --- a/packages/live-status-gateway/src/collections/segmentHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentHandler.ts @@ -1,101 +1,86 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { PartInstancesHandler, SelectedPartInstances } from './partInstancesHandler' +import { SelectedPartInstances } from './partInstancesHandler' import { RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' import { SegmentsHandler } from './segmentsHandler' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { PlaylistHandler } from './playlistHandler' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { CollectionHandlers } from '../liveStatusServer' + +const PLAYLIST_KEYS = ['rundownIdsInOrder'] as const +type Playlist = PickArr + +const PART_INSTANCES_KEYS = ['current'] as const +type PartInstances = PickArr export class SegmentHandler - extends CollectionBase - implements Collection, CollectionObserver, CollectionObserver + extends PublicationCollection + implements Collection { - public observerName: string private _currentSegmentId: SegmentId | undefined private _rundownIds: RundownId[] = [] constructor(logger: Logger, coreHandler: CoreHandler, private _segmentsHandler: SegmentsHandler) { - super(SegmentHandler.name, CollectionName.Segments, CorelibPubSub.segments, logger, coreHandler) - this.observerName = this._name + super(CollectionName.Segments, CorelibPubSub.segments, logger, coreHandler) + } + + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + handlers.partInstancesHandler.subscribe(this.onPartInstancesUpdate, PART_INSTANCES_KEYS) + } + + changed(): void { + this.updateAndNotify() } - async changed(id: SegmentId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName) return - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) + private updateAndNotify() { + const collection = this.getCollectionOrFail() const allSegments = collection.find(undefined) - await this._segmentsHandler.setSegments(allSegments) - await this.updateAndNotify() + this._segmentsHandler.setSegments(allSegments) + if (this._currentSegmentId && collection.findOne(this._currentSegmentId) !== this._collectionData) { + this.updateAndNotifyCurrentSegment() + } } - private async updateAndNotify() { - const collection = this._core.getCollection(this._collectionName) - const newData = this._currentSegmentId ? collection.findOne(this._currentSegmentId) : undefined - if (this._collectionData !== newData) { - this._collectionData = newData - await this.notify(this._collectionData) + private updateAndNotifyCurrentSegment() { + const collection = this.getCollectionOrFail() + if (this._currentSegmentId) { + this._collectionData = collection.findOne(this._currentSegmentId) + this.notify(this._collectionData) } } - async update(source: string, data: SelectedPartInstances | DBRundownPlaylist | undefined): Promise { + private onPlaylistUpdate = (playlist: Playlist | undefined): void => { const previousRundownIds = this._rundownIds - switch (source) { - case PartInstancesHandler.name: { - this.logUpdateReceived('partInstances', source) - const partInstanceMap = data as SelectedPartInstances - this._currentSegmentId = data ? partInstanceMap.current?.segmentId : undefined - break - } - case PlaylistHandler.name: { - this.logUpdateReceived('playlist', source) - this._rundownIds = (data as DBRundownPlaylist | undefined)?.rundownIdsInOrder ?? [] - break - } - default: - throw new Error(`${this._name} received unsupported update from ${source}}`) - } - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return + this.logUpdateReceived('playlist') + this._rundownIds = playlist?.rundownIdsInOrder ?? [] const rundownsChanged = !areElementsShallowEqual(this._rundownIds, previousRundownIds) if (rundownsChanged) { - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - if (this._dbObserver) this._dbObserver.stop() + this.stopSubscription() if (this._rundownIds.length) { - this._subscriptionId = await this._coreHandler.setupSubscription( - this._publicationName, - this._rundownIds, - { - omitHidden: true, - } - ) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - this._dbObserver.removed = (id) => { - void this.changed(id, 'removed').catch(this._logger.error) - } + this.setupSubscription(this._rundownIds, { + omitHidden: true, + }) } } + } - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - if (rundownsChanged) { - const allSegments = collection.find(undefined) - await this._segmentsHandler.setSegments(allSegments) + private onPartInstancesUpdate = (data: PartInstances | undefined): void => { + this.logUpdateReceived('partInstances') + + const previousSegmentId = this._currentSegmentId + this._currentSegmentId = data?.current?.segmentId + + if (previousSegmentId !== this._currentSegmentId) { + this.updateAndNotifyCurrentSegment() } - await this.updateAndNotify() } } diff --git a/packages/live-status-gateway/src/collections/segmentsHandler.ts b/packages/live-status-gateway/src/collections/segmentsHandler.ts index 401def1ead..75bd03b2dc 100644 --- a/packages/live-status-gateway/src/collections/segmentsHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentsHandler.ts @@ -8,31 +8,19 @@ import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collect const THROTTLE_PERIOD_MS = 200 export class SegmentsHandler - extends CollectionBase + extends CollectionBase implements Collection { - public observerName: string - private throttledNotify: (data: DBSegment[]) => Promise + private throttledNotify: (data: DBSegment[]) => void constructor(logger: Logger, coreHandler: CoreHandler) { - super(SegmentsHandler.name, CollectionName.Segments, undefined, logger, coreHandler) - this.observerName = this._name + super(CollectionName.Segments, logger, coreHandler) this.throttledNotify = _.throttle(this.notify.bind(this), THROTTLE_PERIOD_MS, { leading: true, trailing: true }) } - async setSegments(segments: DBSegment[]): Promise { + setSegments(segments: DBSegment[]): void { this.logUpdateReceived('segments', segments.length) this._collectionData = segments - await this.throttledNotify(this._collectionData) - } - - // override notify to implement empty array handling - async notify(data: DBSegment[] | undefined): Promise { - this.logNotifyingUpdate(this._collectionData?.length) - if (data !== undefined) { - for (const observer of this._observers) { - await observer.update(this._name, data) - } - } + this.throttledNotify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts index 2cdcdd6541..81e4e8051b 100644 --- a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts +++ b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts @@ -1,6 +1,6 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { Collection, PublicationCollection } from '../wsHandler' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBShowStyleBase, OutputLayers, SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' @@ -8,6 +8,7 @@ import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collect import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { IOutputLayer, ISourceLayer } from '@sofie-automation/blueprints-integration' +import { CollectionHandlers } from '../liveStatusServer' export interface ShowStyleBaseExt extends DBShowStyleBase { sourceLayerNamesById: ReadonlyMap @@ -16,66 +17,45 @@ export interface ShowStyleBaseExt extends DBShowStyleBase { } export class ShowStyleBaseHandler - extends CollectionBase - implements Collection, CollectionObserver + extends PublicationCollection + implements Collection { - public observerName: string private _showStyleBaseId: ShowStyleBaseId | undefined private _sourceLayersMap: Map = new Map() private _outputLayersMap: Map = new Map() constructor(logger: Logger, coreHandler: CoreHandler) { - super( - ShowStyleBaseHandler.name, - CollectionName.ShowStyleBases, - CorelibPubSub.showStyleBases, - logger, - coreHandler - ) - this.observerName = this._name + super(CollectionName.ShowStyleBases, CorelibPubSub.showStyleBases, logger, coreHandler) } - async changed(id: ShowStyleBaseId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!this._collectionName) return + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.rundownHandler.subscribe(this.onRundownUpdate) + } + + changed(): void { if (this._showStyleBaseId) { this.updateCollectionData() - await this.notify(this._collectionData) + this.notify(this._collectionData) } } - async update(source: string, data: DBRundown | undefined): Promise { - this.logUpdateReceived('rundown', source, `rundownId ${data?._id}, showStyleBaseId ${data?.showStyleBaseId}`) + onRundownUpdate = (data: DBRundown | undefined): void => { + this.logUpdateReceived('rundown', `rundownId ${data?._id}, showStyleBaseId ${data?.showStyleBaseId}`) const prevShowStyleBaseId = this._showStyleBaseId this._showStyleBaseId = data?.showStyleBaseId - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return if (prevShowStyleBaseId !== this._showStyleBaseId) { - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - if (this._dbObserver) this._dbObserver.stop() + this.stopSubscription() if (this._showStyleBaseId) { - this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, [ - this._showStyleBaseId, - ]) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - - this.updateCollectionData() - await this.notify(this._collectionData) + this.setupSubscription([this._showStyleBaseId]) } } } updateCollectionData(): void { - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) + const collection = this.getCollectionOrFail() if (!this._showStyleBaseId) return const showStyleBase = collection.findOne(this._showStyleBaseId) if (!showStyleBase) { diff --git a/packages/live-status-gateway/src/collections/studioHandler.ts b/packages/live-status-gateway/src/collections/studioHandler.ts index fb7be9b795..c709ce2e76 100644 --- a/packages/live-status-gateway/src/collections/studioHandler.ts +++ b/packages/live-status-gateway/src/collections/studioHandler.ts @@ -1,53 +1,29 @@ import { Logger } from 'winston' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection } from '../wsHandler' +import { Collection, PublicationCollection } from '../wsHandler' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { StudioId } from '@sofie-automation/server-core-integration' +import { CollectionHandlers } from '../liveStatusServer' export class StudioHandler - extends CollectionBase + extends PublicationCollection implements Collection { - public observerName: string - constructor(logger: Logger, coreHandler: CoreHandler) { - super(StudioHandler.name, CollectionName.Studios, CorelibPubSub.studios, logger, coreHandler) - this.observerName = this._name + super(CollectionName.Studios, CorelibPubSub.studios, logger, coreHandler) } - async init(): Promise { - await super.init() - if (!this._collectionName) return - if (!this._publicationName) return - if (!this._studioId) return - this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, [this._studioId]) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) + init(handlers: CollectionHandlers): void { + super.init(handlers) - if (this._collectionName) { - const col = this._core.getCollection(this._collectionName) - if (!col) throw new Error(`collection '${this._collectionName}' not found!`) - const studio = col.findOne(this._studioId) - if (!studio) throw new Error(`studio '${this._studioId}' not found!`) - this._collectionData = studio - this._dbObserver.added = (id) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - } + this.setupSubscription([this._studioId]) } - async changed(id: StudioId, changeType: string): Promise { - this.logDocumentChange(id, changeType) - if (!(id === this._studioId && this._collectionName)) return - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - const studio = collection.findOne(id) - if (!studio) throw new Error(`studio '${this._studioId}' not found on changed!`) + changed(): void { + const collection = this.getCollectionOrFail() + const studio = collection.findOne(this._studioId) this._collectionData = studio - await this.notify(this._collectionData) + this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/helpers/equality.ts b/packages/live-status-gateway/src/helpers/equality.ts new file mode 100644 index 0000000000..b720caeaaf --- /dev/null +++ b/packages/live-status-gateway/src/helpers/equality.ts @@ -0,0 +1,75 @@ +import _ = require('underscore') + +export function arePropertiesShallowEqual>( + a: T, + b: Partial, + omitProperties?: readonly (keyof T)[], + selectProperties?: readonly (keyof T)[] +): boolean { + if (typeof a !== 'object' || a == null || typeof b !== 'object' || b == null) { + return false + } + + const keysA = Object.keys(a).filter( + omitProperties + ? (key) => !omitProperties.includes(key) + : selectProperties + ? (key) => selectProperties.includes(key) + : () => true + ) + const keysB = Object.keys(b).filter( + omitProperties + ? (key) => !omitProperties.includes(key) + : selectProperties + ? (key) => selectProperties.includes(key) + : () => true + ) + + if (keysA.length !== keysB.length) return false + + for (const key of keysA) { + if (!keysB.includes(key) || a[key] !== b[key]) { + return false + } + } + + return true +} + +export function arePropertiesDeepEqual>( + a: T, + b: Partial, + omitProperties?: readonly (keyof T)[], + selectProperties?: readonly (keyof T)[] +): boolean { + if (typeof a !== 'object' || a == null || typeof b !== 'object' || b == null) { + return false + } + + const keysA = Object.keys(a).filter( + omitProperties + ? (key) => !omitProperties.includes(key) + : selectProperties + ? (key) => selectProperties.includes(key) + : () => true + ) + const keysB = Object.keys(b).filter( + omitProperties + ? (key) => !omitProperties.includes(key) + : selectProperties + ? (key) => selectProperties.includes(key) + : () => true + ) + + if (keysA.length !== keysB.length) { + return false + } + + for (const key of keysA) { + if (!keysB.includes(key) || !_.isEqual(a[key], b[key])) { + return false + } + } + + return true +} diff --git a/packages/live-status-gateway/src/liveStatusServer.ts b/packages/live-status-gateway/src/liveStatusServer.ts index 90bd64c471..7f0094993e 100644 --- a/packages/live-status-gateway/src/liveStatusServer.ts +++ b/packages/live-status-gateway/src/liveStatusServer.ts @@ -3,7 +3,7 @@ import { CoreHandler } from './coreHandler' import { WebSocket, WebSocketServer } from 'ws' import { StudioHandler } from './collections/studioHandler' import { ShowStyleBaseHandler } from './collections/showStyleBaseHandler' -import { PlaylistHandler } from './collections/playlistHandler' +import { PlaylistHandler, PlaylistsHandler } from './collections/playlistHandler' import { RundownHandler } from './collections/rundownHandler' // import { RundownsHandler } from './collections/rundownsHandler' import { SegmentHandler } from './collections/segmentHandler' @@ -24,6 +24,24 @@ import { PieceInstancesHandler } from './collections/pieceInstancesHandler' import { AdLibsTopic } from './topics/adLibsTopic' import { ActivePiecesTopic } from './topics/activePiecesTopic' +export interface CollectionHandlers { + studioHandler: StudioHandler + showStyleBaseHandler: ShowStyleBaseHandler + playlistHandler: PlaylistHandler + playlistsHandler: PlaylistsHandler + rundownHandler: RundownHandler + segmentsHandler: SegmentsHandler + segmentHandler: SegmentHandler + partsHandler: PartsHandler + partHandler: PartHandler + partInstancesHandler: PartInstancesHandler + pieceInstancesHandler: PieceInstancesHandler + adLibActionsHandler: AdLibActionsHandler + adLibsHandler: AdLibsHandler + globalAdLibActionsHandler: GlobalAdLibActionsHandler + globalAdLibsHandler: GlobalAdLibsHandler +} + export class LiveStatusServer { _logger: Logger _coreHandler: CoreHandler @@ -39,94 +57,55 @@ export class LiveStatusServer { const rootChannel = new RootChannel(this._logger) - const studioTopic = new StudioTopic(this._logger) - const activePiecesTopic = new ActivePiecesTopic(this._logger) - const activePlaylistTopic = new ActivePlaylistTopic(this._logger) - const segmentsTopic = new SegmentsTopic(this._logger) - const adLibsTopic = new AdLibsTopic(this._logger) - - rootChannel.addTopic(StatusChannels.studio, studioTopic) - rootChannel.addTopic(StatusChannels.activePlaylist, activePlaylistTopic) - rootChannel.addTopic(StatusChannels.activePieces, activePiecesTopic) - rootChannel.addTopic(StatusChannels.segments, segmentsTopic) - rootChannel.addTopic(StatusChannels.adLibs, adLibsTopic) - const studioHandler = new StudioHandler(this._logger, this._coreHandler) - await studioHandler.init() const showStyleBaseHandler = new ShowStyleBaseHandler(this._logger, this._coreHandler) - await showStyleBaseHandler.init() const playlistHandler = new PlaylistHandler(this._logger, this._coreHandler) - await playlistHandler.init() - // const rundownsHandler = new RundownsHandler(this._logger, this._coreHandler) - // await rundownsHandler.init() + const playlistsHandler = playlistHandler.playlistsHandler const rundownHandler = new RundownHandler(this._logger, this._coreHandler) - await rundownHandler.init() const segmentsHandler = new SegmentsHandler(this._logger, this._coreHandler) - await segmentsHandler.init() const segmentHandler = new SegmentHandler(this._logger, this._coreHandler, segmentsHandler) - await segmentHandler.init() const partsHandler = new PartsHandler(this._logger, this._coreHandler) - await partsHandler.init() const partHandler = new PartHandler(this._logger, this._coreHandler, partsHandler) - await partHandler.init() const partInstancesHandler = new PartInstancesHandler(this._logger, this._coreHandler) - await partInstancesHandler.init() const pieceInstancesHandler = new PieceInstancesHandler(this._logger, this._coreHandler) - await pieceInstancesHandler.init() const adLibActionsHandler = new AdLibActionsHandler(this._logger, this._coreHandler) - await adLibActionsHandler.init() const adLibsHandler = new AdLibsHandler(this._logger, this._coreHandler) - await adLibsHandler.init() const globalAdLibActionsHandler = new GlobalAdLibActionsHandler(this._logger, this._coreHandler) - await globalAdLibActionsHandler.init() const globalAdLibsHandler = new GlobalAdLibsHandler(this._logger, this._coreHandler) - await globalAdLibsHandler.init() - // add observers for collection subscription updates - await playlistHandler.subscribe(rundownHandler) - await playlistHandler.subscribe(segmentHandler) - await playlistHandler.subscribe(partHandler) - await playlistHandler.subscribe(partInstancesHandler) - await playlistHandler.subscribe(pieceInstancesHandler) - await rundownHandler.subscribe(showStyleBaseHandler) - await partInstancesHandler.subscribe(rundownHandler) - await partInstancesHandler.subscribe(segmentHandler) - // partInstancesHandler.subscribe(partHandler) - await partInstancesHandler.subscribe(adLibActionsHandler) - await partInstancesHandler.subscribe(globalAdLibActionsHandler) - await partInstancesHandler.subscribe(adLibsHandler) - await partInstancesHandler.subscribe(globalAdLibsHandler) - await showStyleBaseHandler.subscribe(pieceInstancesHandler) - await partInstancesHandler.subscribe(pieceInstancesHandler) + const handlers: CollectionHandlers = { + studioHandler, + showStyleBaseHandler, + playlistHandler, + playlistsHandler, + rundownHandler, + segmentsHandler, + segmentHandler, + partsHandler, + partHandler, + partInstancesHandler, + pieceInstancesHandler, + adLibActionsHandler, + adLibsHandler, + globalAdLibActionsHandler, + globalAdLibsHandler, + } + + for (const handlerName in handlers) { + handlers[handlerName as keyof CollectionHandlers].init(handlers) + } + + const studioTopic = new StudioTopic(this._logger, handlers) + const activePiecesTopic = new ActivePiecesTopic(this._logger, handlers) + const activePlaylistTopic = new ActivePlaylistTopic(this._logger, handlers) + const segmentsTopic = new SegmentsTopic(this._logger, handlers) + const adLibsTopic = new AdLibsTopic(this._logger, handlers) - // add observers for websocket topic updates - await studioHandler.subscribe(studioTopic) - await playlistHandler.playlistsHandler.subscribe(studioTopic) - - await playlistHandler.subscribe(activePlaylistTopic) - await showStyleBaseHandler.subscribe(activePlaylistTopic) - await partInstancesHandler.subscribe(activePlaylistTopic) - await partsHandler.subscribe(activePlaylistTopic) - await pieceInstancesHandler.subscribe(activePlaylistTopic) - await segmentHandler.subscribe(activePlaylistTopic) - await segmentsHandler.subscribe(activePlaylistTopic) - - await playlistHandler.subscribe(activePiecesTopic) - await showStyleBaseHandler.subscribe(activePiecesTopic) - await pieceInstancesHandler.subscribe(activePiecesTopic) - - await playlistHandler.subscribe(segmentsTopic) - await segmentsHandler.subscribe(segmentsTopic) - await partsHandler.subscribe(segmentsTopic) - - await showStyleBaseHandler.subscribe(adLibsTopic) - await partsHandler.subscribe(adLibsTopic) - await segmentsHandler.subscribe(adLibsTopic) - await playlistHandler.subscribe(adLibsTopic) - await adLibActionsHandler.subscribe(adLibsTopic) - await adLibsHandler.subscribe(adLibsTopic) - await globalAdLibActionsHandler.subscribe(adLibsTopic) - await globalAdLibsHandler.subscribe(adLibsTopic) + rootChannel.addTopic(StatusChannels.studio, studioTopic) + rootChannel.addTopic(StatusChannels.activePlaylist, activePlaylistTopic) + rootChannel.addTopic(StatusChannels.activePieces, activePiecesTopic) + rootChannel.addTopic(StatusChannels.segments, segmentsTopic) + rootChannel.addTopic(StatusChannels.adLibs, adLibsTopic) const wss = new WebSocketServer({ port: 8080 }) wss.on('connection', (ws, request) => { diff --git a/packages/live-status-gateway/src/topics/__tests__/activePieces.spec.ts b/packages/live-status-gateway/src/topics/__tests__/activePieces.spec.ts index 7e7f469e13..8513169c11 100644 --- a/packages/live-status-gateway/src/topics/__tests__/activePieces.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/activePieces.spec.ts @@ -1,16 +1,16 @@ -import { makeMockLogger, makeMockSubscriber, makeTestPlaylist, makeTestShowStyleBase } from './utils' -import { PlaylistHandler } from '../../collections/playlistHandler' -import { ShowStyleBaseExt, ShowStyleBaseHandler } from '../../collections/showStyleBaseHandler' +import { makeMockHandlers, makeMockLogger, makeMockSubscriber, makeTestPlaylist, makeTestShowStyleBase } from './utils' +import { ShowStyleBaseExt } from '../../collections/showStyleBaseHandler' import { protectString } from '@sofie-automation/server-core-integration/dist' import { PartialDeep } from 'type-fest' import { literal } from '@sofie-automation/corelib/dist/lib' -import { PieceInstancesHandler, SelectedPieceInstances } from '../../collections/pieceInstancesHandler' +import { SelectedPieceInstances } from '../../collections/pieceInstancesHandler' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { ActivePiecesStatus, ActivePiecesTopic } from '../activePiecesTopic' describe('ActivePiecesTopic', () => { it('provides active pieces', async () => { - const topic = new ActivePiecesTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new ActivePiecesTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const currentPartInstanceId = 'CURRENT_PART_INSTANCE_ID' @@ -23,10 +23,10 @@ describe('ActivePiecesTopic', () => { partInstanceId: protectString(currentPartInstanceId), rundownId: playlist.rundownIdsInOrder[0], } - await topic.update(PlaylistHandler.name, playlist) + handlers.playlistHandler.notify(playlist) const testShowStyleBase = makeTestShowStyleBase() - await topic.update(ShowStyleBaseHandler.name, testShowStyleBase as ShowStyleBaseExt) + handlers.showStyleBaseHandler.notify(testShowStyleBase as ShowStyleBaseExt) const testPieceInstances: PartialDeep = { currentPartInstance: [], @@ -44,7 +44,7 @@ describe('ActivePiecesTopic', () => { }), ] as PieceInstance[], } - await topic.update(PieceInstancesHandler.name, testPieceInstances as SelectedPieceInstances) + handlers.pieceInstancesHandler.notify(testPieceInstances as SelectedPieceInstances) topic.addSubscriber(mockSubscriber) 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 b9b1af95b3..79d94fa7bb 100644 --- a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts @@ -1,15 +1,12 @@ import { ActivePlaylistStatus, ActivePlaylistTopic } from '../activePlaylistTopic' -import { makeMockLogger, makeMockSubscriber, makeTestPlaylist, makeTestShowStyleBase } from './utils' -import { PlaylistHandler } from '../../collections/playlistHandler' -import { ShowStyleBaseExt, ShowStyleBaseHandler } from '../../collections/showStyleBaseHandler' -import { PartInstancesHandler, SelectedPartInstances } from '../../collections/partInstancesHandler' +import { makeMockHandlers, makeMockLogger, makeMockSubscriber, makeTestPlaylist, makeTestShowStyleBase } from './utils' +import { ShowStyleBaseExt } from '../../collections/showStyleBaseHandler' +import { SelectedPartInstances } from '../../collections/partInstancesHandler' import { protectString, unprotectString, unprotectStringArray } from '@sofie-automation/server-core-integration/dist' import { PartialDeep } from 'type-fest' import { literal } from '@sofie-automation/corelib/dist/lib' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { PartsHandler } from '../../collections/partsHandler' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { SegmentHandler } from '../../collections/segmentHandler' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { CountdownType } from '@sofie-automation/blueprints-integration' import { PlaylistTimingType } from '@sofie-automation/blueprints-integration' @@ -26,18 +23,19 @@ function makeEmptyTestPartInstances(): SelectedPartInstances { describe('ActivePlaylistTopic', () => { it('notifies subscribers', async () => { - const topic = new ActivePlaylistTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new ActivePlaylistTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const playlist = makeTestPlaylist() playlist.activationId = protectString('somethingRandom') - await topic.update(PlaylistHandler.name, playlist) + handlers.playlistHandler.notify(playlist) const testShowStyleBase = makeTestShowStyleBase() - await topic.update(ShowStyleBaseHandler.name, testShowStyleBase as ShowStyleBaseExt) + handlers.showStyleBaseHandler.notify(testShowStyleBase as ShowStyleBaseExt) const testPartInstancesMap = makeEmptyTestPartInstances() - await topic.update(PartInstancesHandler.name, testPartInstancesMap) + handlers.partInstancesHandler.notify(testPartInstancesMap) topic.addSubscriber(mockSubscriber) @@ -64,7 +62,8 @@ describe('ActivePlaylistTopic', () => { }) it('provides segment and part', async () => { - const topic = new ActivePlaylistTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new ActivePlaylistTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const currentPartInstanceId = 'CURRENT_PART_INSTANCE_ID' @@ -77,12 +76,11 @@ describe('ActivePlaylistTopic', () => { partInstanceId: protectString(currentPartInstanceId), rundownId: playlist.rundownIdsInOrder[0], } - await topic.update(PlaylistHandler.name, playlist) + handlers.playlistHandler.notify(playlist) const testShowStyleBase = makeTestShowStyleBase() - await topic.update(ShowStyleBaseHandler.name, testShowStyleBase as ShowStyleBaseExt) - const segment1id = protectString('SEGMENT_1') + handlers.showStyleBaseHandler.notify(testShowStyleBase as ShowStyleBaseExt) const part1: Partial = { _id: protectString('PART_1'), title: 'Test Part', @@ -107,11 +105,11 @@ describe('ActivePlaylistTopic', () => { }), ] as DBPartInstance[], } - await topic.update(PartInstancesHandler.name, testPartInstances as SelectedPartInstances) + handlers.partInstancesHandler.notify(testPartInstances as SelectedPartInstances) - await topic.update(PartsHandler.name, [part1] as DBPart[]) + handlers.partsHandler.notify([part1] as DBPart[]) - await topic.update(SegmentHandler.name, { + handlers.segmentHandler.notify({ _id: segment1id, } as DBSegment) @@ -154,7 +152,8 @@ describe('ActivePlaylistTopic', () => { }) it('provides segment and part with segment timing', async () => { - const topic = new ActivePlaylistTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new ActivePlaylistTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const currentPartInstanceId = 'CURRENT_PART_INSTANCE_ID' @@ -167,10 +166,10 @@ describe('ActivePlaylistTopic', () => { partInstanceId: protectString(currentPartInstanceId), rundownId: playlist.rundownIdsInOrder[0], } - await topic.update(PlaylistHandler.name, playlist) + handlers.playlistHandler.notify(playlist) const testShowStyleBase = makeTestShowStyleBase() - await topic.update(ShowStyleBaseHandler.name, testShowStyleBase as ShowStyleBaseExt) + handlers.showStyleBaseHandler.notify(testShowStyleBase as ShowStyleBaseExt) const segment1id = protectString('SEGMENT_1') const part1: Partial = { @@ -198,11 +197,11 @@ describe('ActivePlaylistTopic', () => { }), ] as DBPartInstance[], } - await topic.update(PartInstancesHandler.name, testPartInstances as SelectedPartInstances) + handlers.partInstancesHandler.notify(testPartInstances as SelectedPartInstances) - await topic.update(PartsHandler.name, [part1] as DBPart[]) + handlers.partsHandler.notify([part1] as DBPart[]) - await topic.update(SegmentHandler.name, { + handlers.segmentHandler.notify({ _id: segment1id, segmentTiming: { budgetDuration: 12300, countdownType: CountdownType.SEGMENT_BUDGET_DURATION }, } as DBSegment) diff --git a/packages/live-status-gateway/src/topics/__tests__/adLibs.spec.ts b/packages/live-status-gateway/src/topics/__tests__/adLibs.spec.ts index 48d25ea994..b3579f616e 100644 --- a/packages/live-status-gateway/src/topics/__tests__/adLibs.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/adLibs.spec.ts @@ -1,13 +1,16 @@ import { protectString, unprotectString } from '@sofie-automation/server-core-integration' -import { makeMockLogger, makeMockSubscriber, makeTestParts, makeTestPlaylist, makeTestShowStyleBase } from './utils' +import { + makeMockHandlers, + makeMockLogger, + makeMockSubscriber, + makeTestParts, + makeTestPlaylist, + makeTestShowStyleBase, +} from './utils' import { AdLibsStatus, AdLibsTopic } from '../adLibsTopic' -import { PlaylistHandler } from '../../collections/playlistHandler' -import { ShowStyleBaseExt, ShowStyleBaseHandler } from '../../collections/showStyleBaseHandler' +import { ShowStyleBaseExt } from '../../collections/showStyleBaseHandler' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' -import { AdLibActionsHandler } from '../../collections/adLibActionsHandler' -import { GlobalAdLibActionsHandler } from '../../collections/globalAdLibActionsHandler' -import { PartsHandler } from '../../collections/partsHandler' function makeTestAdLibActions(): AdLibAction[] { return [ @@ -52,26 +55,27 @@ function makeTestGlobalAdLibActions(): RundownBaselineAdLibAction[] { ] } -describe('ActivePlaylistTopic', () => { +describe('AdLibsTopic', () => { it('notifies subscribers', async () => { - const topic = new AdLibsTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new AdLibsTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const playlist = makeTestPlaylist() playlist.activationId = protectString('somethingRandom') - await topic.update(PlaylistHandler.name, playlist) + handlers.playlistHandler.notify(playlist) const parts = makeTestParts() - await topic.update(PartsHandler.name, parts) + handlers.partsHandler.notify(parts) const testShowStyleBase = makeTestShowStyleBase() - await topic.update(ShowStyleBaseHandler.name, testShowStyleBase as ShowStyleBaseExt) + handlers.showStyleBaseHandler.notify(testShowStyleBase as ShowStyleBaseExt) const testAdLibActions = makeTestAdLibActions() - await topic.update(AdLibActionsHandler.name, testAdLibActions) + handlers.adLibActionsHandler.notify(testAdLibActions) const testGlobalAdLibActions = makeTestGlobalAdLibActions() - await topic.update(GlobalAdLibActionsHandler.name, testGlobalAdLibActions) + handlers.globalAdLibActionsHandler.notify(testGlobalAdLibActions) // TODO: AdLibPieces and Global AdLibPieces diff --git a/packages/live-status-gateway/src/topics/__tests__/segmentsTopic.spec.ts b/packages/live-status-gateway/src/topics/__tests__/segmentsTopic.spec.ts index c26f1f1763..3c9e06290b 100644 --- a/packages/live-status-gateway/src/topics/__tests__/segmentsTopic.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/segmentsTopic.spec.ts @@ -1,11 +1,8 @@ import { SegmentsStatus, SegmentsTopic } from '../segmentsTopic' -import { PlaylistHandler } from '../../collections/playlistHandler' import { protectString, unprotectString } from '@sofie-automation/server-core-integration' -import { SegmentsHandler } from '../../collections/segmentsHandler' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { makeMockLogger, makeMockSubscriber, makeTestPlaylist } from './utils' +import { makeMockHandlers, makeMockLogger, makeMockSubscriber, makeTestPlaylist } from './utils' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { PartsHandler } from '../../collections/partsHandler' const RUNDOWN_1_ID = 'RUNDOWN_1' const RUNDOWN_2_ID = 'RUNDOWN_2' @@ -50,7 +47,8 @@ describe('SegmentsTopic', () => { }) it('notifies added subscribers immediately', async () => { - const topic = new SegmentsTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new SegmentsTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() topic.addSubscriber(mockSubscriber) @@ -65,35 +63,38 @@ describe('SegmentsTopic', () => { }) it('notifies subscribers when playlist changes from null', async () => { - const topic = new SegmentsTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new SegmentsTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() topic.addSubscriber(mockSubscriber) mockSubscriber.send.mockClear() const testPlaylist = makeTestPlaylist() - await topic.update(PlaylistHandler.name, testPlaylist) + handlers.playlistHandler.notify(testPlaylist) const expectedStatus: SegmentsStatus = { event: 'segments', rundownPlaylistId: unprotectString(testPlaylist._id), segments: [], } + jest.advanceTimersByTime(THROTTLE_PERIOD_MS) expect(mockSubscriber.send.mock.calls).toEqual([[JSON.stringify(expectedStatus)]]) }) it('notifies subscribers when playlist id changes', async () => { - const topic = new SegmentsTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new SegmentsTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const testPlaylist = makeTestPlaylist() - await topic.update(PlaylistHandler.name, testPlaylist) + handlers.playlistHandler.notify(testPlaylist) topic.addSubscriber(mockSubscriber) mockSubscriber.send.mockClear() const testPlaylist2 = makeTestPlaylist('PLAYLIST_2') - await topic.update(PlaylistHandler.name, testPlaylist2) + handlers.playlistHandler.notify(testPlaylist2) jest.advanceTimersByTime(THROTTLE_PERIOD_MS) const expectedStatus2: SegmentsStatus = { @@ -104,45 +105,18 @@ describe('SegmentsTopic', () => { expect(mockSubscriber.send.mock.calls).toEqual([[JSON.stringify(expectedStatus2)]]) }) - it('does not notify subscribers when an unimportant property of the playlist changes', async () => { - const topic = new SegmentsTopic(makeMockLogger()) - const mockSubscriber = makeMockSubscriber() - - const testPlaylist = makeTestPlaylist() - await topic.update(PlaylistHandler.name, testPlaylist) - - topic.addSubscriber(mockSubscriber) - mockSubscriber.send.mockClear() - - const testPlaylist2 = makeTestPlaylist() - testPlaylist2.currentPartInfo = { - partInstanceId: protectString('PI_1'), - consumesQueuedSegmentId: true, - manuallySelected: false, - rundownId: protectString(RUNDOWN_1_ID), - } - testPlaylist2.name = 'Another Playlist' - testPlaylist2.startedPlayback = Date.now() - // ... this is enough to prove that it works as expected - - await topic.update(PlaylistHandler.name, testPlaylist2) - jest.advanceTimersByTime(THROTTLE_PERIOD_MS) - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockSubscriber.send).toHaveBeenCalledTimes(0) - }) - it('notifies subscribers when segments change', async () => { - const topic = new SegmentsTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new SegmentsTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const testPlaylist = makeTestPlaylist() - await topic.update(PlaylistHandler.name, testPlaylist) + handlers.playlistHandler.notify(testPlaylist) topic.addSubscriber(mockSubscriber) mockSubscriber.send.mockClear() - await topic.update(SegmentsHandler.name, [ + handlers.segmentsHandler.notify([ makeTestSegment('2_1', 1, RUNDOWN_2_ID), makeTestSegment('2_2', 2, RUNDOWN_2_ID), makeTestSegment('1_2', 2, RUNDOWN_1_ID), @@ -188,12 +162,13 @@ describe('SegmentsTopic', () => { }) it('notifies subscribers when rundown order changes', async () => { - const topic = new SegmentsTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new SegmentsTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const testPlaylist = makeTestPlaylist() - await topic.update(PlaylistHandler.name, testPlaylist) - await topic.update(SegmentsHandler.name, [ + handlers.playlistHandler.notify(testPlaylist) + handlers.segmentsHandler.notify([ makeTestSegment('2_1', 1, RUNDOWN_2_ID), makeTestSegment('2_2', 2, RUNDOWN_2_ID), makeTestSegment('1_2', 2, RUNDOWN_1_ID), @@ -205,7 +180,7 @@ describe('SegmentsTopic', () => { const testPlaylist2 = makeTestPlaylist() testPlaylist2.rundownIdsInOrder = [protectString(RUNDOWN_2_ID), protectString(RUNDOWN_1_ID)] - await topic.update(PlaylistHandler.name, testPlaylist2) + handlers.playlistHandler.notify(testPlaylist2) jest.advanceTimersByTime(THROTTLE_PERIOD_MS) const expectedStatus: SegmentsStatus = { @@ -246,11 +221,12 @@ describe('SegmentsTopic', () => { }) it('exposes budgetDuration', async () => { - const topic = new SegmentsTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new SegmentsTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const testPlaylist = makeTestPlaylist() - await topic.update(PlaylistHandler.name, testPlaylist) + handlers.playlistHandler.notify(testPlaylist) topic.addSubscriber(mockSubscriber) mockSubscriber.send.mockClear() @@ -258,14 +234,14 @@ describe('SegmentsTopic', () => { const segment_1_1_id = '1_1' const segment_1_2_id = '1_2' const segment_2_2_id = '2_2' - await topic.update(SegmentsHandler.name, [ + handlers.segmentsHandler.notify([ makeTestSegment('2_1', 1, RUNDOWN_2_ID), makeTestSegment(segment_2_2_id, 2, RUNDOWN_2_ID, { segmentTiming: { budgetDuration: 51000 } }), makeTestSegment(segment_1_2_id, 2, RUNDOWN_1_ID, { segmentTiming: { budgetDuration: 15000 } }), makeTestSegment(segment_1_1_id, 1, RUNDOWN_1_ID, { segmentTiming: { budgetDuration: 5000 } }), ]) mockSubscriber.send.mockClear() - await topic.update(PartsHandler.name, [ + handlers.partsHandler.notify([ makeTestPart('1_2_1', 1, RUNDOWN_1_ID, segment_1_2_id), makeTestPart('2_2_1', 1, RUNDOWN_1_ID, segment_2_2_id), makeTestPart('1_2_2', 2, RUNDOWN_1_ID, segment_1_2_id), @@ -319,11 +295,12 @@ describe('SegmentsTopic', () => { }) it('exposes expectedDuration', async () => { - const topic = new SegmentsTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new SegmentsTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const testPlaylist = makeTestPlaylist() - await topic.update(PlaylistHandler.name, testPlaylist) + handlers.playlistHandler.notify(testPlaylist) topic.addSubscriber(mockSubscriber) mockSubscriber.send.mockClear() @@ -331,14 +308,14 @@ describe('SegmentsTopic', () => { const segment_1_1_id = '1_1' const segment_1_2_id = '1_2' const segment_2_2_id = '2_2' - await topic.update(SegmentsHandler.name, [ + handlers.segmentsHandler.notify([ makeTestSegment('2_1', 1, RUNDOWN_2_ID), makeTestSegment(segment_2_2_id, 2, RUNDOWN_2_ID), makeTestSegment(segment_1_2_id, 2, RUNDOWN_1_ID), makeTestSegment(segment_1_1_id, 1, RUNDOWN_1_ID), ]) mockSubscriber.send.mockClear() - await topic.update(PartsHandler.name, [ + handlers.partsHandler.notify([ makeTestPart('1_2_1', 1, RUNDOWN_1_ID, segment_1_2_id, { expectedDurationWithTransition: 10000, }), @@ -401,12 +378,13 @@ describe('SegmentsTopic', () => { }) it('includes segment identifier', async () => { - const topic = new SegmentsTopic(makeMockLogger()) + const handlers = makeMockHandlers() + const topic = new SegmentsTopic(makeMockLogger(), handlers) const mockSubscriber = makeMockSubscriber() const testPlaylist = makeTestPlaylist() - await topic.update(PlaylistHandler.name, testPlaylist) - await topic.update(SegmentsHandler.name, [ + handlers.playlistHandler.notify(testPlaylist) + handlers.segmentsHandler.notify([ { ...makeTestSegment('1_2', 2, RUNDOWN_1_ID), identifier: 'SomeIdentifier' }, makeTestSegment('1_1', 1, RUNDOWN_1_ID), ]) @@ -416,7 +394,7 @@ describe('SegmentsTopic', () => { const testPlaylist2 = makeTestPlaylist() testPlaylist2.rundownIdsInOrder = [protectString(RUNDOWN_2_ID), protectString(RUNDOWN_1_ID)] - await topic.update(PlaylistHandler.name, testPlaylist2) + handlers.playlistHandler.notify(testPlaylist2) jest.advanceTimersByTime(THROTTLE_PERIOD_MS) const expectedStatus: SegmentsStatus = { diff --git a/packages/live-status-gateway/src/topics/__tests__/utils.ts b/packages/live-status-gateway/src/topics/__tests__/utils.ts index f3fb929a09..de3e007eb9 100644 --- a/packages/live-status-gateway/src/topics/__tests__/utils.ts +++ b/packages/live-status-gateway/src/topics/__tests__/utils.ts @@ -7,6 +7,7 @@ import { ShowStyleBaseExt } from '../../collections/showStyleBaseHandler' import { Logger } from 'winston' import { WebSocket } from 'ws' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { CollectionHandlers } from '../../liveStatusServer' const RUNDOWN_1_ID = 'RUNDOWN_1' const RUNDOWN_2_ID = 'RUNDOWN_2' @@ -58,3 +59,41 @@ export function makeTestParts(): DBPart[] { }, ] } + +export function makeMockHandlers(): CollectionHandlers { + return { + adLibActionsHandler: makeMockHandler(), + adLibsHandler: makeMockHandler(), + bucketAdLibActionsHandler: makeMockHandler(), + bucketAdLibsHandler: makeMockHandler(), + bucketsHandler: makeMockHandler(), + globalAdLibActionsHandler: makeMockHandler(), + globalAdLibsHandler: makeMockHandler(), + partHandler: makeMockHandler(), + partInstancesHandler: makeMockHandler(), + partsHandler: makeMockHandler(), + pieceContentStatusesHandler: makeMockHandler(), + pieceInstancesHandler: makeMockHandler(), + playlistHandler: makeMockHandler(), + playlistsHandler: makeMockHandler(), + rundownHandler: makeMockHandler(), + segmentHandler: makeMockHandler(), + segmentsHandler: makeMockHandler(), + showStyleBaseHandler: makeMockHandler(), + studioHandler: makeMockHandler(), + } as unknown as CollectionHandlers +} + +function makeMockHandler() { + const subscribers: Array<(data: unknown) => void> = [] + return { + subscribe: (callback: (data: unknown) => void) => { + subscribers.push(callback) + }, + notify: (data: unknown) => { + subscribers.forEach((callback) => { + callback(data) + }) + }, + } +} diff --git a/packages/live-status-gateway/src/topics/activePiecesTopic.ts b/packages/live-status-gateway/src/topics/activePiecesTopic.ts index fcb53adf4f..abe735ecc1 100644 --- a/packages/live-status-gateway/src/topics/activePiecesTopic.ts +++ b/packages/live-status-gateway/src/topics/activePiecesTopic.ts @@ -1,15 +1,14 @@ import { Logger } from 'winston' import { WebSocket } from 'ws' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' -import { WebSocketTopicBase, WebSocketTopic, CollectionObserver } from '../wsHandler' -import { PlaylistHandler } from '../collections/playlistHandler' -import { ShowStyleBaseExt, ShowStyleBaseHandler } from '../collections/showStyleBaseHandler' -import _ = require('underscore') -import { SelectedPieceInstances, PieceInstancesHandler, PieceInstanceMin } from '../collections/pieceInstancesHandler' +import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' +import { ShowStyleBaseExt } from '../collections/showStyleBaseHandler' +import { SelectedPieceInstances, PieceInstanceMin } from '../collections/pieceInstancesHandler' import { PieceStatus, toPieceStatus } from './helpers/pieceStatus' import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { CollectionHandlers } from '../liveStatusServer' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' const THROTTLE_PERIOD_MS = 100 @@ -19,31 +18,23 @@ export interface ActivePiecesStatus { activePieces: PieceStatus[] } -export class ActivePiecesTopic - extends WebSocketTopicBase - implements - WebSocketTopic, - CollectionObserver, - CollectionObserver, - CollectionObserver -{ - public observerName = ActivePiecesTopic.name +const PLAYLIST_KEYS = ['_id', 'activationId'] as const +type Playlist = PickArr + +const PIECE_INSTANCES_KEYS = ['active'] as const +type PieceInstances = PickArr + +export class ActivePiecesTopic extends WebSocketTopicBase implements WebSocketTopic { private _activePlaylistId: RundownPlaylistId | undefined private _activePieceInstances: PieceInstanceMin[] | undefined private _showStyleBaseExt: ShowStyleBaseExt | undefined - private throttledSendStatusToAll: () => void - constructor(logger: Logger) { - super(ActivePiecesTopic.name, logger) - this.throttledSendStatusToAll = _.throttle(this.sendStatusToAll.bind(this), THROTTLE_PERIOD_MS, { - leading: false, - trailing: true, - }) - } + constructor(logger: Logger, handlers: CollectionHandlers) { + super(ActivePiecesTopic.name, logger, THROTTLE_PERIOD_MS) - addSubscriber(ws: WebSocket): void { - super.addSubscriber(ws) - this.sendStatus([ws]) + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + handlers.showStyleBaseHandler.subscribe(this.onShowStyleBaseUpdate) + handlers.pieceInstancesHandler.subscribe(this.onPieceInstancesUpdate, PIECE_INSTANCES_KEYS) } sendStatus(subscribers: Iterable): void { @@ -63,53 +54,31 @@ export class ActivePiecesTopic this.sendMessage(subscribers, message) } - async update( - source: string, - data: DBRundownPlaylist | ShowStyleBaseExt | SelectedPieceInstances | undefined - ): Promise { - let hasAnythingChanged = false - switch (source) { - case PlaylistHandler.name: { - const rundownPlaylist = data ? (data as DBRundownPlaylist) : undefined - this._logger.info( - `${this._name} received playlist update ${rundownPlaylist?._id}, activationId ${rundownPlaylist?.activationId}` - ) - const previousActivePlaylistId = this._activePlaylistId - this._activePlaylistId = unprotectString(rundownPlaylist?.activationId) - ? rundownPlaylist?._id - : undefined + protected onShowStyleBaseUpdate = (showStyleBase: ShowStyleBaseExt | undefined): void => { + this.logUpdateReceived('showStyleBase') + this._showStyleBaseExt = showStyleBase + this.throttledSendStatusToAll() + } - if (previousActivePlaylistId !== this._activePlaylistId) { - hasAnythingChanged = true - } - break - } - case ShowStyleBaseHandler.name: { - const showStyleBaseExt = data ? (data as ShowStyleBaseExt) : undefined - this._logger.info(`${this._name} received showStyleBase update from ${source}`) - this._showStyleBaseExt = showStyleBaseExt - hasAnythingChanged = true - break - } - case PieceInstancesHandler.name: { - const pieceInstances = data as SelectedPieceInstances - this._logger.info(`${this._name} received pieceInstances update from ${source}`) - if (pieceInstances.active !== this._activePieceInstances) { - hasAnythingChanged = true - } - this._activePieceInstances = pieceInstances.active - break - } - default: - throw new Error(`${this._name} received unsupported update from ${source}}`) - } + protected onPlaylistUpdate = (rundownPlaylist: Playlist | undefined): void => { + this.logUpdateReceived( + 'playlist', + `rundownPlaylistId ${rundownPlaylist?._id}, activationId ${rundownPlaylist?.activationId}` + ) + const previousActivePlaylistId = this._activePlaylistId + this._activePlaylistId = unprotectString(rundownPlaylist?.activationId) ? rundownPlaylist?._id : undefined - if (hasAnythingChanged) { + if (previousActivePlaylistId !== this._activePlaylistId) { this.throttledSendStatusToAll() } } - private sendStatusToAll() { - this.sendStatus(this._subscribers) + protected onPieceInstancesUpdate = (pieceInstances: PieceInstances | undefined): void => { + this.logUpdateReceived('pieceInstances') + const prevPieceInstances = this._activePieceInstances + this._activePieceInstances = pieceInstances?.active + if (prevPieceInstances !== this._activePieceInstances) { + this.throttledSendStatusToAll() + } } } diff --git a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts index b557a4e1d9..faa3cac47c 100644 --- a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -8,22 +8,20 @@ import { } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { assertNever, literal } from '@sofie-automation/shared-lib/dist/lib/lib' -import { WebSocketTopicBase, WebSocketTopic, CollectionObserver } from '../wsHandler' -import { SelectedPartInstances, PartInstancesHandler } from '../collections/partInstancesHandler' -import { PlaylistHandler } from '../collections/playlistHandler' -import { ShowStyleBaseExt, ShowStyleBaseHandler } from '../collections/showStyleBaseHandler' +import { SelectedPartInstances } from '../collections/partInstancesHandler' +import { ShowStyleBaseExt } from '../collections/showStyleBaseHandler' +import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' import { CurrentSegmentTiming, calculateCurrentSegmentTiming } from './helpers/segmentTiming' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { PartsHandler } from '../collections/partsHandler' import _ = require('underscore') import { PartTiming, calculateCurrentPartTiming } from './helpers/partTiming' -import { SelectedPieceInstances, PieceInstancesHandler, PieceInstanceMin } from '../collections/pieceInstancesHandler' +import { SelectedPieceInstances, PieceInstanceMin } from '../collections/pieceInstancesHandler' import { PieceStatus, toPieceStatus } from './helpers/pieceStatus' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { SegmentHandler } from '../collections/segmentHandler' import { PlaylistTimingType } from '@sofie-automation/blueprints-integration' -import { SegmentsHandler } from '../collections/segmentsHandler' import { normalizeArray } from '@sofie-automation/corelib/dist/lib' +import { CollectionHandlers } from '../liveStatusServer' +import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' const THROTTLE_PERIOD_MS = 100 @@ -78,19 +76,31 @@ export interface ActivePlaylistStatus { } } -export class ActivePlaylistTopic - extends WebSocketTopicBase - implements - WebSocketTopic, - CollectionObserver, - CollectionObserver, - CollectionObserver, - CollectionObserver, - CollectionObserver, - CollectionObserver -{ - public observerName = ActivePlaylistTopic.name - private _activePlaylist: DBRundownPlaylist | undefined +const PLAYLIST_KEYS = [ + '_id', + 'activationId', + 'name', + 'rundownIdsInOrder', + 'publicData', + 'currentPartInfo', + 'nextPartInfo', + 'timing', + 'startedPlayback', + 'quickLoop', +] as const +type Playlist = PickArr + +const PART_INSTANCES_KEYS = ['current', 'next', 'inCurrentSegment', 'firstInSegmentPlayout'] as const +type PartInstances = PickArr + +const PIECE_INSTANCES_KEYS = ['currentPartInstance', 'nextPartInstance'] as const +type PieceInstances = PickArr + +const SEGMENT_KEYS = ['_id', 'segmentTiming'] as const +type Segment = PickArr + +export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocketTopic { + private _activePlaylist: Playlist | undefined private _currentPartInstance: DBPartInstance | undefined private _nextPartInstance: DBPartInstance | undefined private _firstInstanceInSegmentPlayout: DBPartInstance | undefined @@ -101,25 +111,24 @@ export class ActivePlaylistTopic private _pieceInstancesInCurrentPartInstance: PieceInstanceMin[] | undefined private _pieceInstancesInNextPartInstance: PieceInstanceMin[] | undefined private _showStyleBaseExt: ShowStyleBaseExt | undefined - private _currentSegment: DBSegment | undefined - private throttledSendStatusToAll: () => void - - constructor(logger: Logger) { - super(ActivePlaylistTopic.name, logger) - this.throttledSendStatusToAll = _.throttle(this.sendStatusToAll.bind(this), THROTTLE_PERIOD_MS, { - leading: false, - trailing: true, - }) - } + private _currentSegment: Segment | undefined + + constructor(logger: Logger, handlers: CollectionHandlers) { + super(ActivePlaylistTopic.name, logger, THROTTLE_PERIOD_MS) - addSubscriber(ws: WebSocket): void { - super.addSubscriber(ws) - this.sendStatus([ws]) + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + handlers.partsHandler.subscribe(this.onPartsUpdate) + handlers.partInstancesHandler.subscribe(this.onPartInstancesUpdate, PART_INSTANCES_KEYS) + handlers.pieceInstancesHandler.subscribe(this.onPieceInstancesUpdate, PIECE_INSTANCES_KEYS) + handlers.showStyleBaseHandler.subscribe(this.onShowStyleBaseUpdate) + handlers.segmentHandler.subscribe(this.onSegmentUpdate, SEGMENT_KEYS) + handlers.segmentsHandler.subscribe(this.onSegmentsUpdate) } sendStatus(subscribers: Iterable): void { if (this.isDataInconsistent()) { // data is inconsistent, let's wait + this._logger.debug('Encountered inconsistent data.') return } @@ -155,7 +164,7 @@ export class ActivePlaylistTopic ? literal({ id: unprotectString(currentPart.segmentId), timing: calculateCurrentSegmentTiming( - this._currentSegment, + this._currentSegment.segmentTiming, this._currentPartInstance, this._firstInstanceInSegmentPlayout, this._partInstancesInCurrentSegment, @@ -282,94 +291,69 @@ export class ActivePlaylistTopic ) } - async update( - source: string, - data: - | DBRundownPlaylist - | ShowStyleBaseExt - | SelectedPartInstances - | DBPart[] - | SelectedPieceInstances - | DBSegment - | DBSegment[] - | undefined - ): Promise { - let hasAnythingChanged = false - switch (source) { - case PlaylistHandler.name: { - const rundownPlaylist = data ? (data as DBRundownPlaylist) : undefined - this.logUpdateReceived( - 'playlist', - source, - `rundownPlaylistId ${rundownPlaylist?._id}, activationId ${rundownPlaylist?.activationId}` - ) - this._activePlaylist = unprotectString(rundownPlaylist?.activationId) ? rundownPlaylist : undefined - hasAnythingChanged = true - break - } - case ShowStyleBaseHandler.name: { - const showStyleBaseExt = data ? (data as ShowStyleBaseExt) : undefined - this.logUpdateReceived('showStyleBase', source) - this._showStyleBaseExt = showStyleBaseExt - hasAnythingChanged = true - break - } - case PartInstancesHandler.name: { - const partInstances = data as SelectedPartInstances - this.logUpdateReceived( - 'partInstances', - source, - `${partInstances.inCurrentSegment.length} instances in segment` - ) - this._currentPartInstance = partInstances.current - this._nextPartInstance = partInstances.next - this._firstInstanceInSegmentPlayout = partInstances.firstInSegmentPlayout - this._partInstancesInCurrentSegment = partInstances.inCurrentSegment - hasAnythingChanged = true - break - } - case PartsHandler.name: { - this._partsById = normalizeArray(data as DBPart[], '_id') - this._partsBySegmentId = _.groupBy(data as DBPart[], 'segmentId') - this.logUpdateReceived('parts', source) - hasAnythingChanged = true // TODO: can this be smarter? - break - } - case PieceInstancesHandler.name: { - const pieceInstances = data as SelectedPieceInstances - this.logUpdateReceived('pieceInstances', source) - if ( - pieceInstances.currentPartInstance !== this._pieceInstancesInCurrentPartInstance || - pieceInstances.nextPartInstance !== this._pieceInstancesInNextPartInstance - ) { - hasAnythingChanged = true - } - this._pieceInstancesInCurrentPartInstance = pieceInstances.currentPartInstance - this._pieceInstancesInNextPartInstance = pieceInstances.nextPartInstance - break - } - case SegmentHandler.name: { - this._currentSegment = data as DBSegment - this.logUpdateReceived('segment', source) - hasAnythingChanged = true - break - } - case SegmentsHandler.name: { - this._segmentsById = normalizeArray(data as DBSegment[], '_id') - this.logUpdateReceived('segments', source) - hasAnythingChanged = true // TODO: can this be smarter? - break - } - default: - throw new Error(`${this._name} received unsupported update from ${source}}`) - } + private onPlaylistUpdate = (rundownPlaylist: Playlist | undefined): void => { + this.logUpdateReceived( + 'playlist', + `rundownPlaylistId ${rundownPlaylist?._id}, activationId ${rundownPlaylist?.activationId}` + ) + this._activePlaylist = unprotectString(rundownPlaylist?.activationId) ? rundownPlaylist : undefined + + this.throttledSendStatusToAll() + } - if (hasAnythingChanged) { + private onPartsUpdate = (parts: DBPart[] | undefined): void => { + const previousParts = this._partsBySegmentId + this._partsBySegmentId = _.groupBy(parts ?? [], 'segmentId') + this.logUpdateReceived('parts') + + const currentSegmentId = unprotectString(this._currentPartInstance?.segmentId) + if ( + currentSegmentId && + !areElementsShallowEqual( + previousParts[currentSegmentId] ?? [], + this._partsBySegmentId[currentSegmentId] ?? [] + ) + ) { + // we have to collect all the parts, but only when those from the current segment change, we should update status this.throttledSendStatusToAll() } } - private sendStatusToAll() { - this.sendStatus(this._subscribers) + private onPartInstancesUpdate = (partInstances: PartInstances | undefined): void => { + this.logUpdateReceived('partInstances', `${partInstances?.inCurrentSegment.length} instances in segment`) + + if (!partInstances) return + this._currentPartInstance = partInstances.current + this._nextPartInstance = partInstances.next + this._firstInstanceInSegmentPlayout = partInstances.firstInSegmentPlayout + this._partInstancesInCurrentSegment = partInstances.inCurrentSegment + this.throttledSendStatusToAll() + } + + private onPieceInstancesUpdate = (pieceInstances: PieceInstances | undefined): void => { + this.logUpdateReceived('pieceInstances') + if (!pieceInstances) return + + this._pieceInstancesInCurrentPartInstance = pieceInstances.currentPartInstance + this._pieceInstancesInNextPartInstance = pieceInstances.nextPartInstance + this.throttledSendStatusToAll() + } + + private onShowStyleBaseUpdate = (showStyleBase: ShowStyleBaseExt | undefined): void => { + this.logUpdateReceived('showStyleBase') + this._showStyleBaseExt = showStyleBase + this.throttledSendStatusToAll() + } + + private onSegmentUpdate = (segment: Segment | undefined): void => { + this.logUpdateReceived('segment') + this._currentSegment = segment + this.throttledSendStatusToAll() + } + + private onSegmentsUpdate = (segments: DBSegment[] | undefined): void => { + this.logUpdateReceived('segments') + this._segmentsById = segments ? normalizeArray(segments, '_id') : {} + this.throttledSendStatusToAll() // TODO: can this be smarter? } } diff --git a/packages/live-status-gateway/src/topics/adLibsTopic.ts b/packages/live-status-gateway/src/topics/adLibsTopic.ts index f3f07b8557..cfcd40644d 100644 --- a/packages/live-status-gateway/src/topics/adLibsTopic.ts +++ b/packages/live-status-gateway/src/topics/adLibsTopic.ts @@ -1,29 +1,21 @@ import { Logger } from 'winston' import { WebSocket } from 'ws' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { WebSocketTopicBase, WebSocketTopic, CollectionObserver } from '../wsHandler' -import { PlaylistHandler } from '../collections/playlistHandler' +import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' import { literal } from '@sofie-automation/corelib/dist/lib' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import _ = require('underscore') import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' -import { AdLibActionsHandler } from '../collections/adLibActionsHandler' -import { GlobalAdLibActionsHandler } from '../collections/globalAdLibActionsHandler' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' import { IBlueprintActionManifestDisplayContent } from '@sofie-automation/blueprints-integration' -import { ShowStyleBaseExt, ShowStyleBaseHandler } from '../collections/showStyleBaseHandler' +import { ShowStyleBaseExt } from '../collections/showStyleBaseHandler' import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage' -import { AdLibsHandler } from '../collections/adLibsHandler' -import { GlobalAdLibsHandler } from '../collections/globalAdLibsHandler' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { PartsHandler } from '../collections/partsHandler' import { PartId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { WithSortingMetadata, getRank, sortContent } from './helpers/contentSorting' -import { isDeepStrictEqual } from 'util' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { SegmentsHandler } from '../collections/segmentsHandler' +import { CollectionHandlers } from '../liveStatusServer' const THROTTLE_PERIOD_MS = 100 @@ -57,39 +49,34 @@ interface AdLibStatusBase { optionsSchema?: any } -export class AdLibsTopic - extends WebSocketTopicBase - implements - WebSocketTopic, - CollectionObserver, - CollectionObserver, - CollectionObserver, - CollectionObserver, - CollectionObserver -{ - public observerName = AdLibsTopic.name - private _activePlaylist: DBRundownPlaylist | undefined +const PLAYLIST_KEYS = ['_id', 'rundownIdsInOrder', 'activationId'] as const +type Playlist = PickArr + +const SHOW_STYLE_BASE_KEYS = ['sourceLayerNamesById', 'outputLayerNamesById'] as const +type ShowStyle = PickArr + +export class AdLibsTopic extends WebSocketTopicBase implements WebSocketTopic { + private _activePlaylist: Playlist | undefined private _sourceLayersMap: ReadonlyMap = new Map() private _outputLayersMap: ReadonlyMap = new Map() private _adLibActions: AdLibAction[] | undefined - private _abLibs: AdLibPiece[] | undefined + private _adLibs: AdLibPiece[] | undefined private _parts: ReadonlyMap = new Map() private _segments: ReadonlyMap = new Map() private _globalAdLibActions: RundownBaselineAdLibAction[] | undefined private _globalAdLibs: RundownBaselineAdLibItem[] | undefined - private throttledSendStatusToAll: () => void - constructor(logger: Logger) { - super(AdLibsTopic.name, logger) - this.throttledSendStatusToAll = _.throttle(this.sendStatusToAll.bind(this), THROTTLE_PERIOD_MS, { - leading: true, - trailing: true, - }) - } + constructor(logger: Logger, handlers: CollectionHandlers) { + super(AdLibsTopic.name, logger, THROTTLE_PERIOD_MS) - addSubscriber(ws: WebSocket): void { - super.addSubscriber(ws) - this.sendStatus([ws]) + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + handlers.showStyleBaseHandler.subscribe(this.onShowStyleBaseUpdate, SHOW_STYLE_BASE_KEYS) + handlers.adLibActionsHandler.subscribe(this.onAdLibActionsUpdate) + handlers.adLibsHandler.subscribe(this.onAdLibsUpdate) + handlers.globalAdLibActionsHandler.subscribe(this.onGlobalAdLibActionsUpdate) + handlers.globalAdLibsHandler.subscribe(this.onGlobalAdLibsUpdate) + handlers.segmentsHandler.subscribe(this.onSegmentsUpdate) + handlers.partsHandler.subscribe(this.onPartsUpdate) } sendStatus(subscribers: Iterable): void { @@ -139,9 +126,9 @@ export class AdLibsTopic ) } - if (this._abLibs) { + if (this._adLibs) { adLibs.push( - ...this._abLibs.map((adLib) => { + ...this._adLibs.map((adLib) => { const sourceLayerName = this._sourceLayersMap.get(adLib.sourceLayerId) const outputLayerName = this._outputLayersMap.get(adLib.outputLayerId) const segmentId = adLib.partId ? this._parts.get(adLib.partId)?.segmentId : undefined @@ -242,92 +229,65 @@ export class AdLibsTopic this.sendMessage(subscribers, adLibsStatus) } - async update( - source: string, - data: - | DBRundownPlaylist - | ShowStyleBaseExt - | AdLibAction[] - | RundownBaselineAdLibAction[] - | AdLibPiece[] - | RundownBaselineAdLibItem[] - | DBPart[] - | DBSegment[] - | undefined - ): Promise { - switch (source) { - case PlaylistHandler.name: { - const previousPlaylist = this._activePlaylist - this.logUpdateReceived('playlist', source) - this._activePlaylist = data as DBRundownPlaylist | undefined - // PlaylistHandler is quite chatty (will update on every take), so let's make sure there's a point - // in sending a status - if ( - previousPlaylist?._id === this._activePlaylist?._id && - isDeepStrictEqual(previousPlaylist?.rundownIdsInOrder, this._activePlaylist?.rundownIdsInOrder) - ) - return - break - } - case AdLibActionsHandler.name: { - const adLibActions = data ? (data as AdLibAction[]) : [] - this.logUpdateReceived('adLibActions', source) - this._adLibActions = adLibActions - break - } - case GlobalAdLibActionsHandler.name: { - const globalAdLibActions = data ? (data as RundownBaselineAdLibAction[]) : [] - this.logUpdateReceived('globalAdLibActions', source) - this._globalAdLibActions = globalAdLibActions - break - } - case AdLibsHandler.name: { - const adLibs = data ? (data as AdLibPiece[]) : [] - this.logUpdateReceived('adLibs', source) - this._abLibs = adLibs - break - } - case GlobalAdLibsHandler.name: { - const globalAdLibs = data ? (data as RundownBaselineAdLibItem[]) : [] - this.logUpdateReceived('globalAdLibs', source) - this._globalAdLibs = globalAdLibs - break - } - case ShowStyleBaseHandler.name: { - const showStyleBaseExt = data ? (data as ShowStyleBaseExt) : undefined - this.logUpdateReceived('showStyleBase', source) - this._sourceLayersMap = showStyleBaseExt?.sourceLayerNamesById ?? new Map() - this._outputLayersMap = showStyleBaseExt?.outputLayerNamesById ?? new Map() - break - } - case SegmentsHandler.name: { - const segments = data ? (data as DBPart[]) : [] - this.logUpdateReceived('segments', source) - const newSegments = new Map() - segments.forEach((segment) => { - newSegments.set(segment._id, segment) - }) - this._segments = newSegments - break - } - case PartsHandler.name: { - const parts = data ? (data as DBPart[]) : [] - this.logUpdateReceived('parts', source) - const newParts = new Map() - parts.forEach((part) => { - newParts.set(part._id, part) - }) - this._parts = newParts - break - } - default: - throw new Error(`${this._name} received unsupported update from ${source}}`) - } + private onPlaylistUpdate = (rundownPlaylist: Playlist | undefined): void => { + this.logUpdateReceived( + 'playlist', + `rundownPlaylistId ${rundownPlaylist?._id}, activationId ${rundownPlaylist?.activationId}` + ) + this._activePlaylist = rundownPlaylist + this.throttledSendStatusToAll() + } + private onShowStyleBaseUpdate = (showStyleBase: ShowStyle | undefined): void => { + this.logUpdateReceived('showStyleBase') + this._sourceLayersMap = showStyleBase?.sourceLayerNamesById ?? new Map() + this._outputLayersMap = showStyleBase?.outputLayerNamesById ?? new Map() this.throttledSendStatusToAll() } - private sendStatusToAll() { - this.sendStatus(this._subscribers) + protected onAdLibActionsUpdate = (adLibActions: AdLibAction[] | undefined): void => { + this.logUpdateReceived('adLibActions') + this._adLibActions = adLibActions + this.throttledSendStatusToAll() + } + + protected onAdLibsUpdate = (adLibs: AdLibPiece[] | undefined): void => { + this.logUpdateReceived('adLibs') + this._adLibs = adLibs + this.throttledSendStatusToAll() + } + + protected onGlobalAdLibActionsUpdate = (adLibActions: RundownBaselineAdLibAction[] | undefined): void => { + this.logUpdateReceived('globalAdLibActions') + this._globalAdLibActions = adLibActions + this.throttledSendStatusToAll() + } + + protected onGlobalAdLibsUpdate = (adLibs: RundownBaselineAdLibItem[] | undefined): void => { + this.logUpdateReceived('globalAdLibs') + this._globalAdLibs = adLibs + this.throttledSendStatusToAll() + } + + protected onSegmentsUpdate = (segments: DBSegment[] | undefined): void => { + this.logUpdateReceived('segments') + const newSegments = new Map() + segments ??= [] + segments.forEach((segment) => { + newSegments.set(segment._id, segment) + }) + this._segments = newSegments + this.throttledSendStatusToAll() + } + + protected onPartsUpdate = (parts: DBPart[] | undefined): void => { + this.logUpdateReceived('parts') + const newParts = new Map() + parts ??= [] + parts.forEach((part) => { + newParts.set(part._id, part) + }) + this._parts = newParts + this.throttledSendStatusToAll() } } diff --git a/packages/live-status-gateway/src/topics/helpers/segmentTiming.ts b/packages/live-status-gateway/src/topics/helpers/segmentTiming.ts index 693dff9555..1f80f8515a 100644 --- a/packages/live-status-gateway/src/topics/helpers/segmentTiming.ts +++ b/packages/live-status-gateway/src/topics/helpers/segmentTiming.ts @@ -1,6 +1,6 @@ +import { SegmentTimingInfo } from '@sofie-automation/blueprints-integration' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' export interface SegmentTiming { budgetDurationMs?: number @@ -13,13 +13,13 @@ export interface CurrentSegmentTiming extends SegmentTiming { } export function calculateCurrentSegmentTiming( - segment: DBSegment, + segmentTimingInfo: SegmentTimingInfo | undefined, currentPartInstance: DBPartInstance, firstInstanceInSegmentPlayout: DBPartInstance | undefined, segmentPartInstances: DBPartInstance[], segmentParts: DBPart[] ): CurrentSegmentTiming { - const segmentTiming = calculateSegmentTiming(segment, segmentParts) + const segmentTiming = calculateSegmentTiming(segmentTimingInfo, segmentParts) const playedDurations = segmentPartInstances.reduce((sum, partInstance) => { return (partInstance.timings?.duration ?? 0) + sum }, 0) @@ -39,14 +39,17 @@ export function calculateCurrentSegmentTiming( } } -export function calculateSegmentTiming(segment: DBSegment, segmentParts: DBPart[]): SegmentTiming { +export function calculateSegmentTiming( + segmentTimingInfo: SegmentTimingInfo | undefined, + segmentParts: DBPart[] +): SegmentTiming { return { - budgetDurationMs: segment.segmentTiming?.budgetDuration, + budgetDurationMs: segmentTimingInfo?.budgetDuration, expectedDurationMs: segmentParts.reduce((sum, part): number => { return part.expectedDurationWithTransition != null && !part.untimed ? sum + part.expectedDurationWithTransition : sum }, 0), - countdownType: segment.segmentTiming?.countdownType, + countdownType: segmentTimingInfo?.countdownType, } } diff --git a/packages/live-status-gateway/src/topics/root.ts b/packages/live-status-gateway/src/topics/root.ts index 6307aae73a..0c3f3cdca1 100644 --- a/packages/live-status-gateway/src/topics/root.ts +++ b/packages/live-status-gateway/src/topics/root.ts @@ -158,4 +158,8 @@ export class RootChannel extends WebSocketTopicBase implements WebSocketTopic { ) } } + + sendStatus(): void { + // no status here + } } diff --git a/packages/live-status-gateway/src/topics/segmentsTopic.ts b/packages/live-status-gateway/src/topics/segmentsTopic.ts index 3f9e17247a..66a671a866 100644 --- a/packages/live-status-gateway/src/topics/segmentsTopic.ts +++ b/packages/live-status-gateway/src/topics/segmentsTopic.ts @@ -1,17 +1,14 @@ import { Logger } from 'winston' import { WebSocket } from 'ws' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { WebSocketTopicBase, WebSocketTopic, CollectionObserver } from '../wsHandler' +import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { PlaylistHandler } from '../collections/playlistHandler' import { groupByToMap } from '@sofie-automation/corelib/dist/lib' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { SegmentsHandler } from '../collections/segmentsHandler' -import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' -import { PartsHandler } from '../collections/partsHandler' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import _ = require('underscore') import { SegmentTiming, calculateSegmentTiming } from './helpers/segmentTiming' +import { CollectionHandlers } from '../liveStatusServer' const THROTTLE_PERIOD_MS = 200 @@ -30,32 +27,21 @@ export interface SegmentsStatus { segments: SegmentStatus[] } -export class SegmentsTopic - extends WebSocketTopicBase - implements - WebSocketTopic, - CollectionObserver, - CollectionObserver, - CollectionObserver -{ - public observerName = SegmentsTopic.name - private _activePlaylist: DBRundownPlaylist | undefined +const PLAYLIST_KEYS = ['_id', 'rundownIdsInOrder', 'activationId'] as const +type Playlist = PickArr + +export class SegmentsTopic extends WebSocketTopicBase implements WebSocketTopic { + private _activePlaylist: Playlist | undefined private _segments: DBSegment[] = [] private _partsBySegment: Record = {} private _orderedSegments: DBSegment[] = [] - private throttledSendStatusToAll: () => void - constructor(logger: Logger) { - super(SegmentsTopic.name, logger) - this.throttledSendStatusToAll = _.throttle(this.sendStatusToAll.bind(this), THROTTLE_PERIOD_MS, { - leading: true, - trailing: true, - }) - } + constructor(logger: Logger, handlers: CollectionHandlers) { + super(SegmentsTopic.name, logger, THROTTLE_PERIOD_MS) - addSubscriber(ws: WebSocket): void { - super.addSubscriber(ws) - this.sendStatus([ws]) + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + handlers.segmentsHandler.subscribe(this.onSegmentsUpdate) + handlers.partsHandler.subscribe(this.onPartsUpdate) } sendStatus(subscribers: Iterable): void { @@ -68,7 +54,7 @@ export class SegmentsTopic id: segmentId, rundownId: unprotectString(segment.rundownId), name: segment.name, - timing: calculateSegmentTiming(segment, this._partsBySegment[segmentId] ?? []), + timing: calculateSegmentTiming(segment.segmentTiming, this._partsBySegment[segmentId] ?? []), identifier: segment.identifier, publicData: segment.publicData, } @@ -78,51 +64,33 @@ export class SegmentsTopic this.sendMessage(subscribers, segmentsStatus) } - async update(source: string, data: DBRundownPlaylist | DBSegment[] | DBPart[] | undefined): Promise { - const prevSegments = this._segments - const prevRundownOrder = this._activePlaylist?.rundownIdsInOrder ?? [] - const prevParts = this._partsBySegment - const prevPlaylistId = this._activePlaylist?._id - switch (source) { - case PlaylistHandler.name: { - this._activePlaylist = data as DBRundownPlaylist | undefined - this.logUpdateReceived('playlist', source) - break - } - case SegmentsHandler.name: { - this._segments = data as DBSegment[] - this.logUpdateReceived('segments', source) - break - } - case PartsHandler.name: { - this._partsBySegment = _.groupBy(data as DBPart[], 'segmentId') - this.logUpdateReceived('parts', source) - break - } - default: - throw new Error(`${this._name} received unsupported update from ${source}}`) - } + private onPlaylistUpdate = (rundownPlaylist: Playlist | undefined): void => { + this.logUpdateReceived( + 'playlist', + `rundownPlaylistId ${rundownPlaylist?._id}, activationId ${rundownPlaylist?.activationId}` + ) + this._activePlaylist = rundownPlaylist + this.updateAndSendStatusToAll() + } - if (this._activePlaylist) { - if ( - this._activePlaylist._id !== prevPlaylistId || - prevSegments !== this._segments || - prevParts !== this._partsBySegment || - !areElementsShallowEqual(prevRundownOrder, this._activePlaylist.rundownIdsInOrder) - ) { - const segmentsByRundownId = groupByToMap(this._segments, 'rundownId') - this._orderedSegments = this._activePlaylist.rundownIdsInOrder.flatMap((rundownId) => { - return segmentsByRundownId.get(rundownId)?.sort((a, b) => a._rank - b._rank) ?? [] - }) - this.throttledSendStatusToAll() - } - } else { - this._orderedSegments = [] - this.throttledSendStatusToAll() - } + protected onSegmentsUpdate = (segments: DBSegment[] | undefined): void => { + this.logUpdateReceived('segments') + this._segments = segments ?? [] + this.updateAndSendStatusToAll() + } + + protected onPartsUpdate = (parts: DBPart[] | undefined): void => { + this.logUpdateReceived('parts') + this._partsBySegment = _.groupBy(parts ?? [], 'segmentId') + this.updateAndSendStatusToAll() } - private sendStatusToAll() { - this.sendStatus(this._subscribers) + private updateAndSendStatusToAll() { + const segmentsByRundownId = groupByToMap(this._segments, 'rundownId') + this._orderedSegments = + this._activePlaylist?.rundownIdsInOrder.flatMap((rundownId) => { + return segmentsByRundownId.get(rundownId)?.sort((a, b) => a._rank - b._rank) ?? [] + }) ?? [] + this.throttledSendStatusToAll() } } diff --git a/packages/live-status-gateway/src/topics/studioTopic.ts b/packages/live-status-gateway/src/topics/studioTopic.ts index 945317718d..9b4da5addf 100644 --- a/packages/live-status-gateway/src/topics/studioTopic.ts +++ b/packages/live-status-gateway/src/topics/studioTopic.ts @@ -4,9 +4,9 @@ import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protected import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' -import { WebSocketTopicBase, WebSocketTopic, CollectionObserver } from '../wsHandler' -import { StudioHandler } from '../collections/studioHandler' -import { PlaylistsHandler } from '../collections/playlistHandler' +import { WebSocketTopicBase, WebSocketTopic } from '../wsHandler' +import { CollectionHandlers } from '../liveStatusServer' +import _ = require('underscore') type PlaylistActivationStatus = 'deactivated' | 'rehearsal' | 'activated' @@ -23,21 +23,16 @@ interface StudioStatus { playlists: PlaylistStatus[] } -export class StudioTopic - extends WebSocketTopicBase - implements WebSocketTopic, CollectionObserver, CollectionObserver -{ - public observerName = 'StudioTopic' +export class StudioTopic extends WebSocketTopicBase implements WebSocketTopic { private _studio: DBStudio | undefined private _playlists: PlaylistStatus[] = [] + private _lastSentPlaylists: PlaylistStatus[] = [] - constructor(logger: Logger) { + constructor(logger: Logger, handlers: CollectionHandlers) { super(StudioTopic.name, logger) - } - addSubscriber(ws: WebSocket): void { - super.addSubscriber(ws) - this.sendStatus([ws]) + handlers.studioHandler.subscribe(this.onStudioUpdate) + handlers.playlistsHandler.subscribe(this.onPlaylistsUpdate) } sendStatus(subscribers: Iterable): void { @@ -58,42 +53,35 @@ export class StudioTopic this.sendMessage(subscribers, studioStatus) } - async update(source: string, data: DBStudio | DBRundownPlaylist[] | undefined): Promise { - const prevPlaylistsStatus = this._playlists - const rundownPlaylists = data ? (data as DBRundownPlaylist[]) : [] - const studio = data ? (data as DBStudio) : undefined - switch (source) { - case StudioHandler.name: - this.logUpdateReceived('studio', source, `studioId ${studio?._id}`) - this._studio = studio - break - case PlaylistsHandler.name: - this.logUpdateReceived('playlists', source) - this._playlists = rundownPlaylists.map((p) => { - let activationStatus: PlaylistActivationStatus = - p.activationId === undefined ? 'deactivated' : 'activated' - if (p.activationId && p.rehearsal) activationStatus = 'rehearsal' - return literal({ - id: unprotectString(p._id), - name: p.name, - activationStatus: activationStatus, - }) + private onStudioUpdate = (studio: DBStudio | undefined): void => { + this.logUpdateReceived('studio', `studioId ${studio?._id}`) + this._studio = studio + this.sendStatusToAll() + } + + private onPlaylistsUpdate = (rundownPlaylists: DBRundownPlaylist[] | undefined): void => { + this.logUpdateReceived('playlists') + this._playlists = + rundownPlaylists?.map((p) => { + let activationStatus: PlaylistActivationStatus = + p.activationId === undefined ? 'deactivated' : 'activated' + if (p.activationId && p.rehearsal) activationStatus = 'rehearsal' + return literal({ + id: unprotectString(p._id), + name: p.name, + activationStatus: activationStatus, }) - break - default: - throw new Error(`${this._name} received unsupported update from ${source}}`) - } + }) ?? [] + this.sendStatusToAll() + } + protected sendStatusToAll = (): void => { const sameStatus = - this._playlists.length === prevPlaylistsStatus.length && - this._playlists.reduce( - (same, status, i) => - same && - !!prevPlaylistsStatus[i] && - status.id === prevPlaylistsStatus[i].id && - status.activationStatus === prevPlaylistsStatus[i].activationStatus, - true - ) - if (!sameStatus) this.sendStatus(this._subscribers) + this._playlists.length === this._lastSentPlaylists.length && + _.isEqual(this._playlists, this._lastSentPlaylists) + if (!sameStatus) { + this.sendStatus(this._subscribers) + this._lastSentPlaylists = this._playlists + } } } diff --git a/packages/live-status-gateway/src/wsHandler.ts b/packages/live-status-gateway/src/wsHandler.ts index dfa5ef411b..b3dcb54954 100644 --- a/packages/live-status-gateway/src/wsHandler.ts +++ b/packages/live-status-gateway/src/wsHandler.ts @@ -1,25 +1,47 @@ import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { CoreConnection, Observer, ProtectedString, SubscriptionId } from '@sofie-automation/server-core-integration' +import { + CollectionDocCheck, + CoreConnection, + Observer, + PeripheralDevicePubSubCollections, + ProtectedString, + SubscriptionId, +} from '@sofie-automation/server-core-integration' import { Logger } from 'winston' import { WebSocket } from 'ws' import { CoreHandler } from './coreHandler' -import { CorelibPubSub, CorelibPubSubCollections, CorelibPubSubTypes } from '@sofie-automation/corelib/dist/pubsub' +import { CorelibPubSubCollections, CorelibPubSubTypes } from '@sofie-automation/corelib/dist/pubsub' +import throttleToNextTick from '@sofie-automation/shared-lib/dist/lib/throttleToNextTick' +import _ = require('underscore') +import { Collection as CoreCollection } from '@sofie-automation/server-core-integration' +import { CollectionHandlers } from './liveStatusServer' +import { arePropertiesShallowEqual } from './helpers/equality' +import { ParametersOfFunctionOrNever } from '@sofie-automation/server-core-integration/dist/lib/subscriptions' export abstract class WebSocketTopicBase { protected _name: string protected _logger: Logger protected _subscribers: Set = new Set() + protected throttledSendStatusToAll: () => void - constructor(name: string, logger: Logger) { + constructor(name: string, logger: Logger, throttlePeriodMs = 0) { this._name = name this._logger = logger this._logger.info(`Starting ${this._name} topic`) + this.throttledSendStatusToAll = + throttlePeriodMs > 0 + ? _.throttle(this.sendStatusToAll, throttlePeriodMs, { + leading: false, + trailing: true, + }) + : this.sendStatusToAll } addSubscriber(ws: WebSocket): void { this._logger.info(`${this._name} adding a websocket subscriber`) this._subscribers.add(ws) + this.sendStatus([ws]) } hasSubscriber(ws: WebSocket): boolean { @@ -54,13 +76,19 @@ export abstract class WebSocketTopicBase { } } - protected logUpdateReceived(collectionName: string, source: string, extraInfo?: string): void { - let message = `${this._name} received ${collectionName} update from ${source}` + protected logUpdateReceived(collectionName: string, extraInfo?: string): void { + let message = `${this._name} received ${collectionName} update` if (extraInfo) { message += `, ${extraInfo}` } this._logger.debug(message) } + + abstract sendStatus(_subscribers: Iterable): void + + protected sendStatusToAll = (): void => { + this.sendStatus(this._subscribers) + } } export interface WebSocketTopic { @@ -71,108 +99,226 @@ export interface WebSocketTopic { sendMessage(ws: WebSocket, msg: object): void } -export type ObserverForCollection = T extends keyof CorelibPubSubCollections - ? Observer - : undefined +const DEFAULT_THROTTLE_PERIOD_MS = 20 -export abstract class CollectionBase< - T, - TPubSub extends CorelibPubSub | undefined, - TCollection extends keyof CorelibPubSubCollections -> { +export abstract class CollectionBase { protected _name: string protected _collectionName: TCollection - protected _publicationName: TPubSub protected _logger: Logger protected _coreHandler: CoreHandler protected _studioId!: StudioId - protected _subscribers: Set = new Set() - protected _observers: Set> = new Set() + protected _observers: Map< + ObserverCallback, + { keysToPick: readonly (keyof T)[] | undefined; lastData: T | undefined } + > = new Map() protected _collectionData: T | undefined - protected _subscriptionId: SubscriptionId | undefined - protected _dbObserver: ObserverForCollection | undefined protected get _core(): CoreConnection { return this._coreHandler.core } + protected throttledChanged: () => void - constructor(name: string, collection: TCollection, publication: TPubSub, logger: Logger, coreHandler: CoreHandler) { - this._name = name + constructor( + collection: TCollection, + logger: Logger, + coreHandler: CoreHandler, + throttlePeriodMs = DEFAULT_THROTTLE_PERIOD_MS + ) { + this._name = this.constructor.name this._collectionName = collection - this._publicationName = publication this._logger = logger this._coreHandler = coreHandler + this.throttledChanged = throttleToNextTick( + throttlePeriodMs > 0 + ? _.throttle(() => this.changed(), throttlePeriodMs, { leading: true, trailing: true }) + : () => this.changed() + ) + this._logger.info(`Starting ${this._name} handler`) } - async init(): Promise { + init(_handlers: CollectionHandlers): void { if (!this._coreHandler.studioId) throw new Error('StudioId is not defined') this._studioId = this._coreHandler.studioId } close(): void { this._logger.info(`Closing ${this._name} handler`) - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - if (this._dbObserver) this._dbObserver.stop() } - async subscribe(observer: CollectionObserver): Promise { - this._logger.info(`${observer.observerName}' added observer for '${this._name}'`) - if (this._collectionData) await observer.update(this._name, this._collectionData) - this._observers.add(observer) + subscribe(callback: ObserverCallback, keysToPick?: readonly K[]): void { + //this._logger.info(`${name}' added observer for '${this._name}'`) + if (this._collectionData) callback(this._collectionData) + this._observers.set(callback, { keysToPick, lastData: this.shallowClone(this._collectionData) }) } - async unsubscribe(observer: CollectionObserver): Promise { - this._logger.info(`${observer.observerName}' removed observer for '${this._name}'`) - this._observers.delete(observer) + /** + * Called after a batch of updates to documents in the collection + */ + protected changed(): void { + // override me } - async notify(data: T | undefined): Promise { - for (const observer of this._observers) { - await observer.update(this._name, data) + notify(data: T | undefined): void { + for (const [observer, o] of this._observers) { + if ( + !o.lastData || + !o.keysToPick || + !data || + !arePropertiesShallowEqual(o.lastData, data, undefined, o.keysToPick) + ) { + observer(data) + o.lastData = this.shallowClone(data) + } } } + protected shallowClone(data: T | undefined): T | undefined { + if (data === undefined) return undefined + if (Array.isArray(data)) return [...data] as T + if (typeof data === 'object') return { ...data } + return data + } + protected logDocumentChange(documentId: string | ProtectedString, changeType: string): void { this._logger.silly(`${this._name} ${changeType} ${documentId}`) } protected logUpdateReceived(collectionName: string, updateCount: number | undefined): void - protected logUpdateReceived(collectionName: string, source: string, extraInfo?: string): void + protected logUpdateReceived(collectionName: string, extraInfo?: string): void protected logUpdateReceived( collectionName: string, - sourceOrUpdateCount: string | number | undefined, - extraInfo?: string + extraInfoOrUpdateCount: string | number | undefined | null = null ): void { - if (typeof sourceOrUpdateCount === 'string') { - let message = `${this._name} received ${collectionName} update from ${sourceOrUpdateCount}` - if (extraInfo) { - message += `, ${extraInfo}` - } - this._logger.debug(message) - } else { - this._logger.debug(`'${this._name}' handler received ${sourceOrUpdateCount} ${collectionName}`) + let message = `${this._name} received ${collectionName} update` + if (typeof extraInfoOrUpdateCount === 'string') { + message += `, ${extraInfoOrUpdateCount}` + } else if (extraInfoOrUpdateCount !== null) { + message += `(${extraInfoOrUpdateCount})` } + this._logger.debug(message) } protected logNotifyingUpdate(updateCount: number | undefined): void { this._logger.debug(`${this._name} notifying update with ${updateCount} ${this._collectionName}`) } + + protected getCollectionOrFail(): CoreCollection> { + const collection = this._core.getCollection(this._collectionName) + if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) + return collection + } +} + +export abstract class PublicationCollection< + T, + TPubSub extends keyof CorelibPubSubTypes, + TCollection extends keyof CorelibPubSubCollections +> extends CollectionBase { + protected _publicationName: TPubSub + protected _subscriptionId: SubscriptionId | undefined + protected _subscriptionPending = false + protected _dbObserver: + | Observer> + | undefined + + constructor( + collection: TCollection, + publication: TPubSub, + logger: Logger, + coreHandler: CoreHandler, + throttlePeriodMs = DEFAULT_THROTTLE_PERIOD_MS + ) { + super(collection, logger, coreHandler, throttlePeriodMs) + this._publicationName = publication + } + + close(): void { + super.close() + if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) + this._dbObserver?.stop() + } + + subscribe(callback: ObserverCallback, keysToPick?: readonly K[]): void { + //this._logger.info(`${name}' added observer for '${this._name}'`) + if (this._collectionData) callback(this._collectionData) + this._observers.set(callback, { keysToPick, lastData: this.shallowClone(this._collectionData) }) + } + + /** + * Called after a batch of updates to documents in the collection + */ + protected changed(): void { + // override me + } + + protected onDocumentEvent(id: ProtectedString | string, changeType: string): void { + this.logDocumentChange(id, changeType) + if (!this._subscriptionId) { + this._logger.silly(`${this._name} ${changeType} ${id} skipping (lack of subscription)`) + return + } + if (this._subscriptionPending) { + this._logger.silly(`${this._name} ${changeType} ${id} skipping (subscription pending)`) + return + } + this.throttledChanged() + } + + protected setupObserver(): void { + this._dbObserver = this._coreHandler.setupObserver(this._collectionName) + this._dbObserver.added = (id) => { + this.onDocumentEvent(id, 'added') + } + this._dbObserver.changed = (id) => { + this.onDocumentEvent(id, 'changed') + } + this._dbObserver.removed = (id) => { + this.onDocumentEvent(id, 'removed') + } + } + + protected stopSubscription(): void { + if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) + this._subscriptionId = undefined + this._dbObserver?.stop() + this._dbObserver = undefined + } + + protected setupSubscription(...args: ParametersOfFunctionOrNever): void { + if (!this._publicationName) throw new Error(`Publication name not set for '${this._name}'`) + this.stopSubscription() + this._subscriptionPending = true + this._coreHandler + .setupSubscription(this._publicationName, ...args) + .then((subscriptionId) => { + this._subscriptionId = subscriptionId + this.setupObserver() + }) + .catch((e) => this._logger.error(e)) + .finally(() => { + this._subscriptionPending = false + this.changed() + }) + } } export interface Collection { - init(): Promise + init(handlers: CollectionHandlers): void close(): void - subscribe(observer: CollectionObserver): Promise - unsubscribe(observer: CollectionObserver): Promise - notify(data: T | undefined): Promise + subscribe(callback: ObserverCallback, keys?: K[]): void + notify(data: T | undefined): void } -export interface CollectionObserver { - observerName: string - update(source: string, data: T | undefined): Promise -} +export type ObserverCallback = (data: Pick | undefined) => void + +export type PickArr = Pick + +// export interface CollectionObserver { +// observerName: string +// update(source: string, data: Pick | undefined): void +// } function isIterable(obj: T | Iterable): obj is Iterable { // checks for null and undefined if (obj == null) { From 5308430233d606a7e237eb6b66bf5119be6c35df Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 28 Jan 2025 23:52:03 +0100 Subject: [PATCH 004/293] feat(EAV-488): add packages topic to LSG --- .../checkPieceContentStatus.ts | 2 +- .../rundown/publication.ts | 11 +-- .../rundown/regenerateItems.ts | 2 +- packages/corelib/src/dataModel/Collections.ts | 7 ++ .../src/dataModel/PieceContentStatus.ts | 50 +++++++++++ packages/corelib/src/pubsub.ts | 17 +++- .../live-status-gateway/api/asyncapi.yaml | 6 ++ .../api/schemas/packages.yaml | 63 ++++++++++++++ .../pieceContentStatusesHandler.ts | 76 ++++++++++++++++ .../src/liveStatusServer.ts | 7 ++ .../topics/__tests__/packagesTopic.spec.ts | 79 +++++++++++++++++ .../src/topics/packagesTopic.ts | 87 +++++++++++++++++++ .../live-status-gateway/src/topics/root.ts | 1 + .../meteor-lib/src/api/pieceContentStatus.ts | 21 ----- packages/meteor-lib/src/api/pubsub.ts | 11 +-- .../src/api/rundownNotifications.ts | 27 +----- packages/webui/src/client/collections/lib.ts | 14 +++ .../src/client/lib/ui/pieceUiClassNames.ts | 2 +- packages/webui/src/client/ui/Collections.tsx | 10 ++- .../src/client/ui/MediaStatus/MediaStatus.tsx | 4 +- packages/webui/src/client/ui/RundownView.tsx | 2 +- .../client/ui/RundownView/RundownNotifier.tsx | 3 +- .../getReactivePieceNoteCountsForSegment.tsx | 3 +- .../ui/SegmentList/PieceHoverInspector.tsx | 2 +- .../Renderers/VTSourceRenderer.tsx | 2 +- .../ui/SegmentTimeline/SourceLayerItem.tsx | 2 +- .../SegmentTimeline/withMediaObjectStatus.tsx | 7 +- .../ItemRenderers/DefaultItemRenderer.tsx | 2 +- .../ItemRenderers/ItemRendererFactory.ts | 2 +- .../ui/Shelf/Renderers/ItemRendererFactory.ts | 2 +- 30 files changed, 442 insertions(+), 82 deletions(-) create mode 100644 packages/corelib/src/dataModel/PieceContentStatus.ts create mode 100644 packages/live-status-gateway/api/schemas/packages.yaml create mode 100644 packages/live-status-gateway/src/collections/pieceContentStatusesHandler.ts create mode 100644 packages/live-status-gateway/src/topics/__tests__/packagesTopic.spec.ts create mode 100644 packages/live-status-gateway/src/topics/packagesTopic.ts delete mode 100644 packages/meteor-lib/src/api/pieceContentStatus.ts diff --git a/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts b/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts index dbead8658e..2e3bc46942 100644 --- a/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts +++ b/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts @@ -32,7 +32,6 @@ import _ from 'underscore' import { getSideEffect } from '@sofie-automation/meteor-lib/dist/collections/ExpectedPackages' import { getActiveRoutes, getRoutedMappings } from '@sofie-automation/meteor-lib/dist/collections/Studios' import { ensureHasTrailingSlash, generateTranslation, unprotectString } from '../../lib/tempLib' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' import { MediaObjects, PackageContainerPackageStatuses, PackageInfos } from '../../collections' import { mediaObjectFieldSpecifier, @@ -43,6 +42,7 @@ import { PackageInfoLight, PieceDependencies, } from './common' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' interface ScanInfoForPackages { [packageId: string]: ScanInfoForPackage diff --git a/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts b/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts index a190378eac..ae8b754280 100644 --- a/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts +++ b/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts @@ -13,8 +13,7 @@ import { } from '@sofie-automation/corelib/dist/dataModel/Ids' import { MongoFieldSpecifierOnesStrict } from '@sofie-automation/corelib/dist/mongo' import { ReadonlyDeep } from 'type-fest' -import { CustomCollectionName, MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' -import { UIPieceContentStatus } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' +import { UIPieceContentStatus } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { MediaObjects, @@ -57,6 +56,8 @@ import { PieceContentStatusStudio } from '../checkPieceContentStatus' import { check, Match } from 'meteor/check' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { triggerWriteAccessBecauseNoCheckNecessary } from '../../../security/securityVerify' +import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { CustomCollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' interface UIPieceContentStatusesArgs { readonly rundownPlaylistId: RundownPlaylistId @@ -469,7 +470,7 @@ function updatePartAndSegmentInfoForExistingDocs( } meteorCustomPublish( - MeteorPubSub.uiPieceContentStatuses, + CorelibPubSub.uiPieceContentStatuses, CustomCollectionName.UIPieceContentStatuses, async function (pub, rundownPlaylistId: RundownPlaylistId | null) { check(rundownPlaylistId, Match.Maybe(String)) @@ -477,7 +478,7 @@ meteorCustomPublish( triggerWriteAccessBecauseNoCheckNecessary() if (!rundownPlaylistId) { - logger.info(`Pub.${CustomCollectionName.UISegmentPartNotes}: Not playlistId`) + logger.info(`Pub.${CustomCollectionName.UIPieceContentStatuses}: Not playlistId`) return } @@ -487,7 +488,7 @@ meteorCustomPublish( UIPieceContentStatusesState, UIPieceContentStatusesUpdateProps >( - `pub_${MeteorPubSub.uiPieceContentStatuses}_${rundownPlaylistId}`, + `pub_${CorelibPubSub.uiPieceContentStatuses}_${rundownPlaylistId}`, { rundownPlaylistId }, setupUIPieceContentStatusesPublicationObservers, manipulateUIPieceContentStatusesPublicationData, diff --git a/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts b/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts index 481b64d978..d33ff8fe6f 100644 --- a/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts +++ b/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts @@ -6,7 +6,7 @@ import { RundownBaselineAdLibActionId, } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' -import { UIPieceContentStatus } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' +import { UIPieceContentStatus } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' import { literal, protectString } from '../../../lib/tempLib' import { CustomPublishCollection } from '../../../lib/customPublication' import { ContentCache } from './reactiveContentCache' diff --git a/packages/corelib/src/dataModel/Collections.ts b/packages/corelib/src/dataModel/Collections.ts index 8f2dd0f5fc..3bbea168b3 100644 --- a/packages/corelib/src/dataModel/Collections.ts +++ b/packages/corelib/src/dataModel/Collections.ts @@ -48,3 +48,10 @@ export enum CollectionName { Workers = 'workers', WorkerThreads = 'workersThreads', } + +/** + * Ids of possible Custom collections, populated by DDP subscriptions + */ +export enum CustomCollectionName { + UIPieceContentStatuses = 'uiPieceContentStatuses', +} diff --git a/packages/corelib/src/dataModel/PieceContentStatus.ts b/packages/corelib/src/dataModel/PieceContentStatus.ts new file mode 100644 index 0000000000..dd50e68584 --- /dev/null +++ b/packages/corelib/src/dataModel/PieceContentStatus.ts @@ -0,0 +1,50 @@ +import { ITranslatableMessage, PackageInfo } from '@sofie-automation/blueprints-integration' +import { ProtectedString } from '../protectedString' +import { + RundownId, + PartId, + SegmentId, + PieceId, + AdLibActionId, + RundownBaselineAdLibActionId, + PieceInstanceId, +} from './Ids' +import { PieceStatusCode } from './Piece' + +export type UIPieceContentStatusId = ProtectedString<'UIPieceContentStatus'> +export interface UIPieceContentStatus { + _id: UIPieceContentStatusId + + segmentRank: number + partRank: number + + rundownId: RundownId + partId: PartId | undefined + segmentId: SegmentId | undefined + + pieceId: PieceId | AdLibActionId | RundownBaselineAdLibActionId | PieceInstanceId + isPieceInstance: boolean + + name: string | ITranslatableMessage + segmentName: string | undefined + + status: PieceContentStatusObj +} + +export interface PieceContentStatusObj { + status: PieceStatusCode + messages: ITranslatableMessage[] + + freezes: Array + blacks: Array + scenes: Array + + thumbnailUrl: string | undefined + previewUrl: string | undefined + + packageName: string | null + + contentDuration: number | undefined + + progress: number | undefined +} diff --git a/packages/corelib/src/pubsub.ts b/packages/corelib/src/pubsub.ts index a8436a1403..f70b215bb5 100644 --- a/packages/corelib/src/pubsub.ts +++ b/packages/corelib/src/pubsub.ts @@ -1,5 +1,5 @@ import { DBPart } from './dataModel/Part' -import { CollectionName } from './dataModel/Collections' +import { CollectionName, CustomCollectionName } from './dataModel/Collections' import { MongoQuery } from './mongo' import { AdLibAction } from './dataModel/AdlibAction' import { AdLibPiece } from './dataModel/AdLibPiece' @@ -37,6 +37,7 @@ import { } from '@sofie-automation/shared-lib/dist/core/model/Ids' import { BlueprintId, BucketId, RundownPlaylistActivationId, SegmentId, ShowStyleVariantId } from './dataModel/Ids' import { PackageInfoDB } from './dataModel/PackageInfos' +import { UIPieceContentStatus } from './dataModel/PieceContentStatus' /** * Ids of possible DDP subscriptions for any the UI and gateways accessing the Rundown & RundownPlaylist model. @@ -180,6 +181,12 @@ export enum CorelibPubSub { * Fetch all the PackageInfos owned by a PeripheralDevice */ packageInfos = 'packageInfos', + + /** + * Fetch the Pieces content-status in the given RundownPlaylist + * If the id is null, nothing will be returned + */ + uiPieceContentStatuses = 'uiPieceContentStatuses', } /** @@ -317,6 +324,10 @@ export interface CorelibPubSubTypes { token?: string ) => CollectionName.PackageContainerStatuses [CorelibPubSub.packageInfos]: (deviceId: PeripheralDeviceId, token?: string) => CollectionName.PackageInfos + + [CorelibPubSub.uiPieceContentStatuses]: ( + rundownPlaylistId: RundownPlaylistId | null + ) => CustomCollectionName.UIPieceContentStatuses } export type CorelibPubSubCollections = { @@ -347,4 +358,8 @@ export type CorelibPubSubCollections = { [CollectionName.Studios]: DBStudio [CollectionName.Timelines]: TimelineComplete [CollectionName.TimelineDatastore]: DBTimelineDatastoreEntry +} & CorelibPubSubCustomCollections + +export type CorelibPubSubCustomCollections = { + [CustomCollectionName.UIPieceContentStatuses]: UIPieceContentStatus } diff --git a/packages/live-status-gateway/api/asyncapi.yaml b/packages/live-status-gateway/api/asyncapi.yaml index 52dd541133..6847f5dd6d 100644 --- a/packages/live-status-gateway/api/asyncapi.yaml +++ b/packages/live-status-gateway/api/asyncapi.yaml @@ -48,6 +48,7 @@ channels: - $ref: '#/components/messages/activePieces' - $ref: '#/components/messages/segments' - $ref: '#/components/messages/adLibs' + - $ref: '#/components/messages/packages' components: messages: ping: @@ -124,3 +125,8 @@ components: description: AdLibs in active Playlist payload: $ref: './schemas/adLibs.yaml#/$defs/adLibs' + packages: + name: packages + description: Status of Packages expected by Pieces + payload: + $ref: './schemas/packages.yaml#/$defs/packages' diff --git a/packages/live-status-gateway/api/schemas/packages.yaml b/packages/live-status-gateway/api/schemas/packages.yaml new file mode 100644 index 0000000000..318d74c57b --- /dev/null +++ b/packages/live-status-gateway/api/schemas/packages.yaml @@ -0,0 +1,63 @@ +title: Packages +description: Packages schema for websocket subscriptions +$defs: + packages: + type: object + properties: + event: + type: string + const: packages + rundownPlaylistId: + description: Unique id of the rundown playlist, or null if no playlist is active + oneOf: + - type: string + - type: 'null' + packages: + description: The Package statuses for this playlist + type: array + items: + $ref: '#/$defs/package' + required: [event, rundownPlaylistId, packages] + additionalProperties: false + examples: + - event: packages + rundownPlaylistId: 'OKAgZmZ0Buc99lE_2uPPSKVbMrQ_' + packages: + $ref: '#/$defs/package/examples' + package: + type: object + properties: + packageName: + description: Name of the package + oneOf: + - type: string + - type: 'null' + statusCode: + description: Status code + type: number + rundownId: + description: Id of the Rundown that expects this package + type: string + partId: + description: Id of the Part that expects this package + type: string + segmentId: + description: Id of the Segment that expects this package + type: string + pieceId: + description: Id of the Piece that expects this package + type: string + thumbnailUrl: + description: URL where the thumbnail can be accessed + type: string + previewUrl: + description: URL where the preview can be accessed + type: string + required: [packageName, statusCode, rundownId, pieceId] + additionalProperties: false + examples: + - packageName: 'MV000123.mxf' + statusCode: 0 + rundownId: 'y9HauyWkcxQS3XaAOsW40BRLLsI_' + pieceId: 'C6K_yIMuGFUk8X_L9A9_jRT6aq4_' + thumbnailUrl: 'https://package-manager.local/package/MV000123.mov_thumbnail.jpg' diff --git a/packages/live-status-gateway/src/collections/pieceContentStatusesHandler.ts b/packages/live-status-gateway/src/collections/pieceContentStatusesHandler.ts new file mode 100644 index 0000000000..20568edd83 --- /dev/null +++ b/packages/live-status-gateway/src/collections/pieceContentStatusesHandler.ts @@ -0,0 +1,76 @@ +import { Logger } from 'winston' +import { CoreHandler } from '../coreHandler' +import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { UIPieceContentStatus } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' +import throttleToNextTick from '@sofie-automation/shared-lib/dist/lib/throttleToNextTick' +import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { CollectionHandlers } from '../liveStatusServer' +import { CustomCollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' + +const PLAYLIST_KEYS = ['_id'] as const +type Playlist = PickArr + +export class PieceContentStatusesHandler + extends PublicationCollection< + UIPieceContentStatus[], + CorelibPubSub.uiPieceContentStatuses, + CustomCollectionName.UIPieceContentStatuses + > + implements Collection +{ + private _currentPlaylistId: RundownPlaylistId | undefined + + private _throttledUpdateAndNotify = throttleToNextTick(() => { + this.updateAndNotify() + }) + + constructor(logger: Logger, coreHandler: CoreHandler) { + super(CustomCollectionName.UIPieceContentStatuses, CorelibPubSub.uiPieceContentStatuses, logger, coreHandler) + } + + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdated, PLAYLIST_KEYS) + } + + changed(): void { + this._throttledUpdateAndNotify() + } + + private updateCollectionData() { + const collection = this.getCollectionOrFail() + this._collectionData = collection.find({}) + } + + private clearCollectionData() { + this._collectionData = [] + } + + onPlaylistUpdated = (playlist: Playlist | undefined): void => { + this.logUpdateReceived('playlist', `rundownPlaylistId ${playlist?._id}`) + const prevPlaylistId = this._currentPlaylistId + this._currentPlaylistId = playlist?._id + + if (this._currentPlaylistId) { + if (prevPlaylistId !== this._currentPlaylistId) { + this.stopSubscription() + this.setupSubscription(this._currentPlaylistId) + } + } else { + this.clearAndNotify() + } + } + + private clearAndNotify() { + this.clearCollectionData() + this.notify(this._collectionData) + } + + private updateAndNotify() { + this.updateCollectionData() + this.notify(this._collectionData) + } +} diff --git a/packages/live-status-gateway/src/liveStatusServer.ts b/packages/live-status-gateway/src/liveStatusServer.ts index 7f0094993e..5f2a9cce51 100644 --- a/packages/live-status-gateway/src/liveStatusServer.ts +++ b/packages/live-status-gateway/src/liveStatusServer.ts @@ -23,6 +23,8 @@ import { PartsHandler } from './collections/partsHandler' import { PieceInstancesHandler } from './collections/pieceInstancesHandler' import { AdLibsTopic } from './topics/adLibsTopic' import { ActivePiecesTopic } from './topics/activePiecesTopic' +import { PieceContentStatusesHandler } from './collections/pieceContentStatusesHandler' +import { PackagesTopic } from './topics/packagesTopic' export interface CollectionHandlers { studioHandler: StudioHandler @@ -40,6 +42,7 @@ export interface CollectionHandlers { adLibsHandler: AdLibsHandler globalAdLibActionsHandler: GlobalAdLibActionsHandler globalAdLibsHandler: GlobalAdLibsHandler + pieceContentStatusesHandler: PieceContentStatusesHandler } export class LiveStatusServer { @@ -72,6 +75,7 @@ export class LiveStatusServer { const adLibsHandler = new AdLibsHandler(this._logger, this._coreHandler) const globalAdLibActionsHandler = new GlobalAdLibActionsHandler(this._logger, this._coreHandler) const globalAdLibsHandler = new GlobalAdLibsHandler(this._logger, this._coreHandler) + const pieceContentStatusesHandler = new PieceContentStatusesHandler(this._logger, this._coreHandler) const handlers: CollectionHandlers = { studioHandler, @@ -89,6 +93,7 @@ export class LiveStatusServer { adLibsHandler, globalAdLibActionsHandler, globalAdLibsHandler, + pieceContentStatusesHandler, } for (const handlerName in handlers) { @@ -100,12 +105,14 @@ export class LiveStatusServer { const activePlaylistTopic = new ActivePlaylistTopic(this._logger, handlers) const segmentsTopic = new SegmentsTopic(this._logger, handlers) const adLibsTopic = new AdLibsTopic(this._logger, handlers) + const packageStatusTopic = new PackagesTopic(this._logger, handlers) rootChannel.addTopic(StatusChannels.studio, studioTopic) rootChannel.addTopic(StatusChannels.activePlaylist, activePlaylistTopic) rootChannel.addTopic(StatusChannels.activePieces, activePiecesTopic) rootChannel.addTopic(StatusChannels.segments, segmentsTopic) rootChannel.addTopic(StatusChannels.adLibs, adLibsTopic) + rootChannel.addTopic(StatusChannels.packages, packageStatusTopic) const wss = new WebSocketServer({ port: 8080 }) wss.on('connection', (ws, request) => { diff --git a/packages/live-status-gateway/src/topics/__tests__/packagesTopic.spec.ts b/packages/live-status-gateway/src/topics/__tests__/packagesTopic.spec.ts new file mode 100644 index 0000000000..c35ca0b4ec --- /dev/null +++ b/packages/live-status-gateway/src/topics/__tests__/packagesTopic.spec.ts @@ -0,0 +1,79 @@ +import { protectString, unprotectString } from '@sofie-automation/server-core-integration' +import { makeMockHandlers, makeMockLogger, makeMockSubscriber } from './utils' +import { PackagesTopic } from '../packagesTopic' +import { UIPieceContentStatus } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' +import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' + +function makeTestUIPieceContentStatuses(): UIPieceContentStatus[] { + return [ + { + pieceId: protectString('PIECE_0'), + rundownId: protectString('RUNDOWN_0'), + partId: protectString('PART_0'), + segmentId: protectString('SEGMENT_0'), + status: { + packageName: 'Test Package', + status: PieceStatusCode.OK, + thumbnailUrl: 'http://example.com/thumbnail.jpg', + previewUrl: 'http://example.com/preview.mp4', + blacks: [], + contentDuration: 5, + freezes: [], + messages: [], + progress: 0, + scenes: [], + }, + _id: protectString('PIECE_CONTENT_STATUS_0'), + segmentRank: 0, + partRank: 0, + isPieceInstance: false, + name: '', + segmentName: 'Segment', + }, + ] +} + +function makeTestPlaylist(): DBRundownPlaylist { + return { + _id: protectString('PLAYLIST_0'), + activationId: protectString('ACTIVATION_0'), + } as DBRundownPlaylist +} + +describe('PackagesTopic', () => { + it('notifies subscribers', async () => { + const handlers = makeMockHandlers() + const topic = new PackagesTopic(makeMockLogger(), handlers) + const mockSubscriber = makeMockSubscriber() + + const playlist = makeTestPlaylist() + handlers.playlistHandler.notify(playlist) + + const testUIPieceContentStatuses = makeTestUIPieceContentStatuses() + handlers.pieceContentStatusesHandler.notify(testUIPieceContentStatuses) + + topic.addSubscriber(mockSubscriber) + + const expectedStatus = { + event: 'packages', + rundownPlaylistId: unprotectString(playlist._id), + packages: [ + { + packageName: 'Test Package', + statusCode: PieceStatusCode.OK, + pieceId: 'PIECE_0', + rundownId: 'RUNDOWN_0', + partId: 'PART_0', + segmentId: 'SEGMENT_0', + thumbnailUrl: 'http://example.com/thumbnail.jpg', + previewUrl: 'http://example.com/preview.mp4', + }, + ], + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockSubscriber.send).toHaveBeenCalledTimes(1) + expect(JSON.parse(mockSubscriber.send.mock.calls[0][0] as string)).toMatchObject(expectedStatus) + }) +}) diff --git a/packages/live-status-gateway/src/topics/packagesTopic.ts b/packages/live-status-gateway/src/topics/packagesTopic.ts new file mode 100644 index 0000000000..a8e0012799 --- /dev/null +++ b/packages/live-status-gateway/src/topics/packagesTopic.ts @@ -0,0 +1,87 @@ +import { Logger } from 'winston' +import { WebSocket } from 'ws' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' +import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' +import { UIPieceContentStatus } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' +import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { CollectionHandlers } from '../liveStatusServer' + +const THROTTLE_PERIOD_MS = 200 + +interface PackageStatus { + packageName: string | null + statusCode: PieceStatusCode + + rundownId: string + partId?: string + segmentId?: string + + pieceId: string + + thumbnailUrl?: string + previewUrl?: string +} + +export interface PackagesStatus { + event: 'packages' + rundownPlaylistId: string | null + packages: PackageStatus[] +} + +const PLAYLIST_KEYS = ['_id', 'activationId'] as const +type Playlist = PickArr + +export class PackagesTopic extends WebSocketTopicBase implements WebSocketTopic { + public observerName = PackagesTopic.name + private _activePlaylist: Playlist | undefined + private _pieceContentStatuses: UIPieceContentStatus[] = [] + + constructor(logger: Logger, handlers: CollectionHandlers) { + super(PackagesTopic.name, logger, THROTTLE_PERIOD_MS) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + handlers.pieceContentStatusesHandler.subscribe(this.onPieceContentStatusUpdate) + } + + sendStatus(subscribers: Iterable): void { + const packagesStatus: PackagesStatus = { + event: 'packages', + rundownPlaylistId: this._activePlaylist ? unprotectString(this._activePlaylist._id) : null, + packages: this._pieceContentStatuses.map((contentStatus) => ({ + packageName: contentStatus.status.packageName, + statusCode: contentStatus.status.status, + pieceId: unprotectString(contentStatus.pieceId), + rundownId: unprotectString(contentStatus.rundownId), + partId: unprotectString(contentStatus.partId), + segmentId: unprotectString(contentStatus.segmentId), + previewUrl: contentStatus.status.previewUrl, + thumbnailUrl: contentStatus.status.thumbnailUrl, + })), + } + + for (const subscriber of subscribers) { + this.sendMessage(subscriber, packagesStatus) + } + } + + private onPlaylistUpdate = (rundownPlaylist: Playlist | undefined): void => { + this.logUpdateReceived( + 'playlist', + `rundownPlaylistId ${rundownPlaylist?._id}, activationId ${rundownPlaylist?.activationId}` + ) + const prevPlaylist = this._activePlaylist + this._activePlaylist = rundownPlaylist + + if (prevPlaylist?._id !== this._activePlaylist?._id) { + this.throttledSendStatusToAll() + } + } + + private onPieceContentStatusUpdate = (data: UIPieceContentStatus[] | undefined): void => { + this.logUpdateReceived('pieceContentStatuses') + if (!data) return + this._pieceContentStatuses = data + this.throttledSendStatusToAll() + } +} diff --git a/packages/live-status-gateway/src/topics/root.ts b/packages/live-status-gateway/src/topics/root.ts index 0c3f3cdca1..39df36ea9e 100644 --- a/packages/live-status-gateway/src/topics/root.ts +++ b/packages/live-status-gateway/src/topics/root.ts @@ -37,6 +37,7 @@ export enum StatusChannels { activePieces = 'activePieces', segments = 'segments', adLibs = 'adLibs', + packages = 'packages', } interface RootMsg { diff --git a/packages/meteor-lib/src/api/pieceContentStatus.ts b/packages/meteor-lib/src/api/pieceContentStatus.ts deleted file mode 100644 index d6612512c2..0000000000 --- a/packages/meteor-lib/src/api/pieceContentStatus.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { PackageInfo } from '@sofie-automation/blueprints-integration' -import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' -import { ITranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' - -export interface PieceContentStatusObj { - status: PieceStatusCode - messages: ITranslatableMessage[] - - freezes: Array - blacks: Array - scenes: Array - - thumbnailUrl: string | undefined - previewUrl: string | undefined - - packageName: string | null - - contentDuration: number | undefined - - progress: number | undefined -} diff --git a/packages/meteor-lib/src/api/pubsub.ts b/packages/meteor-lib/src/api/pubsub.ts index 9c61409c18..ea3deae8ca 100644 --- a/packages/meteor-lib/src/api/pubsub.ts +++ b/packages/meteor-lib/src/api/pubsub.ts @@ -20,7 +20,7 @@ import { SnapshotItem } from '../collections/Snapshots' import { TranslationsBundle } from '../collections/TranslationsBundles' import { DBTriggeredActions, UITriggeredActionsObj } from '../collections/TriggeredActions' import { UserActionsLogItem } from '../collections/UserActionsLog' -import { UIBucketContentStatus, UIPieceContentStatus, UISegmentPartNote } from './rundownNotifications' +import { UIBucketContentStatus, UISegmentPartNote } from './rundownNotifications' import { UIShowStyleBase } from './showStyles' import { UIStudio } from './studios' import { UIDeviceTriggerPreview } from './MountedTriggers' @@ -155,11 +155,6 @@ export enum MeteorPubSub { * If the id is null, nothing will be returned */ uiSegmentPartNotes = 'uiSegmentPartNotes', - /** - * Fetch the Pieces content-status in the given RundownPlaylist - * If the id is null, nothing will be returned - */ - uiPieceContentStatuses = 'uiPieceContentStatuses', /** * Fetch the Pieces content-status in the given Bucket */ @@ -250,9 +245,6 @@ export interface MeteorPubSubTypes { /** Custom publications for the UI */ [MeteorPubSub.uiSegmentPartNotes]: (playlistId: RundownPlaylistId | null) => CustomCollectionName.UISegmentPartNotes - [MeteorPubSub.uiPieceContentStatuses]: ( - rundownPlaylistId: RundownPlaylistId | null - ) => CustomCollectionName.UIPieceContentStatuses [MeteorPubSub.uiBucketContentStatuses]: ( studioId: StudioId, bucketId: BucketId @@ -307,7 +299,6 @@ export type MeteorPubSubCustomCollections = { [CustomCollectionName.UITriggeredActions]: UITriggeredActionsObj [CustomCollectionName.UIDeviceTriggerPreviews]: UIDeviceTriggerPreview [CustomCollectionName.UISegmentPartNotes]: UISegmentPartNote - [CustomCollectionName.UIPieceContentStatuses]: UIPieceContentStatus [CustomCollectionName.UIBucketContentStatuses]: UIBucketContentStatus [CustomCollectionName.UIBlueprintUpgradeStatuses]: UIBlueprintUpgradeStatus [CustomCollectionName.UIParts]: DBPart diff --git a/packages/meteor-lib/src/api/rundownNotifications.ts b/packages/meteor-lib/src/api/rundownNotifications.ts index bd21db24c6..f441407d04 100644 --- a/packages/meteor-lib/src/api/rundownNotifications.ts +++ b/packages/meteor-lib/src/api/rundownNotifications.ts @@ -1,20 +1,15 @@ import { TrackedNote } from '@sofie-automation/corelib/dist/dataModel/Notes' import { - AdLibActionId, BucketAdLibActionId, BucketAdLibId, BucketId, - PartId, - PieceId, - PieceInstanceId, - RundownBaselineAdLibActionId, RundownId, RundownPlaylistId, SegmentId, } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PieceContentStatusObj } from './pieceContentStatus' import { ProtectedString } from '@sofie-automation/corelib/dist/protectedString' import { ITranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' export type UISegmentPartNoteId = ProtectedString<'UISegmentPartNote'> export interface UISegmentPartNote { @@ -26,26 +21,6 @@ export interface UISegmentPartNote { note: TrackedNote } -export type UIPieceContentStatusId = ProtectedString<'UIPieceContentStatus'> -export interface UIPieceContentStatus { - _id: UIPieceContentStatusId - - segmentRank: number - partRank: number - - rundownId: RundownId - partId: PartId | undefined - segmentId: SegmentId | undefined - - pieceId: PieceId | AdLibActionId | RundownBaselineAdLibActionId | PieceInstanceId - isPieceInstance: boolean - - name: string | ITranslatableMessage - segmentName: string | undefined - - status: PieceContentStatusObj -} - export type UIBucketContentStatusId = ProtectedString<'UIBucketContentStatus'> export interface UIBucketContentStatus { _id: UIBucketContentStatusId diff --git a/packages/webui/src/client/collections/lib.ts b/packages/webui/src/client/collections/lib.ts index 40409c8064..dc74a16b0c 100644 --- a/packages/webui/src/client/collections/lib.ts +++ b/packages/webui/src/client/collections/lib.ts @@ -19,6 +19,8 @@ import { UpdateOptions, UpsertOptions, } from '@sofie-automation/meteor-lib/dist/collections/lib' +import { CustomCollectionName as CustomCorelibCollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { CorelibPubSubCustomCollections } from '@sofie-automation/corelib/dist/pubsub' export * from '@sofie-automation/meteor-lib/dist/collections/lib' @@ -125,6 +127,18 @@ export function createSyncCustomPublicationMongoCollection< return wrapped } +export function createSyncCorelibCustomPublicationMongoCollection< + K extends CustomCorelibCollectionName & keyof CorelibPubSubCustomCollections +>(name: K): MongoReadOnlyCollection { + const collection = new Mongo.Collection(name) + const wrapped = new WrappedMongoReadOnlyCollection(collection, name) + + if (PublicationCollections.has(name)) throw new Meteor.Error(`Cannot re-register collection "${name}"`) + PublicationCollections.set(name, wrapped) + + return wrapped +} + export function createSyncPeripheralDeviceCustomPublicationMongoCollection< K extends PeripheralDevicePubSubCollectionsNames & keyof PeripheralDevicePubSubCollections >(name: K): MongoReadOnlyCollection { diff --git a/packages/webui/src/client/lib/ui/pieceUiClassNames.ts b/packages/webui/src/client/lib/ui/pieceUiClassNames.ts index a66a68e8fc..3250a84876 100644 --- a/packages/webui/src/client/lib/ui/pieceUiClassNames.ts +++ b/packages/webui/src/client/lib/ui/pieceUiClassNames.ts @@ -5,7 +5,7 @@ import classNames from 'classnames' import { PieceUi } from '../../ui/SegmentContainer/withResolvedSegment' import { RundownUtils } from '../rundown' import { ReadonlyDeep } from 'type-fest' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' export function pieceUiClassNames( pieceInstance: PieceUi, diff --git a/packages/webui/src/client/ui/Collections.tsx b/packages/webui/src/client/ui/Collections.tsx index c6215a78fd..4e0c4dbbe7 100644 --- a/packages/webui/src/client/ui/Collections.tsx +++ b/packages/webui/src/client/ui/Collections.tsx @@ -1,5 +1,9 @@ import { CustomCollectionName } from '@sofie-automation/meteor-lib/dist/api/pubsub' -import { createSyncCustomPublicationMongoCollection } from '../collections/lib' +import { CustomCollectionName as CustomCorelibCollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { + createSyncCorelibCustomPublicationMongoCollection, + createSyncCustomPublicationMongoCollection, +} from '../collections/lib' /** * A playout UI version of ShowStyleBases. @@ -34,8 +38,8 @@ export const UISegmentPartNotes = createSyncCustomPublicationMongoCollection(Cus /** * Pre-processed MediaObjectIssue for Pieces in the Rundowns */ -export const UIPieceContentStatuses = createSyncCustomPublicationMongoCollection( - CustomCollectionName.UIPieceContentStatuses +export const UIPieceContentStatuses = createSyncCorelibCustomPublicationMongoCollection( + CustomCorelibCollectionName.UIPieceContentStatuses ) /** diff --git a/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx b/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx index 646a039c33..977b952892 100644 --- a/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx +++ b/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx @@ -28,7 +28,7 @@ import { ProtectedString, unprotectString } from '@sofie-automation/corelib/dist import { ExpectedPackage } from '@sofie-automation/shared-lib/dist/package-manager/package' import { PartInvalidReason } from '@sofie-automation/corelib/dist/dataModel/Part' import { IBlueprintActionManifestDisplayContent, SourceLayerType } from '@sofie-automation/blueprints-integration' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' import { Piece, PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { assertNever, literal } from '@sofie-automation/corelib/dist/lib' import { UIPieceContentStatuses, UIShowStyleBases } from '../Collections' @@ -277,7 +277,7 @@ function useMediaStatusSubscriptions( () => playlistIds.map((playlistIds) => [playlistIds] as [RundownPlaylistId]), [playlistIds] ) - readyStatus[counter++] = useSubscriptions(MeteorPubSub.uiPieceContentStatuses, uiPieceContentStatusesSubArguments) + readyStatus[counter++] = useSubscriptions(CorelibPubSub.uiPieceContentStatuses, uiPieceContentStatusesSubArguments) return readyStatus.reduce((mem, current) => mem && current, true) } diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 225b06c567..4766087759 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -1313,7 +1313,7 @@ export function RundownView(props: Readonly): JSX.Element { // Load once the playlist is confirmed to exist auxSubsReady.push(useSubscriptionIfEnabled(MeteorPubSub.uiSegmentPartNotes, !!playlistStudioId, playlistId)) - auxSubsReady.push(useSubscriptionIfEnabled(MeteorPubSub.uiPieceContentStatuses, !!playlistStudioId, playlistId)) + auxSubsReady.push(useSubscriptionIfEnabled(CorelibPubSub.uiPieceContentStatuses, !!playlistStudioId, playlistId)) useTracker(() => { const playlist = RundownPlaylists.findOne(playlistId, { diff --git a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx index 56999eca0e..c683d6d1aa 100644 --- a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx @@ -27,7 +27,7 @@ import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { PeripheralDevicesAPI } from '../../lib/clientAPI' import { handleRundownReloadResponse } from '../RundownView' import { MeteorCall } from '../../lib/meteorApi' -import { UIPieceContentStatus, UISegmentPartNote } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' +import { UISegmentPartNote } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' import { isTranslatableMessage, translateMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' import { NoteSeverity, StatusCode } from '@sofie-automation/blueprints-integration' import { getIgnorePieceContentStatus } from '../../lib/localStorage' @@ -51,6 +51,7 @@ import { UserPermissionsContext, UserPermissions } from '../UserPermissions' import { PartInstance } from '@sofie-automation/meteor-lib/dist/collections/PartInstances' import { assertNever } from '@sofie-automation/corelib/dist/lib' import { DBNotificationTargetType } from '@sofie-automation/corelib/dist/dataModel/Notifications' +import { UIPieceContentStatus } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' export const onRONotificationClick = new ReactiveVar<((e: RONotificationEvent) => void) | undefined>(undefined) export const reloadRundownPlaylistClick = new ReactiveVar<((e: any) => void) | undefined>(undefined) diff --git a/packages/webui/src/client/ui/SegmentContainer/getReactivePieceNoteCountsForSegment.tsx b/packages/webui/src/client/ui/SegmentContainer/getReactivePieceNoteCountsForSegment.tsx index b96d9e0179..b44c92fef0 100644 --- a/packages/webui/src/client/ui/SegmentContainer/getReactivePieceNoteCountsForSegment.tsx +++ b/packages/webui/src/client/ui/SegmentContainer/getReactivePieceNoteCountsForSegment.tsx @@ -1,7 +1,7 @@ import { NoteSeverity } from '@sofie-automation/blueprints-integration' import { assertNever, literal } from '@sofie-automation/corelib/dist/lib' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' -import { UIPieceContentStatus, UISegmentPartNote } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' +import { UISegmentPartNote } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { getIgnorePieceContentStatus } from '../../lib/localStorage' import { UIPartInstances, UIPieceContentStatuses, UISegmentPartNotes } from '../Collections' @@ -9,6 +9,7 @@ import { SegmentNoteCounts, SegmentUi } from './withResolvedSegment' import { Notifications } from '../../collections' import { PartInstance } from '@sofie-automation/meteor-lib/dist/collections/PartInstances' import { DBNotificationObj } from '@sofie-automation/corelib/dist/dataModel/Notifications' +import { UIPieceContentStatus } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' export function getReactivePieceNoteCountsForSegment(segment: SegmentUi): SegmentNoteCounts { const segmentNoteCounts: SegmentNoteCounts = { diff --git a/packages/webui/src/client/ui/SegmentList/PieceHoverInspector.tsx b/packages/webui/src/client/ui/SegmentList/PieceHoverInspector.tsx index 78229d6551..565a4e13d7 100644 --- a/packages/webui/src/client/ui/SegmentList/PieceHoverInspector.tsx +++ b/packages/webui/src/client/ui/SegmentList/PieceHoverInspector.tsx @@ -15,7 +15,7 @@ import { L3rdFloatingInspector } from '../FloatingInspectors/L3rdFloatingInspect import { VTFloatingInspector } from '../FloatingInspectors/VTFloatingInspector' import { PieceUi } from '../SegmentContainer/withResolvedSegment' import { ReadonlyDeep } from 'type-fest' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' export function PieceHoverInspector({ studio, diff --git a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx index 97a38d2dde..d2e138a25a 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx @@ -22,7 +22,7 @@ import { IFloatingInspectorPosition } from '../../FloatingInspectors/IFloatingIn import { logger } from '../../../lib/logging' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import { ReadonlyDeep } from 'type-fest' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' interface IProps extends ICustomLayerItemProps { studio: UIStudio | undefined diff --git a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx index efadf6d9c1..54505ecea9 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx @@ -22,7 +22,7 @@ import { SourceDurationLabelAlignment } from './Renderers/CustomLayerItemRendere import { TransitionSourceRenderer } from './Renderers/TransitionSourceRenderer' import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { ReadonlyDeep } from 'type-fest' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' import { SelectedElementsContext } from '../RundownView/SelectedElementsContext' const LEFT_RIGHT_ANCHOR_SPACER = 15 const MARGINAL_ANCHORED_WIDTH = 5 diff --git a/packages/webui/src/client/ui/SegmentTimeline/withMediaObjectStatus.tsx b/packages/webui/src/client/ui/SegmentTimeline/withMediaObjectStatus.tsx index 106fb5c888..d9d1e213c0 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/withMediaObjectStatus.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/withMediaObjectStatus.tsx @@ -5,10 +5,13 @@ import { BucketAdLibUi, BucketAdLibActionUi } from '../Shelf/RundownViewBuckets' import { AdLibPieceUi } from '../../lib/shelf' import { UIBucketContentStatuses, UIPieceContentStatuses } from '../Collections' import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' +import { + PieceContentStatusObj, + UIPieceContentStatus, +} from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' import { useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { UIBucketContentStatus, UIPieceContentStatus } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' +import { UIBucketContentStatus } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { ReadonlyDeep } from 'type-fest' diff --git a/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx index 0eece72ab4..e8f2776f0e 100644 --- a/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx +++ b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx @@ -9,7 +9,7 @@ import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyle import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { ReadonlyDeep } from 'type-fest' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' export default function DefaultItemRenderer( props: Readonly<{ diff --git a/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/ItemRendererFactory.ts b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/ItemRendererFactory.ts index 94a77dc8de..c38a886eaa 100644 --- a/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/ItemRendererFactory.ts +++ b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/ItemRendererFactory.ts @@ -11,7 +11,7 @@ import { AdLibPieceUi } from '../../../../lib/shelf' import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyles' import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { ReadonlyDeep } from 'type-fest' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' export default function renderItem( piece: BucketAdLibItem | IAdLibListItem | PieceUi, diff --git a/packages/webui/src/client/ui/Shelf/Renderers/ItemRendererFactory.ts b/packages/webui/src/client/ui/Shelf/Renderers/ItemRendererFactory.ts index 2846dc2c85..f40eeaf9e3 100644 --- a/packages/webui/src/client/ui/Shelf/Renderers/ItemRendererFactory.ts +++ b/packages/webui/src/client/ui/Shelf/Renderers/ItemRendererFactory.ts @@ -8,7 +8,7 @@ import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { ITranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' import { ReadonlyDeep } from 'type-fest' -import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' +import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' export interface ILayerItemRendererProps { adLibListItem: IAdLibListItem From 513c04863f84bd56cbfff16e7fd0e167054eac5f Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 28 Jan 2025 20:33:07 +0100 Subject: [PATCH 005/293] feat(EAV-487): add buckets topic to LSG currently only featuring adLibs and actions valid for all variants (showStyleVariantId === null) --- meteor/server/api/buckets.ts | 2 +- meteor/server/api/rest/v1/typeConversion.ts | 2 +- meteor/server/api/userActions.ts | 2 +- meteor/server/collections/bucket.ts | 2 +- meteor/server/publications/buckets.ts | 25 ++- .../bucket/publication.ts | 2 +- .../src/dataModel/Bucket.ts} | 2 +- packages/corelib/src/pubsub.ts | 15 +- .../docs/for-developers/data-model.md | 2 +- .../for-developers/data-model.md | 2 +- .../live-status-gateway/api/asyncapi.yaml | 6 + .../api/schemas/buckets.yaml | 39 +++++ .../collections/bucketAdLibActionsHandler.ts | 31 ++++ .../src/collections/bucketAdLibsHandler.ts | 27 ++++ .../src/collections/bucketsHandler.ts | 27 ++++ .../src/liveStatusServer.ts | 15 ++ .../src/topics/__tests__/bucketsTopic.spec.ts | 144 ++++++++++++++++++ .../src/topics/adLibsTopic.ts | 4 +- .../src/topics/bucketsTopic.ts | 136 +++++++++++++++++ .../live-status-gateway/src/topics/root.ts | 1 + packages/meteor-lib/src/api/pubsub.ts | 7 +- packages/meteor-lib/src/api/userActions.ts | 2 +- .../src/triggers/RundownViewEventBus.ts | 2 +- .../webui/src/client/collections/index.ts | 2 +- packages/webui/src/client/ui/RundownView.tsx | 4 +- .../webui/src/client/ui/Shelf/BucketPanel.tsx | 4 +- .../client/ui/Shelf/RundownViewBuckets.tsx | 2 +- packages/webui/src/client/ui/Shelf/Shelf.tsx | 2 +- .../src/client/ui/Shelf/ShelfContextMenu.tsx | 2 +- 29 files changed, 468 insertions(+), 45 deletions(-) rename packages/{meteor-lib/src/collections/Buckets.ts => corelib/src/dataModel/Bucket.ts} (90%) create mode 100644 packages/live-status-gateway/api/schemas/buckets.yaml create mode 100644 packages/live-status-gateway/src/collections/bucketAdLibActionsHandler.ts create mode 100644 packages/live-status-gateway/src/collections/bucketAdLibsHandler.ts create mode 100644 packages/live-status-gateway/src/collections/bucketsHandler.ts create mode 100644 packages/live-status-gateway/src/topics/__tests__/bucketsTopic.spec.ts create mode 100644 packages/live-status-gateway/src/topics/bucketsTopic.ts diff --git a/meteor/server/api/buckets.ts b/meteor/server/api/buckets.ts index 109ae82ba9..04e1fce3cf 100644 --- a/meteor/server/api/buckets.ts +++ b/meteor/server/api/buckets.ts @@ -1,6 +1,6 @@ import * as _ from 'underscore' import { Meteor } from 'meteor/meteor' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { getRandomId, getRandomString, literal } from '../lib/tempLib' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' import { AdLibAction, AdLibActionCommon } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index aefdfbe439..657ebe5884 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -52,7 +52,7 @@ import { DEFAULT_MINIMUM_TAKE_SPAN, DEFAULT_FALLBACK_PART_DURATION, } from '@sofie-automation/shared-lib/dist/core/constants' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { ForceQuickLoopAutoNext } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings' /* diff --git a/meteor/server/api/userActions.ts b/meteor/server/api/userActions.ts index 348380cbf6..043152f287 100644 --- a/meteor/server/api/userActions.ts +++ b/meteor/server/api/userActions.ts @@ -15,7 +15,7 @@ import { MOSDeviceActions } from './ingest/mosDevice/actions' import { MethodContextAPI } from './methodContext' import { ServerClientAPI } from './client' import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { BucketsAPI } from './buckets' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' import { AdLibActionCommon } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' diff --git a/meteor/server/collections/bucket.ts b/meteor/server/collections/bucket.ts index 5479fcfeef..8742f762ff 100644 --- a/meteor/server/collections/bucket.ts +++ b/meteor/server/collections/bucket.ts @@ -1,7 +1,7 @@ import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { createAsyncOnlyMongoCollection } from './collection' import { registerIndex } from './indices' diff --git a/meteor/server/publications/buckets.ts b/meteor/server/publications/buckets.ts index 589f63d3bb..2c9d7c6ea4 100644 --- a/meteor/server/publications/buckets.ts +++ b/meteor/server/publications/buckets.ts @@ -1,7 +1,6 @@ import { FindOptions } from '@sofie-automation/meteor-lib/dist/collections/lib' import { meteorPublish } from './lib/lib' -import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { BucketAdLibActions, BucketAdLibs, Buckets } from '../collections' import { check, Match } from 'meteor/check' import { StudioId, BucketId, ShowStyleVariantId } from '@sofie-automation/corelib/dist/dataModel/Ids' @@ -9,7 +8,7 @@ import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' meteorPublish( - MeteorPubSub.buckets, + CorelibPubSub.buckets, async function (studioId: StudioId, bucketId: BucketId | null, _token: string | undefined) { check(studioId, String) check(bucketId, Match.Maybe(String)) @@ -21,14 +20,10 @@ meteorPublish( } return Buckets.findWithCursor( - bucketId - ? { - _id: bucketId, - studioId, - } - : { - studioId, - }, + { + _id: bucketId ?? undefined, + studioId, + }, modifier ) } @@ -36,9 +31,9 @@ meteorPublish( meteorPublish( CorelibPubSub.bucketAdLibPieces, - async function (studioId: StudioId, bucketId: BucketId, showStyleVariantIds: ShowStyleVariantId[]) { + async function (studioId: StudioId, bucketId: BucketId | null, showStyleVariantIds: ShowStyleVariantId[]) { check(studioId, String) - check(bucketId, String) + check(bucketId, Match.Maybe(String)) check(showStyleVariantIds, Array) triggerWriteAccessBecauseNoCheckNecessary() @@ -46,7 +41,7 @@ meteorPublish( return BucketAdLibs.findWithCursor( { studioId: studioId, - bucketId: bucketId, + bucketId: bucketId ?? undefined, showStyleVariantId: { $in: [null, ...showStyleVariantIds], // null = valid for all variants }, @@ -62,7 +57,7 @@ meteorPublish( meteorPublish( CorelibPubSub.bucketAdLibActions, - async function (studioId: StudioId, bucketId: BucketId, showStyleVariantIds: ShowStyleVariantId[]) { + async function (studioId: StudioId, bucketId: BucketId | null, showStyleVariantIds: ShowStyleVariantId[]) { check(studioId, String) check(bucketId, String) check(showStyleVariantIds, Array) diff --git a/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts b/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts index 8942d5f75d..838fca39f8 100644 --- a/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts +++ b/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts @@ -21,7 +21,7 @@ import { SetupObserversResult, } from '../../../lib/customPublication' import { BucketContentCache, createReactiveContentCache } from './bucketContentCache' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { addItemsWithDependenciesChangesToChangedSet, fetchStudio, diff --git a/packages/meteor-lib/src/collections/Buckets.ts b/packages/corelib/src/dataModel/Bucket.ts similarity index 90% rename from packages/meteor-lib/src/collections/Buckets.ts rename to packages/corelib/src/dataModel/Bucket.ts index 72750e7507..519dadb87e 100644 --- a/packages/meteor-lib/src/collections/Buckets.ts +++ b/packages/corelib/src/dataModel/Bucket.ts @@ -1,4 +1,4 @@ -import { BucketId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { BucketId, StudioId } from './Ids' /** * A Bucket is an container for AdLib pieces that do not come from a MOS gateway and are diff --git a/packages/corelib/src/pubsub.ts b/packages/corelib/src/pubsub.ts index a8436a1403..b09c67c589 100644 --- a/packages/corelib/src/pubsub.ts +++ b/packages/corelib/src/pubsub.ts @@ -37,6 +37,7 @@ import { } from '@sofie-automation/shared-lib/dist/core/model/Ids' import { BlueprintId, BucketId, RundownPlaylistActivationId, SegmentId, ShowStyleVariantId } from './dataModel/Ids' import { PackageInfoDB } from './dataModel/PackageInfos' +import { Bucket } from './dataModel/Bucket' /** * Ids of possible DDP subscriptions for any the UI and gateways accessing the Rundown & RundownPlaylist model. @@ -135,12 +136,16 @@ export enum CorelibPubSub { packageContainerStatuses = 'packageContainerStatuses', /** - * Fetch all bucket adlib pieces for the specified Studio and Bucket. + * Fetch either all buckets for the given Studio, or the Bucket specified. + */ + buckets = 'buckets', + /** + * Fetch all bucket adlib pieces for the specified Studio and Bucket (or all buckets in a Studio). * The result will be limited to ones valid to the ShowStyleVariants specified, as well as ones marked as valid in any ShowStyleVariant */ bucketAdLibPieces = 'bucketAdLibPieces', /** - * Fetch all bucket adlib action for the specified Studio and Bucket. + * Fetch all bucket adlib action for the specified Studio and Bucket (or all buckets in a Studio). * The result will be limited to ones valid to the ShowStyleVariants specified, as well as ones marked as valid in any ShowStyleVariant */ bucketAdLibActions = 'bucketAdLibActions', @@ -297,14 +302,15 @@ export interface CorelibPubSubTypes { token?: string ) => CollectionName.Studios [CorelibPubSub.timelineDatastore]: (studioId: StudioId, token?: string) => CollectionName.TimelineDatastore + [CorelibPubSub.buckets]: (studioId: StudioId, bucketId: BucketId | null, token?: string) => CollectionName.Buckets [CorelibPubSub.bucketAdLibPieces]: ( studioId: StudioId, - bucketId: BucketId, + bucketId: BucketId | null, showStyleVariantIds: ShowStyleVariantId[] ) => CollectionName.BucketAdLibPieces [CorelibPubSub.bucketAdLibActions]: ( studioId: StudioId, - bucketId: BucketId, + bucketId: BucketId | null, showStyleVariantIds: ShowStyleVariantId[] ) => CollectionName.BucketAdLibActions [CorelibPubSub.expectedPackages]: (studioIds: StudioId[], token?: string) => CollectionName.ExpectedPackages @@ -323,6 +329,7 @@ export type CorelibPubSubCollections = { [CollectionName.AdLibActions]: AdLibAction [CollectionName.AdLibPieces]: AdLibPiece [CollectionName.Blueprints]: Blueprint + [CollectionName.Buckets]: Bucket [CollectionName.BucketAdLibActions]: BucketAdLibAction [CollectionName.BucketAdLibPieces]: BucketAdLib [CollectionName.ExpectedMediaItems]: ExpectedMediaItem diff --git a/packages/documentation/docs/for-developers/data-model.md b/packages/documentation/docs/for-developers/data-model.md index 975f29747c..5c6496dc34 100644 --- a/packages/documentation/docs/for-developers/data-model.md +++ b/packages/documentation/docs/for-developers/data-model.md @@ -24,7 +24,7 @@ Currently, there is not a very clearly defined flow for modifying these document This includes: - [Blueprints](https://github.com/nrkno/sofie-core/blob/master/packages/corelib/src/dataModel/Blueprint.ts) -- [Buckets](https://github.com/nrkno/sofie-core/blob/master/meteor/lib/collections/Buckets.ts) +- [Buckets](https://github.com/nrkno/sofie-core/blob/master/packages/corelib/src/dataModel/Bucket.ts) - [CoreSystem](https://github.com/nrkno/sofie-core/blob/master/meteor/lib/collections/CoreSystem.ts) - [Evaluations](https://github.com/nrkno/sofie-core/blob/master/meteor/lib/collections/Evaluations.ts) - [ExternalMessageQueue](https://github.com/nrkno/sofie-core/blob/master/packages/corelib/src/dataModel/ExternalMessageQueue.ts) diff --git a/packages/documentation/versioned_docs/version-1.50.0/for-developers/data-model.md b/packages/documentation/versioned_docs/version-1.50.0/for-developers/data-model.md index 975f29747c..5c6496dc34 100644 --- a/packages/documentation/versioned_docs/version-1.50.0/for-developers/data-model.md +++ b/packages/documentation/versioned_docs/version-1.50.0/for-developers/data-model.md @@ -24,7 +24,7 @@ Currently, there is not a very clearly defined flow for modifying these document This includes: - [Blueprints](https://github.com/nrkno/sofie-core/blob/master/packages/corelib/src/dataModel/Blueprint.ts) -- [Buckets](https://github.com/nrkno/sofie-core/blob/master/meteor/lib/collections/Buckets.ts) +- [Buckets](https://github.com/nrkno/sofie-core/blob/master/packages/corelib/src/dataModel/Bucket.ts) - [CoreSystem](https://github.com/nrkno/sofie-core/blob/master/meteor/lib/collections/CoreSystem.ts) - [Evaluations](https://github.com/nrkno/sofie-core/blob/master/meteor/lib/collections/Evaluations.ts) - [ExternalMessageQueue](https://github.com/nrkno/sofie-core/blob/master/packages/corelib/src/dataModel/ExternalMessageQueue.ts) diff --git a/packages/live-status-gateway/api/asyncapi.yaml b/packages/live-status-gateway/api/asyncapi.yaml index 52dd541133..447bfc93b2 100644 --- a/packages/live-status-gateway/api/asyncapi.yaml +++ b/packages/live-status-gateway/api/asyncapi.yaml @@ -48,6 +48,7 @@ channels: - $ref: '#/components/messages/activePieces' - $ref: '#/components/messages/segments' - $ref: '#/components/messages/adLibs' + - $ref: '#/components/messages/buckets' components: messages: ping: @@ -124,3 +125,8 @@ components: description: AdLibs in active Playlist payload: $ref: './schemas/adLibs.yaml#/$defs/adLibs' + buckets: + name: buckets + description: Buckets in Studio + payload: + $ref: './schemas/buckets.yaml#/$defs/buckets' diff --git a/packages/live-status-gateway/api/schemas/buckets.yaml b/packages/live-status-gateway/api/schemas/buckets.yaml new file mode 100644 index 0000000000..cfc81db99e --- /dev/null +++ b/packages/live-status-gateway/api/schemas/buckets.yaml @@ -0,0 +1,39 @@ +title: Buckets +description: Buckets schema for websocket subscriptions +$defs: + buckets: + type: object + properties: + event: + type: string + const: buckets + buckets: + description: Buckets available in the Studio + type: array + items: + $ref: '#/$defs/bucket' + required: [event, buckets] + additionalProperties: false + examples: + - event: buckets + buckets: + $ref: '#/$defs/bucket/examples' + bucket: + type: object + properties: + id: + description: Unique id of the bucket + type: string + name: + description: The user defined bucket name + type: string + adLibs: + description: The AdLibs in this bucket + type: array + items: + $ref: './adLibs.yaml#/$defs/adLib' + examples: + - id: 'C6K_yIMuGFUk8X_L9A9_jRT6aq4_' + name: My Bucket + adLibs: + $ref: './adLibs.yaml#/$defs/adLib/examples' diff --git a/packages/live-status-gateway/src/collections/bucketAdLibActionsHandler.ts b/packages/live-status-gateway/src/collections/bucketAdLibActionsHandler.ts new file mode 100644 index 0000000000..3dd02c95b8 --- /dev/null +++ b/packages/live-status-gateway/src/collections/bucketAdLibActionsHandler.ts @@ -0,0 +1,31 @@ +import { Logger } from 'winston' +import { CoreHandler } from '../coreHandler' +import { Collection, PublicationCollection } from '../wsHandler' +import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' +import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { CollectionHandlers } from '../liveStatusServer' + +export class BucketAdLibActionsHandler + extends PublicationCollection< + BucketAdLibAction[], + CorelibPubSub.bucketAdLibActions, + CollectionName.BucketAdLibActions + > + implements Collection +{ + constructor(logger: Logger, coreHandler: CoreHandler) { + super(CollectionName.BucketAdLibActions, CorelibPubSub.bucketAdLibActions, logger, coreHandler) + } + + changed(): void { + const collection = this.getCollectionOrFail() + this._collectionData = collection.find(undefined) + this.notify(this._collectionData) + } + + init(handlers: CollectionHandlers): void { + super.init(handlers) + this.setupSubscription(this._studioId, null, []) // This only matches adLibs avilable to all variants + } +} diff --git a/packages/live-status-gateway/src/collections/bucketAdLibsHandler.ts b/packages/live-status-gateway/src/collections/bucketAdLibsHandler.ts new file mode 100644 index 0000000000..b6edf80aa8 --- /dev/null +++ b/packages/live-status-gateway/src/collections/bucketAdLibsHandler.ts @@ -0,0 +1,27 @@ +import { Logger } from 'winston' +import { CoreHandler } from '../coreHandler' +import { Collection, PublicationCollection } from '../wsHandler' +import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' +import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { CollectionHandlers } from '../liveStatusServer' + +export class BucketAdLibsHandler + extends PublicationCollection + implements Collection +{ + constructor(logger: Logger, coreHandler: CoreHandler) { + super(CollectionName.BucketAdLibPieces, CorelibPubSub.bucketAdLibPieces, logger, coreHandler) + } + + changed(): void { + const collection = this.getCollectionOrFail() + this._collectionData = collection.find(undefined) + this.notify(this._collectionData) + } + + init(handlers: CollectionHandlers): void { + super.init(handlers) + this.setupSubscription(this._studioId, null, []) // This only matches adLibs avilable to all variants + } +} diff --git a/packages/live-status-gateway/src/collections/bucketsHandler.ts b/packages/live-status-gateway/src/collections/bucketsHandler.ts new file mode 100644 index 0000000000..3277941d34 --- /dev/null +++ b/packages/live-status-gateway/src/collections/bucketsHandler.ts @@ -0,0 +1,27 @@ +import { Logger } from 'winston' +import { CoreHandler } from '../coreHandler' +import { Collection, PublicationCollection } from '../wsHandler' +import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' +import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { CollectionHandlers } from '../liveStatusServer' + +export class BucketsHandler + extends PublicationCollection + implements Collection +{ + constructor(logger: Logger, coreHandler: CoreHandler) { + super(CollectionName.Buckets, CorelibPubSub.buckets, logger, coreHandler) + } + + changed(): void { + const collection = this.getCollectionOrFail() + this._collectionData = collection.find(undefined) + this.notify(this._collectionData) + } + + init(handlers: CollectionHandlers): void { + super.init(handlers) + this.setupSubscription(this._studioId, null) + } +} diff --git a/packages/live-status-gateway/src/liveStatusServer.ts b/packages/live-status-gateway/src/liveStatusServer.ts index 7f0094993e..22e3557a94 100644 --- a/packages/live-status-gateway/src/liveStatusServer.ts +++ b/packages/live-status-gateway/src/liveStatusServer.ts @@ -23,6 +23,10 @@ import { PartsHandler } from './collections/partsHandler' import { PieceInstancesHandler } from './collections/pieceInstancesHandler' import { AdLibsTopic } from './topics/adLibsTopic' import { ActivePiecesTopic } from './topics/activePiecesTopic' +import { BucketsHandler } from './collections/bucketsHandler' +import { BucketAdLibsHandler } from './collections/bucketAdLibsHandler' +import { BucketAdLibActionsHandler } from './collections/bucketAdLibActionsHandler' +import { BucketsTopic } from './topics/bucketsTopic' export interface CollectionHandlers { studioHandler: StudioHandler @@ -40,6 +44,9 @@ export interface CollectionHandlers { adLibsHandler: AdLibsHandler globalAdLibActionsHandler: GlobalAdLibActionsHandler globalAdLibsHandler: GlobalAdLibsHandler + bucketsHandler: BucketsHandler + bucketAdLibsHandler: BucketAdLibsHandler + bucketAdLibActionsHandler: BucketAdLibActionsHandler } export class LiveStatusServer { @@ -72,6 +79,9 @@ export class LiveStatusServer { const adLibsHandler = new AdLibsHandler(this._logger, this._coreHandler) const globalAdLibActionsHandler = new GlobalAdLibActionsHandler(this._logger, this._coreHandler) const globalAdLibsHandler = new GlobalAdLibsHandler(this._logger, this._coreHandler) + const bucketsHandler = new BucketsHandler(this._logger, this._coreHandler) + const bucketAdLibsHandler = new BucketAdLibsHandler(this._logger, this._coreHandler) + const bucketAdLibActionsHandler = new BucketAdLibActionsHandler(this._logger, this._coreHandler) const handlers: CollectionHandlers = { studioHandler, @@ -89,6 +99,9 @@ export class LiveStatusServer { adLibsHandler, globalAdLibActionsHandler, globalAdLibsHandler, + bucketsHandler, + bucketAdLibsHandler, + bucketAdLibActionsHandler, } for (const handlerName in handlers) { @@ -100,12 +113,14 @@ export class LiveStatusServer { const activePlaylistTopic = new ActivePlaylistTopic(this._logger, handlers) const segmentsTopic = new SegmentsTopic(this._logger, handlers) const adLibsTopic = new AdLibsTopic(this._logger, handlers) + const bucketsTopic = new BucketsTopic(this._logger, handlers) rootChannel.addTopic(StatusChannels.studio, studioTopic) rootChannel.addTopic(StatusChannels.activePlaylist, activePlaylistTopic) rootChannel.addTopic(StatusChannels.activePieces, activePiecesTopic) rootChannel.addTopic(StatusChannels.segments, segmentsTopic) rootChannel.addTopic(StatusChannels.adLibs, adLibsTopic) + rootChannel.addTopic(StatusChannels.buckets, bucketsTopic) const wss = new WebSocketServer({ port: 8080 }) wss.on('connection', (ws, request) => { diff --git a/packages/live-status-gateway/src/topics/__tests__/bucketsTopic.spec.ts b/packages/live-status-gateway/src/topics/__tests__/bucketsTopic.spec.ts new file mode 100644 index 0000000000..0a041a62b4 --- /dev/null +++ b/packages/live-status-gateway/src/topics/__tests__/bucketsTopic.spec.ts @@ -0,0 +1,144 @@ +import { protectString } from '@sofie-automation/server-core-integration' +import { + makeMockHandlers, + makeMockLogger, + makeMockSubscriber, + makeTestParts, + makeTestPlaylist, + makeTestShowStyleBase, +} from './utils' +import { ShowStyleBaseExt } from '../../collections/showStyleBaseHandler' +import { BucketsStatus, BucketsTopic } from '../bucketsTopic' +import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' +import { RundownImportVersions } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { BucketAdLib, BucketAdLibIngestInfo } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' +import { PieceLifespan } from '@sofie-automation/blueprints-integration' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' + +describe('BucketsTopic', () => { + it('notifies subscribers', async () => { + const handlers = makeMockHandlers() + const topic = new BucketsTopic(makeMockLogger(), handlers) + const mockSubscriber = makeMockSubscriber() + + const playlist = makeTestPlaylist() + playlist.activationId = protectString('somethingRandom') + handlers.playlistHandler.notify(playlist) + + const parts = makeTestParts() + handlers.partsHandler.notify(parts) + + const testShowStyleBase = makeTestShowStyleBase() + handlers.showStyleBaseHandler.notify(testShowStyleBase as ShowStyleBaseExt) + + const testBuckets = makeTestBuckets() + handlers.bucketsHandler.notify(testBuckets) + + const testAdLibActions = makeTestAdLibActions() + handlers.bucketAdLibActionsHandler.notify(testAdLibActions) + + const testGlobalAdLibActions = makeTestAdLibs() + handlers.bucketAdLibsHandler.notify(testGlobalAdLibActions) + + topic.addSubscriber(mockSubscriber) + + const expectedStatus: BucketsStatus = { + event: 'buckets', + buckets: [ + { + id: 'BUCKET_0', + name: 'A Bucket', + adLibs: [ + { + actionType: [], + id: 'ADLIB_0', + name: 'Bucket AdLib', + outputLayer: 'PGM', + sourceLayer: 'Layer 0', + tags: ['adlib_tag'], + publicData: { c: 'd' }, + externalId: 'BUCKET_ADLIB_0', + }, + { + actionType: [], + id: 'ACTION_0', + name: 'Bucket Action', + outputLayer: 'PGM', + sourceLayer: 'Layer 0', + tags: ['adlib_action_tag'], + publicData: { a: 'b' }, + externalId: 'BUCKET_ACTION_0', + }, + ], + }, + ], + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockSubscriber.send).toHaveBeenCalledTimes(1) + expect(JSON.parse(mockSubscriber.send.mock.calls[0][0] as string)).toMatchObject(expectedStatus) + }) +}) + +function makeTestAdLibActions(): BucketAdLibAction[] { + return [ + { + _id: protectString('ACTION_0'), + actionId: 'ACTION_0', + bucketId: protectString('BUCKET_0'), + studioId: protectString('STUDIO_0'), + importVersions: {} as RundownImportVersions, + ingestInfo: {} as BucketAdLibIngestInfo, + showStyleBaseId: protectString('SHOWSTYLE_0'), + showStyleVariantId: null, + display: { + content: {}, + label: { key: 'Bucket Action' }, + sourceLayerId: 'layer0', + outputLayerId: 'pgm', + tags: ['adlib_action_tag'], + }, + externalId: 'BUCKET_ACTION_0', + userData: {}, + userDataManifest: {}, + publicData: { a: 'b' }, + }, + ] +} + +function makeTestAdLibs(): BucketAdLib[] { + return [ + { + _id: protectString('ADLIB_0'), + bucketId: protectString('BUCKET_0'), + studioId: protectString('STUDIO_0'), + importVersions: {} as RundownImportVersions, + ingestInfo: {} as BucketAdLibIngestInfo, + showStyleBaseId: protectString('SHOWSTYLE_0'), + showStyleVariantId: null, + externalId: 'BUCKET_ADLIB_0', + _rank: 0, + content: {}, + lifespan: PieceLifespan.WithinPart, + name: 'Bucket AdLib', + outputLayerId: 'pgm', + sourceLayerId: 'layer0', + publicData: { c: 'd' }, + timelineObjectsString: protectString(''), + tags: ['adlib_tag'], + }, + ] +} + +function makeTestBuckets(): Bucket[] { + return [ + { + _id: protectString('BUCKET_0'), + studioId: protectString('STUDIO_0'), + _rank: 0, + name: 'A Bucket', + buttonHeightScale: 1, + buttonWidthScale: 1, + }, + ] +} diff --git a/packages/live-status-gateway/src/topics/adLibsTopic.ts b/packages/live-status-gateway/src/topics/adLibsTopic.ts index cfcd40644d..3140524feb 100644 --- a/packages/live-status-gateway/src/topics/adLibsTopic.ts +++ b/packages/live-status-gateway/src/topics/adLibsTopic.ts @@ -26,12 +26,12 @@ export interface AdLibsStatus { globalAdLibs: GlobalAdLibStatus[] } -interface AdLibActionType { +export interface AdLibActionType { name: string label: string } -interface AdLibStatus extends AdLibStatusBase { +export interface AdLibStatus extends AdLibStatusBase { segmentId: string partId: string } diff --git a/packages/live-status-gateway/src/topics/bucketsTopic.ts b/packages/live-status-gateway/src/topics/bucketsTopic.ts new file mode 100644 index 0000000000..c501b30602 --- /dev/null +++ b/packages/live-status-gateway/src/topics/bucketsTopic.ts @@ -0,0 +1,136 @@ +import { Logger } from 'winston' +import { WebSocket } from 'ws' +import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' +import _ = require('underscore') +import { IBlueprintActionManifestDisplayContent } from '@sofie-automation/blueprints-integration' +import { ShowStyleBaseExt } from '../collections/showStyleBaseHandler' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' +import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' +import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' +import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage' +import { AdLibActionType, AdLibStatus } from './adLibsTopic' +import { CollectionHandlers } from '../liveStatusServer' + +const THROTTLE_PERIOD_MS = 100 + +export interface BucketsStatus { + event: 'buckets' + buckets: BucketStatus[] +} + +interface BucketAdLibStatus extends Omit { + externalId: string +} + +export interface BucketStatus { + id: string + name: string + adLibs: BucketAdLibStatus[] +} + +const SHOW_STYLE_BASE_KEYS = ['sourceLayerNamesById', 'outputLayerNamesById'] as const +type ShowStyle = PickArr + +export class BucketsTopic extends WebSocketTopicBase implements WebSocketTopic { + private _buckets: Bucket[] = [] + private _adLibActionsByBucket: Record | undefined + private _adLibsByBucket: Record | undefined + private _sourceLayersMap: ReadonlyMap = new Map() + private _outputLayersMap: ReadonlyMap = new Map() + + constructor(logger: Logger, handlers: CollectionHandlers) { + super(BucketsTopic.name, logger, THROTTLE_PERIOD_MS) + + handlers.bucketsHandler.subscribe(this.onBucketsUpdate) + handlers.bucketAdLibActionsHandler.subscribe(this.onBucketAdLibActionsUpdate) + handlers.bucketAdLibsHandler.subscribe(this.onBucketAdLibsUpdate) + handlers.showStyleBaseHandler.subscribe(this.onShowStyleBaseUpdate) + } + + sendStatus(subscribers: Iterable): void { + const buckets: BucketStatus[] = this._buckets.map((bucket) => { + const bucketId = unprotectString(bucket._id) + const bucketAdLibs = (this._adLibsByBucket?.[bucketId] ?? []).map((adLib) => { + const sourceLayerName = this._sourceLayersMap.get(adLib.sourceLayerId) + const outputLayerName = this._outputLayersMap.get(adLib.outputLayerId) + return literal({ + id: unprotectString(adLib._id), + name: adLib.name, + sourceLayer: sourceLayerName ?? 'invalid', + outputLayer: outputLayerName ?? 'invalid', + actionType: [], + tags: adLib.tags, + externalId: adLib.externalId, + publicData: adLib.publicData, + }) + }) + const bucketAdLibActions = (this._adLibActionsByBucket?.[bucketId] ?? []).map((action) => { + const sourceLayerName = this._sourceLayersMap.get( + (action.display as IBlueprintActionManifestDisplayContent).sourceLayerId + ) + const outputLayerName = this._outputLayersMap.get( + (action.display as IBlueprintActionManifestDisplayContent).outputLayerId + ) + const triggerModes = action.triggerModes + ? action.triggerModes.map((t) => + literal({ + name: t.data, + label: interpollateTranslation(t.display.label.key, t.display.label.args), + }) + ) + : [] + return literal({ + id: unprotectString(action._id), + name: interpollateTranslation(action.display.label.key, action.display.label.args), + sourceLayer: sourceLayerName ?? 'invalid', + outputLayer: outputLayerName ?? 'invalid', + actionType: triggerModes, + tags: action.display.tags, + externalId: action.externalId, + publicData: action.publicData, + }) + }) + return { + id: bucketId, + name: bucket.name, + adLibs: [...bucketAdLibs, ...bucketAdLibActions], + } + }) + + const bucketsStatus: BucketsStatus = { + event: 'buckets', + buckets: buckets, + } + + for (const subscriber of subscribers) { + this.sendMessage(subscriber, bucketsStatus) + } + } + + private onShowStyleBaseUpdate = (showStyleBase: ShowStyle | undefined): void => { + this.logUpdateReceived('showStyleBase') + this._sourceLayersMap = showStyleBase?.sourceLayerNamesById ?? new Map() + this._outputLayersMap = showStyleBase?.outputLayerNamesById ?? new Map() + this.throttledSendStatusToAll() + } + + private onBucketsUpdate = (buckets: Bucket[] | undefined): void => { + this.logUpdateReceived('buckets') + this._buckets = buckets ?? [] + this.throttledSendStatusToAll() + } + + private onBucketAdLibActionsUpdate = (adLibActions: BucketAdLibAction[] | undefined): void => { + this.logUpdateReceived('buketAdLibActions') + this._adLibActionsByBucket = _.groupBy(adLibActions ?? [], 'bucketId') + this.throttledSendStatusToAll() + } + + private onBucketAdLibsUpdate = (adLibs: BucketAdLib[] | undefined): void => { + this.logUpdateReceived('bucketAdLibs') + this._adLibsByBucket = _.groupBy(adLibs ?? [], 'bucketId') + this.throttledSendStatusToAll() + } +} diff --git a/packages/live-status-gateway/src/topics/root.ts b/packages/live-status-gateway/src/topics/root.ts index 0c3f3cdca1..d716bb7cb5 100644 --- a/packages/live-status-gateway/src/topics/root.ts +++ b/packages/live-status-gateway/src/topics/root.ts @@ -37,6 +37,7 @@ export enum StatusChannels { activePieces = 'activePieces', segments = 'segments', adLibs = 'adLibs', + buckets = 'buckets', } interface RootMsg { diff --git a/packages/meteor-lib/src/api/pubsub.ts b/packages/meteor-lib/src/api/pubsub.ts index 9c61409c18..377e806754 100644 --- a/packages/meteor-lib/src/api/pubsub.ts +++ b/packages/meteor-lib/src/api/pubsub.ts @@ -8,7 +8,7 @@ import { ShowStyleBaseId, StudioId, } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { Bucket } from '../collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { ICoreSystem } from '../collections/CoreSystem' import { Evaluation } from '../collections/Evaluations' import { ExpectedPlayoutItem } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' @@ -102,10 +102,6 @@ export enum MeteorPubSub { * If null is provided, nothing will be returned */ organization = 'organization', - /** - * Fetch either all buckets for the given Studio, or the Bucket specified. - */ - buckets = 'buckets', /** * Fetch all translation bundles */ @@ -218,7 +214,6 @@ export interface MeteorPubSubTypes { token?: string ) => CollectionName.RundownLayouts [MeteorPubSub.organization]: (organizationId: OrganizationId | null, token?: string) => CollectionName.Organizations - [MeteorPubSub.buckets]: (studioId: StudioId, bucketId: BucketId | null, token?: string) => CollectionName.Buckets [MeteorPubSub.translationsBundles]: (token?: string) => CollectionName.TranslationsBundles [MeteorPubSub.notificationsForRundown]: (studioId: StudioId, rundownId: RundownId) => CollectionName.Notifications [MeteorPubSub.notificationsForRundownPlaylist]: ( diff --git a/packages/meteor-lib/src/api/userActions.ts b/packages/meteor-lib/src/api/userActions.ts index 5ff3351320..c67aa1ba80 100644 --- a/packages/meteor-lib/src/api/userActions.ts +++ b/packages/meteor-lib/src/api/userActions.ts @@ -1,6 +1,6 @@ import { ClientAPI } from './client' import { EvaluationBase } from '../collections/Evaluations' -import { Bucket } from '../collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { IngestAdlib, ActionUserData, UserOperationTarget } from '@sofie-automation/blueprints-integration' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' import { AdLibActionCommon } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' diff --git a/packages/meteor-lib/src/triggers/RundownViewEventBus.ts b/packages/meteor-lib/src/triggers/RundownViewEventBus.ts index 8460e67108..9bda66f384 100644 --- a/packages/meteor-lib/src/triggers/RundownViewEventBus.ts +++ b/packages/meteor-lib/src/triggers/RundownViewEventBus.ts @@ -1,5 +1,4 @@ import * as EventEmitter from 'events' -import { Bucket } from '../collections/Buckets' import { BucketId, PartId, @@ -14,6 +13,7 @@ import type { PieceUi } from '../uiTypes/Piece' import type { ShelfTabs } from '../uiTypes/ShelfTabs' import type { IAdLibListItem } from '../uiTypes/Adlib' import type { BucketAdLibItem } from '../uiTypes/Bucket' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' export enum RundownViewEvents { ACTIVATE_RUNDOWN_PLAYLIST = 'activateRundownPlaylist', diff --git a/packages/webui/src/client/collections/index.ts b/packages/webui/src/client/collections/index.ts index 0a8e5dd5bd..56be7799a5 100644 --- a/packages/webui/src/client/collections/index.ts +++ b/packages/webui/src/client/collections/index.ts @@ -16,7 +16,7 @@ import { ExternalMessageQueueObj } from '@sofie-automation/corelib/dist/dataMode import { PackageContainerStatusDB } from '@sofie-automation/corelib/dist/dataModel/PackageContainerStatus' import { MediaWorkFlow } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlows' import { MediaWorkFlowStep } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlowSteps' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { ICoreSystem, SYSTEM_ID } from '@sofie-automation/meteor-lib/dist/collections/CoreSystem' import { Evaluation } from '@sofie-automation/meteor-lib/dist/collections/Evaluations' import { ExpectedPackageDB } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 225b06c567..711f5f5d5b 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -91,7 +91,7 @@ import { import { VirtualElement } from '../lib/VirtualElement' import { SEGMENT_TIMELINE_ELEMENT_ID } from './SegmentTimeline/SegmentTimeline' import { NoraPreviewRenderer } from './FloatingInspectors/NoraFloatingInspector' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { contextMenuHoldToDisplayTime, isEventInInputField } from '../lib/lib' import { OffsetPosition } from '../utils/positions' import { MeteorCall } from '../lib/meteorApi' @@ -1263,7 +1263,7 @@ export function RundownView(props: Readonly): JSX.Element { useSubscriptionIfEnabled(MeteorPubSub.uiStudio, !!playlistStudioId, playlistStudioId ?? protectString('')) ) auxSubsReady.push( - useSubscriptionIfEnabled(MeteorPubSub.buckets, !!playlistStudioId, playlistStudioId ?? protectString(''), null) + useSubscriptionIfEnabled(CorelibPubSub.buckets, !!playlistStudioId, playlistStudioId ?? protectString(''), null) ) const playlistActivationId = useTracker(() => { diff --git a/packages/webui/src/client/ui/Shelf/BucketPanel.tsx b/packages/webui/src/client/ui/Shelf/BucketPanel.tsx index f6246f9513..35fb953c6a 100644 --- a/packages/webui/src/client/ui/Shelf/BucketPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/BucketPanel.tsx @@ -28,7 +28,7 @@ import { literal, unprotectString, protectString } from '../../lib/tempLib' import { contextMenuHoldToDisplayTime, UserAgentPointer, USER_AGENT_POINTER_PROPERTY } from '../../lib/lib' import { IDashboardPanelTrackedProps } from './DashboardPanel' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { Events as MOSEvents } from '../../lib/data/mos/plugin-support' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { MeteorCall } from '../../lib/meteorApi' @@ -273,7 +273,7 @@ interface BucketTargetCollectedProps { export const BucketPanel = React.memo( function BucketPanel(props: Readonly): JSX.Element | null { // Data subscriptions: - useSubscription(MeteorPubSub.buckets, props.playlist.studioId, props.bucket._id) + useSubscription(CorelibPubSub.buckets, props.playlist.studioId, props.bucket._id) useSubscription(MeteorPubSub.uiBucketContentStatuses, props.playlist.studioId, props.bucket._id) useSubscription(MeteorPubSub.uiStudio, props.playlist.studioId) diff --git a/packages/webui/src/client/ui/Shelf/RundownViewBuckets.tsx b/packages/webui/src/client/ui/Shelf/RundownViewBuckets.tsx index b688ef355b..269fffbe6a 100644 --- a/packages/webui/src/client/ui/Shelf/RundownViewBuckets.tsx +++ b/packages/webui/src/client/ui/Shelf/RundownViewBuckets.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { BucketPanel } from './BucketPanel' import { doUserAction, UserAction } from '../../lib/clientUserAction' import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' diff --git a/packages/webui/src/client/ui/Shelf/Shelf.tsx b/packages/webui/src/client/ui/Shelf/Shelf.tsx index e342d2c315..3b548e0ddb 100644 --- a/packages/webui/src/client/ui/Shelf/Shelf.tsx +++ b/packages/webui/src/client/ui/Shelf/Shelf.tsx @@ -22,7 +22,7 @@ import { contextMenuHoldToDisplayTime } from '../../lib/lib' import { ErrorBoundary } from '../../lib/ErrorBoundary' import { ShelfRundownLayout } from './ShelfRundownLayout' import { ShelfDashboardLayout } from './ShelfDashboardLayout' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { RundownViewBuckets, BucketAdLibItem } from './RundownViewBuckets' import { ContextMenuTrigger } from '@jstarpl/react-contextmenu' import { ShelfInspector } from './Inspector/ShelfInspector' diff --git a/packages/webui/src/client/ui/Shelf/ShelfContextMenu.tsx b/packages/webui/src/client/ui/Shelf/ShelfContextMenu.tsx index f9039c0eab..f26a73be71 100644 --- a/packages/webui/src/client/ui/Shelf/ShelfContextMenu.tsx +++ b/packages/webui/src/client/ui/Shelf/ShelfContextMenu.tsx @@ -4,7 +4,7 @@ import { useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import Escape from './../../lib/Escape' import { ContextMenu, MenuItem } from '@jstarpl/react-contextmenu' import { ReactiveVar } from 'meteor/reactive-var' -import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { BucketAdLibItem, BucketAdLibActionUi } from './RundownViewBuckets' import RundownViewEventBus, { RundownViewEvents } from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' import { IAdLibListItem } from './AdLibListItem' From 3f215046ccf8495e359383191f96913cfad5dd0d Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 30 Jan 2025 00:12:02 +0100 Subject: [PATCH 006/293] feat(EAV-296): implement tally for device trigger previews --- .../deviceTriggers/PieceInstancesObserver.ts | 98 ++++++++ .../StudioDeviceTriggerManager.ts | 28 ++- .../api/deviceTriggers/StudioObserver.ts | 39 ++- .../server/api/deviceTriggers/TagsService.ts | 168 +++++++++++++ .../__tests__/TagsService.test.ts | 236 ++++++++++++++++++ meteor/server/api/deviceTriggers/observer.ts | 25 +- .../reactiveContentCacheForPieceInstances.ts | 108 ++++++++ .../triggers/actionFilterChainCompilers.ts | 3 + .../input-gateway/deviceTriggerPreviews.ts | 2 + 9 files changed, 693 insertions(+), 14 deletions(-) create mode 100644 meteor/server/api/deviceTriggers/PieceInstancesObserver.ts create mode 100644 meteor/server/api/deviceTriggers/TagsService.ts create mode 100644 meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts create mode 100644 meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts diff --git a/meteor/server/api/deviceTriggers/PieceInstancesObserver.ts b/meteor/server/api/deviceTriggers/PieceInstancesObserver.ts new file mode 100644 index 0000000000..451adc75bb --- /dev/null +++ b/meteor/server/api/deviceTriggers/PieceInstancesObserver.ts @@ -0,0 +1,98 @@ +import { Meteor } from 'meteor/meteor' +import { RundownPlaylistActivationId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { RundownPlaylists, ShowStyleBases, PieceInstances, PartInstances } from '../../collections' +import { logger } from '../../logging' +import { rundownPlaylistFieldSpecifier } from './reactiveContentCache' +import { + ContentCache, + createReactiveContentCache, + partInstanceFieldSpecifier, + pieceInstanceFieldSpecifier, +} from './reactiveContentCacheForPieceInstances' +import { waitForAllObserversReady } from '../../publications/lib/lib' + +const REACTIVITY_DEBOUNCE = 20 + +type ChangedHandler = (cache: ContentCache) => () => void + +export class PieceInstancesObserver { + #observers: Meteor.LiveQueryHandle[] = [] + #cache: ContentCache + #cancelCache: () => void + #cleanup: (() => void) | undefined + #disposed = false + + constructor(onChanged: ChangedHandler) { + const { cache, cancel: cancelCache } = createReactiveContentCache(() => { + this.#cleanup = onChanged(cache) + if (this.#disposed) this.#cleanup() + }, REACTIVITY_DEBOUNCE) + + this.#cache = cache + this.#cancelCache = cancelCache + } + + static async create( + activationId: RundownPlaylistActivationId, + showStyleBaseId: ShowStyleBaseId, + onChanged: ChangedHandler + ): Promise { + logger.silly(`Creating PieceInstancesObserver for activationId "${activationId}"`) + + const observer = new PieceInstancesObserver(onChanged) + + await observer.initObservers(activationId, showStyleBaseId) + + return observer + } + + private async initObservers(activationId: RundownPlaylistActivationId, showStyleBaseId: ShowStyleBaseId) { + this.#observers = await waitForAllObserversReady([ + RundownPlaylists.observeChanges( + { + activationId, + }, + this.#cache.RundownPlaylists.link(), + { + projection: rundownPlaylistFieldSpecifier, + } + ), + ShowStyleBases.observeChanges(showStyleBaseId, this.#cache.ShowStyleBases.link()), + PieceInstances.observeChanges( + { + playlistActivationId: activationId, + reset: { $ne: true }, + disabled: { $ne: true }, + reportedStoppedPlayback: { $exists: false }, + 'piece.virtual': { $ne: true }, + }, + this.#cache.PieceInstances.link(), + { + projection: pieceInstanceFieldSpecifier, + } + ), + PartInstances.observeChanges( + { + playlistActivationId: activationId, + reset: { $ne: true }, + 'timings.reportedStoppedPlayback': { $ne: true }, + }, + this.#cache.PartInstances.link(), + { + projection: partInstanceFieldSpecifier, + } + ), + ]) + } + + public get cache(): ContentCache { + return this.#cache + } + + public stop = (): void => { + this.#disposed = true + this.#cancelCache() + this.#observers.forEach((observer) => observer.stop()) + this.#cleanup?.() + } +} diff --git a/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts b/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts index ebb3e41ee8..35420430a8 100644 --- a/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts +++ b/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts @@ -25,16 +25,20 @@ import { protectString } from '../../lib/tempLib' import { StudioActionManager, StudioActionManagers } from './StudioActionManagers' import { DeviceTriggerMountedActionAdlibsPreview, DeviceTriggerMountedActions } from './observer' import { ContentCache } from './reactiveContentCache' +import { ContentCache as PieceInstancesContentCache } from './reactiveContentCacheForPieceInstances' import { logger } from '../../logging' import { SomeAction, SomeBlueprintTrigger } from '@sofie-automation/blueprints-integration' import { DeviceActions } from '@sofie-automation/shared-lib/dist/core/model/ShowStyle' import { DummyReactiveVar } from '@sofie-automation/meteor-lib/dist/triggers/reactive-var' import { MeteorTriggersContext } from './triggersContext' +import { TagsService } from './TagsService' export class StudioDeviceTriggerManager { #lastShowStyleBaseId: ShowStyleBaseId | null = null - constructor(public studioId: StudioId) { + lastCache: ContentCache | undefined + + constructor(public studioId: StudioId, protected tagsService: TagsService) { if (StudioActionManagers.get(studioId)) { logger.error(`A StudioActionManager for "${studioId}" already exists`) return @@ -45,6 +49,7 @@ export class StudioDeviceTriggerManager { async updateTriggers(cache: ContentCache, showStyleBaseId: ShowStyleBaseId): Promise { const studioId = this.studioId + this.lastCache = cache this.#lastShowStyleBaseId = showStyleBaseId const [showStyleBase, rundownPlaylist] = await Promise.all([ @@ -79,6 +84,8 @@ export class StudioDeviceTriggerManager { const upsertedDeviceTriggerMountedActionIds: DeviceTriggerMountedActionId[] = [] const touchedActionIds: DeviceActionId[] = [] + this.tagsService.clearObservedTags() + for (const rawTriggeredAction of allTriggeredActions) { const triggeredAction = convertDocument(rawTriggeredAction) @@ -163,6 +170,8 @@ export class StudioDeviceTriggerManager { sourceLayerType: undefined, sourceLayerName: undefined, styleClassNames: triggeredAction.styleClassNames, + isCurrent: undefined, + isNext: undefined, }), }) } else { @@ -174,6 +183,9 @@ export class StudioDeviceTriggerManager { ) addedPreviewIds.push(adLibPreviewId) + + this.tagsService.observeTallyTags(adLib) + const { isCurrent, isNext } = this.tagsService.getTallyStateFromTags(adLib) return DeviceTriggerMountedActionAdlibsPreview.upsertAsync(adLibPreviewId, { $set: literal({ ...adLib, @@ -192,6 +204,8 @@ export class StudioDeviceTriggerManager { } : undefined, styleClassNames: triggeredAction.styleClassNames, + isCurrent, + isNext, }), }) }) @@ -224,6 +238,18 @@ export class StudioDeviceTriggerManager { actionManager.deleteActionsOtherThan(touchedActionIds) } + protected async updateTriggersFromLastCache(): Promise { + if (!this.lastCache || !this.#lastShowStyleBaseId) return + return this.updateTriggers(this.lastCache, this.#lastShowStyleBaseId) + } + + async updatePieceInstances(cache: PieceInstancesContentCache, showStyleBaseId: ShowStyleBaseId): Promise { + const shouldUpdateTriggers = this.tagsService.updatePieceInstances(cache, showStyleBaseId) + if (shouldUpdateTriggers) { + await this.updateTriggersFromLastCache() + } + } + async clearTriggers(): Promise { const studioId = this.studioId const showStyleBaseId = this.#lastShowStyleBaseId diff --git a/meteor/server/api/deviceTriggers/StudioObserver.ts b/meteor/server/api/deviceTriggers/StudioObserver.ts index eea90f3f77..6c5e7720ae 100644 --- a/meteor/server/api/deviceTriggers/StudioObserver.ts +++ b/meteor/server/api/deviceTriggers/StudioObserver.ts @@ -16,13 +16,16 @@ import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowSt import { logger } from '../../logging' import { observerChain } from '../../publications/lib/observerChain' import { ContentCache } from './reactiveContentCache' +import { ContentCache as PieceInstancesContentCache } from './reactiveContentCacheForPieceInstances' import { RundownContentObserver } from './RundownContentObserver' import { RundownsObserver } from './RundownsObserver' import { RundownPlaylists, Rundowns, ShowStyleBases } from '../../collections' import { PromiseDebounce } from '../../publications/lib/PromiseDebounce' import { MinimalMongoCursor } from '../../collections/implementations/asyncCollection' +import { PieceInstancesObserver } from './PieceInstancesObserver' -type ChangedHandler = (showStyleBaseId: ShowStyleBaseId, cache: ContentCache) => () => void +type RundownContentChangeHandler = (showStyleBaseId: ShowStyleBaseId, cache: ContentCache) => () => void +type PieceInstancesChangeHandler = (showStyleBaseId: ShowStyleBaseId, cache: PieceInstancesContentCache) => () => void const REACTIVITY_DEBOUNCE = 20 @@ -60,18 +63,26 @@ export class StudioObserver extends EventEmitter { #playlistInStudioLiveQuery: Meteor.LiveQueryHandle #showStyleOfRundownLiveQuery: Meteor.LiveQueryHandle | undefined #rundownsLiveQuery: Meteor.LiveQueryHandle | undefined + #pieceInstancesLiveQuery: Meteor.LiveQueryHandle | undefined + showStyleBaseId: ShowStyleBaseId | undefined currentProps: StudioObserverProps | undefined = undefined nextProps: StudioObserverProps | undefined = undefined - #changed: ChangedHandler + #rundownContentChanged: RundownContentChangeHandler + #pieceInstancesChanged: PieceInstancesChangeHandler #disposed = false - constructor(studioId: StudioId, onChanged: ChangedHandler) { + constructor( + studioId: StudioId, + onRundownContentChanged: RundownContentChangeHandler, + pieceInstancesChanged: PieceInstancesChangeHandler + ) { super() - this.#changed = onChanged + this.#rundownContentChanged = onRundownContentChanged + this.#pieceInstancesChanged = pieceInstancesChanged this.#playlistInStudioLiveQuery = observerChain() .next( 'activePlaylist', @@ -172,6 +183,9 @@ export class StudioObserver extends EventEmitter { this.#rundownsLiveQuery?.stop() this.#rundownsLiveQuery = undefined this.showStyleBaseId = showStyleBaseId + + this.#pieceInstancesLiveQuery?.stop() + this.#pieceInstancesLiveQuery = undefined return } @@ -186,10 +200,15 @@ export class StudioObserver extends EventEmitter { this.#rundownsLiveQuery?.stop() this.#rundownsLiveQuery = undefined + this.#pieceInstancesLiveQuery?.stop() + this.#pieceInstancesLiveQuery = undefined + + this.showStyleBaseId = showStyleBaseId + this.currentProps = this.nextProps this.nextProps = undefined - const { activePlaylistId } = this.currentProps + const { activePlaylistId, activationId } = this.currentProps this.showStyleBaseId = showStyleBaseId @@ -197,7 +216,7 @@ export class StudioObserver extends EventEmitter { logger.silly(`Creating new RundownContentObserver`) const obs1 = await RundownContentObserver.create(activePlaylistId, showStyleBaseId, rundownIds, (cache) => { - return this.#changed(showStyleBaseId, cache) + return this.#rundownContentChanged(showStyleBaseId, cache) }) return () => { @@ -205,6 +224,14 @@ export class StudioObserver extends EventEmitter { } }) + this.#pieceInstancesLiveQuery = await PieceInstancesObserver.create(activationId, showStyleBaseId, (cache) => { + const cleanupChanges = this.#pieceInstancesChanged(showStyleBaseId, cache) + + return () => { + cleanupChanges?.() + } + }) + if (this.#disposed) { // If we were disposed of while waiting for the observer to be created, stop it immediately this.#rundownsLiveQuery.stop() diff --git a/meteor/server/api/deviceTriggers/TagsService.ts b/meteor/server/api/deviceTriggers/TagsService.ts new file mode 100644 index 0000000000..4894512ae5 --- /dev/null +++ b/meteor/server/api/deviceTriggers/TagsService.ts @@ -0,0 +1,168 @@ +import { PartInstanceId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { PieceInstanceFields, ContentCache } from './reactiveContentCacheForPieceInstances' +import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { + PieceInstanceWithTimings, + processAndPrunePieceInstanceTimings, +} from '@sofie-automation/corelib/dist/playout/processAndPrune' +import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { IWrappedAdLib } from '@sofie-automation/meteor-lib/dist/triggers/actionFilterChainCompilers' + +export class TagsService { + protected onAirPiecesTags: Set = new Set() + protected nextPiecesTags: Set = new Set() + + protected tagsObservedByTriggers: Set = new Set() + + public clearObservedTags(): void { + this.tagsObservedByTriggers.clear() + } + + public observeTallyTags(adLib: IWrappedAdLib): void { + if ('currentPieceTags' in adLib && adLib.currentPieceTags) { + adLib.currentPieceTags.forEach((tag) => { + this.tagsObservedByTriggers.add(tag) + }) + } + } + + public getTallyStateFromTags(adLib: IWrappedAdLib): { isCurrent: boolean; isNext: boolean } { + let isCurrent = false + let isNext = false + if ('currentPieceTags' in adLib && adLib.currentPieceTags) { + isCurrent = adLib.currentPieceTags.every((tag) => this.onAirPiecesTags.has(tag)) + isNext = adLib.currentPieceTags.every((tag) => this.nextPiecesTags.has(tag)) + } + return { isCurrent, isNext } + } + + /** + * @param cache + * @param showStyleBaseId + * @returns whether triggers should be updated + */ + public updatePieceInstances(cache: ContentCache, showStyleBaseId: ShowStyleBaseId): boolean { + const rundownPlaylist = cache.RundownPlaylists.findOne({ + activationId: { + $exists: true, + }, + }) + if (!rundownPlaylist) { + return false + } + + const previousPartInstanceId = rundownPlaylist?.previousPartInfo?.partInstanceId + const currentPartInstanceId = rundownPlaylist?.currentPartInfo?.partInstanceId + const nextPartInstanceId = rundownPlaylist?.nextPartInfo?.partInstanceId + + const showStyleBase = cache.ShowStyleBases.findOne(showStyleBaseId) + + if (!showStyleBase) return false + + const resolvedSourceLayers = applyAndValidateOverrides(showStyleBase.sourceLayersWithOverrides).obj + + const inPreviousPartInstance = previousPartInstanceId + ? this.processAndPrunePieceInstanceTimings( + cache.PartInstances.findOne(previousPartInstanceId)?.timings, + cache.PieceInstances.find({ partInstanceId: previousPartInstanceId }).fetch(), + resolvedSourceLayers + ) + : [] + const inCurrentPartInstance = currentPartInstanceId + ? this.processAndPrunePieceInstanceTimings( + cache.PartInstances.findOne(currentPartInstanceId)?.timings, + cache.PieceInstances.find({ partInstanceId: currentPartInstanceId }).fetch(), + resolvedSourceLayers + ) + : [] + const inNextPartInstance = nextPartInstanceId + ? this.processAndPrunePieceInstanceTimings( + undefined, + cache.PieceInstances.find({ partInstanceId: nextPartInstanceId }).fetch(), + resolvedSourceLayers + ) + : [] + + const activePieceInstances = [...inPreviousPartInstance, ...inCurrentPartInstance].filter((pieceInstance) => + this.isPieceInstanceActive(pieceInstance, previousPartInstanceId, currentPartInstanceId) + ) + + const activePieceInstancesTags = new Set() + activePieceInstances.forEach((pieceInstance) => { + pieceInstance.piece.tags?.forEach((tag) => { + activePieceInstancesTags.add(tag) + }) + }) + + const nextPieceInstancesTags = new Set() + inNextPartInstance.forEach((pieceInstance) => { + pieceInstance.piece.tags?.forEach((tag) => { + nextPieceInstancesTags.add(tag) + }) + }) + + const shouldUpdateTriggers = this.shouldUpdateTriggers(activePieceInstancesTags, nextPieceInstancesTags) + + this.onAirPiecesTags = activePieceInstancesTags + this.nextPiecesTags = nextPieceInstancesTags + + return shouldUpdateTriggers + } + + private shouldUpdateTriggers(activePieceInstancesTags: Set, nextPieceInstancesTags: Set) { + return ( + (!this.areSetsEqual(this.onAirPiecesTags, activePieceInstancesTags) || + !this.areSetsEqual(this.nextPiecesTags, nextPieceInstancesTags)) && + (this.doSetsIntersect(activePieceInstancesTags, this.tagsObservedByTriggers) || + this.doSetsIntersect(nextPieceInstancesTags, this.tagsObservedByTriggers) || + this.doSetsIntersect(this.onAirPiecesTags, this.tagsObservedByTriggers) || + this.doSetsIntersect(this.nextPiecesTags, this.tagsObservedByTriggers)) + ) + } + + protected areSetsEqual(a: Set, b: Set): boolean { + return a.size === b.size && [...a].every((value) => b.has(value)) + } + + protected doSetsIntersect(a: Set, b: Set): boolean { + return [...a].some((value) => b.has(value)) + } + + protected processAndPrunePieceInstanceTimings( + partInstanceTimings: DBPartInstance['timings'] | undefined, + pieceInstances: Array>, + sourceLayers: SourceLayers + ): PieceInstanceWithTimings[] { + // Approximate when 'now' is in the PartInstance, so that any adlibbed Pieces will be timed roughly correctly + const partStarted = partInstanceTimings?.plannedStartedPlayback + const nowInPart = partStarted === undefined ? 0 : Date.now() - partStarted + + return processAndPrunePieceInstanceTimings( + sourceLayers, + pieceInstances as PieceInstance[], + nowInPart, + false, + false + ) + } + + private isPieceInstanceActive( + pieceInstance: PieceInstanceWithTimings, + previousPartInstanceId: PartInstanceId | undefined, + currentPartInstanceId: PartInstanceId | undefined + ) { + return ( + pieceInstance.reportedStoppedPlayback == null && + pieceInstance.piece.virtual !== true && + pieceInstance.disabled !== true && + (pieceInstance.partInstanceId === previousPartInstanceId || // a piece from previous part instance may be active during transition + pieceInstance.partInstanceId === currentPartInstanceId) && + (pieceInstance.reportedStartedPlayback != null || // has been reported to have started by the Playout Gateway + pieceInstance.plannedStartedPlayback != null || // a time to start playing has been set by Core + (pieceInstance.partInstanceId === currentPartInstanceId && pieceInstance.piece.enable.start === 0) || // this is to speed things up immediately after a part instance is taken when not yet reported by the Playout Gateway + pieceInstance.infinite?.fromPreviousPart) // infinites from previous part also are on air from the start of the current part + ) + } +} diff --git a/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts b/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts new file mode 100644 index 0000000000..3da3c4435e --- /dev/null +++ b/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts @@ -0,0 +1,236 @@ +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { TagsService } from '../TagsService' +import { + PartInstanceId, + PieceInstanceId, + RundownPlaylistActivationId, + RundownPlaylistId, + ShowStyleBaseId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ContentCache } from '../reactiveContentCacheForPieceInstances' +import { ReactiveCacheCollection } from '../../../publications/lib/ReactiveCacheCollection' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { wrapDefaultObject } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { literal, normalizeArray } from '@sofie-automation/corelib/dist/lib' +import { ISourceLayer, PieceLifespan, SourceLayerType } from '@sofie-automation/blueprints-integration' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { IWrappedAdLib } from '@sofie-automation/meteor-lib/dist/triggers/actionFilterChainCompilers' + +const createTestee = () => new TagsService() + +const playlistId = protectString('playlist0') +const activationId = protectString('activation0') +const showStyleBaseId = protectString('showStyleBase0') +const partInstanceId0 = protectString('partInstance0') +const partInstanceId1 = protectString('partInstance1') +const partInstanceId2 = protectString('partInstance2') +const pieceInstanceId0 = protectString('pieceInstance0') +const pieceInstanceId1 = protectString('pieceInstance1') +const pieceInstanceId2 = protectString('pieceInstance2') +const pieceInstanceId3 = protectString('pieceInstance3') + +const sourceLayerId0 = 'sourceLayerId0' +const sourceLayerId1 = 'sourceLayerId1' + +const tag0 = 'tag0' +const tag1 = 'tag1' +const tag2 = 'tag2' +const tag3 = 'tag3' + +const tag4 = 'tag4' + +function createAndPopulateMockCache(): ContentCache { + const newCache: ContentCache = { + RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'), + ShowStyleBases: new ReactiveCacheCollection('showStyleBases'), + PieceInstances: new ReactiveCacheCollection('pieceInstances'), + PartInstances: new ReactiveCacheCollection('partInstances'), + } + + newCache.RundownPlaylists.insert({ + _id: playlistId, + activationId: activationId, + currentPartInfo: { + partInstanceId: partInstanceId0, + }, + nextPartInfo: { + partInstanceId: partInstanceId1, + }, + } as DBRundownPlaylist) + + newCache.ShowStyleBases.insert({ + _id: showStyleBaseId, + sourceLayersWithOverrides: wrapDefaultObject( + normalizeArray( + [ + literal({ + _id: sourceLayerId0, + _rank: 0, + name: 'Camera', + type: SourceLayerType.CAMERA, + exclusiveGroup: 'main', + }), + literal({ + _id: sourceLayerId1, + _rank: 1, + name: 'Graphic', + type: SourceLayerType.GRAPHICS, + }), + ], + '_id' + ) + ), + } as DBShowStyleBase) + + newCache.PieceInstances.insert({ + _id: pieceInstanceId0, + piece: { + tags: [tag0, tag2], + sourceLayerId: sourceLayerId0, + enable: { start: 0 }, + lifespan: PieceLifespan.WithinPart, + }, + partInstanceId: partInstanceId0, + } as PieceInstance) + newCache.PieceInstances.insert({ + _id: pieceInstanceId1, + piece: { + tags: [tag1], + sourceLayerId: sourceLayerId0, + enable: { start: 0 }, + lifespan: PieceLifespan.WithinPart, + }, + partInstanceId: partInstanceId1, + } as PieceInstance) + newCache.PieceInstances.insert({ + _id: pieceInstanceId2, + piece: { + tags: [tag2], + sourceLayerId: sourceLayerId1, + enable: { start: 0 }, + lifespan: PieceLifespan.WithinPart, + }, + partInstanceId: partInstanceId1, + } as PieceInstance) + newCache.PieceInstances.insert({ + _id: pieceInstanceId3, + piece: { + tags: [tag3], + sourceLayerId: sourceLayerId0, + enable: { start: 0 }, + lifespan: PieceLifespan.WithinPart, + }, + partInstanceId: partInstanceId2, + } as PieceInstance) + + newCache.PartInstances.insert({ + _id: partInstanceId0, + } as DBPartInstance) + newCache.PartInstances.insert({ + _id: partInstanceId1, + } as DBPartInstance) + newCache.PartInstances.insert({ + _id: partInstanceId2, + } as DBPartInstance) + + return newCache +} + +describe('TagsService', () => { + test('adlib that has no tags', () => { + const testee = createTestee() + const cache = createAndPopulateMockCache() + + testee.updatePieceInstances(cache, showStyleBaseId) + const result = testee.getTallyStateFromTags({} as IWrappedAdLib) + expect(result).toEqual({ isCurrent: false, isNext: false }) + }) + + test('adlib that is neither on air or next', () => { + const testee = createTestee() + const cache = createAndPopulateMockCache() + + testee.updatePieceInstances(cache, showStyleBaseId) + const result = testee.getTallyStateFromTags({ + currentPieceTags: [tag3], + } as IWrappedAdLib) + expect(result).toEqual({ isCurrent: false, isNext: false }) + }) + + test('adlib that is both on air and next', () => { + const testee = createTestee() + const cache = createAndPopulateMockCache() + + testee.updatePieceInstances(cache, showStyleBaseId) + const result = testee.getTallyStateFromTags({ + currentPieceTags: [tag2], + } as IWrappedAdLib) + + expect(result).toEqual({ isCurrent: true, isNext: true }) + }) + + test('adlib that is only on air', () => { + const testee = createTestee() + const cache = createAndPopulateMockCache() + + testee.updatePieceInstances(cache, showStyleBaseId) + const result = testee.getTallyStateFromTags({ + currentPieceTags: [tag0], + } as IWrappedAdLib) + expect(result).toEqual({ isCurrent: true, isNext: false }) + }) + + test('adlib that is only next', () => { + const testee = createTestee() + const cache = createAndPopulateMockCache() + + testee.updatePieceInstances(cache, showStyleBaseId) + const result = testee.getTallyStateFromTags({ + currentPieceTags: [tag1], + } as IWrappedAdLib) + expect(result).toEqual({ isCurrent: false, isNext: true }) + }) + + test('updatePieceInstances returns true if observed tags are present in pieces', () => { + const testee = createTestee() + const cache = createAndPopulateMockCache() + + testee.observeTallyTags({ + currentPieceTags: [tag1], + } as IWrappedAdLib) + const result = testee.updatePieceInstances(cache, showStyleBaseId) + + expect(result).toEqual(true) + }) + + test('updatePieceInstances returns false if observed tags are not included in pieces', () => { + const testee = createTestee() + const cache = createAndPopulateMockCache() + + testee.observeTallyTags({ + currentPieceTags: [tag4], + } as IWrappedAdLib) + const result = testee.updatePieceInstances(cache, showStyleBaseId) + + expect(result).toEqual(false) + }) + + test('updatePieceInstances returns true if observed tags are no longer present on pieces', () => { + const testee = createTestee() + const cache = createAndPopulateMockCache() + + testee.observeTallyTags({ + currentPieceTags: [tag1], + } as IWrappedAdLib) + testee.updatePieceInstances(cache, showStyleBaseId) + + cache.PieceInstances.find({}).forEach((pieceInstance) => { + pieceInstance.piece.tags = [tag2] + }) + const result = testee.updatePieceInstances(cache, showStyleBaseId) + + expect(result).toEqual(true) + }) +}) diff --git a/meteor/server/api/deviceTriggers/observer.ts b/meteor/server/api/deviceTriggers/observer.ts index ebe1939df2..cbabcc5356 100644 --- a/meteor/server/api/deviceTriggers/observer.ts +++ b/meteor/server/api/deviceTriggers/observer.ts @@ -18,6 +18,7 @@ import { StudioObserver } from './StudioObserver' import { Studios } from '../../collections' import { ReactiveCacheCollection } from '../../publications/lib/ReactiveCacheCollection' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { TagsService } from './TagsService' type ObserverAndManager = { observer: StudioObserver @@ -43,15 +44,25 @@ Meteor.startup(async () => { function createObserverAndManager(studioId: StudioId) { logger.debug(`Creating observer for studio "${studioId}"`) - const manager = new StudioDeviceTriggerManager(studioId) - const observer = new StudioObserver(studioId, (showStyleBaseId, cache) => { - logger.silly(`Studio observer updating triggers for "${studioId}":"${showStyleBaseId}"`) - workInQueue(async () => manager.updateTriggers(cache, showStyleBaseId)) + const manager = new StudioDeviceTriggerManager(studioId, new TagsService()) + const observer = new StudioObserver( + studioId, + (showStyleBaseId, cache) => { + logger.silly(`Studio observer updating triggers for "${studioId}":"${showStyleBaseId}"`) + workInQueue(async () => manager.updateTriggers(cache, showStyleBaseId)) + + return () => { + workInQueue(async () => manager.clearTriggers()) + } + }, + (showStyleBaseId, cache) => { + workInQueue(async () => manager.updatePieceInstances(cache, showStyleBaseId)) - return () => { - workInQueue(async () => manager.clearTriggers()) + return () => { + return + } } - }) + ) studioObserversAndManagers.set(studioId, { manager, observer }) } diff --git a/meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts b/meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts new file mode 100644 index 0000000000..fafba70f46 --- /dev/null +++ b/meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts @@ -0,0 +1,108 @@ +import { Meteor } from 'meteor/meteor' +import _ from 'underscore' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { ReactiveCacheCollection } from '../../publications/lib/ReactiveCacheCollection' +import { MongoFieldSpecifierOnesStrict } from '@sofie-automation/corelib/dist/mongo' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' + +export type RundownPlaylistFields = + | '_id' + | 'name' + | 'activationId' + | 'currentPartInfo' + | 'nextPartInfo' + | 'previousPartInfo' +export const rundownPlaylistFieldSpecifier = literal< + MongoFieldSpecifierOnesStrict> +>({ + _id: 1, + name: 1, + activationId: 1, + currentPartInfo: 1, + nextPartInfo: 1, + previousPartInfo: 1, +}) + +export type PieceInstanceFields = + | '_id' + | 'partInstanceId' + | 'playlistActivationId' + | 'reportedStartedPlayback' + | 'reportedStoppedPlayback' + | 'piece' + | 'disabled' + | 'infinite' + | 'reset' +export const pieceInstanceFieldSpecifier = literal< + MongoFieldSpecifierOnesStrict> +>({ + _id: 1, + partInstanceId: 1, + playlistActivationId: 1, + reportedStartedPlayback: 1, + reportedStoppedPlayback: 1, + piece: 1, + disabled: 1, + infinite: 1, + reset: 1, +}) + +export type PartInstanceFields = '_id' | 'playlistActivationId' | 'timings' | 'reset' +export const partInstanceFieldSpecifier = literal< + MongoFieldSpecifierOnesStrict> +>({ + _id: 1, + playlistActivationId: 1, + timings: 1, + reset: 1, +}) + +export interface ContentCache { + RundownPlaylists: ReactiveCacheCollection> + ShowStyleBases: ReactiveCacheCollection + PieceInstances: ReactiveCacheCollection> + PartInstances: ReactiveCacheCollection> +} + +type ReactionWithCache = (cache: ContentCache) => void + +export function createReactiveContentCache( + reaction: ReactionWithCache, + reactivityDebounce: number +): { cache: ContentCache; cancel: () => void } { + let isCancelled = false + const innerReaction = _.debounce( + Meteor.bindEnvironment(() => { + if (isCancelled) return + reaction(cache) + }), + reactivityDebounce + ) + const cancel = () => { + isCancelled = true + innerReaction.cancel() + } + + const cache: ContentCache = { + RundownPlaylists: new ReactiveCacheCollection>( + 'rundownPlaylists', + innerReaction + ), + ShowStyleBases: new ReactiveCacheCollection('showStyleBases', innerReaction), + PieceInstances: new ReactiveCacheCollection>( + 'pieceInstances', + innerReaction + ), + PartInstances: new ReactiveCacheCollection>( + 'partInstances', + innerReaction + ), + } + + innerReaction() + + return { cache, cancel } +} diff --git a/packages/meteor-lib/src/triggers/actionFilterChainCompilers.ts b/packages/meteor-lib/src/triggers/actionFilterChainCompilers.ts index d8afa19d94..abd3ace750 100644 --- a/packages/meteor-lib/src/triggers/actionFilterChainCompilers.ts +++ b/packages/meteor-lib/src/triggers/actionFilterChainCompilers.ts @@ -59,6 +59,7 @@ interface IWrappedAdLibType & { } | undefined styleClassNames: string | undefined + isCurrent: boolean | undefined + isNext: boolean | undefined } From 723b0acfac5f4abbecf186c39ecdae8131c2503c Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 30 Jan 2025 12:17:38 +0100 Subject: [PATCH 007/293] fix: allow bucketId to be null in bucketAdLibActions pub --- meteor/server/publications/buckets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/meteor/server/publications/buckets.ts b/meteor/server/publications/buckets.ts index 2c9d7c6ea4..d71470f106 100644 --- a/meteor/server/publications/buckets.ts +++ b/meteor/server/publications/buckets.ts @@ -59,7 +59,7 @@ meteorPublish( CorelibPubSub.bucketAdLibActions, async function (studioId: StudioId, bucketId: BucketId | null, showStyleVariantIds: ShowStyleVariantId[]) { check(studioId, String) - check(bucketId, String) + check(bucketId, Match.Maybe(String)) check(showStyleVariantIds, Array) triggerWriteAccessBecauseNoCheckNecessary() @@ -67,7 +67,7 @@ meteorPublish( return BucketAdLibActions.findWithCursor( { studioId: studioId, - bucketId: bucketId, + bucketId: bucketId ?? undefined, showStyleVariantId: { $in: [null, ...showStyleVariantIds], // null = valid for all variants }, From 6e12eff38b7ecf2e2179e62f80bfce362ad6e3d7 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 30 Jan 2025 13:59:01 +0100 Subject: [PATCH 008/293] fix: buckets gone from the UI --- meteor/server/publications/buckets.ts | 66 +++++++++++++-------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/meteor/server/publications/buckets.ts b/meteor/server/publications/buckets.ts index d71470f106..61ad2f3fd2 100644 --- a/meteor/server/publications/buckets.ts +++ b/meteor/server/publications/buckets.ts @@ -6,6 +6,9 @@ import { check, Match } from 'meteor/check' import { StudioId, BucketId, ShowStyleVariantId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { triggerWriteAccessBecauseNoCheckNecessary } from '../security/securityVerify' +import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' +import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' meteorPublish( CorelibPubSub.buckets, @@ -19,13 +22,12 @@ meteorPublish( fields: {}, } - return Buckets.findWithCursor( - { - _id: bucketId ?? undefined, - studioId, - }, - modifier - ) + const selector: MongoQuery = { + studioId, + } + if (bucketId) selector._id = bucketId + + return Buckets.findWithCursor(selector, modifier) } ) @@ -38,20 +40,19 @@ meteorPublish( triggerWriteAccessBecauseNoCheckNecessary() - return BucketAdLibs.findWithCursor( - { - studioId: studioId, - bucketId: bucketId ?? undefined, - showStyleVariantId: { - $in: [null, ...showStyleVariantIds], // null = valid for all variants - }, + const selector: MongoQuery = { + studioId: studioId, + showStyleVariantId: { + $in: [null, ...showStyleVariantIds], // null = valid for all variants }, - { - fields: { - ingestInfo: 0, // This is a large blob, and is not of interest to the UI - }, - } - ) + } + if (bucketId) selector.bucketId = bucketId + + return BucketAdLibs.findWithCursor(selector, { + fields: { + ingestInfo: 0, // This is a large blob, and is not of interest to the UI + }, + }) } ) @@ -64,19 +65,18 @@ meteorPublish( triggerWriteAccessBecauseNoCheckNecessary() - return BucketAdLibActions.findWithCursor( - { - studioId: studioId, - bucketId: bucketId ?? undefined, - showStyleVariantId: { - $in: [null, ...showStyleVariantIds], // null = valid for all variants - }, + const selector: MongoQuery = { + studioId: studioId, + showStyleVariantId: { + $in: [null, ...showStyleVariantIds], // null = valid for all variants + }, + } + if (bucketId) selector.bucketId = bucketId + + return BucketAdLibActions.findWithCursor(selector, { + fields: { + ingestInfo: 0, // This is a large blob, and is not of interest to the UI }, - { - fields: { - ingestInfo: 0, // This is a large blob, and is not of interest to the UI - }, - } - ) + }) } ) From 95b11871c60ab10ca1d570640fb8e0e091829957 Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 31 Jan 2025 15:49:34 +0100 Subject: [PATCH 009/293] fix: cleanup after pieceInstancesLiveQuery --- meteor/server/api/deviceTriggers/PieceInstancesObserver.ts | 1 + meteor/server/api/deviceTriggers/StudioObserver.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/meteor/server/api/deviceTriggers/PieceInstancesObserver.ts b/meteor/server/api/deviceTriggers/PieceInstancesObserver.ts index 451adc75bb..e23f92eb5d 100644 --- a/meteor/server/api/deviceTriggers/PieceInstancesObserver.ts +++ b/meteor/server/api/deviceTriggers/PieceInstancesObserver.ts @@ -94,5 +94,6 @@ export class PieceInstancesObserver { this.#cancelCache() this.#observers.forEach((observer) => observer.stop()) this.#cleanup?.() + this.#cleanup = undefined } } diff --git a/meteor/server/api/deviceTriggers/StudioObserver.ts b/meteor/server/api/deviceTriggers/StudioObserver.ts index 6c5e7720ae..d4f559dd14 100644 --- a/meteor/server/api/deviceTriggers/StudioObserver.ts +++ b/meteor/server/api/deviceTriggers/StudioObserver.ts @@ -235,6 +235,7 @@ export class StudioObserver extends EventEmitter { if (this.#disposed) { // If we were disposed of while waiting for the observer to be created, stop it immediately this.#rundownsLiveQuery.stop() + this.#pieceInstancesLiveQuery.stop() } }, REACTIVITY_DEBOUNCE) From 3c4a4faaf0b32f315bbfa8739301dcba7857baab Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 4 Feb 2025 10:35:40 +0100 Subject: [PATCH 010/293] feat: unify Piece Icons styling and handle empty vs undefined abbreviation --- packages/webui/src/client/styles/main.scss | 1 + .../src/client/ui/PieceIcons/PieceIcons.scss | 92 +++++++++++++++++++ .../ui/PieceIcons/Renderers/CamInputIcon.tsx | 32 ++----- .../Renderers/GraphicsInputIcon.tsx | 23 ++--- .../Renderers/LiveSpeakInputIcon.tsx | 30 ++---- .../PieceIcons/Renderers/LocalInputIcon.tsx | 7 +- .../PieceIcons/Renderers/RemoteInputIcon.tsx | 33 ++----- .../PieceIcons/Renderers/SplitInputIcon.tsx | 4 +- .../PieceIcons/Renderers/UnknownInputIcon.tsx | 25 ++--- .../ui/PieceIcons/Renderers/VTInputIcon.tsx | 25 ++--- 10 files changed, 147 insertions(+), 125 deletions(-) create mode 100644 packages/webui/src/client/ui/PieceIcons/PieceIcons.scss diff --git a/packages/webui/src/client/styles/main.scss b/packages/webui/src/client/styles/main.scss index a1d77edf9c..95b6db31a2 100644 --- a/packages/webui/src/client/styles/main.scss +++ b/packages/webui/src/client/styles/main.scss @@ -98,6 +98,7 @@ input { @import '../ui/SegmentList/LinePartTransitionPiece/LinePartTransitionPiece.scss'; @import '../ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.scss'; @import '../ui/PieceIcons/IconColors.scss'; +@import '../ui/PieceIcons/PieceIcons'; @import '../ui/ClockView/CameraScreen/CameraScreen.scss'; @import '../ui/RundownView/MediaStatusPopUp/MediaStatusPopUpItem.scss'; @import '../ui/RundownView/MediaStatusPopUp/MediaStatusPopUp.scss'; diff --git a/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss new file mode 100644 index 0000000000..309866c197 --- /dev/null +++ b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss @@ -0,0 +1,92 @@ +// Variables +$text-color: #ffffff; +$font-size-base: 75px; +$text-shadow: 0 2px 9px rgba(0, 0, 0, 0.5); + +// Base icon styles +.piece-icon { + .camera { + @include item-type-colors-svg(); + rx: 4; + ry: 4; + } + + .live-speak { + fill: url(#background-gradient); + rx: 4; + ry: 4; + } + + + .graphics { + @include item-type-colors-svg(); + rx: 4; + ry: 4; + } + + .local { + @include item-type-colors-svg(); + rx: 4; + ry: 4; + } + + .remote { + @include item-type-colors-svg(); + rx: 4; + ry: 4; + } + + .vt { + @include item-type-colors-svg(); + rx: 4; + ry: 4; + } + + .unknown { + @include item-type-colors-svg(); + rx: 4; + ry: 4; + } + + // Gradient styles for live-speak + #background-gradient { + .stop1 { + stop-color: #954c4c; + } + .stop2 { + stop-color: #4c954c; + } + } + + // Split view specific styles + .upper, .lower { + rx: 4; + ry: 4; + + &.camera { + @include item-type-colors-svg(); + } + &.remote { + @include item-type-colors-svg(); + } + &.remote.second { + @include item-type-colors-svg(); + } + } + + // Common text styles + .piece-icon-text { + fill: $text-color; + font-family: 'Open Sans', sans-serif; + font-size: 40px; + + filter: drop-shadow($text-shadow); + + .label { + fill: $text-color; + font-family: Roboto, sans-serif; + font-size: $font-size-base; + font-weight: 100; + } + } +} \ No newline at end of file diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx index 305420d58f..4ae68b5a7b 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx @@ -4,35 +4,19 @@ import * as React from 'react' export default class CamInputIcon extends React.Component<{ inputIndex?: string; abbreviation?: string }> { render(): JSX.Element { return ( - + - - {this.props.abbreviation ? this.props.abbreviation : 'C'} - - {this.props.inputIndex !== undefined ? this.props.inputIndex : ''} - + + {this.props.abbreviation !== undefined ? this.props.abbreviation : 'C'} + {this.props.inputIndex !== undefined ? this.props.inputIndex : ''} diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx index 582ada7e26..a76869ee5d 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx @@ -2,28 +2,17 @@ import * as React from 'react' export default class GraphicsInputIcon extends React.Component<{ abbreviation?: string }> { render(): JSX.Element { return ( - + - + {this.props.abbreviation ? this.props.abbreviation : 'G'} diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx index f7fa3635ae..409556a427 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx @@ -3,34 +3,22 @@ import * as React from 'react' export default class LiveSpeakInputIcon extends React.Component<{ abbreviation?: string }> { render(): JSX.Element { return ( - - + + - - {this.props.abbreviation ? this.props.abbreviation : 'LSK'} + + {this.props.abbreviation !== undefined ? this.props.abbreviation : 'LSK'} diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx index 00356ed2e1..4c9a166df9 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx @@ -1,5 +1,10 @@ import { BaseRemoteInputIcon } from './RemoteInputIcon' export default function LocalInputIcon(props: Readonly<{ inputIndex?: string; abbreviation?: string }>): JSX.Element { - return {props.abbreviation ? props.abbreviation : 'EVS'} + return ( + + {props.abbreviation !== undefined ? props.abbreviation : 'EVS'} + {props.inputIndex ?? ''} + + ) } diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx index 6d7058618c..4a62917edc 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx @@ -2,32 +2,17 @@ import React from 'react' export function BaseRemoteInputIcon(props: Readonly>): JSX.Element { return ( - + - + {props.children} @@ -38,8 +23,8 @@ export function BaseRemoteInputIcon(props: Readonly): JSX.Element { return ( - {props.abbreviation ? props.abbreviation : 'LIVE'} - {props.inputIndex ?? ''} + {props.abbreviation !== undefined ? props.abbreviation : 'LIVE'} + {props.inputIndex ?? ''} ) } diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx index af2e97dd4e..d9c983e534 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx @@ -25,10 +25,10 @@ export default class SplitInputIcon extends React.Component<{ ) } else { - return this.props.abbreviation ? this.props.abbreviation : 'Spl' + return this.props.abbreviation !== undefined ? this.props.abbreviation : 'Spl' } } else { - return this.props.abbreviation ? this.props.abbreviation : 'Spl' + return this.props.abbreviation !== undefined ? this.props.abbreviation : 'Spl' } } diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx index 0d64a1d642..c599c4d390 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx @@ -3,28 +3,17 @@ import * as React from 'react' export default class UnknownInputIcon extends React.Component<{ abbreviation?: string }> { render(): JSX.Element { return ( - - + + - + ? diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx index 4d701d6e2a..2768ad6dc5 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx @@ -3,29 +3,18 @@ import * as React from 'react' export default class VTInputIcon extends React.Component<{ abbreviation?: string }> { render(): JSX.Element { return ( - + - - {this.props.abbreviation ? this.props.abbreviation : 'VT'} + + {this.props.abbreviation !== undefined ? this.props.abbreviation : 'VT'} From 0a87149d18cc79afb0fabb3a0fb1ee08b9723a50 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 4 Feb 2025 10:36:14 +0100 Subject: [PATCH 011/293] feat: missed graphicsInputIcon in last commit --- .../src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx index a76869ee5d..4dfc0667dd 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx @@ -13,7 +13,7 @@ export default class GraphicsInputIcon extends React.Component<{ abbreviation?: xmlSpace="preserve" > - {this.props.abbreviation ? this.props.abbreviation : 'G'} + {this.props.abbreviation !== undefined ? this.props.abbreviation : 'G'} From cf6276289b3bc47df3635b34ca75994ccc37713b Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 4 Feb 2025 11:29:32 +0100 Subject: [PATCH 012/293] feat: optional studioLabelShort for presenters view --- packages/blueprints-integration/src/content.ts | 4 ++++ packages/webui/src/client/ui/PieceIcons/PieceIcon.tsx | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/blueprints-integration/src/content.ts b/packages/blueprints-integration/src/content.ts index 67824be905..8dcab9cd92 100644 --- a/packages/blueprints-integration/src/content.ts +++ b/packages/blueprints-integration/src/content.ts @@ -67,17 +67,20 @@ export interface GraphicsContent extends BaseContent { export interface CameraContent extends BaseContent { studioLabel: string + studioLabelShort?: string switcherInput: number | string } export interface RemoteContent extends BaseContent { studioLabel: string + studioLabelShort?: string switcherInput: number | string } /** Content description for the EVS variant of a LOCAL source */ export interface EvsContent extends BaseContent { studioLabel: string + studioLabelShort?: string /** Switcher input for the EVS channel */ switcherInput: number | string /** Name of the EVS channel as used in the studio */ @@ -155,6 +158,7 @@ export interface NoraContent extends BaseContent { export interface SplitsContentBoxProperties { type: SourceLayerType studioLabel: string + studioLabelShort?: string switcherInput: number | string /** Geometry information for a given box item in the Split. X,Y are relative to center of Box, Scale is 0...1, where 1 is Full-Screen */ geometry?: { diff --git a/packages/webui/src/client/ui/PieceIcons/PieceIcon.tsx b/packages/webui/src/client/ui/PieceIcons/PieceIcon.tsx index 1285e5695f..b9e1aa2e59 100644 --- a/packages/webui/src/client/ui/PieceIcons/PieceIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/PieceIcon.tsx @@ -49,7 +49,7 @@ export const PieceIcon = (props: { const rmContent = piece ? (piece.content as RemoteContent | undefined) : undefined return ( ) @@ -58,7 +58,7 @@ export const PieceIcon = (props: { const localContent = piece ? (piece.content as EvsContent | undefined) : undefined return ( ) @@ -71,7 +71,7 @@ export const PieceIcon = (props: { const camContent = piece ? (piece.content as CameraContent | undefined) : undefined return ( ) From 38846fe96ff31fc68193bf36f53bc680894d5cd1 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 4 Feb 2025 12:28:38 +0100 Subject: [PATCH 013/293] feat: Styling on PieceIcons --- .../webui/src/client/ui/PieceIcons/PieceIcons.scss | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss index 309866c197..0e109df757 100644 --- a/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss +++ b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss @@ -1,7 +1,8 @@ // Variables $text-color: #ffffff; -$font-size-base: 75px; +$font-size-base: 70px; $text-shadow: 0 2px 9px rgba(0, 0, 0, 0.5); +$letter-spacing: 0.02em; // Base icon styles .piece-icon { @@ -77,16 +78,17 @@ $text-shadow: 0 2px 9px rgba(0, 0, 0, 0.5); // Common text styles .piece-icon-text { fill: $text-color; - font-family: 'Open Sans', sans-serif; + font-family: Roboto Condensed, Roboto, sans-serif; font-size: 40px; filter: drop-shadow($text-shadow); .label { fill: $text-color; - font-family: Roboto, sans-serif; + font-family: Roboto Condensed, Roboto, sans-serif; font-size: $font-size-base; - font-weight: 100; + font-weight: 300; + letter-spacing: $letter-spacing; } } } \ No newline at end of file From 46c947f9605df0059600cc85eaf6bdab25f1cc7c Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 4 Feb 2025 12:49:37 +0100 Subject: [PATCH 014/293] fix: splitscreen should follow the other PieceIcons style --- .../PieceIcons/Renderers/SplitInputIcon.tsx | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx index d9c983e534..050cd40589 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx @@ -17,7 +17,7 @@ export default class SplitInputIcon extends React.Component<{ const c = piece.content as SplitsContent const camera = c.boxSourceConfiguration.find((i) => i.type === SourceLayerType.CAMERA) if (camera && camera.studioLabel) { - const label = camera.studioLabel.match(/([a-zA-Z]+)?(\d+)/) + const label = camera.studioLabelShort || camera.studioLabel.match(/([a-zA-Z]+)?(\d+)/) return ( {label && label[1] ? label[1].substr(0, 1).toUpperCase() + ' ' : ''} @@ -69,29 +69,14 @@ export default class SplitInputIcon extends React.Component<{ /> {!this.props.hideLabel && ( - + {this.getCameraLabel(this.props.piece)} From 55ffea8fa31da0c60e3c5d502b22bf1b0974b466 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 4 Feb 2025 13:41:11 +0100 Subject: [PATCH 015/293] chore: release53 --- meteor/package.json | 2 +- meteor/yarn.lock | 18 +++---- packages/blueprints-integration/package.json | 4 +- packages/corelib/package.json | 6 +-- packages/documentation/package.json | 2 +- packages/job-worker/package.json | 8 +-- packages/lerna.json | 8 +-- packages/live-status-gateway/package.json | 10 ++-- packages/meteor-lib/package.json | 8 +-- packages/mos-gateway/package.json | 6 +-- packages/openapi/package.json | 2 +- packages/playout-gateway/package.json | 6 +-- packages/server-core-integration/package.json | 4 +- packages/shared-lib/package.json | 2 +- packages/webui/package.json | 10 ++-- packages/yarn.lock | 54 +++++++++---------- 16 files changed, 75 insertions(+), 75 deletions(-) diff --git a/meteor/package.json b/meteor/package.json index 06652ce280..331f9a216f 100644 --- a/meteor/package.json +++ b/meteor/package.json @@ -1,6 +1,6 @@ { "name": "automation-core", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "private": true, "engines": { "node": ">=22.11" diff --git a/meteor/yarn.lock b/meteor/yarn.lock index 0af7e86b06..b98ecfa781 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -1078,7 +1078,7 @@ __metadata: version: 0.0.0-use.local resolution: "@sofie-automation/blueprints-integration@portal:../packages/blueprints-integration::locator=automation-core%40workspace%3A." dependencies: - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" tslib: "npm:^2.8.1" type-fest: "npm:^4.33.0" languageName: node @@ -1119,8 +1119,8 @@ __metadata: version: 0.0.0-use.local resolution: "@sofie-automation/corelib@portal:../packages/corelib::locator=automation-core%40workspace%3A." dependencies: - "@sofie-automation/blueprints-integration": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/blueprints-integration": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" fast-clone: "npm:^1.5.13" i18next: "npm:^21.10.0" influx: "npm:^5.9.7" @@ -1151,9 +1151,9 @@ __metadata: resolution: "@sofie-automation/job-worker@portal:../packages/job-worker::locator=automation-core%40workspace%3A." dependencies: "@slack/webhook": "npm:^7.0.4" - "@sofie-automation/blueprints-integration": "npm:1.52.0-in-development" - "@sofie-automation/corelib": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/blueprints-integration": "npm:1.53.0-in-development" + "@sofie-automation/corelib": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" amqplib: "npm:^0.10.5" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.11.0" @@ -1173,9 +1173,9 @@ __metadata: resolution: "@sofie-automation/meteor-lib@portal:../packages/meteor-lib::locator=automation-core%40workspace%3A." dependencies: "@mos-connection/helper": "npm:^4.2.2" - "@sofie-automation/blueprints-integration": "npm:1.52.0-in-development" - "@sofie-automation/corelib": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/blueprints-integration": "npm:1.53.0-in-development" + "@sofie-automation/corelib": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" deep-extend: "npm:0.6.0" semver: "npm:^7.6.3" type-fest: "npm:^4.33.0" diff --git a/packages/blueprints-integration/package.json b/packages/blueprints-integration/package.json index 26c9ef1489..fa824281fb 100644 --- a/packages/blueprints-integration/package.json +++ b/packages/blueprints-integration/package.json @@ -1,6 +1,6 @@ { "name": "@sofie-automation/blueprints-integration", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "description": "Library to define the interaction between core and the blueprints.", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -38,7 +38,7 @@ "/LICENSE" ], "dependencies": { - "@sofie-automation/shared-lib": "1.52.0-in-development", + "@sofie-automation/shared-lib": "1.53.0-in-development", "tslib": "^2.8.1", "type-fest": "^4.33.0" }, diff --git a/packages/corelib/package.json b/packages/corelib/package.json index 3c334941d4..3be36cc16b 100644 --- a/packages/corelib/package.json +++ b/packages/corelib/package.json @@ -1,6 +1,6 @@ { "name": "@sofie-automation/corelib", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "private": true, "description": "Internal library for some types shared by core and workers", "main": "dist/index.js", @@ -39,8 +39,8 @@ "/LICENSE" ], "dependencies": { - "@sofie-automation/blueprints-integration": "1.52.0-in-development", - "@sofie-automation/shared-lib": "1.52.0-in-development", + "@sofie-automation/blueprints-integration": "1.53.0-in-development", + "@sofie-automation/shared-lib": "1.53.0-in-development", "fast-clone": "^1.5.13", "i18next": "^21.10.0", "influx": "^5.9.7", diff --git a/packages/documentation/package.json b/packages/documentation/package.json index f1aac70280..138c998596 100644 --- a/packages/documentation/package.json +++ b/packages/documentation/package.json @@ -1,6 +1,6 @@ { "name": "sofie-documentation", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/packages/job-worker/package.json b/packages/job-worker/package.json index dec7813c13..d17f87dad3 100644 --- a/packages/job-worker/package.json +++ b/packages/job-worker/package.json @@ -1,6 +1,6 @@ { "name": "@sofie-automation/job-worker", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "description": "Worker for things", "main": "dist/index.js", "license": "MIT", @@ -41,9 +41,9 @@ ], "dependencies": { "@slack/webhook": "^7.0.4", - "@sofie-automation/blueprints-integration": "1.52.0-in-development", - "@sofie-automation/corelib": "1.52.0-in-development", - "@sofie-automation/shared-lib": "1.52.0-in-development", + "@sofie-automation/blueprints-integration": "1.53.0-in-development", + "@sofie-automation/corelib": "1.53.0-in-development", + "@sofie-automation/shared-lib": "1.53.0-in-development", "amqplib": "^0.10.5", "deepmerge": "^4.3.1", "elastic-apm-node": "^4.11.0", diff --git a/packages/lerna.json b/packages/lerna.json index f7e15393fd..a68d13c21f 100644 --- a/packages/lerna.json +++ b/packages/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.52.0-in-development", - "npmClient": "yarn", - "$schema": "node_modules/lerna/schemas/lerna-schema.json" -} + "version": "1.53.0-in-development", + "npmClient": "yarn", + "$schema": "node_modules/lerna/schemas/lerna-schema.json" +} \ No newline at end of file diff --git a/packages/live-status-gateway/package.json b/packages/live-status-gateway/package.json index 41f6f356e4..6b287cbb9b 100644 --- a/packages/live-status-gateway/package.json +++ b/packages/live-status-gateway/package.json @@ -1,6 +1,6 @@ { "name": "live-status-gateway", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "private": true, "description": "Provides state from Sofie over sockets", "license": "MIT", @@ -53,10 +53,10 @@ "production" ], "dependencies": { - "@sofie-automation/blueprints-integration": "1.52.0-in-development", - "@sofie-automation/corelib": "1.52.0-in-development", - "@sofie-automation/server-core-integration": "1.52.0-in-development", - "@sofie-automation/shared-lib": "1.52.0-in-development", + "@sofie-automation/blueprints-integration": "1.53.0-in-development", + "@sofie-automation/corelib": "1.53.0-in-development", + "@sofie-automation/server-core-integration": "1.53.0-in-development", + "@sofie-automation/shared-lib": "1.53.0-in-development", "debug": "^4.4.0", "fast-clone": "^1.5.13", "influx": "^5.9.7", diff --git a/packages/meteor-lib/package.json b/packages/meteor-lib/package.json index ee497ea4db..4343a8458b 100644 --- a/packages/meteor-lib/package.json +++ b/packages/meteor-lib/package.json @@ -1,6 +1,6 @@ { "name": "@sofie-automation/meteor-lib", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "private": true, "description": "Temporary internal library for some types shared by meteor and webui", "main": "dist/index.js", @@ -40,9 +40,9 @@ ], "dependencies": { "@mos-connection/helper": "^4.2.2", - "@sofie-automation/blueprints-integration": "1.52.0-in-development", - "@sofie-automation/corelib": "1.52.0-in-development", - "@sofie-automation/shared-lib": "1.52.0-in-development", + "@sofie-automation/blueprints-integration": "1.53.0-in-development", + "@sofie-automation/corelib": "1.53.0-in-development", + "@sofie-automation/shared-lib": "1.53.0-in-development", "deep-extend": "0.6.0", "semver": "^7.6.3", "type-fest": "^4.33.0", diff --git a/packages/mos-gateway/package.json b/packages/mos-gateway/package.json index 9f7b10e46a..b8be483fa1 100644 --- a/packages/mos-gateway/package.json +++ b/packages/mos-gateway/package.json @@ -1,6 +1,6 @@ { "name": "mos-gateway", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "private": true, "description": "MOS-Gateway for the Sofie project", "license": "MIT", @@ -66,8 +66,8 @@ ], "dependencies": { "@mos-connection/connector": "^4.2.2", - "@sofie-automation/server-core-integration": "1.52.0-in-development", - "@sofie-automation/shared-lib": "1.52.0-in-development", + "@sofie-automation/server-core-integration": "1.53.0-in-development", + "@sofie-automation/shared-lib": "1.53.0-in-development", "tslib": "^2.8.1", "type-fest": "^4.33.0", "underscore": "^1.13.7", diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 507f7b82f4..87368afeaa 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -1,6 +1,6 @@ { "name": "@sofie-automation/openapi", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "license": "MIT", "repository": { "type": "git", diff --git a/packages/playout-gateway/package.json b/packages/playout-gateway/package.json index 287c6d717c..48f17065da 100644 --- a/packages/playout-gateway/package.json +++ b/packages/playout-gateway/package.json @@ -1,6 +1,6 @@ { "name": "playout-gateway", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "private": true, "description": "Connect to Core, play stuff", "license": "MIT", @@ -56,8 +56,8 @@ "production" ], "dependencies": { - "@sofie-automation/server-core-integration": "1.52.0-in-development", - "@sofie-automation/shared-lib": "1.52.0-in-development", + "@sofie-automation/server-core-integration": "1.53.0-in-development", + "@sofie-automation/shared-lib": "1.53.0-in-development", "debug": "^4.4.0", "influx": "^5.9.7", "timeline-state-resolver": "9.2.0-nightly-release52-20250204-101510-3576f2cd8.0", diff --git a/packages/server-core-integration/package.json b/packages/server-core-integration/package.json index 63c4bc8fce..320e4b94ca 100644 --- a/packages/server-core-integration/package.json +++ b/packages/server-core-integration/package.json @@ -1,6 +1,6 @@ { "name": "@sofie-automation/server-core-integration", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "description": "Library for connecting to Core", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -70,7 +70,7 @@ "production" ], "dependencies": { - "@sofie-automation/shared-lib": "1.52.0-in-development", + "@sofie-automation/shared-lib": "1.53.0-in-development", "ejson": "^2.2.3", "faye-websocket": "^0.11.4", "got": "^11.8.6", diff --git a/packages/shared-lib/package.json b/packages/shared-lib/package.json index 9fb9c8cb56..3975f5c868 100644 --- a/packages/shared-lib/package.json +++ b/packages/shared-lib/package.json @@ -1,6 +1,6 @@ { "name": "@sofie-automation/shared-lib", - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "description": "Library for types & values shared by core, workers and gateways", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/packages/webui/package.json b/packages/webui/package.json index 066184be4a..025a04b83e 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,7 +1,7 @@ { "name": "@sofie-automation/webui", "private": true, - "version": "1.52.0-in-development", + "version": "1.53.0-in-development", "type": "module", "license": "MIT", "repository": { @@ -41,10 +41,10 @@ "@jstarpl/react-contextmenu": "^2.15.0", "@nrk/core-icons": "^9.6.0", "@popperjs/core": "^2.11.8", - "@sofie-automation/blueprints-integration": "1.52.0-in-development", - "@sofie-automation/corelib": "1.52.0-in-development", - "@sofie-automation/meteor-lib": "1.52.0-in-development", - "@sofie-automation/shared-lib": "1.52.0-in-development", + "@sofie-automation/blueprints-integration": "1.53.0-in-development", + "@sofie-automation/corelib": "1.53.0-in-development", + "@sofie-automation/meteor-lib": "1.53.0-in-development", + "@sofie-automation/shared-lib": "1.53.0-in-development", "@sofie-automation/sorensen": "^1.5.8", "@testing-library/user-event": "^14.6.1", "@types/sinon": "^10.0.20", diff --git a/packages/yarn.lock b/packages/yarn.lock index 2e4d810b81..621c01712e 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -5920,11 +5920,11 @@ __metadata: languageName: node linkType: hard -"@sofie-automation/blueprints-integration@npm:1.52.0-in-development, @sofie-automation/blueprints-integration@workspace:blueprints-integration": +"@sofie-automation/blueprints-integration@npm:1.53.0-in-development, @sofie-automation/blueprints-integration@workspace:blueprints-integration": version: 0.0.0-use.local resolution: "@sofie-automation/blueprints-integration@workspace:blueprints-integration" dependencies: - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" tslib: "npm:^2.8.1" type-fest: "npm:^4.33.0" languageName: unknown @@ -5961,12 +5961,12 @@ __metadata: languageName: node linkType: hard -"@sofie-automation/corelib@npm:1.52.0-in-development, @sofie-automation/corelib@workspace:corelib": +"@sofie-automation/corelib@npm:1.53.0-in-development, @sofie-automation/corelib@workspace:corelib": version: 0.0.0-use.local resolution: "@sofie-automation/corelib@workspace:corelib" dependencies: - "@sofie-automation/blueprints-integration": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/blueprints-integration": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" fast-clone: "npm:^1.5.13" i18next: "npm:^21.10.0" influx: "npm:^5.9.7" @@ -5997,9 +5997,9 @@ __metadata: resolution: "@sofie-automation/job-worker@workspace:job-worker" dependencies: "@slack/webhook": "npm:^7.0.4" - "@sofie-automation/blueprints-integration": "npm:1.52.0-in-development" - "@sofie-automation/corelib": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/blueprints-integration": "npm:1.53.0-in-development" + "@sofie-automation/corelib": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" amqplib: "npm:^0.10.5" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.11.0" @@ -6015,14 +6015,14 @@ __metadata: languageName: unknown linkType: soft -"@sofie-automation/meteor-lib@npm:1.52.0-in-development, @sofie-automation/meteor-lib@workspace:meteor-lib": +"@sofie-automation/meteor-lib@npm:1.53.0-in-development, @sofie-automation/meteor-lib@workspace:meteor-lib": version: 0.0.0-use.local resolution: "@sofie-automation/meteor-lib@workspace:meteor-lib" dependencies: "@mos-connection/helper": "npm:^4.2.2" - "@sofie-automation/blueprints-integration": "npm:1.52.0-in-development" - "@sofie-automation/corelib": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/blueprints-integration": "npm:1.53.0-in-development" + "@sofie-automation/corelib": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" "@types/deep-extend": "npm:^0.6.2" "@types/semver": "npm:^7.5.8" "@types/underscore": "npm:^1.13.0" @@ -6048,11 +6048,11 @@ __metadata: languageName: unknown linkType: soft -"@sofie-automation/server-core-integration@npm:1.52.0-in-development, @sofie-automation/server-core-integration@workspace:server-core-integration": +"@sofie-automation/server-core-integration@npm:1.53.0-in-development, @sofie-automation/server-core-integration@workspace:server-core-integration": version: 0.0.0-use.local resolution: "@sofie-automation/server-core-integration@workspace:server-core-integration" dependencies: - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" ejson: "npm:^2.2.3" faye-websocket: "npm:^0.11.4" got: "npm:^11.8.6" @@ -6061,7 +6061,7 @@ __metadata: languageName: unknown linkType: soft -"@sofie-automation/shared-lib@npm:1.52.0-in-development, @sofie-automation/shared-lib@workspace:shared-lib": +"@sofie-automation/shared-lib@npm:1.53.0-in-development, @sofie-automation/shared-lib@workspace:shared-lib": version: 0.0.0-use.local resolution: "@sofie-automation/shared-lib@workspace:shared-lib" dependencies: @@ -6092,10 +6092,10 @@ __metadata: "@jstarpl/react-contextmenu": "npm:^2.15.0" "@nrk/core-icons": "npm:^9.6.0" "@popperjs/core": "npm:^2.11.8" - "@sofie-automation/blueprints-integration": "npm:1.52.0-in-development" - "@sofie-automation/corelib": "npm:1.52.0-in-development" - "@sofie-automation/meteor-lib": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/blueprints-integration": "npm:1.53.0-in-development" + "@sofie-automation/corelib": "npm:1.53.0-in-development" + "@sofie-automation/meteor-lib": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" "@sofie-automation/sorensen": "npm:^1.5.8" "@testing-library/dom": "npm:^10.4.0" "@testing-library/jest-dom": "npm:^6.6.3" @@ -18614,10 +18614,10 @@ asn1@evs-broadcast/node-asn1: "@asyncapi/generator": "npm:^2.6.0" "@asyncapi/html-template": "npm:^3.1.0" "@asyncapi/nodejs-ws-template": "npm:^0.10.0" - "@sofie-automation/blueprints-integration": "npm:1.52.0-in-development" - "@sofie-automation/corelib": "npm:1.52.0-in-development" - "@sofie-automation/server-core-integration": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/blueprints-integration": "npm:1.53.0-in-development" + "@sofie-automation/corelib": "npm:1.53.0-in-development" + "@sofie-automation/server-core-integration": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" debug: "npm:^4.4.0" fast-clone: "npm:^1.5.13" influx: "npm:^5.9.7" @@ -20667,8 +20667,8 @@ asn1@evs-broadcast/node-asn1: resolution: "mos-gateway@workspace:mos-gateway" dependencies: "@mos-connection/connector": "npm:^4.2.2" - "@sofie-automation/server-core-integration": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/server-core-integration": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" tslib: "npm:^2.8.1" type-fest: "npm:^4.33.0" underscore: "npm:^1.13.7" @@ -22881,8 +22881,8 @@ asn1@evs-broadcast/node-asn1: version: 0.0.0-use.local resolution: "playout-gateway@workspace:playout-gateway" dependencies: - "@sofie-automation/server-core-integration": "npm:1.52.0-in-development" - "@sofie-automation/shared-lib": "npm:1.52.0-in-development" + "@sofie-automation/server-core-integration": "npm:1.53.0-in-development" + "@sofie-automation/shared-lib": "npm:1.53.0-in-development" debug: "npm:^4.4.0" influx: "npm:^5.9.7" timeline-state-resolver: "npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0" From eda0ccfb0d0f0e23bbbc44665c74076ba19c7d0e Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 4 Feb 2025 15:04:53 +0100 Subject: [PATCH 016/293] chore: update CURRENT_SYSTEM_VERSION --- meteor/server/migration/currentSystemVersion.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/meteor/server/migration/currentSystemVersion.ts b/meteor/server/migration/currentSystemVersion.ts index 6a2a3c3b0c..bb7d1be26c 100644 --- a/meteor/server/migration/currentSystemVersion.ts +++ b/meteor/server/migration/currentSystemVersion.ts @@ -51,7 +51,8 @@ * 1.50.0: Release 50 (2024-02-23) * 1.51.0: Release 51 (TBD) * 1.52.0: Release 52 (TBD) + * 1.53.0: Release 53 (TBD) */ // Note: Only set this to release versions, (ie X.Y.Z), not pre-releases (ie X.Y.Z-0-pre-release) -export const CURRENT_SYSTEM_VERSION = '1.52.0' +export const CURRENT_SYSTEM_VERSION = '1.53.0' From 955d96e29d44998f78fd76ea46be9a9fdf405bd0 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 4 Feb 2025 15:21:35 +0100 Subject: [PATCH 017/293] chore: update glob dependency --- meteor/package.json | 2 +- meteor/scripts/extract-i18next-pot.mjs | 7 +-- meteor/scripts/i18n-compile-json.mjs | 7 +-- meteor/yarn.lock | 69 +++++++++++++++++++++++++- 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/meteor/package.json b/meteor/package.json index 331f9a216f..a8e2e0efea 100644 --- a/meteor/package.json +++ b/meteor/package.json @@ -103,7 +103,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.2.1", "fast-clone": "^1.5.13", - "glob": "^8.1.0", + "glob": "^11.0.1", "i18next-conv": "^10.2.0", "i18next-scanner": "^4.6.0", "jest": "^29.7.0", diff --git a/meteor/scripts/extract-i18next-pot.mjs b/meteor/scripts/extract-i18next-pot.mjs index 443cf5417e..a9d5f795e1 100644 --- a/meteor/scripts/extract-i18next-pot.mjs +++ b/meteor/scripts/extract-i18next-pot.mjs @@ -27,12 +27,11 @@ * SOFTWARE. */ -import { promisify } from 'util' import fs from 'fs' import yargs from 'yargs' import { Parser } from 'i18next-scanner' import converter from 'i18next-conv' -import glob from 'glob' +import { glob } from 'glob' const args = yargs(process.argv) .option('files', { @@ -66,8 +65,6 @@ const args = yargs(process.argv) .help() .alias('help', 'h').argv -const pGlob = promisify(glob) - const parserOptions = { // Include react helpers into parsing attr: { @@ -101,7 +98,7 @@ console.log('Extracting translatable strings...') console.log('This process may print out some error messages, but the translation template should work fine.') console.log('──────\n') -const files = await pGlob(fileGlob) +const files = await glob(fileGlob) // console.debug('Loading content of ' + files.length + ' files') diff --git a/meteor/scripts/i18n-compile-json.mjs b/meteor/scripts/i18n-compile-json.mjs index fb2a0b4d55..464922ede8 100644 --- a/meteor/scripts/i18n-compile-json.mjs +++ b/meteor/scripts/i18n-compile-json.mjs @@ -1,9 +1,6 @@ -import { promisify } from 'util' -import glob from 'glob' +import { glob } from 'glob' import { spawn } from 'child_process' -const pGlob = promisify(glob) - /************************************************* This script goes through all of the languages (.po files) @@ -14,7 +11,7 @@ and compiles the json-files (used in production). const errors = [] const failedLanguages = [] // List all po-files: -const poFiles = await pGlob('./i18n/*.po') +const poFiles = await glob('./i18n/*.po') const languages = [] for (const poFile of poFiles) { diff --git a/meteor/yarn.lock b/meteor/yarn.lock index 31e7560f36..54ee1a473e 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -2259,7 +2259,7 @@ __metadata: eslint-plugin-node: "npm:^11.1.0" eslint-plugin-prettier: "npm:^4.2.1" fast-clone: "npm:^1.5.13" - glob: "npm:^8.1.0" + glob: "npm:^11.0.1" i18next: "npm:^21.10.0" i18next-conv: "npm:^10.2.0" i18next-scanner: "npm:^4.6.0" @@ -5098,6 +5098,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:^11.0.1": + version: 11.0.1 + resolution: "glob@npm:11.0.1" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^4.0.1" + minimatch: "npm:^10.0.0" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^2.0.0" + bin: + glob: dist/esm/bin.mjs + checksum: 10/57b12a05cc25f1c38f3b24cf6ea7a8bacef11e782c4b9a8c5b0bef3e6c5bcb8c4548cb31eb4115592e0490a024c1bde7359c470565608dd061d3b21179740457 + languageName: node + linkType: hard + "glob@npm:^7.0.0, glob@npm:^7.1.1, glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -5112,7 +5128,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^8.0.1, glob@npm:^8.1.0": +"glob@npm:^8.0.1": version: 8.1.0 resolution: "glob@npm:8.1.0" dependencies: @@ -6174,6 +6190,15 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^4.0.1": + version: 4.0.2 + resolution: "jackspeak@npm:4.0.2" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + checksum: 10/d9722f0e55f6c322c57aedf094c405f4201b834204629817187953988075521cfddb23df83e2a7b845723ca7eb0555068c5ce1556732e9c275d32a531881efa8 + languageName: node + linkType: hard + "jake@npm:^10.8.5": version: 10.9.2 resolution: "jake@npm:10.9.2" @@ -7093,6 +7118,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.0.0": + version: 11.0.2 + resolution: "lru-cache@npm:11.0.2" + checksum: 10/25fcb66e9d91eaf17227c6abfe526a7bed5903de74f93bfde380eb8a13410c5e8d3f14fe447293f3f322a7493adf6f9f015c6f1df7a235ff24ec30f366e1c058 + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -7462,6 +7494,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.0.0": + version: 10.0.1 + resolution: "minimatch@npm:10.0.1" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10/082e7ccbc090d5f8c4e4e029255d5a1d1e3af37bda837da2b8b0085b1503a1210c91ac90d9ebfe741d8a5f286ece820a1abb4f61dc1f82ce602a055d461d93f3 + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -7596,6 +7637,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 10/c25f0ee8196d8e6036661104bacd743785b2599a21de5c516b32b3fa2b83113ac89a2358465bc04956baab37ffb956ae43be679b2262bf7be15fce467ccd7950 + languageName: node + linkType: hard + "minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" @@ -8355,6 +8403,13 @@ __metadata: languageName: node linkType: hard +"package-json-from-dist@npm:^1.0.0": + version: 1.0.1 + resolution: "package-json-from-dist@npm:1.0.1" + checksum: 10/58ee9538f2f762988433da00e26acc788036914d57c71c246bf0be1b60cdbd77dd60b6a3e1a30465f0b248aeb80079e0b34cb6050b1dfa18c06953bb1cbc7602 + languageName: node + linkType: hard + "pako@npm:~1.0.5": version: 1.0.11 resolution: "pako@npm:1.0.11" @@ -8487,6 +8542,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^2.0.0": + version: 2.0.0 + resolution: "path-scurry@npm:2.0.0" + dependencies: + lru-cache: "npm:^11.0.0" + minipass: "npm:^7.1.2" + checksum: 10/285ae0c2d6c34ae91dc1d5378ede21981c9a2f6de1ea9ca5a88b5a270ce9763b83dbadc7a324d512211d8d36b0c540427d3d0817030849d97a60fa840a2c59ec + languageName: node + linkType: hard + "path-to-regexp@npm:^6.3.0": version: 6.3.0 resolution: "path-to-regexp@npm:6.3.0" From 2fac5208de11305e41ef33677ff9c2c09e32885b Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 16 Dec 2024 10:44:19 +0000 Subject: [PATCH 018/293] fix: simplify meteor collection auth checks This removes some deprecation warnings See https://github.com/meteor/meteor/issues/13444 --- meteor/.meteor/packages | 10 +++++----- meteor/.meteor/release | 2 +- meteor/.meteor/versions | 16 ++++++++-------- meteor/server/collections/collection.ts | 11 ++++------- packages/webui/vite.config.mts | 1 + 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/meteor/.meteor/packages b/meteor/.meteor/packages index 34ab0cf5f5..5a1bc49c8a 100644 --- a/meteor/.meteor/packages +++ b/meteor/.meteor/packages @@ -8,14 +8,14 @@ # but you can also edit it by hand. -meteor@2.0.1 -webapp@2.0.3 +meteor@2.1.0 +webapp@2.0.4 ddp@1.4.2 -mongo@2.0.2 # The database Meteor supports right now +mongo@2.1.0 # The database Meteor supports right now -ecmascript@0.16.9 # Enable ECMAScript2015+ syntax in app code -typescript@5.4.3 # Enable TypeScript syntax in .ts and .tsx modules +ecmascript@0.16.10 # Enable ECMAScript2015+ syntax in app code +typescript@5.6.3 # Enable TypeScript syntax in .ts and .tsx modules tracker@1.3.4 # Meteor's client-side reactive programming library diff --git a/meteor/.meteor/release b/meteor/.meteor/release index 8d20e1a2d3..eaae1a48e1 100644 --- a/meteor/.meteor/release +++ b/meteor/.meteor/release @@ -1 +1 @@ -METEOR@3.1 +METEOR@3.1.1 diff --git a/meteor/.meteor/versions b/meteor/.meteor/versions index 93c057c752..ad14217784 100644 --- a/meteor/.meteor/versions +++ b/meteor/.meteor/versions @@ -1,5 +1,5 @@ -allow-deny@2.0.0 -babel-compiler@7.11.2 +allow-deny@2.1.0 +babel-compiler@7.11.3 babel-runtime@1.5.2 base64@1.0.13 binary-heap@1.0.12 @@ -8,9 +8,9 @@ callback-hook@1.6.0 check@1.4.4 core-runtime@1.0.0 ddp@1.4.2 -ddp-client@3.0.3 +ddp-client@3.1.0 ddp-common@1.4.4 -ddp-server@3.0.3 +ddp-server@3.1.0 diff-sequence@1.1.3 dynamic-import@0.7.4 ecmascript@0.16.10 @@ -24,16 +24,16 @@ geojson-utils@1.0.12 id-map@1.2.0 inter-process-messaging@0.1.2 logging@1.3.5 -meteor@2.0.2 +meteor@2.1.0 minimongo@2.0.2 modern-browsers@0.1.11 modules@0.20.3 modules-runtime@0.13.2 -mongo@2.0.3 +mongo@2.1.0 mongo-decimal@0.2.0 mongo-dev-server@1.1.1 mongo-id@1.0.9 -npm-mongo@6.10.0 +npm-mongo@6.10.2 ordered-dict@1.2.0 promise@1.0.0 random@1.2.2 @@ -41,7 +41,7 @@ react-fast-refresh@0.2.9 reload@1.3.2 retry@1.1.1 routepolicy@1.1.2 -socket-stream-client@0.5.3 +socket-stream-client@0.6.0 tracker@1.3.4 typescript@5.6.3 webapp@2.0.4 diff --git a/meteor/server/collections/collection.ts b/meteor/server/collections/collection.ts index 8ae8aae4a8..e0617c77e7 100644 --- a/meteor/server/collections/collection.ts +++ b/meteor/server/collections/collection.ts @@ -112,13 +112,10 @@ function setupCollectionAllowRules['allow']>[0]*/ = { - update: () => false, - updateAsync: origUpdate - ? (userId: string | null, doc: DBInterface, fieldNames: string[], modifier: any) => - origUpdate(protectString(userId), doc, fieldNames as any, modifier) as any + const options: Parameters['allow']>[0] = { + update: origUpdate + ? async (userId: string | null, doc: DBInterface, fieldNames: string[], modifier: any) => + origUpdate(protectString(userId), doc, fieldNames as any, modifier) : () => false, } diff --git a/packages/webui/vite.config.mts b/packages/webui/vite.config.mts index fff95dd146..3275aaebb2 100644 --- a/packages/webui/vite.config.mts +++ b/packages/webui/vite.config.mts @@ -55,6 +55,7 @@ export default defineConfig({ }, server: { + allowedHosts: true, proxy: { '/api': 'http://127.0.0.1:3000', '/site.webmanifest': 'http://127.0.0.1:3000', From 1da577062ffd22163b7a8358f7186bb5b96ba59b Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 5 Feb 2025 13:48:52 +0100 Subject: [PATCH 019/293] fix: typo in css className --- .../webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx index 050cd40589..cfe351bf26 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx @@ -54,7 +54,7 @@ export default class SplitInputIcon extends React.Component<{ render(): JSX.Element { return ( Date: Fri, 7 Feb 2025 07:46:15 +0100 Subject: [PATCH 020/293] Feat: director screen initial commit --- .../src/client/styles/countdown/director.scss | 347 +++++++++++++ packages/webui/src/client/styles/main.scss | 1 + .../src/client/ui/ClockView/ClockView.tsx | 10 + .../client/ui/ClockView/DirectorScreen.tsx | 473 ++++++++++++++++++ 4 files changed, 831 insertions(+) create mode 100644 packages/webui/src/client/styles/countdown/director.scss create mode 100644 packages/webui/src/client/ui/ClockView/DirectorScreen.tsx diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss new file mode 100644 index 0000000000..09d6298b8a --- /dev/null +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -0,0 +1,347 @@ +@import '../colorScheme'; +$liveline-timecode-color: $general-countdown-to-next-color; //$general-live-color; +$hold-status-color: $liveline-timecode-color; + +.piece__label__colored-mark { + display: inline-block; + background-color: currentColor; + border-radius: 100%; + width: 0.75em; + height: 0.75em; + margin-right: 0.125em; + margin-left: 0.125em; + line-height: 0.75em; +} + +.director-screen { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + + font-size: 0.4vh; + + display: grid; + grid-template: 4fr 3fr fit-content(1em) / auto; + + overflow: hidden; + white-space: nowrap; + + .director-screen__part { + display: grid; + grid-template: + 10em + 4fr + 6fr / 13vw auto; // allow a fallback for CasparCG + grid-template: + 10em + 4fr + 6fr / #{'min(13vw, 27vh)'} auto; + + .director-screen__segment-name { + grid-row: 1; + grid-column: 1 / -1; + text-align: center; + font-size: 8em; + font-weight: bold; + + &.live { + background: $general-live-color; + color: #fff; + border-top: 0.1em solid #fff; + -webkit-text-stroke: black; + -webkit-text-stroke-width: 0.025em; + text-shadow: 0px 0px 20px #00000044; + } + + &.next { + background: $general-next-color; + color: #000; + border-top: 0.1em solid #fff; + } + } + + .director-screen__rundown-countdown { + grid-row: 2 / -1; + grid-column: 1 / -1; + + text-align: center; + display: flex; + align-items: center; + justify-content: center; + + font-size: 12vw; + } + + .director-screen__part__piece-icon { + grid-row: 2; + grid-column: 1; + padding: 0em; + + text-align: center; + display: flex; + align-items: center; + justify-content: center; + + > svg { + flex-grow: 1; + } + } + + .director-screen__part__piece-name { + grid-row: 2; + grid-column: 2; + text-align: left; + font-size: 13em; + overflow: hidden; + white-space: nowrap; + padding-left: 0.2em; + + display: flex; + align-items: center; + + .director-screen__part__auto-next-icon { + display: block; + min-width: 1em; + max-width: 1em; + } + } + + .director-screen__part__piece-countdown { + text-align: left; + + display: flex; + align-items: center; + font-size: 13em; // Allow a fallback for CasparCG + font-size: #{'min(13em, 8vw)'}; + padding: 0 0.2em; + line-height: 1em; + + > .overtime { + color: $general-late-color; + } + + > img.freeze-icon { + width: 0.9em; + height: 0.9em; + margin-left: -0.05em; + margin-top: -0.05em; + } + } + + .director-screen__part__part-countdown { + text-align: right; + + display: flex; + align-items: center; + justify-content: flex-end; + font-size: 13em; + padding: 0 0.2em; + line-height: 1em; + + > span { + font-size: 2em; // Allow a fallback for CasparCG + font-size: #{'min(2em, 20vw)'}; + } + } + + .director-screen__part__piece-countdown, + .director-screen__part__part-countdown { + grid-row: 3; + grid-column: 2; + color: $general-countdown-to-next-color; + } + + &.director-screen__part--next-part { + .director-screen__part__piece-icon, + .director-screen__part__piece-name { + grid-row: 2 / -1; + } + } + } + + .director-screen__rundown-status-bar { + display: grid; + grid-template-columns: auto fit-content(5em); + grid-template-rows: fit-content(1em); + font-size: 6em; + color: #888; + padding: 0 0.2em; + + .director-screen__rundown-status-bar__rundown-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + line-height: 1.44em; + } + + .director-screen__rundown-status-bar__countdown { + white-space: nowrap; + + color: $general-countdown-to-next-color; + + font-weight: 600; + font-size: 1.2em; + + &.over { + color: $general-late-color; + } + } + } + + .director-screen__part + .director-screen__part { + border-top: solid 0.8em #454545; + } + + .clocks-segment-countdown-red { + color: $general-late-color; + } + + .clocks-counter-heavy { + font-weight: 600; + } + + .dashboard { + .timing { + margin: 0 0; + min-width: auto; + width: 100%; + text-align: center; + + .timing-clock { + position: relative; + margin-right: 1em; + font-weight: 100; + color: $general-clock; + font-size: 1.5em; + margin-top: 0.8em; + word-break: keep-all; + white-space: nowrap; + + &.visual-last-child { + margin-right: 0; + } + + &.countdown { + font-weight: 400; + } + + &.playback-started { + display: inline-block; + width: 25%; + } + + &.left { + text-align: left; + } + + &.time-now { + position: absolute; + top: 0.05em; + left: 50%; + transform: translateX(-50%); + margin-top: 0px; + margin-right: 0; + font-size: 2.3em; + font-weight: 100; + text-align: center; + } + + &.current-remaining { + position: absolute; + left: calc(50% + 3.5em); + text-align: left; + color: $liveline-timecode-color; + font-weight: 500; + + .overtime { + color: $general-fast-color; + text-shadow: 0px 0px 6px $general-fast-color--shadow; + } + } + + .timing-clock-label { + position: absolute; + top: -1em; + color: #b8b8b8; + text-transform: uppercase; + white-space: nowrap; + font-weight: 300; + font-size: 0.5em; + + &.left { + left: 0; + right: auto; + text-align: left; + } + + &.right { + right: 0; + left: auto; + text-align: right; + } + + &.hide-overflow { + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + } + + &.rundown-name { + width: auto; + max-width: calc(40vw - 138px); + min-width: 100%; + + > strong { + margin-right: 0.4em; + } + + > svg.icon.looping { + width: 1.4em; + height: 1.4em; + } + } + } + + &.heavy-light { + font-weight: 600; + + &.heavy { + // color: $general-late-color; + color: #ffe900; + background: none; + } + + &.light { + color: $general-fast-color; + text-shadow: 0px 0px 6px $general-fast-color--shadow; + background: none; + } + } + } + + .rundown__header-status { + position: absolute; + font-size: 0.7rem; + text-transform: uppercase; + background: #fff; + border-radius: 1rem; + line-height: 1em; + font-weight: 700; + color: #000; + top: 2.4em; + left: 0; + padding: 2px 5px 1px; + + &.rundown__header-status--hold { + background: $hold-status-color; + } + } + + .timing-clock-header-label { + font-weight: 100px; + } + } + } +} diff --git a/packages/webui/src/client/styles/main.scss b/packages/webui/src/client/styles/main.scss index 95b6db31a2..aeab815a73 100644 --- a/packages/webui/src/client/styles/main.scss +++ b/packages/webui/src/client/styles/main.scss @@ -46,6 +46,7 @@ input { @import 'countdown/overlay'; @import 'countdown/presenter'; +@import 'countdown/director'; @import 'customizations/nrk/shelf/taPanel'; diff --git a/packages/webui/src/client/ui/ClockView/ClockView.tsx b/packages/webui/src/client/ui/ClockView/ClockView.tsx index 8b80da1cef..9de31f1216 100644 --- a/packages/webui/src/client/ui/ClockView/ClockView.tsx +++ b/packages/webui/src/client/ui/ClockView/ClockView.tsx @@ -5,6 +5,7 @@ import { RundownTimingProvider } from '../RundownView/RundownTiming/RundownTimin import { StudioScreenSaver } from '../StudioScreenSaver/StudioScreenSaver' import { PresenterScreen } from './PresenterScreen' +import { DirectorScreen } from './DirectorScreen' import { OverlayScreen } from './OverlayScreen' import { OverlayScreenSaver } from './OverlayScreenSaver' import { RundownPlaylists } from '../../collections' @@ -35,6 +36,15 @@ export function ClockView({ studioId }: Readonly<{ studioId: StudioId }>): JSX.E )} + + {playlist ? ( + + + + ) : ( + + )} + {playlist ? ( diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx new file mode 100644 index 0000000000..1dc9063d47 --- /dev/null +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -0,0 +1,473 @@ +import ClassNames from 'classnames' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { PartUi } from '../SegmentTimeline/SegmentTimelineContainer' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { withTiming, WithTiming } from '../RundownView/RundownTiming/withTiming' +import { useSubscription, useSubscriptions, useTracker, withTracker } from '../../lib/ReactMeteorData/ReactMeteorData' +import { getCurrentTime } from '../../lib/systemTime' +import { PartInstance } from '@sofie-automation/meteor-lib/dist/collections/PartInstances' +import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' +import { PieceIconContainer } from '../PieceIcons/PieceIcon' +import { PieceNameContainer } from '../PieceIcons/PieceName' +import { Timediff } from './Timediff' +import { RundownUtils } from '../../lib/rundown' +import { CountdownType, PieceLifespan } from '@sofie-automation/blueprints-integration' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PieceCountdownContainer } from '../PieceIcons/PieceCountdown' +import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { + RundownId, + RundownPlaylistId, + ShowStyleBaseId, + ShowStyleVariantId, + StudioId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' +import { calculatePartInstanceExpectedDurationWithTransition } from '@sofie-automation/corelib/dist/playout/timings' +import { getPlaylistTimingDiff } from '../../lib/rundownTiming' +import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyles' +import { UIShowStyleBases, UIStudios } from '../Collections' +import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' +import { PieceInstances, RundownPlaylists, Rundowns, ShowStyleVariants } from '../../collections' +import { RundownPlaylistCollectionUtil } from '../../collections/rundownPlaylistUtil' +import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { useSetDocumentClass } from '../util/useSetDocumentClass' +import { useRundownAndShowStyleIdsForPlaylist } from '../util/useRundownAndShowStyleIdsForPlaylist' +import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' +import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining' + +interface SegmentUi extends DBSegment { + items: Array +} + +interface TimeMap { + [key: string]: number +} + +interface DirectorScreenProps { + studioId: StudioId + playlistId: RundownPlaylistId + segmentLiveDurations?: TimeMap +} +export interface DirectorScreenTrackedProps { + studio: UIStudio | undefined + playlist: DBRundownPlaylist | undefined + rundowns: Rundown[] + segments: Array + currentSegment: SegmentUi | undefined + currentPartInstance: PartUi | undefined + nextSegment: SegmentUi | undefined + nextPartInstance: PartUi | undefined + currentShowStyleBaseId: ShowStyleBaseId | undefined + currentShowStyleBase: UIShowStyleBase | undefined + currentShowStyleVariantId: ShowStyleVariantId | undefined + currentShowStyleVariant: DBShowStyleVariant | undefined + nextShowStyleBaseId: ShowStyleBaseId | undefined + showStyleBaseIds: ShowStyleBaseId[] + rundownIds: RundownId[] +} + +function getShowStyleBaseIdSegmentPartUi( + partInstance: PartInstance, + playlist: DBRundownPlaylist, + orderedSegmentsAndParts: { + segments: DBSegment[] + parts: DBPart[] + }, + rundownsToShowstyles: Map, + currentPartInstance: PartInstance | undefined, + nextPartInstance: PartInstance | undefined +): { + showStyleBaseId: ShowStyleBaseId | undefined + showStyleBase: UIShowStyleBase | undefined + showStyleVariantId: ShowStyleVariantId | undefined + showStyleVariant: DBShowStyleVariant | undefined + segment: SegmentUi | undefined + partInstance: PartUi | undefined +} { + let showStyleBaseId: ShowStyleBaseId | undefined = undefined + let showStyleBase: UIShowStyleBase | undefined = undefined + let showStyleVariantId: ShowStyleVariantId | undefined = undefined + let showStyleVariant: DBShowStyleVariant | undefined = undefined + let segment: SegmentUi | undefined = undefined + let partInstanceUi: PartUi | undefined = undefined + + const currentRundown = Rundowns.findOne(partInstance.rundownId, { + fields: { + _id: 1, + showStyleBaseId: 1, + showStyleVariantId: 1, + name: 1, + timing: 1, + }, + }) + showStyleBaseId = currentRundown?.showStyleBaseId + showStyleVariantId = currentRundown?.showStyleVariantId + + const segmentIndex = orderedSegmentsAndParts.segments.findIndex((s) => s._id === partInstance.segmentId) + if (currentRundown && segmentIndex >= 0) { + const rundownOrder = RundownPlaylistCollectionUtil.getRundownOrderedIDs(playlist) + const rundownIndex = rundownOrder.indexOf(partInstance.rundownId) + showStyleBase = UIShowStyleBases.findOne(showStyleBaseId) + showStyleVariant = ShowStyleVariants.findOne(showStyleVariantId) + + if (showStyleBase) { + // This registers a reactive dependency on infinites-capping pieces, so that the segment can be + // re-evaluated when a piece like that appears. + + const o = RundownUtils.getResolvedSegment( + showStyleBase, + playlist, + currentRundown, + orderedSegmentsAndParts.segments[segmentIndex], + new Set(orderedSegmentsAndParts.segments.map((s) => s._id).slice(0, segmentIndex)), + rundownOrder.slice(0, rundownIndex), + rundownsToShowstyles, + orderedSegmentsAndParts.parts.map((part) => part._id), + currentPartInstance, + nextPartInstance, + true, + true + ) + + segment = { + ...o.segmentExtended, + items: o.parts, + } + + partInstanceUi = o.parts.find((part) => part.instance._id === partInstance._id) + } + } + + return { + showStyleBaseId: showStyleBaseId, + showStyleBase, + showStyleVariantId, + showStyleVariant, + segment: segment, + partInstance: partInstanceUi, + } +} + +export const getDirectorScreenReactive = (props: DirectorScreenProps): DirectorScreenTrackedProps => { + const studio = UIStudios.findOne(props.studioId) + + let playlist: DBRundownPlaylist | undefined + + if (props.playlistId) + playlist = RundownPlaylists.findOne(props.playlistId, { + fields: { + lastIncorrectPartPlaybackReported: 0, + modified: 0, + previousPersistentState: 0, + rundownRanksAreSetInSofie: 0, + trackedAbSessions: 0, + restoredFromSnapshotId: 0, + }, + }) + const segments: Array = [] + let showStyleBaseIds: ShowStyleBaseId[] = [] + let rundowns: Rundown[] = [] + let rundownIds: RundownId[] = [] + + let currentSegment: SegmentUi | undefined = undefined + let currentPartInstanceUi: PartUi | undefined = undefined + let currentShowStyleBaseId: ShowStyleBaseId | undefined = undefined + let currentShowStyleBase: UIShowStyleBase | undefined = undefined + let currentShowStyleVariantId: ShowStyleVariantId | undefined = undefined + let currentShowStyleVariant: DBShowStyleVariant | undefined = undefined + + let nextSegment: SegmentUi | undefined = undefined + let nextPartInstanceUi: PartUi | undefined = undefined + let nextShowStyleBaseId: ShowStyleBaseId | undefined = undefined + + if (playlist) { + rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) + const orderedSegmentsAndParts = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) + rundownIds = rundowns.map((rundown) => rundown._id) + const rundownsToShowstyles: Map = new Map() + for (const rundown of rundowns) { + rundownsToShowstyles.set(rundown._id, rundown.showStyleBaseId) + } + showStyleBaseIds = rundowns.map((rundown) => rundown.showStyleBaseId) + const { currentPartInstance, nextPartInstance } = RundownPlaylistClientUtil.getSelectedPartInstances(playlist) + const partInstance = currentPartInstance ?? nextPartInstance + if (partInstance) { + // This is to register a reactive dependency on Rundown-spanning PieceInstances, that we may miss otherwise. + PieceInstances.find({ + rundownId: { + $in: rundownIds, + }, + dynamicallyInserted: { + $exists: true, + }, + 'infinite.fromPreviousPart': false, + 'piece.lifespan': { + $in: [PieceLifespan.OutOnRundownEnd, PieceLifespan.OutOnRundownChange, PieceLifespan.OutOnShowStyleEnd], + }, + reset: { + $ne: true, + }, + }).fetch() + + if (currentPartInstance) { + const current = getShowStyleBaseIdSegmentPartUi( + currentPartInstance, + playlist, + orderedSegmentsAndParts, + rundownsToShowstyles, + currentPartInstance, + nextPartInstance + ) + currentSegment = current.segment + currentPartInstanceUi = current.partInstance + currentShowStyleBaseId = current.showStyleBaseId + currentShowStyleBase = current.showStyleBase + currentShowStyleVariantId = current.showStyleVariantId + currentShowStyleVariant = current.showStyleVariant + } + + if (nextPartInstance) { + const next = getShowStyleBaseIdSegmentPartUi( + nextPartInstance, + playlist, + orderedSegmentsAndParts, + rundownsToShowstyles, + currentPartInstance, + nextPartInstance + ) + nextSegment = next.segment + nextPartInstanceUi = next.partInstance + nextShowStyleBaseId = next.showStyleBaseId + } + } + } + return { + studio, + segments, + playlist, + rundowns, + showStyleBaseIds, + rundownIds, + currentSegment, + currentPartInstance: currentPartInstanceUi, + currentShowStyleBaseId, + currentShowStyleBase, + currentShowStyleVariantId, + currentShowStyleVariant, + nextSegment, + nextPartInstance: nextPartInstanceUi, + nextShowStyleBaseId, + } +} + +export function useDirectorScreenSubscriptions(props: DirectorScreenProps): void { + useSubscription(MeteorPubSub.uiStudio, props.studioId) + + const playlist = useTracker( + () => + RundownPlaylists.findOne(props.playlistId, { + fields: { + _id: 1, + activationId: 1, + }, + }) as Pick | undefined, + [props.playlistId] + ) + + useSubscription(CorelibPubSub.rundownsInPlaylists, playlist ? [playlist._id] : []) + + const { rundownIds, showStyleBaseIds, showStyleVariantIds } = useRundownAndShowStyleIdsForPlaylist(playlist?._id) + + useSubscription(CorelibPubSub.segments, rundownIds, {}) + useSubscription(CorelibPubSub.parts, rundownIds, null) + useSubscription(MeteorPubSub.uiPartInstances, playlist?.activationId ?? null) + useSubscriptions( + MeteorPubSub.uiShowStyleBase, + showStyleBaseIds.map((id) => [id]) + ) + useSubscription(CorelibPubSub.showStyleVariants, null, showStyleVariantIds) + useSubscription(MeteorPubSub.rundownLayouts, showStyleBaseIds) + + const { currentPartInstance, nextPartInstance } = useTracker( + () => { + const playlist = RundownPlaylists.findOne(props.playlistId, { + fields: { + _id: 1, + currentPartInfo: 1, + nextPartInfo: 1, + previousPartInfo: 1, + }, + }) as Pick | undefined + + if (playlist) { + return RundownPlaylistClientUtil.getSelectedPartInstances(playlist) + } else { + return { currentPartInstance: undefined, nextPartInstance: undefined, previousPartInstance: undefined } + } + }, + [props.playlistId], + { currentPartInstance: undefined, nextPartInstance: undefined, previousPartInstance: undefined } + ) + + useSubscriptions(CorelibPubSub.pieceInstances, [ + currentPartInstance && [[currentPartInstance.rundownId], [currentPartInstance._id], {}], + nextPartInstance && [[nextPartInstance.rundownId], [nextPartInstance._id], {}], + ]) +} + +function DirectorScreenWithSubscription( + props: WithTiming +): JSX.Element { + useDirectorScreenSubscriptions(props) + + return +} + +function DirectorScreenRender({ + playlist, + segments, + currentShowStyleBaseId, + nextShowStyleBaseId, + playlistId, + currentPartInstance, + currentSegment, + timingDurations, + nextPartInstance, + nextSegment, + rundownIds, +}: Readonly>) { + useSetDocumentClass('dark', 'xdark') + + if (playlist && playlistId && segments) { + const currentPartOrSegmentCountdown = + timingDurations.remainingBudgetOnCurrentSegment ?? timingDurations.remainingTimeOnCurrentPart ?? 0 + + const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) + const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) ?? 0 + + return ( +
+
+
+ {currentSegment?.name} +
+ {currentPartInstance && currentShowStyleBaseId ? ( + <> +
+ +
+
+ +
+
+ {currentSegment?.segmentTiming?.countdownType === CountdownType.SEGMENT_BUDGET_DURATION ? ( + + ) : ( + + )} +
+
+ +
+ + ) : expectedStart ? ( +
+ +
+ ) : null} +
+
+
+ {nextSegment?._id !== currentSegment?._id ? nextSegment?.name : undefined} +
+ {nextPartInstance && nextShowStyleBaseId ? ( + <> +
+ +
+
+ {currentPartInstance && currentPartInstance.instance.part.autoNext ? ( + Autonext + ) : null} + {nextPartInstance && nextShowStyleBaseId && nextPartInstance.instance.part.title ? ( + + ) : ( + '_' + )} +
+ + ) : null} +
+
+
+ {playlist ? playlist.name : 'UNKNOWN'} +
+
= 0, + })} + > + {RundownUtils.formatDiffToTimecode(overUnderClock, true, false, true, true, true, undefined, true, true)} +
+
+
+ ) + } + return null +} + +/** + * This component renders the Director screen for a given playlist + */ +export const DirectorScreen = withTracker( + getDirectorScreenReactive +)(withTiming()(DirectorScreenWithSubscription)) From c7d87a7b463a4dbb546e967f87620badedfd0046 Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 10 Feb 2025 10:29:22 +0100 Subject: [PATCH 021/293] feat: PieceGeneric type - optional nameShort and nameTruncated --- .../blueprints-integration/src/documents/pieceGeneric.ts | 7 +++++++ packages/job-worker/src/blueprints/context/lib.ts | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/packages/blueprints-integration/src/documents/pieceGeneric.ts b/packages/blueprints-integration/src/documents/pieceGeneric.ts index 32161577c3..1394490f9f 100644 --- a/packages/blueprints-integration/src/documents/pieceGeneric.ts +++ b/packages/blueprints-integration/src/documents/pieceGeneric.ts @@ -34,6 +34,13 @@ export interface IBlueprintPieceGeneric pieceType: true, extendOnHold: true, name: true, + nameShort: true, + nameTruncated: true, privateData: true, publicData: true, sourceLayerId: true, @@ -201,6 +203,8 @@ function convertPieceGenericToBlueprintsInner(piece: ReadonlyDeep) const obj: Complete = { externalId: piece.externalId, name: piece.name, + nameShort: piece.nameShort, + nameTruncated: piece.nameTruncated, privateData: clone(piece.privateData), publicData: clone(piece.publicData), lifespan: piece.lifespan, From 0bad4dde8f2b98c4f4bb741307e23ea6636a7572 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 11 Feb 2025 11:24:36 +0100 Subject: [PATCH 022/293] feat: mini shelfview --- .../ui/SegmentList/SegmentListContainer.tsx | 55 ++++++++----- .../SegmentStoryboardContainer.tsx | 77 +++++++++++-------- 2 files changed, 81 insertions(+), 51 deletions(-) diff --git a/packages/webui/src/client/ui/SegmentList/SegmentListContainer.tsx b/packages/webui/src/client/ui/SegmentList/SegmentListContainer.tsx index d019f3da7a..2d98ba5fcc 100644 --- a/packages/webui/src/client/ui/SegmentList/SegmentListContainer.tsx +++ b/packages/webui/src/client/ui/SegmentList/SegmentListContainer.tsx @@ -13,6 +13,7 @@ import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../Segm import { Segments } from '../../collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { UIPartInstances, UIParts } from '../Collections' +import { RundownViewShelf } from '../RundownView/RundownViewShelf' export const LIVELINE_HISTORY_SIZE = TIMELINE_LIVELINE_HISTORY_SIZE @@ -189,25 +190,39 @@ export const SegmentListContainer = withResolvedSegment(function Segment // }, [subscriptionsReady, firstNonInvalidPart?.pieces.length]) return ( - + <> + + {props.segmentui.showShelf && props.adLibSegmentUi && ( + + )} + ) }) diff --git a/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx index 3acd103493..0291602f5b 100644 --- a/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx @@ -17,6 +17,7 @@ import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' import { PartInstance } from '@sofie-automation/meteor-lib/dist/collections/PartInstances' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { UIPartInstances, UIParts } from '../Collections' +import { RundownViewShelf } from '../RundownView/RundownViewShelf' export const LIVELINE_HISTORY_SIZE = TIMELINE_LIVELINE_HISTORY_SIZE @@ -201,36 +202,50 @@ export const SegmentStoryboardContainer = withResolvedSegment(function S } return ( - + <> + + {props.segmentui.showShelf && props.adLibSegmentUi && ( + + )} + ) }) From eae0cb1c4efe7f6e4bfc28299dca5bfa26c3927c Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 13 Feb 2025 11:52:11 +0100 Subject: [PATCH 023/293] refactor(LSG): improve access modifier consistency --- .../src/collections/adLibActionsHandler.ts | 2 +- .../src/collections/adLibsHandler.ts | 4 ++-- .../src/collections/globalAdLibActionsHandler.ts | 4 ++-- .../src/collections/globalAdLibsHandler.ts | 4 ++-- .../src/collections/partHandler.ts | 2 +- .../src/collections/partInstancesHandler.ts | 2 +- .../src/collections/pieceInstancesHandler.ts | 2 +- .../src/collections/playlistHandler.ts | 4 ++-- .../src/collections/rundownHandler.ts | 4 ++-- .../src/collections/segmentHandler.ts | 2 +- .../src/collections/showStyleBaseHandler.ts | 2 +- .../src/collections/studioHandler.ts | 2 +- .../src/topics/activePiecesTopic.ts | 6 +++--- .../live-status-gateway/src/topics/adLibsTopic.ts | 12 ++++++------ .../live-status-gateway/src/topics/segmentsTopic.ts | 4 ++-- packages/live-status-gateway/src/wsHandler.ts | 4 ++-- 16 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/live-status-gateway/src/collections/adLibActionsHandler.ts b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts index 8411660e64..c0761f1f00 100644 --- a/packages/live-status-gateway/src/collections/adLibActionsHandler.ts +++ b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts @@ -48,7 +48,7 @@ export class AdLibActionsHandler } } - protected updateAndNotify(): void { + private updateAndNotify(): void { const col = this.getCollectionOrFail() this._collectionData = col.find({ rundownId: this._currentRundownId }) this.notify(this._collectionData) diff --git a/packages/live-status-gateway/src/collections/adLibsHandler.ts b/packages/live-status-gateway/src/collections/adLibsHandler.ts index 15ae8ce348..6df4730e67 100644 --- a/packages/live-status-gateway/src/collections/adLibsHandler.ts +++ b/packages/live-status-gateway/src/collections/adLibsHandler.ts @@ -27,7 +27,7 @@ export class AdLibsHandler handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) } - changed(): void { + protected changed(): void { this.updateAndNotify() } @@ -47,7 +47,7 @@ export class AdLibsHandler } } - protected updateAndNotify(): void { + private updateAndNotify(): void { const collection = this.getCollectionOrFail() this._collectionData = collection.find({ rundownId: this._currentRundownId }) this.notify(this._collectionData) diff --git a/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts index 349ec2cbca..5e8b7f426a 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts @@ -36,7 +36,7 @@ export class GlobalAdLibActionsHandler handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) } - changed(): void { + protected changed(): void { this.updateAndNotify() } @@ -55,7 +55,7 @@ export class GlobalAdLibActionsHandler } } - protected updateAndNotify(): void { + private updateAndNotify(): void { const collection = this.getCollectionOrFail() this._collectionData = collection.find({ rundownId: this._currentRundownId }) this.notify(this._collectionData) diff --git a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts index fd449dd25e..62eddddebf 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts @@ -31,7 +31,7 @@ export class GlobalAdLibsHandler handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) } - changed(): void { + protected changed(): void { this.updateAndNotify() } @@ -50,7 +50,7 @@ export class GlobalAdLibsHandler } } - protected updateAndNotify(): void { + private updateAndNotify(): void { const collection = this.getCollectionOrFail() this._collectionData = collection.find({ rundownId: this._currentRundownId }) this.notify(this._collectionData) diff --git a/packages/live-status-gateway/src/collections/partHandler.ts b/packages/live-status-gateway/src/collections/partHandler.ts index 0af5bb9ee7..b0d1fc9206 100644 --- a/packages/live-status-gateway/src/collections/partHandler.ts +++ b/packages/live-status-gateway/src/collections/partHandler.ts @@ -34,7 +34,7 @@ export class PartHandler handlers.partInstancesHandler.subscribe(this.onPartInstanceUpdate, PART_INSTANCES_KEYS) } - changed(): void { + protected changed(): void { const collection = this.getCollectionOrFail() const allParts = collection.find(undefined) this._partsHandler.setParts(allParts) diff --git a/packages/live-status-gateway/src/collections/partInstancesHandler.ts b/packages/live-status-gateway/src/collections/partInstancesHandler.ts index 0597800745..2435d3c27a 100644 --- a/packages/live-status-gateway/src/collections/partInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/partInstancesHandler.ts @@ -59,7 +59,7 @@ export class PartInstancesHandler handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) } - changed(): void { + protected changed(): void { this._throttledUpdateAndNotify() } diff --git a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts index 30fdf1f280..b22125ef01 100644 --- a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts @@ -76,7 +76,7 @@ export class PieceInstancesHandler handlers.showStyleBaseHandler.subscribe(this.onShowStyleBaseUpdate, SHOW_STYLE_BASE_KEYS) } - changed(): void { + protected changed(): void { this.updateAndNotify() } diff --git a/packages/live-status-gateway/src/collections/playlistHandler.ts b/packages/live-status-gateway/src/collections/playlistHandler.ts index 4251cc874f..00df7c9ed7 100644 --- a/packages/live-status-gateway/src/collections/playlistHandler.ts +++ b/packages/live-status-gateway/src/collections/playlistHandler.ts @@ -37,11 +37,11 @@ export class PlaylistHandler this.setupSubscription(null, [this._studioId]) } - changed(): void { + protected changed(): void { this.updateAndNotify() } - protected updateAndNotify(): void { + private updateAndNotify(): void { const collection = this.getCollectionOrFail() const playlists = collection.find(undefined) this._playlistsHandler.setPlaylists(playlists) diff --git a/packages/live-status-gateway/src/collections/rundownHandler.ts b/packages/live-status-gateway/src/collections/rundownHandler.ts index a29bd994e9..36b6bfbd92 100644 --- a/packages/live-status-gateway/src/collections/rundownHandler.ts +++ b/packages/live-status-gateway/src/collections/rundownHandler.ts @@ -30,11 +30,11 @@ export class RundownHandler handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) } - changed(): void { + protected changed(): void { this.updateAndNotify() } - protected updateAndNotify(): void { + private updateAndNotify(): void { const collection = this.getCollectionOrFail() this._rundownsHandler?.setRundowns(collection.find(undefined)) if (this._currentRundownId) { diff --git a/packages/live-status-gateway/src/collections/segmentHandler.ts b/packages/live-status-gateway/src/collections/segmentHandler.ts index 773c6126a2..2b7ccff992 100644 --- a/packages/live-status-gateway/src/collections/segmentHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentHandler.ts @@ -35,7 +35,7 @@ export class SegmentHandler handlers.partInstancesHandler.subscribe(this.onPartInstancesUpdate, PART_INSTANCES_KEYS) } - changed(): void { + protected changed(): void { this.updateAndNotify() } diff --git a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts index 81e4e8051b..b2b4d32f96 100644 --- a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts +++ b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts @@ -34,7 +34,7 @@ export class ShowStyleBaseHandler handlers.rundownHandler.subscribe(this.onRundownUpdate) } - changed(): void { + protected changed(): void { if (this._showStyleBaseId) { this.updateCollectionData() this.notify(this._collectionData) diff --git a/packages/live-status-gateway/src/collections/studioHandler.ts b/packages/live-status-gateway/src/collections/studioHandler.ts index c709ce2e76..d60a41ca62 100644 --- a/packages/live-status-gateway/src/collections/studioHandler.ts +++ b/packages/live-status-gateway/src/collections/studioHandler.ts @@ -20,7 +20,7 @@ export class StudioHandler this.setupSubscription([this._studioId]) } - changed(): void { + protected changed(): void { const collection = this.getCollectionOrFail() const studio = collection.findOne(this._studioId) this._collectionData = studio diff --git a/packages/live-status-gateway/src/topics/activePiecesTopic.ts b/packages/live-status-gateway/src/topics/activePiecesTopic.ts index abe735ecc1..765dc1449a 100644 --- a/packages/live-status-gateway/src/topics/activePiecesTopic.ts +++ b/packages/live-status-gateway/src/topics/activePiecesTopic.ts @@ -54,13 +54,13 @@ export class ActivePiecesTopic extends WebSocketTopicBase implements WebSocketTo this.sendMessage(subscribers, message) } - protected onShowStyleBaseUpdate = (showStyleBase: ShowStyleBaseExt | undefined): void => { + private onShowStyleBaseUpdate = (showStyleBase: ShowStyleBaseExt | undefined): void => { this.logUpdateReceived('showStyleBase') this._showStyleBaseExt = showStyleBase this.throttledSendStatusToAll() } - protected onPlaylistUpdate = (rundownPlaylist: Playlist | undefined): void => { + private onPlaylistUpdate = (rundownPlaylist: Playlist | undefined): void => { this.logUpdateReceived( 'playlist', `rundownPlaylistId ${rundownPlaylist?._id}, activationId ${rundownPlaylist?.activationId}` @@ -73,7 +73,7 @@ export class ActivePiecesTopic extends WebSocketTopicBase implements WebSocketTo } } - protected onPieceInstancesUpdate = (pieceInstances: PieceInstances | undefined): void => { + private onPieceInstancesUpdate = (pieceInstances: PieceInstances | undefined): void => { this.logUpdateReceived('pieceInstances') const prevPieceInstances = this._activePieceInstances this._activePieceInstances = pieceInstances?.active diff --git a/packages/live-status-gateway/src/topics/adLibsTopic.ts b/packages/live-status-gateway/src/topics/adLibsTopic.ts index cfcd40644d..4e7606b36e 100644 --- a/packages/live-status-gateway/src/topics/adLibsTopic.ts +++ b/packages/live-status-gateway/src/topics/adLibsTopic.ts @@ -245,31 +245,31 @@ export class AdLibsTopic extends WebSocketTopicBase implements WebSocketTopic { this.throttledSendStatusToAll() } - protected onAdLibActionsUpdate = (adLibActions: AdLibAction[] | undefined): void => { + private onAdLibActionsUpdate = (adLibActions: AdLibAction[] | undefined): void => { this.logUpdateReceived('adLibActions') this._adLibActions = adLibActions this.throttledSendStatusToAll() } - protected onAdLibsUpdate = (adLibs: AdLibPiece[] | undefined): void => { + private onAdLibsUpdate = (adLibs: AdLibPiece[] | undefined): void => { this.logUpdateReceived('adLibs') this._adLibs = adLibs this.throttledSendStatusToAll() } - protected onGlobalAdLibActionsUpdate = (adLibActions: RundownBaselineAdLibAction[] | undefined): void => { + private onGlobalAdLibActionsUpdate = (adLibActions: RundownBaselineAdLibAction[] | undefined): void => { this.logUpdateReceived('globalAdLibActions') this._globalAdLibActions = adLibActions this.throttledSendStatusToAll() } - protected onGlobalAdLibsUpdate = (adLibs: RundownBaselineAdLibItem[] | undefined): void => { + private onGlobalAdLibsUpdate = (adLibs: RundownBaselineAdLibItem[] | undefined): void => { this.logUpdateReceived('globalAdLibs') this._globalAdLibs = adLibs this.throttledSendStatusToAll() } - protected onSegmentsUpdate = (segments: DBSegment[] | undefined): void => { + private onSegmentsUpdate = (segments: DBSegment[] | undefined): void => { this.logUpdateReceived('segments') const newSegments = new Map() segments ??= [] @@ -280,7 +280,7 @@ export class AdLibsTopic extends WebSocketTopicBase implements WebSocketTopic { this.throttledSendStatusToAll() } - protected onPartsUpdate = (parts: DBPart[] | undefined): void => { + private onPartsUpdate = (parts: DBPart[] | undefined): void => { this.logUpdateReceived('parts') const newParts = new Map() parts ??= [] diff --git a/packages/live-status-gateway/src/topics/segmentsTopic.ts b/packages/live-status-gateway/src/topics/segmentsTopic.ts index 66a671a866..413cd57a44 100644 --- a/packages/live-status-gateway/src/topics/segmentsTopic.ts +++ b/packages/live-status-gateway/src/topics/segmentsTopic.ts @@ -73,13 +73,13 @@ export class SegmentsTopic extends WebSocketTopicBase implements WebSocketTopic this.updateAndSendStatusToAll() } - protected onSegmentsUpdate = (segments: DBSegment[] | undefined): void => { + private onSegmentsUpdate = (segments: DBSegment[] | undefined): void => { this.logUpdateReceived('segments') this._segments = segments ?? [] this.updateAndSendStatusToAll() } - protected onPartsUpdate = (parts: DBPart[] | undefined): void => { + private onPartsUpdate = (parts: DBPart[] | undefined): void => { this.logUpdateReceived('parts') this._partsBySegment = _.groupBy(parts ?? [], 'segmentId') this.updateAndSendStatusToAll() diff --git a/packages/live-status-gateway/src/wsHandler.ts b/packages/live-status-gateway/src/wsHandler.ts index b3dcb54954..7f0c7b66df 100644 --- a/packages/live-status-gateway/src/wsHandler.ts +++ b/packages/live-status-gateway/src/wsHandler.ts @@ -253,7 +253,7 @@ export abstract class PublicationCollection< // override me } - protected onDocumentEvent(id: ProtectedString | string, changeType: string): void { + private onDocumentEvent(id: ProtectedString | string, changeType: string): void { this.logDocumentChange(id, changeType) if (!this._subscriptionId) { this._logger.silly(`${this._name} ${changeType} ${id} skipping (lack of subscription)`) @@ -266,7 +266,7 @@ export abstract class PublicationCollection< this.throttledChanged() } - protected setupObserver(): void { + private setupObserver(): void { this._dbObserver = this._coreHandler.setupObserver(this._collectionName) this._dbObserver.added = (id) => { this.onDocumentEvent(id, 'added') From ff6516bad6566e0d9521699e7d423722845d47f0 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 13 Feb 2025 13:30:32 +0100 Subject: [PATCH 024/293] refactor(LSG): move classes and types around --- .../live-status-gateway/src/collectionBase.ts | 127 ++++++++++ .../src/collections/adLibActionsHandler.ts | 6 +- .../src/collections/adLibsHandler.ts | 6 +- .../collections/globalAdLibActionsHandler.ts | 6 +- .../src/collections/globalAdLibsHandler.ts | 6 +- .../src/collections/partHandler.ts | 8 +- .../src/collections/partInstancesHandler.ts | 6 +- .../src/collections/partsHandler.ts | 3 +- .../src/collections/pieceInstancesHandler.ts | 10 +- .../src/collections/playlistHandler.ts | 4 +- .../src/collections/rundownHandler.ts | 6 +- .../src/collections/rundownsHandler.ts | 3 +- .../src/collections/segmentHandler.ts | 8 +- .../src/collections/segmentsHandler.ts | 3 +- .../src/collections/showStyleBaseHandler.ts | 3 +- .../src/collections/studioHandler.ts | 3 +- .../src/publicationCollection.ts | 106 +++++++++ .../src/topics/activePiecesTopic.ts | 7 +- .../src/topics/activePlaylistTopic.ts | 11 +- .../src/topics/adLibsTopic.ts | 7 +- .../src/topics/segmentsTopic.ts | 5 +- packages/live-status-gateway/src/wsHandler.ts | 222 ------------------ packages/shared-lib/src/lib/types.ts | 29 +++ 23 files changed, 332 insertions(+), 263 deletions(-) create mode 100644 packages/live-status-gateway/src/collectionBase.ts create mode 100644 packages/live-status-gateway/src/publicationCollection.ts diff --git a/packages/live-status-gateway/src/collectionBase.ts b/packages/live-status-gateway/src/collectionBase.ts new file mode 100644 index 0000000000..160d543477 --- /dev/null +++ b/packages/live-status-gateway/src/collectionBase.ts @@ -0,0 +1,127 @@ +import { CorelibPubSubCollections, CorelibPubSubTypes } from '@sofie-automation/corelib/dist/pubsub' +import { + StudioId, + CoreConnection, + ProtectedString, + Collection as CoreCollection, + CollectionDocCheck, +} from '@sofie-automation/server-core-integration' +import throttleToNextTick from '@sofie-automation/shared-lib/dist/lib/throttleToNextTick' +import * as _ from 'underscore' +import { Logger } from 'winston' +import { CoreHandler } from './coreHandler' +import { arePropertiesShallowEqual } from './helpers/equality' +import { CollectionHandlers } from './liveStatusServer' +import { ObserverCallback } from './wsHandler' + +export const DEFAULT_THROTTLE_PERIOD_MS = 20 + +export abstract class CollectionBase { + protected _name: string + protected _collectionName: TCollection + protected _logger: Logger + protected _coreHandler: CoreHandler + protected _studioId!: StudioId + protected _observers: Map< + ObserverCallback, + { keysToPick: readonly (keyof T)[] | undefined; lastData: T | undefined } + > = new Map() + protected _collectionData: T | undefined + + protected get _core(): CoreConnection { + return this._coreHandler.core + } + protected throttledChanged: () => void + + constructor( + collection: TCollection, + logger: Logger, + coreHandler: CoreHandler, + throttlePeriodMs = DEFAULT_THROTTLE_PERIOD_MS + ) { + this._name = this.constructor.name + this._collectionName = collection + this._logger = logger + this._coreHandler = coreHandler + + this.throttledChanged = throttleToNextTick( + throttlePeriodMs > 0 + ? _.throttle(() => this.changed(), throttlePeriodMs, { leading: true, trailing: true }) + : () => this.changed() + ) + + this._logger.info(`Starting ${this._name} handler`) + } + + init(_handlers: CollectionHandlers): void { + if (!this._coreHandler.studioId) throw new Error('StudioId is not defined') + this._studioId = this._coreHandler.studioId + } + + close(): void { + this._logger.info(`Closing ${this._name} handler`) + } + + subscribe(callback: ObserverCallback, keysToPick?: readonly K[]): void { + //this._logger.info(`${name}' added observer for '${this._name}'`) + if (this._collectionData) callback(this._collectionData) + this._observers.set(callback, { keysToPick, lastData: this.shallowClone(this._collectionData) }) + } + + /** + * Called after a batch of updates to documents in the collection + */ + protected changed(): void { + // override me + } + + notify(data: T | undefined): void { + for (const [observer, o] of this._observers) { + if ( + !o.lastData || + !o.keysToPick || + !data || + !arePropertiesShallowEqual(o.lastData, data, undefined, o.keysToPick) + ) { + observer(data) + o.lastData = this.shallowClone(data) + } + } + } + + protected shallowClone(data: T | undefined): T | undefined { + if (data === undefined) return undefined + if (Array.isArray(data)) return [...data] as T + if (typeof data === 'object') return { ...data } + return data + } + + protected logDocumentChange(documentId: string | ProtectedString, changeType: string): void { + this._logger.silly(`${this._name} ${changeType} ${documentId}`) + } + + protected logUpdateReceived(collectionName: string, updateCount: number | undefined): void + protected logUpdateReceived(collectionName: string, extraInfo?: string): void + protected logUpdateReceived( + collectionName: string, + extraInfoOrUpdateCount: string | number | undefined | null = null + ): void { + let message = `${this._name} received ${collectionName} update` + if (typeof extraInfoOrUpdateCount === 'string') { + message += `, ${extraInfoOrUpdateCount}` + } else if (extraInfoOrUpdateCount !== null) { + message += `(${extraInfoOrUpdateCount})` + } + this._logger.debug(message) + } + + protected logNotifyingUpdate(updateCount: number | undefined): void { + this._logger.debug(`${this._name} notifying update with ${updateCount} ${this._collectionName}`) + } + + protected getCollectionOrFail(): CoreCollection> { + const collection = this._core.getCollection(this._collectionName) + if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) + return collection + } +} diff --git a/packages/live-status-gateway/src/collections/adLibActionsHandler.ts b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts index c0761f1f00..9680608d91 100644 --- a/packages/live-status-gateway/src/collections/adLibActionsHandler.ts +++ b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts @@ -1,15 +1,17 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const -type Playlist = PickArr +type Playlist = PickKeys export class AdLibActionsHandler extends PublicationCollection diff --git a/packages/live-status-gateway/src/collections/adLibsHandler.ts b/packages/live-status-gateway/src/collections/adLibsHandler.ts index 6df4730e67..caed2c081c 100644 --- a/packages/live-status-gateway/src/collections/adLibsHandler.ts +++ b/packages/live-status-gateway/src/collections/adLibsHandler.ts @@ -1,15 +1,17 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const -type Playlist = PickArr +type Playlist = PickKeys export class AdLibsHandler extends PublicationCollection diff --git a/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts index 5e8b7f426a..a11d832ce1 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts @@ -1,15 +1,17 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { CollectionHandlers } from '../liveStatusServer' import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const -type Playlist = PickArr +type Playlist = PickKeys export class GlobalAdLibActionsHandler extends PublicationCollection< diff --git a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts index 62eddddebf..4e57e91b42 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts @@ -1,15 +1,17 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const -type Playlist = PickArr +type Playlist = PickKeys export class GlobalAdLibsHandler extends PublicationCollection< diff --git a/packages/live-status-gateway/src/collections/partHandler.ts b/packages/live-status-gateway/src/collections/partHandler.ts index b0d1fc9206..fdef5a0e39 100644 --- a/packages/live-status-gateway/src/collections/partHandler.ts +++ b/packages/live-status-gateway/src/collections/partHandler.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' @@ -9,12 +10,13 @@ import { PartsHandler } from './partsHandler' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const PLAYLIST_KEYS = ['_id', 'rundownIdsInOrder'] as const -type Playlist = PickArr +type Playlist = PickKeys const PART_INSTANCES_KEYS = ['current'] as const -type PartInstances = PickArr +type PartInstances = PickKeys export class PartHandler extends PublicationCollection diff --git a/packages/live-status-gateway/src/collections/partInstancesHandler.ts b/packages/live-status-gateway/src/collections/partInstancesHandler.ts index 2435d3c27a..f6c31da396 100644 --- a/packages/live-status-gateway/src/collections/partInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/partInstancesHandler.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' @@ -10,6 +11,7 @@ import throttleToNextTick from '@sofie-automation/shared-lib/dist/lib/throttleTo import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { RundownId, RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' export interface SelectedPartInstances { previous: DBPartInstance | undefined @@ -27,7 +29,7 @@ const PLAYLIST_KEYS = [ 'nextPartInfo', 'rundownIdsInOrder', ] as const -type Playlist = PickArr +type Playlist = PickKeys export class PartInstancesHandler extends PublicationCollection diff --git a/packages/live-status-gateway/src/collections/partsHandler.ts b/packages/live-status-gateway/src/collections/partsHandler.ts index 1d12956527..be4a9917ff 100644 --- a/packages/live-status-gateway/src/collections/partsHandler.ts +++ b/packages/live-status-gateway/src/collections/partsHandler.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { CollectionBase } from '../collectionBase' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import _ = require('underscore') import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' diff --git a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts index b22125ef01..eaaaa5e539 100644 --- a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' @@ -20,6 +21,7 @@ import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartIns import { arePropertiesDeepEqual } from '../helpers/equality' import { CollectionHandlers } from '../liveStatusServer' import { ReadonlyDeep } from 'type-fest' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const PLAYLIST_KEYS = [ '_id', @@ -29,13 +31,13 @@ const PLAYLIST_KEYS = [ 'previousPartInfo', 'rundownIdsInOrder', ] as const -type Playlist = PickArr +type Playlist = PickKeys const PART_INSTANCES_KEYS = ['previous', 'current'] as const -type PartInstances = PickArr +type PartInstances = PickKeys const SHOW_STYLE_BASE_KEYS = ['sourceLayers'] as const -type ShowStyle = PickArr +type ShowStyle = PickKeys export type PieceInstanceMin = Omit, 'reportedStartedPlayback' | 'reportedStoppedPlayback'> diff --git a/packages/live-status-gateway/src/collections/playlistHandler.ts b/packages/live-status-gateway/src/collections/playlistHandler.ts index 00df7c9ed7..072f9a37a2 100644 --- a/packages/live-status-gateway/src/collections/playlistHandler.ts +++ b/packages/live-status-gateway/src/collections/playlistHandler.ts @@ -1,6 +1,8 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' +import { CollectionBase } from '../collectionBase' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' diff --git a/packages/live-status-gateway/src/collections/rundownHandler.ts b/packages/live-status-gateway/src/collections/rundownHandler.ts index 36b6bfbd92..6d888c0460 100644 --- a/packages/live-status-gateway/src/collections/rundownHandler.ts +++ b/packages/live-status-gateway/src/collections/rundownHandler.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' @@ -9,9 +10,10 @@ import { RundownsHandler } from './rundownsHandler' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { unprotectString } from '@sofie-automation/server-core-integration' import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const PLAYLIST_KEYS = ['_id', 'currentPartInfo', 'nextPartInfo'] as const -type Playlist = PickArr +type Playlist = PickKeys export class RundownHandler extends PublicationCollection diff --git a/packages/live-status-gateway/src/collections/rundownsHandler.ts b/packages/live-status-gateway/src/collections/rundownsHandler.ts index 539c4c87df..21960f6565 100644 --- a/packages/live-status-gateway/src/collections/rundownsHandler.ts +++ b/packages/live-status-gateway/src/collections/rundownsHandler.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { CollectionBase } from '../collectionBase' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' diff --git a/packages/live-status-gateway/src/collections/segmentHandler.ts b/packages/live-status-gateway/src/collections/segmentHandler.ts index 2b7ccff992..8b53e4bc71 100644 --- a/packages/live-status-gateway/src/collections/segmentHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentHandler.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PickArr, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { SelectedPartInstances } from './partInstancesHandler' import { RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' @@ -10,12 +11,13 @@ import { SegmentsHandler } from './segmentsHandler' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const PLAYLIST_KEYS = ['rundownIdsInOrder'] as const -type Playlist = PickArr +type Playlist = PickKeys const PART_INSTANCES_KEYS = ['current'] as const -type PartInstances = PickArr +type PartInstances = PickKeys export class SegmentHandler extends PublicationCollection diff --git a/packages/live-status-gateway/src/collections/segmentsHandler.ts b/packages/live-status-gateway/src/collections/segmentsHandler.ts index 75bd03b2dc..1a47a1571d 100644 --- a/packages/live-status-gateway/src/collections/segmentsHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentsHandler.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { CollectionBase } from '../collectionBase' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import * as _ from 'underscore' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' diff --git a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts index b2b4d32f96..15e959d118 100644 --- a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts +++ b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBShowStyleBase, OutputLayers, SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' diff --git a/packages/live-status-gateway/src/collections/studioHandler.ts b/packages/live-status-gateway/src/collections/studioHandler.ts index d60a41ca62..b42b5390a8 100644 --- a/packages/live-status-gateway/src/collections/studioHandler.ts +++ b/packages/live-status-gateway/src/collections/studioHandler.ts @@ -1,7 +1,8 @@ import { Logger } from 'winston' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { CoreHandler } from '../coreHandler' -import { Collection, PublicationCollection } from '../wsHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { CollectionHandlers } from '../liveStatusServer' diff --git a/packages/live-status-gateway/src/publicationCollection.ts b/packages/live-status-gateway/src/publicationCollection.ts new file mode 100644 index 0000000000..314979291d --- /dev/null +++ b/packages/live-status-gateway/src/publicationCollection.ts @@ -0,0 +1,106 @@ +import { CorelibPubSubTypes, CorelibPubSubCollections } from '@sofie-automation/corelib/dist/pubsub' +import { + SubscriptionId, + Observer, + CollectionDocCheck, + PeripheralDevicePubSubCollections, + ProtectedString, +} from '@sofie-automation/server-core-integration' +import { ParametersOfFunctionOrNever } from '@sofie-automation/server-core-integration/dist/lib/subscriptions' +import { Logger } from 'winston' +import { CollectionBase, DEFAULT_THROTTLE_PERIOD_MS } from './collectionBase' +import { CoreHandler } from './coreHandler' +import { ObserverCallback } from './wsHandler' + +export abstract class PublicationCollection< + T, + TPubSub extends keyof CorelibPubSubTypes, + TCollection extends keyof CorelibPubSubCollections +> extends CollectionBase { + protected _publicationName: TPubSub + protected _subscriptionId: SubscriptionId | undefined + protected _subscriptionPending = false + protected _dbObserver: + | Observer> + | undefined + + constructor( + collection: TCollection, + publication: TPubSub, + logger: Logger, + coreHandler: CoreHandler, + throttlePeriodMs = DEFAULT_THROTTLE_PERIOD_MS + ) { + super(collection, logger, coreHandler, throttlePeriodMs) + this._publicationName = publication + } + + close(): void { + super.close() + if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) + this._dbObserver?.stop() + } + + subscribe(callback: ObserverCallback, keysToPick?: readonly K[]): void { + //this._logger.info(`${name}' added observer for '${this._name}'`) + if (this._collectionData) callback(this._collectionData) + this._observers.set(callback, { keysToPick, lastData: this.shallowClone(this._collectionData) }) + } + + /** + * Called after a batch of updates to documents in the collection + */ + protected changed(): void { + // override me + } + + private onDocumentEvent(id: ProtectedString | string, changeType: string): void { + this.logDocumentChange(id, changeType) + if (!this._subscriptionId) { + this._logger.silly(`${this._name} ${changeType} ${id} skipping (lack of subscription)`) + return + } + if (this._subscriptionPending) { + this._logger.silly(`${this._name} ${changeType} ${id} skipping (subscription pending)`) + return + } + this.throttledChanged() + } + + private setupObserver(): void { + this._dbObserver = this._coreHandler.setupObserver(this._collectionName) + this._dbObserver.added = (id) => { + this.onDocumentEvent(id, 'added') + } + this._dbObserver.changed = (id) => { + this.onDocumentEvent(id, 'changed') + } + this._dbObserver.removed = (id) => { + this.onDocumentEvent(id, 'removed') + } + } + + protected stopSubscription(): void { + if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) + this._subscriptionId = undefined + this._dbObserver?.stop() + this._dbObserver = undefined + } + + protected setupSubscription(...args: ParametersOfFunctionOrNever): void { + if (!this._publicationName) throw new Error(`Publication name not set for '${this._name}'`) + this.stopSubscription() + this._subscriptionPending = true + this._coreHandler + .setupSubscription(this._publicationName, ...args) + .then((subscriptionId) => { + this._subscriptionId = subscriptionId + this.setupObserver() + }) + .catch((e) => this._logger.error(e)) + .finally(() => { + this._subscriptionPending = false + this.changed() + }) + } +} diff --git a/packages/live-status-gateway/src/topics/activePiecesTopic.ts b/packages/live-status-gateway/src/topics/activePiecesTopic.ts index 765dc1449a..a918fc15c4 100644 --- a/packages/live-status-gateway/src/topics/activePiecesTopic.ts +++ b/packages/live-status-gateway/src/topics/activePiecesTopic.ts @@ -2,13 +2,14 @@ import { Logger } from 'winston' import { WebSocket } from 'ws' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' -import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' +import { WebSocketTopicBase, WebSocketTopic } from '../wsHandler' import { ShowStyleBaseExt } from '../collections/showStyleBaseHandler' import { SelectedPieceInstances, PieceInstanceMin } from '../collections/pieceInstancesHandler' import { PieceStatus, toPieceStatus } from './helpers/pieceStatus' import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CollectionHandlers } from '../liveStatusServer' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const THROTTLE_PERIOD_MS = 100 @@ -19,10 +20,10 @@ export interface ActivePiecesStatus { } const PLAYLIST_KEYS = ['_id', 'activationId'] as const -type Playlist = PickArr +type Playlist = PickKeys const PIECE_INSTANCES_KEYS = ['active'] as const -type PieceInstances = PickArr +type PieceInstances = PickKeys export class ActivePiecesTopic extends WebSocketTopicBase implements WebSocketTopic { private _activePlaylistId: RundownPlaylistId | undefined diff --git a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts index faa3cac47c..88bba08f27 100644 --- a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -10,7 +10,7 @@ import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartIns import { assertNever, literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { SelectedPartInstances } from '../collections/partInstancesHandler' import { ShowStyleBaseExt } from '../collections/showStyleBaseHandler' -import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' +import { WebSocketTopicBase, WebSocketTopic } from '../wsHandler' import { CurrentSegmentTiming, calculateCurrentSegmentTiming } from './helpers/segmentTiming' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import _ = require('underscore') @@ -22,6 +22,7 @@ import { PlaylistTimingType } from '@sofie-automation/blueprints-integration' import { normalizeArray } from '@sofie-automation/corelib/dist/lib' import { CollectionHandlers } from '../liveStatusServer' import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const THROTTLE_PERIOD_MS = 100 @@ -88,16 +89,16 @@ const PLAYLIST_KEYS = [ 'startedPlayback', 'quickLoop', ] as const -type Playlist = PickArr +type Playlist = PickKeys const PART_INSTANCES_KEYS = ['current', 'next', 'inCurrentSegment', 'firstInSegmentPlayout'] as const -type PartInstances = PickArr +type PartInstances = PickKeys const PIECE_INSTANCES_KEYS = ['currentPartInstance', 'nextPartInstance'] as const -type PieceInstances = PickArr +type PieceInstances = PickKeys const SEGMENT_KEYS = ['_id', 'segmentTiming'] as const -type Segment = PickArr +type Segment = PickKeys export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocketTopic { private _activePlaylist: Playlist | undefined diff --git a/packages/live-status-gateway/src/topics/adLibsTopic.ts b/packages/live-status-gateway/src/topics/adLibsTopic.ts index 4e7606b36e..5b976a29c2 100644 --- a/packages/live-status-gateway/src/topics/adLibsTopic.ts +++ b/packages/live-status-gateway/src/topics/adLibsTopic.ts @@ -1,7 +1,7 @@ import { Logger } from 'winston' import { WebSocket } from 'ws' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' +import { WebSocketTopicBase, WebSocketTopic } from '../wsHandler' import { literal } from '@sofie-automation/corelib/dist/lib' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' @@ -16,6 +16,7 @@ import { PartId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { WithSortingMetadata, getRank, sortContent } from './helpers/contentSorting' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const THROTTLE_PERIOD_MS = 100 @@ -50,10 +51,10 @@ interface AdLibStatusBase { } const PLAYLIST_KEYS = ['_id', 'rundownIdsInOrder', 'activationId'] as const -type Playlist = PickArr +type Playlist = PickKeys const SHOW_STYLE_BASE_KEYS = ['sourceLayerNamesById', 'outputLayerNamesById'] as const -type ShowStyle = PickArr +type ShowStyle = PickKeys export class AdLibsTopic extends WebSocketTopicBase implements WebSocketTopic { private _activePlaylist: Playlist | undefined diff --git a/packages/live-status-gateway/src/topics/segmentsTopic.ts b/packages/live-status-gateway/src/topics/segmentsTopic.ts index 413cd57a44..c6f00caece 100644 --- a/packages/live-status-gateway/src/topics/segmentsTopic.ts +++ b/packages/live-status-gateway/src/topics/segmentsTopic.ts @@ -1,7 +1,7 @@ import { Logger } from 'winston' import { WebSocket } from 'ws' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { WebSocketTopicBase, WebSocketTopic, PickArr } from '../wsHandler' +import { WebSocketTopicBase, WebSocketTopic } from '../wsHandler' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { groupByToMap } from '@sofie-automation/corelib/dist/lib' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' @@ -9,6 +9,7 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import _ = require('underscore') import { SegmentTiming, calculateSegmentTiming } from './helpers/segmentTiming' import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const THROTTLE_PERIOD_MS = 200 @@ -28,7 +29,7 @@ export interface SegmentsStatus { } const PLAYLIST_KEYS = ['_id', 'rundownIdsInOrder', 'activationId'] as const -type Playlist = PickArr +type Playlist = PickKeys export class SegmentsTopic extends WebSocketTopicBase implements WebSocketTopic { private _activePlaylist: Playlist | undefined diff --git a/packages/live-status-gateway/src/wsHandler.ts b/packages/live-status-gateway/src/wsHandler.ts index 7f0c7b66df..08e58fdc78 100644 --- a/packages/live-status-gateway/src/wsHandler.ts +++ b/packages/live-status-gateway/src/wsHandler.ts @@ -1,22 +1,7 @@ -import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { - CollectionDocCheck, - CoreConnection, - Observer, - PeripheralDevicePubSubCollections, - ProtectedString, - SubscriptionId, -} from '@sofie-automation/server-core-integration' import { Logger } from 'winston' import { WebSocket } from 'ws' -import { CoreHandler } from './coreHandler' -import { CorelibPubSubCollections, CorelibPubSubTypes } from '@sofie-automation/corelib/dist/pubsub' -import throttleToNextTick from '@sofie-automation/shared-lib/dist/lib/throttleToNextTick' import _ = require('underscore') -import { Collection as CoreCollection } from '@sofie-automation/server-core-integration' import { CollectionHandlers } from './liveStatusServer' -import { arePropertiesShallowEqual } from './helpers/equality' -import { ParametersOfFunctionOrNever } from '@sofie-automation/server-core-integration/dist/lib/subscriptions' export abstract class WebSocketTopicBase { protected _name: string @@ -99,211 +84,6 @@ export interface WebSocketTopic { sendMessage(ws: WebSocket, msg: object): void } -const DEFAULT_THROTTLE_PERIOD_MS = 20 - -export abstract class CollectionBase { - protected _name: string - protected _collectionName: TCollection - protected _logger: Logger - protected _coreHandler: CoreHandler - protected _studioId!: StudioId - protected _observers: Map< - ObserverCallback, - { keysToPick: readonly (keyof T)[] | undefined; lastData: T | undefined } - > = new Map() - protected _collectionData: T | undefined - - protected get _core(): CoreConnection { - return this._coreHandler.core - } - protected throttledChanged: () => void - - constructor( - collection: TCollection, - logger: Logger, - coreHandler: CoreHandler, - throttlePeriodMs = DEFAULT_THROTTLE_PERIOD_MS - ) { - this._name = this.constructor.name - this._collectionName = collection - this._logger = logger - this._coreHandler = coreHandler - - this.throttledChanged = throttleToNextTick( - throttlePeriodMs > 0 - ? _.throttle(() => this.changed(), throttlePeriodMs, { leading: true, trailing: true }) - : () => this.changed() - ) - - this._logger.info(`Starting ${this._name} handler`) - } - - init(_handlers: CollectionHandlers): void { - if (!this._coreHandler.studioId) throw new Error('StudioId is not defined') - this._studioId = this._coreHandler.studioId - } - - close(): void { - this._logger.info(`Closing ${this._name} handler`) - } - - subscribe(callback: ObserverCallback, keysToPick?: readonly K[]): void { - //this._logger.info(`${name}' added observer for '${this._name}'`) - if (this._collectionData) callback(this._collectionData) - this._observers.set(callback, { keysToPick, lastData: this.shallowClone(this._collectionData) }) - } - - /** - * Called after a batch of updates to documents in the collection - */ - protected changed(): void { - // override me - } - - notify(data: T | undefined): void { - for (const [observer, o] of this._observers) { - if ( - !o.lastData || - !o.keysToPick || - !data || - !arePropertiesShallowEqual(o.lastData, data, undefined, o.keysToPick) - ) { - observer(data) - o.lastData = this.shallowClone(data) - } - } - } - - protected shallowClone(data: T | undefined): T | undefined { - if (data === undefined) return undefined - if (Array.isArray(data)) return [...data] as T - if (typeof data === 'object') return { ...data } - return data - } - - protected logDocumentChange(documentId: string | ProtectedString, changeType: string): void { - this._logger.silly(`${this._name} ${changeType} ${documentId}`) - } - - protected logUpdateReceived(collectionName: string, updateCount: number | undefined): void - protected logUpdateReceived(collectionName: string, extraInfo?: string): void - protected logUpdateReceived( - collectionName: string, - extraInfoOrUpdateCount: string | number | undefined | null = null - ): void { - let message = `${this._name} received ${collectionName} update` - if (typeof extraInfoOrUpdateCount === 'string') { - message += `, ${extraInfoOrUpdateCount}` - } else if (extraInfoOrUpdateCount !== null) { - message += `(${extraInfoOrUpdateCount})` - } - this._logger.debug(message) - } - - protected logNotifyingUpdate(updateCount: number | undefined): void { - this._logger.debug(`${this._name} notifying update with ${updateCount} ${this._collectionName}`) - } - - protected getCollectionOrFail(): CoreCollection> { - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - return collection - } -} - -export abstract class PublicationCollection< - T, - TPubSub extends keyof CorelibPubSubTypes, - TCollection extends keyof CorelibPubSubCollections -> extends CollectionBase { - protected _publicationName: TPubSub - protected _subscriptionId: SubscriptionId | undefined - protected _subscriptionPending = false - protected _dbObserver: - | Observer> - | undefined - - constructor( - collection: TCollection, - publication: TPubSub, - logger: Logger, - coreHandler: CoreHandler, - throttlePeriodMs = DEFAULT_THROTTLE_PERIOD_MS - ) { - super(collection, logger, coreHandler, throttlePeriodMs) - this._publicationName = publication - } - - close(): void { - super.close() - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - this._dbObserver?.stop() - } - - subscribe(callback: ObserverCallback, keysToPick?: readonly K[]): void { - //this._logger.info(`${name}' added observer for '${this._name}'`) - if (this._collectionData) callback(this._collectionData) - this._observers.set(callback, { keysToPick, lastData: this.shallowClone(this._collectionData) }) - } - - /** - * Called after a batch of updates to documents in the collection - */ - protected changed(): void { - // override me - } - - private onDocumentEvent(id: ProtectedString | string, changeType: string): void { - this.logDocumentChange(id, changeType) - if (!this._subscriptionId) { - this._logger.silly(`${this._name} ${changeType} ${id} skipping (lack of subscription)`) - return - } - if (this._subscriptionPending) { - this._logger.silly(`${this._name} ${changeType} ${id} skipping (subscription pending)`) - return - } - this.throttledChanged() - } - - private setupObserver(): void { - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id) => { - this.onDocumentEvent(id, 'added') - } - this._dbObserver.changed = (id) => { - this.onDocumentEvent(id, 'changed') - } - this._dbObserver.removed = (id) => { - this.onDocumentEvent(id, 'removed') - } - } - - protected stopSubscription(): void { - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - this._subscriptionId = undefined - this._dbObserver?.stop() - this._dbObserver = undefined - } - - protected setupSubscription(...args: ParametersOfFunctionOrNever): void { - if (!this._publicationName) throw new Error(`Publication name not set for '${this._name}'`) - this.stopSubscription() - this._subscriptionPending = true - this._coreHandler - .setupSubscription(this._publicationName, ...args) - .then((subscriptionId) => { - this._subscriptionId = subscriptionId - this.setupObserver() - }) - .catch((e) => this._logger.error(e)) - .finally(() => { - this._subscriptionPending = false - this.changed() - }) - } -} - export interface Collection { init(handlers: CollectionHandlers): void close(): void @@ -313,8 +93,6 @@ export interface Collection { export type ObserverCallback = (data: Pick | undefined) => void -export type PickArr = Pick - // export interface CollectionObserver { // observerName: string // update(source: string, data: Pick | undefined): void diff --git a/packages/shared-lib/src/lib/types.ts b/packages/shared-lib/src/lib/types.ts index 94d7ae43f7..681944c585 100644 --- a/packages/shared-lib/src/lib/types.ts +++ b/packages/shared-lib/src/lib/types.ts @@ -38,3 +38,32 @@ export type KeysByType = Diff< }[keyof TObj], undefined > + +/** + * Creates a new type by picking properties from `T` using an array of keys. + * + * @template T - The source type. + * @template K - An array of keys from `T` to pick. + * + * @example + * ```ts + * type User = { + * id: number; + * name: string; + * email: string; + * age: number; + * }; + * + * // Using an inline tuple: + * type PickedInline = PickKeys; + * // Equivalent to: + * // type PickedInline = { id: number; name: string }; + * + * // Using a separate constant array: + * const userKeys = ['id', 'name'] as const; + * type PickedFromConst = PickKeys; + * // Equivalent to: + * // type PickedFromConst = { id: number; name: string }; + * ``` + */ +export type PickKeys = Pick From 05471e3ddce14862bb96fb15adc4b9c2e9f2ff99 Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 17 Feb 2025 10:32:45 +0100 Subject: [PATCH 025/293] feat: testtool - show AB-Session in Timeline --- packages/webui/src/client/ui/TestTools/Timeline.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/webui/src/client/ui/TestTools/Timeline.tsx b/packages/webui/src/client/ui/TestTools/Timeline.tsx index 77f2af6bd8..084ae0b933 100644 --- a/packages/webui/src/client/ui/TestTools/Timeline.tsx +++ b/packages/webui/src/client/ui/TestTools/Timeline.tsx @@ -258,6 +258,12 @@ function renderTimelineState(state: TimelineState, filter: RegExp | string | und {(o.classes ?? []).join('
')}
{JSON.stringify(o.content, undefined, '\t')}
+
+					{
+						//@ts-expect-error - abSessions is not in the type but are still in the object if used:
+						o.abSessions && 'AB-Sessions:' + '\n' + JSON.stringify(o.abSessions, undefined, '\t')
+					}
+				
)) From ab7c6bc116b768dd030c9160a90554db37880762 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 12 Feb 2025 15:54:02 +0000 Subject: [PATCH 026/293] feat: expose persistent playout store to more methods --- .../src/api/showStyle.ts | 24 ++++++++------ .../src/context/playoutStore.ts | 26 +++++++++++++++ .../corelib/src/dataModel/RundownPlaylist.ts | 5 ++- .../context/services/PersistantStateStore.ts | 32 +++++++++++++++++++ .../job-worker/src/playout/adlibAction.ts | 8 +++++ .../src/playout/model/PlayoutModel.ts | 12 ++++--- .../model/implementation/PlayoutModelImpl.ts | 10 ++++-- packages/job-worker/src/playout/setNext.ts | 9 +++++- packages/job-worker/src/playout/take.ts | 19 +++++++++-- .../src/playout/timeline/generate.ts | 25 ++++++++------- 10 files changed, 138 insertions(+), 32 deletions(-) create mode 100644 packages/blueprints-integration/src/context/playoutStore.ts create mode 100644 packages/job-worker/src/blueprints/context/services/PersistantStateStore.ts diff --git a/packages/blueprints-integration/src/api/showStyle.ts b/packages/blueprints-integration/src/api/showStyle.ts index b9bd2c9d74..9ccf91c393 100644 --- a/packages/blueprints-integration/src/api/showStyle.ts +++ b/packages/blueprints-integration/src/api/showStyle.ts @@ -48,6 +48,7 @@ import type { ExpectedPackage } from '../package' import type { ABResolverConfiguration } from '../abPlayback' import type { SofieIngestSegment } from '../ingest-types' import { PackageStatusMessage } from '@sofie-automation/shared-lib/dist/packageStatusMessages' +import { BlueprintPlayoutPersistentStore } from '../context/playoutStore' export { PackageStatusMessage } @@ -111,7 +112,6 @@ export interface ShowStyleBlueprintManifest void @@ -130,12 +130,13 @@ export interface ShowStyleBlueprintManifest, actionId: string, userData: ActionUserData, triggerMode: string | undefined, - privateData?: unknown, - publicData?: unknown, - actionOptions?: { [key: string]: any } + privateData: unknown | undefined, + publicData: unknown | undefined, + actionOptions: { [key: string]: any } | undefined ) => Promise<{ validationErrors: any } | void> /** Generate adlib piece from ingest data */ @@ -204,7 +205,10 @@ export interface ShowStyleBlueprintManifest Promise + onTake?: ( + context: IOnTakeContext, + playoutPersistentState: BlueprintPlayoutPersistentStore + ) => Promise /** Called after a Take action */ onPostTake?: (context: IPartEventContext) => Promise @@ -212,13 +216,16 @@ export interface ShowStyleBlueprintManifest Promise + onSetAsNext?: ( + context: IOnSetAsNextContext, + playoutPersistentState: BlueprintPlayoutPersistentStore + ) => Promise /** Called after the timeline has been generated, used to manipulate the timeline */ onTimelineGenerate?: ( context: ITimelineEventContext, timeline: OnGenerateTimelineObj[], - previousPersistentState: TimelinePersistentState | undefined, + playoutPersistentState: BlueprintPlayoutPersistentStore, previousPartEndState: PartEndState | undefined, resolvedPieces: IBlueprintResolvedPieceInstance[] ) => Promise @@ -229,7 +236,7 @@ export interface ShowStyleBlueprintManifest, partInstance: IBlueprintPartInstance, resolvedPieces: IBlueprintResolvedPieceInstance[], time: number @@ -249,7 +256,6 @@ export interface ShowStyleBlueprintManifest[] - persistentState: TimelinePersistentState } export interface BlueprintResultBaseline { timelineObjects: TimelineObjectCoreExt[] diff --git a/packages/blueprints-integration/src/context/playoutStore.ts b/packages/blueprints-integration/src/context/playoutStore.ts new file mode 100644 index 0000000000..8ecb499fd4 --- /dev/null +++ b/packages/blueprints-integration/src/context/playoutStore.ts @@ -0,0 +1,26 @@ +/** + * A store for persisting playout state between bluerpint method calls + * This belongs to the Playlist and will be discarded when the Playlist is reset + */ +export interface BlueprintPlayoutPersistentStore { + /** + * Get all the data in the store + */ + getAll(): Partial + /** + * Retrieve a key of data from the store + * @param k The key to retrieve + */ + getKey(k: K): T[K] | undefined + /** + * Update a key of data in the store + * @param k The key to update + * @param v The value to set + */ + setKey(k: K, v: T[K]): void + /** + * Replace all the data in the store + * @param obj The new data + */ + setAll(obj: T): void +} diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index f9ff938c02..8663633741 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -169,7 +169,10 @@ export interface DBRundownPlaylist { /** If the order of rundowns in this playlist has ben set manually by a user/blueprints in Sofie */ rundownIdsInOrder: RundownId[] - /** Previous state persisted from ShowStyleBlueprint.onTimelineGenerate */ + /** + * Persistent state belong to blueprint playout methods + * This can be accessed and modified by the blueprints in various methods + */ previousPersistentState?: TimelinePersistentState /** AB playback sessions calculated in the last timeline genertaion */ trackedAbSessions?: ABSessionInfo[] diff --git a/packages/job-worker/src/blueprints/context/services/PersistantStateStore.ts b/packages/job-worker/src/blueprints/context/services/PersistantStateStore.ts new file mode 100644 index 0000000000..6150eac095 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/PersistantStateStore.ts @@ -0,0 +1,32 @@ +import type { TimelinePersistentState } from '@sofie-automation/blueprints-integration' +import type { BlueprintPlayoutPersistentStore } from '@sofie-automation/blueprints-integration/dist/context/playoutStore' +import { clone } from '@sofie-automation/corelib/dist/lib' + +export class PersistentPlayoutStateStore implements BlueprintPlayoutPersistentStore { + #state: TimelinePersistentState | undefined + #hasChanges = false + + get hasChanges(): boolean { + return this.#hasChanges + } + + constructor(state: TimelinePersistentState | undefined) { + this.#state = clone(state) + } + + getAll(): Partial { + return this.#state || {} + } + getKey(k: K): unknown { + return this.#state?.[k] + } + setKey(k: K, v: unknown): void { + if (!this.#state) this.#state = {} + ;(this.#state as any)[k] = v + this.#hasChanges = true + } + setAll(obj: unknown): void { + this.#state = obj + this.#hasChanges = true + } +} diff --git a/packages/job-worker/src/playout/adlibAction.ts b/packages/job-worker/src/playout/adlibAction.ts index f2fa07118b..68ca530bd9 100644 --- a/packages/job-worker/src/playout/adlibAction.ts +++ b/packages/job-worker/src/playout/adlibAction.ts @@ -34,6 +34,7 @@ import { convertNoteToNotification } from '../notifications/util' import type { INoteBase } from '@sofie-automation/corelib/dist/dataModel/Notes' import { NotificationsModelHelper } from '../notifications/NotificationsModelHelper' import type { INotificationsModel } from '../notifications/NotificationsModel' +import { PersistentPlayoutStateStore } from '../blueprints/context/services/PersistantStateStore' /** * Execute an AdLib Action @@ -230,8 +231,11 @@ export async function executeActionInner( ) try { + const blueprintPersistentState = new PersistentPlayoutStateStore(playoutModel.playlist.previousPersistentState) + await blueprint.blueprint.executeAction( actionContext, + blueprintPersistentState, actionParameters.actionId, actionParameters.userData, actionParameters.triggerMode, @@ -239,6 +243,10 @@ export async function executeActionInner( actionParameters.publicData, actionParameters.actionOptions ?? {} ) + + if (blueprintPersistentState.hasChanges) { + playoutModel.setBlueprintPersistentState(blueprintPersistentState.getAll()) + } } catch (err) { logger.error(`Error in showStyleBlueprint.executeAction: ${stringifyError(err)}`) throw UserError.fromUnknown(err) diff --git a/packages/job-worker/src/playout/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index 7ded24012f..f93bae234d 100644 --- a/packages/job-worker/src/playout/model/PlayoutModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutModel.ts @@ -317,17 +317,21 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa setHoldState(newState: RundownHoldState): void /** - * Store the persistent results of the AB playback resolving and onTimelineGenerate - * @param persistentState Blueprint owned state from onTimelineGenerate + * Store the persistent results of the AB playback resolving * @param assignedAbSessions The applied AB sessions * @param trackedAbSessions The known AB sessions */ - setOnTimelineGenerateResult( - persistentState: unknown | undefined, + setAbResolvingState( assignedAbSessions: Record, trackedAbSessions: ABSessionInfo[] ): void + /** + * Store the blueprint persistent state + * @param persistentState Blueprint owned state + */ + setBlueprintPersistentState(persistentState: unknown | undefined): void + /** * Set a PartInstance as the nexted PartInstance * @param partInstance PartInstance to be set as next, or none diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 9a0a8b1bb4..4ae156fbc6 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -727,18 +727,22 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } - setOnTimelineGenerateResult( - persistentState: unknown | undefined, + setAbResolvingState( assignedAbSessions: Record, trackedAbSessions: ABSessionInfo[] ): void { - this.playlistImpl.previousPersistentState = persistentState this.playlistImpl.assignedAbSessions = assignedAbSessions this.playlistImpl.trackedAbSessions = trackedAbSessions this.#playlistHasChanged = true } + setBlueprintPersistentState(persistentState: unknown | undefined): void { + this.playlistImpl.previousPersistentState = persistentState + + this.#playlistHasChanged = true + } + setPartInstanceAsNext( partInstance: PlayoutPartInstanceModel | null, setManually: boolean, diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index acb7d281a2..377bc0336d 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -31,6 +31,7 @@ import { } from '../blueprints/context/services/PartAndPieceInstanceActionService' import { NoteSeverity } from '@sofie-automation/blueprints-integration' import { convertNoteToNotification } from '../notifications/util' +import { PersistentPlayoutStateStore } from '../blueprints/context/services/PersistantStateStore' /** * Set or clear the nexted part, from a given PartInstance, or SelectNextPartResult @@ -225,9 +226,15 @@ async function executeOnSetAsNextCallback( playoutModel.clearAllNotifications(NOTIFICATION_CATEGORY) try { - await blueprint.blueprint.onSetAsNext(onSetAsNextContext) + const blueprintPersistentState = new PersistentPlayoutStateStore(playoutModel.playlist.previousPersistentState) + + await blueprint.blueprint.onSetAsNext(onSetAsNextContext, blueprintPersistentState) await applyOnSetAsNextSideEffects(context, playoutModel, onSetAsNextContext) + if (blueprintPersistentState.hasChanges) { + playoutModel.setBlueprintPersistentState(blueprintPersistentState.getAll()) + } + for (const note of onSetAsNextContext.notes) { // Update the notifications. Even though these are related to a partInstance, they will be cleared on the next take playoutModel.setNotification(NOTIFICATION_CATEGORY, { diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index bdb6fd73b4..d5b737ce26 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -34,6 +34,7 @@ import { } from '../blueprints/context/services/PartAndPieceInstanceActionService' import { PlayoutRundownModel } from './model/PlayoutRundownModel' import { convertNoteToNotification } from '../notifications/util' +import { PersistentPlayoutStateStore } from '../blueprints/context/services/PersistantStateStore' /** * Take the currently Next:ed Part (start playing it) @@ -316,10 +317,18 @@ async function executeOnTakeCallback( new PartAndPieceInstanceActionService(context, playoutModel, showStyle, currentRundown) ) try { - await blueprint.blueprint.onTake(onSetAsNextContext) + const blueprintPersistentState = new PersistentPlayoutStateStore( + playoutModel.playlist.previousPersistentState + ) + + await blueprint.blueprint.onTake(onSetAsNextContext, blueprintPersistentState) await applyOnTakeSideEffects(context, playoutModel, onSetAsNextContext) isTakeAborted = onSetAsNextContext.isTakeAborted + if (blueprintPersistentState.hasChanges) { + playoutModel.setBlueprintPersistentState(blueprintPersistentState.getAll()) + } + for (const note of onSetAsNextContext.notes) { // Update the notifications. Even though these are related to a partInstance, they will be cleared on the next take playoutModel.setNotification(NOTIFICATION_CATEGORY, { @@ -511,13 +520,19 @@ export function updatePartInstanceOnTake( context.getShowStyleBlueprintConfig(showStyle), takeRundown ) + const blueprintPersistentState = new PersistentPlayoutStateStore( + playoutModel.playlist.previousPersistentState + ) previousPartEndState = blueprint.blueprint.getEndStateForPart( context2, - playoutModel.playlist.previousPersistentState, + blueprintPersistentState, convertPartInstanceToBlueprints(currentPartInstance.partInstance), resolvedPieces.map(convertResolvedPieceInstanceToBlueprints), time ) + if (blueprintPersistentState.hasChanges) { + playoutModel.setBlueprintPersistentState(blueprintPersistentState.getAll()) + } if (span) span.end() logger.info(`Calculated end state in ${getCurrentTime() - time}ms`) } catch (err) { diff --git a/packages/job-worker/src/playout/timeline/generate.ts b/packages/job-worker/src/playout/timeline/generate.ts index 8bc040653b..2ed02cc4fd 100644 --- a/packages/job-worker/src/playout/timeline/generate.ts +++ b/packages/job-worker/src/playout/timeline/generate.ts @@ -1,13 +1,7 @@ import { BlueprintId, TimelineHash } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext, JobStudio } from '../../jobs' import { ReadonlyDeep } from 'type-fest' -import { - BlueprintResultBaseline, - BlueprintResultTimeline, - OnGenerateTimelineObj, - Time, - TSR, -} from '@sofie-automation/blueprints-integration' +import { BlueprintResultBaseline, OnGenerateTimelineObj, Time, TSR } from '@sofie-automation/blueprints-integration' import { deserializeTimelineBlob, OnGenerateTimelineObjExt, @@ -46,6 +40,7 @@ import { getPartTimingsOrDefaults, PartCalculatedTimings } from '@sofie-automati import { applyAbPlaybackForTimeline } from '../abPlayback' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import { PlayoutPartInstanceModel } from '../model/PlayoutPartInstanceModel' +import { PersistentPlayoutStateStore } from '../../blueprints/context/services/PersistantStateStore' function isModelForStudio(model: StudioPlayoutModelBase): model is StudioPlayoutModel { const tmp = model as StudioPlayoutModel @@ -388,14 +383,17 @@ async function getTimelineRundown( }) } - let tlGenRes: BlueprintResultTimeline | undefined if (blueprint.blueprint.onTimelineGenerate) { + const blueprintPersistentState = new PersistentPlayoutStateStore( + playoutModel.playlist.previousPersistentState + ) + const span = context.startSpan('blueprint.onTimelineGenerate') const influxTrace = startTrace('blueprints:onTimelineGenerate') - tlGenRes = await blueprint.blueprint.onTimelineGenerate( + const tlGenRes = await blueprint.blueprint.onTimelineGenerate( blueprintContext, timelineObjs, - clone(playoutModel.playlist.previousPersistentState), + blueprintPersistentState, clone(currentPartInstance?.partInstance?.previousPartEndState), resolvedPieces.map(convertResolvedPieceInstanceToBlueprints) ) @@ -408,10 +406,13 @@ async function getTimelineRundown( objectType: TimelineObjType.RUNDOWN, }) }) + + if (blueprintPersistentState.hasChanges) { + playoutModel.setBlueprintPersistentState(blueprintPersistentState.getAll()) + } } - playoutModel.setOnTimelineGenerateResult( - tlGenRes?.persistentState, + playoutModel.setAbResolvingState( newAbSessionsResult.assignments, blueprintContext.abSessionsHelper.knownSessions ) From e860c39f5eb2e7701365e7345837bea577005588 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 17 Feb 2025 15:34:54 +0100 Subject: [PATCH 027/293] refactor: extract logic shared by adlibs(actions) handlers --- .../src/collections/adLibActionsHandler.ts | 51 +----------- .../src/collections/adLibsHandler.ts | 50 +----------- .../collections/globalAdLibActionsHandler.ts | 53 +------------ .../src/collections/globalAdLibsHandler.ts | 54 +------------ .../collections/rundownContentHandlerBase.ts | 79 +++++++++++++++++++ 5 files changed, 87 insertions(+), 200 deletions(-) create mode 100644 packages/live-status-gateway/src/collections/rundownContentHandlerBase.ts diff --git a/packages/live-status-gateway/src/collections/adLibActionsHandler.ts b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts index 9680608d91..f8079250da 100644 --- a/packages/live-status-gateway/src/collections/adLibActionsHandler.ts +++ b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts @@ -1,58 +1,11 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' -import { PublicationCollection } from '../publicationCollection' -import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { CollectionHandlers } from '../liveStatusServer' -import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' - -const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const -type Playlist = PickKeys - -export class AdLibActionsHandler - extends PublicationCollection - implements Collection -{ - private _currentRundownId: RundownId | undefined +import { RundownContentHandlerBase } from './rundownContentHandlerBase' +export class AdLibActionsHandler extends RundownContentHandlerBase { constructor(logger: Logger, coreHandler: CoreHandler) { super(CollectionName.AdLibActions, CorelibPubSub.adLibActions, logger, coreHandler) } - - init(handlers: CollectionHandlers): void { - super.init(handlers) - - handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) - } - - protected changed(): void { - this.updateAndNotify() - } - - private onPlaylistUpdate = (data: Playlist | undefined): void => { - this.logUpdateReceived('playlist') - const prevRundownId = this._currentRundownId - - const rundownPlaylist = data - - this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId - - if (prevRundownId !== this._currentRundownId) { - this.stopSubscription() - if (this._currentRundownId) { - this.setupSubscription([this._currentRundownId]) - } - // no need to trigger updateAndNotify() because the subscription will take care of this - } - } - - private updateAndNotify(): void { - const col = this.getCollectionOrFail() - this._collectionData = col.find({ rundownId: this._currentRundownId }) - this.notify(this._collectionData) - } } diff --git a/packages/live-status-gateway/src/collections/adLibsHandler.ts b/packages/live-status-gateway/src/collections/adLibsHandler.ts index caed2c081c..44e020a837 100644 --- a/packages/live-status-gateway/src/collections/adLibsHandler.ts +++ b/packages/live-status-gateway/src/collections/adLibsHandler.ts @@ -1,57 +1,11 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' -import { PublicationCollection } from '../publicationCollection' -import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { CollectionHandlers } from '../liveStatusServer' -import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' - -const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const -type Playlist = PickKeys - -export class AdLibsHandler - extends PublicationCollection - implements Collection -{ - private _currentRundownId: RundownId | undefined +import { RundownContentHandlerBase } from './rundownContentHandlerBase' +export class AdLibsHandler extends RundownContentHandlerBase { constructor(logger: Logger, coreHandler: CoreHandler) { super(CollectionName.AdLibPieces, CorelibPubSub.adLibPieces, logger, coreHandler) } - - init(handlers: CollectionHandlers): void { - super.init(handlers) - - handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) - } - - protected changed(): void { - this.updateAndNotify() - } - - private onPlaylistUpdate = (data: Playlist | undefined): void => { - this.logUpdateReceived('playlist') - const prevRundownId = this._currentRundownId - const rundownPlaylist = data - - this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId - - if (prevRundownId !== this._currentRundownId) { - this.stopSubscription() - if (this._currentRundownId) { - this.setupSubscription([this._currentRundownId]) - } - // no need to trigger updateAndNotify() because the subscription will take care of this - } - } - - private updateAndNotify(): void { - const collection = this.getCollectionOrFail() - this._collectionData = collection.find({ rundownId: this._currentRundownId }) - this.notify(this._collectionData) - } } diff --git a/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts index a11d832ce1..57bdab319d 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts @@ -1,28 +1,10 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' -import { PublicationCollection } from '../publicationCollection' -import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { CollectionHandlers } from '../liveStatusServer' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' - -const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const -type Playlist = PickKeys - -export class GlobalAdLibActionsHandler - extends PublicationCollection< - RundownBaselineAdLibAction[], - CorelibPubSub.rundownBaselineAdLibActions, - CollectionName.RundownBaselineAdLibActions - > - implements Collection -{ - private _currentRundownId: RundownId | undefined +import { RundownContentHandlerBase } from './rundownContentHandlerBase' +export class GlobalAdLibActionsHandler extends RundownContentHandlerBase { constructor(logger: Logger, coreHandler: CoreHandler) { super( CollectionName.RundownBaselineAdLibActions, @@ -31,35 +13,4 @@ export class GlobalAdLibActionsHandler coreHandler ) } - - init(handlers: CollectionHandlers): void { - super.init(handlers) - - handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) - } - - protected changed(): void { - this.updateAndNotify() - } - - private onPlaylistUpdate = (data: Playlist | undefined): void => { - this.logUpdateReceived('playlist') - const prevRundownId = this._currentRundownId - const rundownPlaylist = data - - this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId - - if (prevRundownId !== this._currentRundownId) { - this.stopSubscription() - if (this._currentRundownId) { - this.setupSubscription([this._currentRundownId]) - } - } - } - - private updateAndNotify(): void { - const collection = this.getCollectionOrFail() - this._collectionData = collection.find({ rundownId: this._currentRundownId }) - this.notify(this._collectionData) - } } diff --git a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts index 4e57e91b42..630612e3d3 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts @@ -1,60 +1,10 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' -import { PublicationCollection } from '../publicationCollection' -import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { CollectionHandlers } from '../liveStatusServer' -import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' - -const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const -type Playlist = PickKeys - -export class GlobalAdLibsHandler - extends PublicationCollection< - RundownBaselineAdLibItem[], - CorelibPubSub.rundownBaselineAdLibPieces, - CollectionName.RundownBaselineAdLibPieces - > - implements Collection -{ - private _currentRundownId: RundownId | undefined - +import { RundownContentHandlerBase } from './rundownContentHandlerBase' +export class GlobalAdLibsHandler extends RundownContentHandlerBase { constructor(logger: Logger, coreHandler: CoreHandler) { super(CollectionName.RundownBaselineAdLibPieces, CorelibPubSub.rundownBaselineAdLibPieces, logger, coreHandler) } - - init(handlers: CollectionHandlers): void { - super.init(handlers) - - handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) - } - - protected changed(): void { - this.updateAndNotify() - } - - private onPlaylistUpdate = (data: Playlist | undefined): void => { - this.logUpdateReceived('playlist') - const prevRundownId = this._currentRundownId - const rundownPlaylist = data - - this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId - - if (prevRundownId !== this._currentRundownId) { - this.stopSubscription() - if (this._currentRundownId) { - this.setupSubscription([this._currentRundownId]) - } - } - } - - private updateAndNotify(): void { - const collection = this.getCollectionOrFail() - this._collectionData = collection.find({ rundownId: this._currentRundownId }) - this.notify(this._collectionData) - } } diff --git a/packages/live-status-gateway/src/collections/rundownContentHandlerBase.ts b/packages/live-status-gateway/src/collections/rundownContentHandlerBase.ts new file mode 100644 index 0000000000..d1ba1676aa --- /dev/null +++ b/packages/live-status-gateway/src/collections/rundownContentHandlerBase.ts @@ -0,0 +1,79 @@ +import { Logger } from 'winston' +import { CoreHandler } from '../coreHandler' +import { Collection } from '../wsHandler' +import { PublicationCollection } from '../publicationCollection' +import { CorelibPubSubCollections, CorelibPubSubTypes } from '@sofie-automation/corelib/dist/pubsub' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { CollectionHandlers } from '../liveStatusServer' +import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' +import { CollectionDocCheck } from '@sofie-automation/server-core-integration' +import { ParametersOfFunctionOrNever } from '@sofie-automation/server-core-integration/dist/lib/subscriptions' + +const PLAYLIST_KEYS = ['currentPartInfo', 'nextPartInfo'] as const +type Playlist = PickKeys + +type MatchingKeys = { + [K in keyof T]: T[K] extends (...args: Args) => any ? K : never +}[keyof T] + +type RundownMatchingKeys = MatchingKeys + +/** + * For items whose `rundownId` should equal `rundownId` of the current Part (or next Part, if the firts Take was not performed) + */ +export abstract class RundownContentHandlerBase + extends PublicationCollection< + CollectionDocCheck]>[], + TPubSub, + ReturnType + > + implements Collection]>[]> +{ + private _currentRundownId: RundownId | undefined + + constructor( + collection: ReturnType, + publication: TPubSub, + logger: Logger, + coreHandler: CoreHandler + ) { + super(collection, publication, logger, coreHandler) + } + + init(handlers: CollectionHandlers): void { + super.init(handlers) + + handlers.playlistHandler.subscribe(this.onPlaylistUpdate, PLAYLIST_KEYS) + } + + protected changed(): void { + this.updateAndNotify() + } + + private onPlaylistUpdate = (data: Playlist | undefined): void => { + this.logUpdateReceived('playlist') + const prevRundownId = this._currentRundownId + + const rundownPlaylist = data + + this._currentRundownId = rundownPlaylist?.currentPartInfo?.rundownId ?? rundownPlaylist?.nextPartInfo?.rundownId + + if (prevRundownId !== this._currentRundownId) { + this.stopSubscription() + if (this._currentRundownId) { + const args = [[this._currentRundownId]] as unknown as ParametersOfFunctionOrNever< + CorelibPubSubTypes[TPubSub] + > // TODO: get rid of this type conversion + this.setupSubscription(...args) + } + // no need to trigger updateAndNotify() because the subscription will take care of this + } + } + + private updateAndNotify(): void { + const col = this.getCollectionOrFail() + this._collectionData = col.find({ rundownId: this._currentRundownId }) + this.notify(this._collectionData) + } +} From 17046571ec2a2779554b03f8ca219221caa6165d Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 17 Feb 2025 20:17:08 +0100 Subject: [PATCH 028/293] refactor(LSG): remove redundant type --- .../live-status-gateway/src/collectionBase.ts | 3 ++- .../src/collections/globalAdLibsHandler.ts | 1 + .../src/collections/partHandler.ts | 6 +----- .../src/collections/partInstancesHandler.ts | 10 +++++----- .../src/collections/partsHandler.ts | 3 +-- .../src/collections/pieceInstancesHandler.ts | 10 +++++----- .../src/collections/playlistHandler.ts | 15 ++++++--------- .../src/collections/rundownContentHandlerBase.ts | 14 +++++--------- .../src/collections/rundownHandler.ts | 10 +++++----- .../src/collections/rundownsHandler.ts | 6 +----- .../src/collections/segmentHandler.ts | 6 +----- .../src/collections/segmentsHandler.ts | 6 +----- .../src/collections/showStyleBaseHandler.ts | 10 +++++----- .../src/collections/studioHandler.ts | 6 +----- .../src/publicationCollection.ts | 2 +- packages/live-status-gateway/src/wsHandler.ts | 14 -------------- 16 files changed, 41 insertions(+), 81 deletions(-) diff --git a/packages/live-status-gateway/src/collectionBase.ts b/packages/live-status-gateway/src/collectionBase.ts index 160d543477..834d545053 100644 --- a/packages/live-status-gateway/src/collectionBase.ts +++ b/packages/live-status-gateway/src/collectionBase.ts @@ -12,7 +12,8 @@ import { Logger } from 'winston' import { CoreHandler } from './coreHandler' import { arePropertiesShallowEqual } from './helpers/equality' import { CollectionHandlers } from './liveStatusServer' -import { ObserverCallback } from './wsHandler' + +export type ObserverCallback = (data: Pick | undefined) => void export const DEFAULT_THROTTLE_PERIOD_MS = 20 diff --git a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts index 630612e3d3..c5ad62c27f 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts @@ -3,6 +3,7 @@ import { CoreHandler } from '../coreHandler' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { RundownContentHandlerBase } from './rundownContentHandlerBase' + export class GlobalAdLibsHandler extends RundownContentHandlerBase { constructor(logger: Logger, coreHandler: CoreHandler) { super(CollectionName.RundownBaselineAdLibPieces, CorelibPubSub.rundownBaselineAdLibPieces, logger, coreHandler) diff --git a/packages/live-status-gateway/src/collections/partHandler.ts b/packages/live-status-gateway/src/collections/partHandler.ts index fdef5a0e39..4ff05b292c 100644 --- a/packages/live-status-gateway/src/collections/partHandler.ts +++ b/packages/live-status-gateway/src/collections/partHandler.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { PublicationCollection } from '../publicationCollection' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' @@ -18,10 +17,7 @@ type Playlist = PickKeys const PART_INSTANCES_KEYS = ['current'] as const type PartInstances = PickKeys -export class PartHandler - extends PublicationCollection - implements Collection -{ +export class PartHandler extends PublicationCollection { private _activePlaylist: Playlist | undefined private _currentPartInstance: DBPartInstance | undefined diff --git a/packages/live-status-gateway/src/collections/partInstancesHandler.ts b/packages/live-status-gateway/src/collections/partInstancesHandler.ts index f6c31da396..d56d4833ed 100644 --- a/packages/live-status-gateway/src/collections/partInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/partInstancesHandler.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { PublicationCollection } from '../publicationCollection' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' @@ -31,10 +30,11 @@ const PLAYLIST_KEYS = [ ] as const type Playlist = PickKeys -export class PartInstancesHandler - extends PublicationCollection - implements Collection -{ +export class PartInstancesHandler extends PublicationCollection< + SelectedPartInstances, + CorelibPubSub.partInstances, + CollectionName.PartInstances +> { private _currentPlaylist: Playlist | undefined private _rundownIds: RundownId[] = [] private _activationId: RundownPlaylistActivationId | undefined diff --git a/packages/live-status-gateway/src/collections/partsHandler.ts b/packages/live-status-gateway/src/collections/partsHandler.ts index be4a9917ff..74a9ee8ae9 100644 --- a/packages/live-status-gateway/src/collections/partsHandler.ts +++ b/packages/live-status-gateway/src/collections/partsHandler.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { CollectionBase } from '../collectionBase' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import _ = require('underscore') @@ -8,7 +7,7 @@ import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collect const THROTTLE_PERIOD_MS = 200 -export class PartsHandler extends CollectionBase implements Collection { +export class PartsHandler extends CollectionBase { private throttledNotify: (data: DBPart[]) => void constructor(logger: Logger, coreHandler: CoreHandler) { diff --git a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts index eaaaa5e539..902e6ebe6a 100644 --- a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { PublicationCollection } from '../publicationCollection' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' @@ -52,10 +51,11 @@ export interface SelectedPieceInstances { nextPartInstance: PieceInstanceMin[] } -export class PieceInstancesHandler - extends PublicationCollection - implements Collection -{ +export class PieceInstancesHandler extends PublicationCollection< + SelectedPieceInstances, + CorelibPubSub.pieceInstances, + CollectionName.PieceInstances +> { private _currentPlaylist: Playlist | undefined private _partInstanceIds: PartInstanceId[] = [] private _sourceLayers: SourceLayers = {} diff --git a/packages/live-status-gateway/src/collections/playlistHandler.ts b/packages/live-status-gateway/src/collections/playlistHandler.ts index 072f9a37a2..5e3d24b5c4 100644 --- a/packages/live-status-gateway/src/collections/playlistHandler.ts +++ b/packages/live-status-gateway/src/collections/playlistHandler.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { PublicationCollection } from '../publicationCollection' import { CollectionBase } from '../collectionBase' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' @@ -8,10 +7,7 @@ import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collect import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { CollectionHandlers } from '../liveStatusServer' -export class PlaylistsHandler - extends CollectionBase - implements Collection -{ +export class PlaylistsHandler extends CollectionBase { constructor(logger: Logger, coreHandler: CoreHandler) { super(CollectionName.RundownPlaylists, logger, coreHandler) } @@ -23,10 +19,11 @@ export class PlaylistsHandler } } -export class PlaylistHandler - extends PublicationCollection - implements Collection -{ +export class PlaylistHandler extends PublicationCollection< + DBRundownPlaylist, + CorelibPubSub.rundownPlaylists, + CollectionName.RundownPlaylists +> { private _playlistsHandler: PlaylistsHandler constructor(logger: Logger, coreHandler: CoreHandler) { diff --git a/packages/live-status-gateway/src/collections/rundownContentHandlerBase.ts b/packages/live-status-gateway/src/collections/rundownContentHandlerBase.ts index d1ba1676aa..49a972f531 100644 --- a/packages/live-status-gateway/src/collections/rundownContentHandlerBase.ts +++ b/packages/live-status-gateway/src/collections/rundownContentHandlerBase.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { PublicationCollection } from '../publicationCollection' import { CorelibPubSubCollections, CorelibPubSubTypes } from '@sofie-automation/corelib/dist/pubsub' import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' @@ -22,14 +21,11 @@ type RundownMatchingKeys = MatchingKeys - extends PublicationCollection< - CollectionDocCheck]>[], - TPubSub, - ReturnType - > - implements Collection]>[]> -{ +export abstract class RundownContentHandlerBase extends PublicationCollection< + CollectionDocCheck]>[], + TPubSub, + ReturnType +> { private _currentRundownId: RundownId | undefined constructor( diff --git a/packages/live-status-gateway/src/collections/rundownHandler.ts b/packages/live-status-gateway/src/collections/rundownHandler.ts index 6d888c0460..1f0127e2ef 100644 --- a/packages/live-status-gateway/src/collections/rundownHandler.ts +++ b/packages/live-status-gateway/src/collections/rundownHandler.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { PublicationCollection } from '../publicationCollection' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' @@ -15,10 +14,11 @@ import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' const PLAYLIST_KEYS = ['_id', 'currentPartInfo', 'nextPartInfo'] as const type Playlist = PickKeys -export class RundownHandler - extends PublicationCollection - implements Collection -{ +export class RundownHandler extends PublicationCollection< + DBRundown, + CorelibPubSub.rundownsInPlaylists, + CollectionName.Rundowns +> { private _currentPlaylistId: RundownPlaylistId | undefined private _currentRundownId: RundownId | undefined diff --git a/packages/live-status-gateway/src/collections/rundownsHandler.ts b/packages/live-status-gateway/src/collections/rundownsHandler.ts index 21960f6565..eb16de09b9 100644 --- a/packages/live-status-gateway/src/collections/rundownsHandler.ts +++ b/packages/live-status-gateway/src/collections/rundownsHandler.ts @@ -1,14 +1,10 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { CollectionBase } from '../collectionBase' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' -export class RundownsHandler - extends CollectionBase - implements Collection -{ +export class RundownsHandler extends CollectionBase { constructor(logger: Logger, coreHandler: CoreHandler) { super(CollectionName.Rundowns, logger, coreHandler) } diff --git a/packages/live-status-gateway/src/collections/segmentHandler.ts b/packages/live-status-gateway/src/collections/segmentHandler.ts index 8b53e4bc71..96a1b9d88e 100644 --- a/packages/live-status-gateway/src/collections/segmentHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentHandler.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { PublicationCollection } from '../publicationCollection' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { SelectedPartInstances } from './partInstancesHandler' @@ -19,10 +18,7 @@ type Playlist = PickKeys const PART_INSTANCES_KEYS = ['current'] as const type PartInstances = PickKeys -export class SegmentHandler - extends PublicationCollection - implements Collection -{ +export class SegmentHandler extends PublicationCollection { private _currentSegmentId: SegmentId | undefined private _rundownIds: RundownId[] = [] diff --git a/packages/live-status-gateway/src/collections/segmentsHandler.ts b/packages/live-status-gateway/src/collections/segmentsHandler.ts index 1a47a1571d..6e0d7064eb 100644 --- a/packages/live-status-gateway/src/collections/segmentsHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentsHandler.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { CollectionBase } from '../collectionBase' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import * as _ from 'underscore' @@ -8,10 +7,7 @@ import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collect const THROTTLE_PERIOD_MS = 200 -export class SegmentsHandler - extends CollectionBase - implements Collection -{ +export class SegmentsHandler extends CollectionBase { private throttledNotify: (data: DBSegment[]) => void constructor(logger: Logger, coreHandler: CoreHandler) { diff --git a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts index 15e959d118..3d8a8e4204 100644 --- a/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts +++ b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts @@ -1,6 +1,5 @@ import { Logger } from 'winston' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { PublicationCollection } from '../publicationCollection' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBShowStyleBase, OutputLayers, SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' @@ -17,10 +16,11 @@ export interface ShowStyleBaseExt extends DBShowStyleBase { sourceLayers: SourceLayers } -export class ShowStyleBaseHandler - extends PublicationCollection - implements Collection -{ +export class ShowStyleBaseHandler extends PublicationCollection< + ShowStyleBaseExt, + CorelibPubSub.showStyleBases, + CollectionName.ShowStyleBases +> { private _showStyleBaseId: ShowStyleBaseId | undefined private _sourceLayersMap: Map = new Map() private _outputLayersMap: Map = new Map() diff --git a/packages/live-status-gateway/src/collections/studioHandler.ts b/packages/live-status-gateway/src/collections/studioHandler.ts index b42b5390a8..32e19fb284 100644 --- a/packages/live-status-gateway/src/collections/studioHandler.ts +++ b/packages/live-status-gateway/src/collections/studioHandler.ts @@ -1,16 +1,12 @@ import { Logger } from 'winston' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { CoreHandler } from '../coreHandler' -import { Collection } from '../wsHandler' import { PublicationCollection } from '../publicationCollection' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { CollectionHandlers } from '../liveStatusServer' -export class StudioHandler - extends PublicationCollection - implements Collection -{ +export class StudioHandler extends PublicationCollection { constructor(logger: Logger, coreHandler: CoreHandler) { super(CollectionName.Studios, CorelibPubSub.studios, logger, coreHandler) } diff --git a/packages/live-status-gateway/src/publicationCollection.ts b/packages/live-status-gateway/src/publicationCollection.ts index 314979291d..77b5940bd4 100644 --- a/packages/live-status-gateway/src/publicationCollection.ts +++ b/packages/live-status-gateway/src/publicationCollection.ts @@ -10,7 +10,7 @@ import { ParametersOfFunctionOrNever } from '@sofie-automation/server-core-integ import { Logger } from 'winston' import { CollectionBase, DEFAULT_THROTTLE_PERIOD_MS } from './collectionBase' import { CoreHandler } from './coreHandler' -import { ObserverCallback } from './wsHandler' +import { ObserverCallback } from './collectionBase' export abstract class PublicationCollection< T, diff --git a/packages/live-status-gateway/src/wsHandler.ts b/packages/live-status-gateway/src/wsHandler.ts index 08e58fdc78..0018f6a8ec 100644 --- a/packages/live-status-gateway/src/wsHandler.ts +++ b/packages/live-status-gateway/src/wsHandler.ts @@ -1,7 +1,6 @@ import { Logger } from 'winston' import { WebSocket } from 'ws' import _ = require('underscore') -import { CollectionHandlers } from './liveStatusServer' export abstract class WebSocketTopicBase { protected _name: string @@ -84,19 +83,6 @@ export interface WebSocketTopic { sendMessage(ws: WebSocket, msg: object): void } -export interface Collection { - init(handlers: CollectionHandlers): void - close(): void - subscribe(callback: ObserverCallback, keys?: K[]): void - notify(data: T | undefined): void -} - -export type ObserverCallback = (data: Pick | undefined) => void - -// export interface CollectionObserver { -// observerName: string -// update(source: string, data: Pick | undefined): void -// } function isIterable(obj: T | Iterable): obj is Iterable { // checks for null and undefined if (obj == null) { From 3e74c66fc1168215b117da89e44d762f028a6f3b Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 17 Feb 2025 22:59:16 +0100 Subject: [PATCH 029/293] feat(LSG): sort buckets and their adlibs --- .../src/topics/bucketsTopic.ts | 124 +++++++++++------- 1 file changed, 77 insertions(+), 47 deletions(-) diff --git a/packages/live-status-gateway/src/topics/bucketsTopic.ts b/packages/live-status-gateway/src/topics/bucketsTopic.ts index c501b30602..e324acfcb2 100644 --- a/packages/live-status-gateway/src/topics/bucketsTopic.ts +++ b/packages/live-status-gateway/src/topics/bucketsTopic.ts @@ -12,6 +12,7 @@ import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLi import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage' import { AdLibActionType, AdLibStatus } from './adLibsTopic' import { CollectionHandlers } from '../liveStatusServer' +import { sortContent, WithSortingMetadata } from './helpers/contentSorting' const THROTTLE_PERIOD_MS = 100 @@ -50,63 +51,29 @@ export class BucketsTopic extends WebSocketTopicBase implements WebSocketTopic { } sendStatus(subscribers: Iterable): void { - const buckets: BucketStatus[] = this._buckets.map((bucket) => { + const sortedBuckets = sortContent(this._buckets.map(this.addBucketSortingMetadata)) + + const bucketStatuses: BucketStatus[] = sortedBuckets.map((bucket) => { const bucketId = unprotectString(bucket._id) - const bucketAdLibs = (this._adLibsByBucket?.[bucketId] ?? []).map((adLib) => { - const sourceLayerName = this._sourceLayersMap.get(adLib.sourceLayerId) - const outputLayerName = this._outputLayersMap.get(adLib.outputLayerId) - return literal({ - id: unprotectString(adLib._id), - name: adLib.name, - sourceLayer: sourceLayerName ?? 'invalid', - outputLayer: outputLayerName ?? 'invalid', - actionType: [], - tags: adLib.tags, - externalId: adLib.externalId, - publicData: adLib.publicData, - }) - }) - const bucketAdLibActions = (this._adLibActionsByBucket?.[bucketId] ?? []).map((action) => { - const sourceLayerName = this._sourceLayersMap.get( - (action.display as IBlueprintActionManifestDisplayContent).sourceLayerId - ) - const outputLayerName = this._outputLayersMap.get( - (action.display as IBlueprintActionManifestDisplayContent).outputLayerId - ) - const triggerModes = action.triggerModes - ? action.triggerModes.map((t) => - literal({ - name: t.data, - label: interpollateTranslation(t.display.label.key, t.display.label.args), - }) - ) - : [] - return literal({ - id: unprotectString(action._id), - name: interpollateTranslation(action.display.label.key, action.display.label.args), - sourceLayer: sourceLayerName ?? 'invalid', - outputLayer: outputLayerName ?? 'invalid', - actionType: triggerModes, - tags: action.display.tags, - externalId: action.externalId, - publicData: action.publicData, - }) - }) + + const bucketAdLibs = (this._adLibsByBucket?.[bucketId] ?? []).map(this.toSortableBucketAdLib) + const bucketAdLibActions = (this._adLibActionsByBucket?.[bucketId] ?? []).map( + this.toSortableBucketAdLibAction + ) + return { id: bucketId, name: bucket.name, - adLibs: [...bucketAdLibs, ...bucketAdLibActions], + adLibs: sortContent([...bucketAdLibs, ...bucketAdLibActions]), } }) const bucketsStatus: BucketsStatus = { event: 'buckets', - buckets: buckets, + buckets: bucketStatuses, } - for (const subscriber of subscribers) { - this.sendMessage(subscriber, bucketsStatus) - } + this.sendMessage(subscribers, bucketsStatus) } private onShowStyleBaseUpdate = (showStyleBase: ShowStyle | undefined): void => { @@ -118,7 +85,8 @@ export class BucketsTopic extends WebSocketTopicBase implements WebSocketTopic { private onBucketsUpdate = (buckets: Bucket[] | undefined): void => { this.logUpdateReceived('buckets') - this._buckets = buckets ?? [] + buckets ??= [] + this._buckets = sortContent(buckets.map(this.addBucketSortingMetadata)) this.throttledSendStatusToAll() } @@ -133,4 +101,66 @@ export class BucketsTopic extends WebSocketTopicBase implements WebSocketTopic { this._adLibsByBucket = _.groupBy(adLibs ?? [], 'bucketId') this.throttledSendStatusToAll() } + + private addBucketSortingMetadata = (bucket: Bucket): WithSortingMetadata => { + return { + obj: bucket, + id: unprotectString(bucket._id), + itemRank: bucket._rank, + label: bucket.name, + } + } + + private toSortableBucketAdLib = (adLib: BucketAdLib): WithSortingMetadata => { + const sourceLayerName = this._sourceLayersMap.get(adLib.sourceLayerId) + const outputLayerName = this._outputLayersMap.get(adLib.outputLayerId) + return { + obj: { + id: unprotectString(adLib._id), + name: adLib.name, + sourceLayer: sourceLayerName ?? 'invalid', + outputLayer: outputLayerName ?? 'invalid', + actionType: [], + tags: adLib.tags, + externalId: adLib.externalId, + publicData: adLib.publicData, + }, + id: unprotectString(adLib._id), + itemRank: adLib._rank, + label: adLib.name, + } + } + + private toSortableBucketAdLibAction = (action: BucketAdLibAction): WithSortingMetadata => { + const sourceLayerName = this._sourceLayersMap.get( + (action.display as IBlueprintActionManifestDisplayContent).sourceLayerId + ) + const outputLayerName = this._outputLayersMap.get( + (action.display as IBlueprintActionManifestDisplayContent).outputLayerId + ) + const triggerModes = action.triggerModes + ? action.triggerModes.map((t) => + literal({ + name: t.data, + label: interpollateTranslation(t.display.label.key, t.display.label.args), + }) + ) + : [] + const name = interpollateTranslation(action.display.label.key, action.display.label.args) + return { + obj: { + id: unprotectString(action._id), + name, + sourceLayer: sourceLayerName ?? 'invalid', + outputLayer: outputLayerName ?? 'invalid', + actionType: triggerModes, + tags: action.display.tags, + externalId: action.externalId, + publicData: action.publicData, + }, + id: unprotectString(action._id), + itemRank: action.display._rank, + label: name, + } + } } From 14696fd437426d20077892bc711064a349bfac12 Mon Sep 17 00:00:00 2001 From: Krzysztof Zegzula Date: Mon, 17 Feb 2025 23:16:29 +0100 Subject: [PATCH 030/293] chore: update api docs Co-authored-by: Jan Starzak --- packages/live-status-gateway/api/schemas/packages.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/live-status-gateway/api/schemas/packages.yaml b/packages/live-status-gateway/api/schemas/packages.yaml index 318d74c57b..3c125e34bf 100644 --- a/packages/live-status-gateway/api/schemas/packages.yaml +++ b/packages/live-status-gateway/api/schemas/packages.yaml @@ -1,5 +1,5 @@ title: Packages -description: Packages schema for websocket subscriptions +description: Packages schema for websocket subscriptions. Packages are assets that need to be prepared by Sofie Package Manager or third-party systems to make AdLibs and Pieces playable. $defs: packages: type: object From 3edfcd8b306044f5a1aa09b527ae8477f83ac367 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 17 Feb 2025 23:42:46 +0100 Subject: [PATCH 031/293] fix(LSG): don't return null for `packageName` --- packages/live-status-gateway/api/schemas/packages.yaml | 6 ++---- packages/live-status-gateway/src/topics/packagesTopic.ts | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/live-status-gateway/api/schemas/packages.yaml b/packages/live-status-gateway/api/schemas/packages.yaml index 3c125e34bf..dad18b144b 100644 --- a/packages/live-status-gateway/api/schemas/packages.yaml +++ b/packages/live-status-gateway/api/schemas/packages.yaml @@ -29,9 +29,7 @@ $defs: properties: packageName: description: Name of the package - oneOf: - - type: string - - type: 'null' + type: string statusCode: description: Status code type: number @@ -53,7 +51,7 @@ $defs: previewUrl: description: URL where the preview can be accessed type: string - required: [packageName, statusCode, rundownId, pieceId] + required: [statusCode, rundownId, pieceId] additionalProperties: false examples: - packageName: 'MV000123.mxf' diff --git a/packages/live-status-gateway/src/topics/packagesTopic.ts b/packages/live-status-gateway/src/topics/packagesTopic.ts index a8e0012799..c9d9111db4 100644 --- a/packages/live-status-gateway/src/topics/packagesTopic.ts +++ b/packages/live-status-gateway/src/topics/packagesTopic.ts @@ -10,7 +10,7 @@ import { CollectionHandlers } from '../liveStatusServer' const THROTTLE_PERIOD_MS = 200 interface PackageStatus { - packageName: string | null + packageName?: string statusCode: PieceStatusCode rundownId: string @@ -49,7 +49,7 @@ export class PackagesTopic extends WebSocketTopicBase implements WebSocketTopic event: 'packages', rundownPlaylistId: this._activePlaylist ? unprotectString(this._activePlaylist._id) : null, packages: this._pieceContentStatuses.map((contentStatus) => ({ - packageName: contentStatus.status.packageName, + packageName: contentStatus.status.packageName ?? undefined, statusCode: contentStatus.status.status, pieceId: unprotectString(contentStatus.pieceId), rundownId: unprotectString(contentStatus.rundownId), From 6cb24a9898bbd5c42a23c4f37303740c99e37f11 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 18 Feb 2025 00:46:39 +0100 Subject: [PATCH 032/293] chore(LSG): update packages topic docs --- packages/live-status-gateway/api/asyncapi.yaml | 1 + .../api/schemas/activePlaylist.yaml | 2 +- .../live-status-gateway/api/schemas/packages.yaml | 14 +++++++------- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/live-status-gateway/api/asyncapi.yaml b/packages/live-status-gateway/api/asyncapi.yaml index 6847f5dd6d..5bfea9bd48 100644 --- a/packages/live-status-gateway/api/asyncapi.yaml +++ b/packages/live-status-gateway/api/asyncapi.yaml @@ -127,6 +127,7 @@ components: $ref: './schemas/adLibs.yaml#/$defs/adLibs' packages: name: packages + messageId: packages description: Status of Packages expected by Pieces payload: $ref: './schemas/packages.yaml#/$defs/packages' diff --git a/packages/live-status-gateway/api/schemas/activePlaylist.yaml b/packages/live-status-gateway/api/schemas/activePlaylist.yaml index 2624e4e684..3b363ddb3c 100644 --- a/packages/live-status-gateway/api/schemas/activePlaylist.yaml +++ b/packages/live-status-gateway/api/schemas/activePlaylist.yaml @@ -14,7 +14,7 @@ $defs: description: User-presentable name for the active playlist type: string rundownIds: - description: The set of rundownIds in the active playlist + description: The set of rundownIds in the active playlist, in order type: array items: type: string diff --git a/packages/live-status-gateway/api/schemas/packages.yaml b/packages/live-status-gateway/api/schemas/packages.yaml index dad18b144b..f60fffce5d 100644 --- a/packages/live-status-gateway/api/schemas/packages.yaml +++ b/packages/live-status-gateway/api/schemas/packages.yaml @@ -34,16 +34,16 @@ $defs: description: Status code type: number rundownId: - description: Id of the Rundown that expects this package + description: Id of the Rundown that a Piece (or AdLib) expecting this package belongs to type: string partId: - description: Id of the Part that expects this package + description: Id of the Part that a Piece (or AdLib) expecting this package belongs to. It could be an Id of a Part from the Active Playlist topic, or a Part not exposed otherwise by the LSG. type: string segmentId: - description: Id of the Segment that expects this package + description: Id of the Segment that a Piece (or AdLib) expecting this package belongs to type: string - pieceId: - description: Id of the Piece that expects this package + pieceOrAdLibId: + description: Id of the Piece or AdLib that expects this package. It could be an Id of a Piece from the Active Pieces and Active Playlist topics, or an Id of an AdLib from the AdLibs topic. It could also be an Id of a Piece not exposed otherwise by the LSG, but still relevant, e.g. to summarize the status of packages within a specific Part/Segment. type: string thumbnailUrl: description: URL where the thumbnail can be accessed @@ -51,11 +51,11 @@ $defs: previewUrl: description: URL where the preview can be accessed type: string - required: [statusCode, rundownId, pieceId] + required: [statusCode, rundownId, pieceOrAdLibId] additionalProperties: false examples: - packageName: 'MV000123.mxf' statusCode: 0 rundownId: 'y9HauyWkcxQS3XaAOsW40BRLLsI_' - pieceId: 'C6K_yIMuGFUk8X_L9A9_jRT6aq4_' + pieceOrAdLibId: 'C6K_yIMuGFUk8X_L9A9_jRT6aq4_' thumbnailUrl: 'https://package-manager.local/package/MV000123.mov_thumbnail.jpg' From 4e91099ac407e3630e7f1eade35955777b89b947 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 18 Feb 2025 01:39:25 +0100 Subject: [PATCH 033/293] fix(LSG): expose package status as custom enum --- .../api/schemas/packages.yaml | 28 +++++++++--- .../topics/__tests__/packagesTopic.spec.ts | 4 +- .../src/topics/packagesTopic.ts | 44 +++++++++++++++++-- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/live-status-gateway/api/schemas/packages.yaml b/packages/live-status-gateway/api/schemas/packages.yaml index f60fffce5d..23bb0a7368 100644 --- a/packages/live-status-gateway/api/schemas/packages.yaml +++ b/packages/live-status-gateway/api/schemas/packages.yaml @@ -30,9 +30,27 @@ $defs: packageName: description: Name of the package type: string - statusCode: - description: Status code - type: number + status: + type: string + enum: + - unknown + - ok + - source_broken + - source_has_issues + - source_missing + - source_not_ready + - source_not_set + - source_unknown_state + description: | + Status: + * `unknown` - status not determined (yet) + * `ok` - no faults, can be played + * `source_broken` - the source is present, but should not be played due to a technical malfunction (file is broken, camera robotics failed, REMOTE input is just bars, etc.) + * `source_has_issues` - technically it can be played, but some issues with it were detected + * `source_missing` - the source (file, live input) is missing and cannot be played + * `source_not_ready` - can't be played for a non-technical reason (e.g. a placeholder clip with no content) + * `source_not_set` - missing a file path + * `source_unknown_state` - reported, but unrecognized state rundownId: description: Id of the Rundown that a Piece (or AdLib) expecting this package belongs to type: string @@ -51,11 +69,11 @@ $defs: previewUrl: description: URL where the preview can be accessed type: string - required: [statusCode, rundownId, pieceOrAdLibId] + required: [status, rundownId, pieceOrAdLibId] additionalProperties: false examples: - packageName: 'MV000123.mxf' - statusCode: 0 + status: ok rundownId: 'y9HauyWkcxQS3XaAOsW40BRLLsI_' pieceOrAdLibId: 'C6K_yIMuGFUk8X_L9A9_jRT6aq4_' thumbnailUrl: 'https://package-manager.local/package/MV000123.mov_thumbnail.jpg' diff --git a/packages/live-status-gateway/src/topics/__tests__/packagesTopic.spec.ts b/packages/live-status-gateway/src/topics/__tests__/packagesTopic.spec.ts index c35ca0b4ec..ed8a73cd96 100644 --- a/packages/live-status-gateway/src/topics/__tests__/packagesTopic.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/packagesTopic.spec.ts @@ -1,6 +1,6 @@ import { protectString, unprotectString } from '@sofie-automation/server-core-integration' import { makeMockHandlers, makeMockLogger, makeMockSubscriber } from './utils' -import { PackagesTopic } from '../packagesTopic' +import { PackageStatus, PackagesTopic } from '../packagesTopic' import { UIPieceContentStatus } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' @@ -61,7 +61,7 @@ describe('PackagesTopic', () => { packages: [ { packageName: 'Test Package', - statusCode: PieceStatusCode.OK, + status: PackageStatus.OK, pieceId: 'PIECE_0', rundownId: 'RUNDOWN_0', partId: 'PART_0', diff --git a/packages/live-status-gateway/src/topics/packagesTopic.ts b/packages/live-status-gateway/src/topics/packagesTopic.ts index c9d9111db4..d1cf178fe1 100644 --- a/packages/live-status-gateway/src/topics/packagesTopic.ts +++ b/packages/live-status-gateway/src/topics/packagesTopic.ts @@ -6,12 +6,13 @@ import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protected import { UIPieceContentStatus } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { CollectionHandlers } from '../liveStatusServer' +import { assertNever } from '@sofie-automation/server-core-integration' const THROTTLE_PERIOD_MS = 200 -interface PackageStatus { +interface Package { packageName?: string - statusCode: PieceStatusCode + status: PackageStatus rundownId: string partId?: string @@ -23,10 +24,21 @@ interface PackageStatus { previewUrl?: string } +export enum PackageStatus { + UNKNOWN = 'unknown', + OK = 'ok', + SOURCE_BROKEN = 'source_broken', + SOURCE_HAS_ISSUES = 'source_has_issues', + SOURCE_MISSING = 'source_missing', + SOURCE_NOT_READY = 'source_not_ready', + SOURCE_NOT_SET = 'source_not_set', + SOURCE_UNKNOWN_STATE = 'source_unknown_state', +} + export interface PackagesStatus { event: 'packages' rundownPlaylistId: string | null - packages: PackageStatus[] + packages: Package[] } const PLAYLIST_KEYS = ['_id', 'activationId'] as const @@ -50,7 +62,7 @@ export class PackagesTopic extends WebSocketTopicBase implements WebSocketTopic rundownPlaylistId: this._activePlaylist ? unprotectString(this._activePlaylist._id) : null, packages: this._pieceContentStatuses.map((contentStatus) => ({ packageName: contentStatus.status.packageName ?? undefined, - statusCode: contentStatus.status.status, + status: this.toStatusString(contentStatus.status.status), pieceId: unprotectString(contentStatus.pieceId), rundownId: unprotectString(contentStatus.rundownId), partId: unprotectString(contentStatus.partId), @@ -65,6 +77,30 @@ export class PackagesTopic extends WebSocketTopicBase implements WebSocketTopic } } + private toStatusString(status: PieceStatusCode): PackageStatus { + switch (status) { + case PieceStatusCode.UNKNOWN: + return PackageStatus.UNKNOWN + case PieceStatusCode.OK: + return PackageStatus.OK + case PieceStatusCode.SOURCE_BROKEN: + return PackageStatus.SOURCE_BROKEN + case PieceStatusCode.SOURCE_HAS_ISSUES: + return PackageStatus.SOURCE_HAS_ISSUES + case PieceStatusCode.SOURCE_MISSING: + return PackageStatus.SOURCE_MISSING + case PieceStatusCode.SOURCE_NOT_READY: + return PackageStatus.SOURCE_NOT_READY + case PieceStatusCode.SOURCE_NOT_SET: + return PackageStatus.SOURCE_NOT_SET + case PieceStatusCode.SOURCE_UNKNOWN_STATE: + return PackageStatus.SOURCE_UNKNOWN_STATE + default: + assertNever(status) + return PackageStatus.UNKNOWN + } + } + private onPlaylistUpdate = (rundownPlaylist: Playlist | undefined): void => { this.logUpdateReceived( 'playlist', From 1405a086e49cb3ff60241d3e06cf897299860603 Mon Sep 17 00:00:00 2001 From: ianshade Date: Wed, 19 Feb 2025 17:21:02 +0100 Subject: [PATCH 034/293] refactor(EAV-296): rename `isCurrent` to `isActive` --- .../StudioDeviceTriggerManager.ts | 6 ++-- .../server/api/deviceTriggers/TagsService.ts | 31 +++++++------------ .../__tests__/TagsService.test.ts | 10 +++--- packages/corelib/src/lib.ts | 8 +++++ .../input-gateway/deviceTriggerPreviews.ts | 2 +- 5 files changed, 29 insertions(+), 28 deletions(-) diff --git a/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts b/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts index 35420430a8..828d9d6b3f 100644 --- a/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts +++ b/meteor/server/api/deviceTriggers/StudioDeviceTriggerManager.ts @@ -170,7 +170,7 @@ export class StudioDeviceTriggerManager { sourceLayerType: undefined, sourceLayerName: undefined, styleClassNames: triggeredAction.styleClassNames, - isCurrent: undefined, + isActive: undefined, isNext: undefined, }), }) @@ -185,7 +185,7 @@ export class StudioDeviceTriggerManager { addedPreviewIds.push(adLibPreviewId) this.tagsService.observeTallyTags(adLib) - const { isCurrent, isNext } = this.tagsService.getTallyStateFromTags(adLib) + const { isActive, isNext } = this.tagsService.getTallyStateFromTags(adLib) return DeviceTriggerMountedActionAdlibsPreview.upsertAsync(adLibPreviewId, { $set: literal({ ...adLib, @@ -204,7 +204,7 @@ export class StudioDeviceTriggerManager { } : undefined, styleClassNames: triggeredAction.styleClassNames, - isCurrent, + isActive, isNext, }), }) diff --git a/meteor/server/api/deviceTriggers/TagsService.ts b/meteor/server/api/deviceTriggers/TagsService.ts index 4894512ae5..499a764f5b 100644 --- a/meteor/server/api/deviceTriggers/TagsService.ts +++ b/meteor/server/api/deviceTriggers/TagsService.ts @@ -9,6 +9,7 @@ import { } from '@sofie-automation/corelib/dist/playout/processAndPrune' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { IWrappedAdLib } from '@sofie-automation/meteor-lib/dist/triggers/actionFilterChainCompilers' +import { areSetsEqual, doSetsIntersect } from '@sofie-automation/corelib/dist/lib' export class TagsService { protected onAirPiecesTags: Set = new Set() @@ -28,14 +29,14 @@ export class TagsService { } } - public getTallyStateFromTags(adLib: IWrappedAdLib): { isCurrent: boolean; isNext: boolean } { - let isCurrent = false + public getTallyStateFromTags(adLib: IWrappedAdLib): { isActive: boolean; isNext: boolean } { + let isActive = false let isNext = false if ('currentPieceTags' in adLib && adLib.currentPieceTags) { - isCurrent = adLib.currentPieceTags.every((tag) => this.onAirPiecesTags.has(tag)) + isActive = adLib.currentPieceTags.every((tag) => this.onAirPiecesTags.has(tag)) isNext = adLib.currentPieceTags.every((tag) => this.nextPiecesTags.has(tag)) } - return { isCurrent, isNext } + return { isActive, isNext } } /** @@ -113,24 +114,16 @@ export class TagsService { private shouldUpdateTriggers(activePieceInstancesTags: Set, nextPieceInstancesTags: Set) { return ( - (!this.areSetsEqual(this.onAirPiecesTags, activePieceInstancesTags) || - !this.areSetsEqual(this.nextPiecesTags, nextPieceInstancesTags)) && - (this.doSetsIntersect(activePieceInstancesTags, this.tagsObservedByTriggers) || - this.doSetsIntersect(nextPieceInstancesTags, this.tagsObservedByTriggers) || - this.doSetsIntersect(this.onAirPiecesTags, this.tagsObservedByTriggers) || - this.doSetsIntersect(this.nextPiecesTags, this.tagsObservedByTriggers)) + (!areSetsEqual(this.onAirPiecesTags, activePieceInstancesTags) || + !areSetsEqual(this.nextPiecesTags, nextPieceInstancesTags)) && + (doSetsIntersect(activePieceInstancesTags, this.tagsObservedByTriggers) || + doSetsIntersect(nextPieceInstancesTags, this.tagsObservedByTriggers) || + doSetsIntersect(this.onAirPiecesTags, this.tagsObservedByTriggers) || + doSetsIntersect(this.nextPiecesTags, this.tagsObservedByTriggers)) ) } - protected areSetsEqual(a: Set, b: Set): boolean { - return a.size === b.size && [...a].every((value) => b.has(value)) - } - - protected doSetsIntersect(a: Set, b: Set): boolean { - return [...a].some((value) => b.has(value)) - } - - protected processAndPrunePieceInstanceTimings( + private processAndPrunePieceInstanceTimings( partInstanceTimings: DBPartInstance['timings'] | undefined, pieceInstances: Array>, sourceLayers: SourceLayers diff --git a/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts b/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts index 3da3c4435e..1248e39158 100644 --- a/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts +++ b/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts @@ -145,7 +145,7 @@ describe('TagsService', () => { testee.updatePieceInstances(cache, showStyleBaseId) const result = testee.getTallyStateFromTags({} as IWrappedAdLib) - expect(result).toEqual({ isCurrent: false, isNext: false }) + expect(result).toEqual({ isActive: false, isNext: false }) }) test('adlib that is neither on air or next', () => { @@ -156,7 +156,7 @@ describe('TagsService', () => { const result = testee.getTallyStateFromTags({ currentPieceTags: [tag3], } as IWrappedAdLib) - expect(result).toEqual({ isCurrent: false, isNext: false }) + expect(result).toEqual({ isActive: false, isNext: false }) }) test('adlib that is both on air and next', () => { @@ -168,7 +168,7 @@ describe('TagsService', () => { currentPieceTags: [tag2], } as IWrappedAdLib) - expect(result).toEqual({ isCurrent: true, isNext: true }) + expect(result).toEqual({ isActive: true, isNext: true }) }) test('adlib that is only on air', () => { @@ -179,7 +179,7 @@ describe('TagsService', () => { const result = testee.getTallyStateFromTags({ currentPieceTags: [tag0], } as IWrappedAdLib) - expect(result).toEqual({ isCurrent: true, isNext: false }) + expect(result).toEqual({ isActive: true, isNext: false }) }) test('adlib that is only next', () => { @@ -190,7 +190,7 @@ describe('TagsService', () => { const result = testee.getTallyStateFromTags({ currentPieceTags: [tag1], } as IWrappedAdLib) - expect(result).toEqual({ isCurrent: false, isNext: true }) + expect(result).toEqual({ isActive: false, isNext: true }) }) test('updatePieceInstances returns true if observed tags are present in pieces', () => { diff --git a/packages/corelib/src/lib.ts b/packages/corelib/src/lib.ts index 399db4fead..a45f97fcbb 100644 --- a/packages/corelib/src/lib.ts +++ b/packages/corelib/src/lib.ts @@ -469,3 +469,11 @@ export function generateTranslation( namespaces, } } + +export function areSetsEqual(a: Set, b: Set): boolean { + return a.size === b.size && [...a].every((value) => b.has(value)) +} + +export function doSetsIntersect(a: Set, b: Set): boolean { + return [...a].some((value) => b.has(value)) +} diff --git a/packages/shared-lib/src/input-gateway/deviceTriggerPreviews.ts b/packages/shared-lib/src/input-gateway/deviceTriggerPreviews.ts index a44387d412..c8c2c0a46d 100644 --- a/packages/shared-lib/src/input-gateway/deviceTriggerPreviews.ts +++ b/packages/shared-lib/src/input-gateway/deviceTriggerPreviews.ts @@ -61,6 +61,6 @@ export type PreviewWrappedAdLib = Omit & { } | undefined styleClassNames: string | undefined - isCurrent: boolean | undefined + isActive: boolean | undefined isNext: boolean | undefined } From c8e669f333010cc88930d1684bd2d2795104cc88 Mon Sep 17 00:00:00 2001 From: olzzon Date: Thu, 20 Feb 2025 09:05:06 +0100 Subject: [PATCH 035/293] feat: GW config types in Blueprints --- .../blueprints-integration/src/api/studio.ts | 8 +++- packages/scripts/schema-types.mjs | 4 ++ .../LiveStatusGatewayOptionsTypes.ts | 13 ++++++ .../src/generated/MosGatewayDevicesTypes.ts | 40 +++++++++++++++++++ .../src/generated/MosGatewayOptionsTypes.ts | 17 ++++++++ .../generated/PlayoutGatewayConfigTypes.ts | 34 ++++++++++++++++ 6 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/shared-lib/src/generated/LiveStatusGatewayOptionsTypes.ts create mode 100644 packages/shared-lib/src/generated/MosGatewayDevicesTypes.ts create mode 100644 packages/shared-lib/src/generated/MosGatewayOptionsTypes.ts create mode 100644 packages/shared-lib/src/generated/PlayoutGatewayConfigTypes.ts diff --git a/packages/blueprints-integration/src/api/studio.ts b/packages/blueprints-integration/src/api/studio.ts index 32031f9d65..18113cfca0 100644 --- a/packages/blueprints-integration/src/api/studio.ts +++ b/packages/blueprints-integration/src/api/studio.ts @@ -28,6 +28,8 @@ import type { } from '@sofie-automation/shared-lib/dist/core/model/StudioRouteSet' import type { StudioPackageContainer } from '@sofie-automation/shared-lib/dist/core/model/PackageContainer' import type { IStudioSettings } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings' +import type { MosDeviceConfig } from '@sofie-automation/shared-lib/dist/generated/MosGatewayDevicesTypes' +import type { PlayoutGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/PlayoutGatewayConfigTypes' export interface StudioBlueprintManifest extends BlueprintManifestBase { @@ -149,7 +151,7 @@ export interface BlueprintResultApplyStudioConfig { /** Playout-gateway subdevices */ playoutDevices: Record /** Ingest-gateway subdevices, the types here depend on the gateway you use */ - ingestDevices: Record + ingestDevices: Record /** Input-gateway subdevices */ inputDevices: Record /** Route Sets */ @@ -170,6 +172,10 @@ export interface BlueprintParentDeviceSettings { options: Record } +export type BlueprintMosDeviceConfig = MosDeviceConfig + +export type BlueprintPlayoutGatewayConfig = PlayoutGatewayConfig + export interface IStudioConfigPreset { name: string diff --git a/packages/scripts/schema-types.mjs b/packages/scripts/schema-types.mjs index 03ba65f454..799bfbef18 100644 --- a/packages/scripts/schema-types.mjs +++ b/packages/scripts/schema-types.mjs @@ -23,6 +23,7 @@ try { }) await fs.writeFile('./playout-gateway/src/generated/options.ts', BANNER + '\n' + schema) + await fs.writeFile('./shared-lib/src/generated/PlayoutGatewayConfigTypes.ts', BANNER + '\n' + schema) } catch (e) { console.error('Error while generating playout-gateway options.json, continuing...') console.error(e) @@ -37,6 +38,7 @@ try { }) await fs.writeFile('./mos-gateway/src/generated/options.ts', BANNER + '\n' + schema) + await fs.writeFile('./shared-lib/src/generated/MosGatewayOptionsTypes.ts', BANNER + '\n' + schema) } catch (e) { console.error('Error while generating mos-gateway options.json, continuing...') console.error(e) @@ -49,6 +51,7 @@ try { }) await fs.writeFile('./mos-gateway/src/generated/devices.ts', BANNER + '\n' + schema) + await fs.writeFile('./shared-lib/src/generated/MosGatewayDevicesTypes.ts', BANNER + '\n' + schema) } catch (e) { console.error('Error while generating mos-gateway devices.json, continuing...') console.error(e) @@ -63,6 +66,7 @@ try { }) await fs.writeFile('./live-status-gateway/src/generated/options.ts', BANNER + '\n' + schema) + await fs.writeFile('./shared-lib/src/generated/LiveStatusGatewayOptionsTypes.ts', BANNER + '\n' + schema) } catch (e) { console.error('Error while generating live-status-gateway options.json, continuing...') console.error(e) diff --git a/packages/shared-lib/src/generated/LiveStatusGatewayOptionsTypes.ts b/packages/shared-lib/src/generated/LiveStatusGatewayOptionsTypes.ts new file mode 100644 index 0000000000..99d75b2319 --- /dev/null +++ b/packages/shared-lib/src/generated/LiveStatusGatewayOptionsTypes.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run "yarn generate-schema-types" to regenerate this file. + */ + +export interface LiveStatusGatewayConfig { + /** + * Activate Debug Logging + */ + debugLogging?: boolean +} diff --git a/packages/shared-lib/src/generated/MosGatewayDevicesTypes.ts b/packages/shared-lib/src/generated/MosGatewayDevicesTypes.ts new file mode 100644 index 0000000000..b6ebdc5665 --- /dev/null +++ b/packages/shared-lib/src/generated/MosGatewayDevicesTypes.ts @@ -0,0 +1,40 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run "yarn generate-schema-types" to regenerate this file. + */ + +export interface MosDeviceConfig { + primary: { + id: string + host: string + dontUseQueryPort?: boolean + timeout?: number + heartbeatInterval?: number + ports?: { + lower: number + upper: number + query: number + } + } + secondary?: { + id: string + host: string + dontUseQueryPort?: boolean + timeout?: number + heartbeatInterval?: number + openMediaHotStandby?: boolean + ports?: { + lower: number + upper: number + query: number + } + } + statuses: MosDeviceStatusesConfig +} +export interface MosDeviceStatusesConfig { + enabled: boolean + sendInRehearsal?: boolean + onlySendPlay?: boolean +} diff --git a/packages/shared-lib/src/generated/MosGatewayOptionsTypes.ts b/packages/shared-lib/src/generated/MosGatewayOptionsTypes.ts new file mode 100644 index 0000000000..a5dcc78bd2 --- /dev/null +++ b/packages/shared-lib/src/generated/MosGatewayOptionsTypes.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run "yarn generate-schema-types" to regenerate this file. + */ + +export interface MosGatewayConfig { + mosId: string + debugLogging?: boolean + strict?: boolean + ports?: { + lower: number + upper: number + query: number + } +} diff --git a/packages/shared-lib/src/generated/PlayoutGatewayConfigTypes.ts b/packages/shared-lib/src/generated/PlayoutGatewayConfigTypes.ts new file mode 100644 index 0000000000..d07edd8909 --- /dev/null +++ b/packages/shared-lib/src/generated/PlayoutGatewayConfigTypes.ts @@ -0,0 +1,34 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run "yarn generate-schema-types" to regenerate this file. + */ + +export interface PlayoutGatewayConfig { + /** + * Activate Debug Logging + */ + debugLogging?: boolean + debugState?: boolean + /** + * Activate Multi-Threading + */ + multiThreading?: boolean + /** + * Requires restart of Gateway to apply + */ + multiThreadedResolver?: boolean + /** + * Requires restart of Gateway to apply + */ + useCacheWhenResolving?: boolean + /** + * Report command timings on all commands + */ + reportAllCommands?: boolean + /** + * Adjust resolve-time estimation + */ + estimateResolveTimeMultiplier?: number +} From f54d9ca63bc00a05915aac45e0be5b595c980567 Mon Sep 17 00:00:00 2001 From: olzzon Date: Thu, 20 Feb 2025 09:19:25 +0100 Subject: [PATCH 036/293] feat: move GW config types to generated in shared lib --- .../blueprints-integration/src/api/studio.ts | 6 ++++ .../live-status-gateway/src/coreHandler.ts | 2 +- .../src/generated/options.ts | 13 ------- packages/mos-gateway/src/generated/devices.ts | 34 ------------------- packages/mos-gateway/src/generated/options.ts | 17 ---------- packages/mos-gateway/src/mosHandler.ts | 4 +-- packages/playout-gateway/src/coreHandler.ts | 2 +- .../playout-gateway/src/generated/options.ts | 34 ------------------- packages/playout-gateway/src/tsrHandler.ts | 2 +- packages/scripts/schema-types.mjs | 4 --- 10 files changed, 11 insertions(+), 107 deletions(-) delete mode 100644 packages/live-status-gateway/src/generated/options.ts delete mode 100644 packages/mos-gateway/src/generated/devices.ts delete mode 100644 packages/mos-gateway/src/generated/options.ts delete mode 100644 packages/playout-gateway/src/generated/options.ts diff --git a/packages/blueprints-integration/src/api/studio.ts b/packages/blueprints-integration/src/api/studio.ts index 18113cfca0..f6f5a5b648 100644 --- a/packages/blueprints-integration/src/api/studio.ts +++ b/packages/blueprints-integration/src/api/studio.ts @@ -29,7 +29,9 @@ import type { import type { StudioPackageContainer } from '@sofie-automation/shared-lib/dist/core/model/PackageContainer' import type { IStudioSettings } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings' import type { MosDeviceConfig } from '@sofie-automation/shared-lib/dist/generated/MosGatewayDevicesTypes' +import type { MosGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/MosGatewayOptionsTypes' import type { PlayoutGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/PlayoutGatewayConfigTypes' +import type { LiveStatusGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/LiveStatusGatewayOptionsTypes' export interface StudioBlueprintManifest extends BlueprintManifestBase { @@ -172,10 +174,14 @@ export interface BlueprintParentDeviceSettings { options: Record } +export type BlueprintMosGatewayConfig = MosGatewayConfig + export type BlueprintMosDeviceConfig = MosDeviceConfig export type BlueprintPlayoutGatewayConfig = PlayoutGatewayConfig +export type BlueprintLiveStatusGatewayConfig = LiveStatusGatewayConfig + export interface IStudioConfigPreset { name: string diff --git a/packages/live-status-gateway/src/coreHandler.ts b/packages/live-status-gateway/src/coreHandler.ts index 254f308b3d..62840acb52 100644 --- a/packages/live-status-gateway/src/coreHandler.ts +++ b/packages/live-status-gateway/src/coreHandler.ts @@ -23,7 +23,7 @@ import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedSt 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' +import { LiveStatusGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/LiveStatusGatewayOptionsTypes' import { CorelibPubSubTypes, CorelibPubSubCollections } from '@sofie-automation/corelib/dist/pubsub' import { ParametersOfFunctionOrNever } from '@sofie-automation/server-core-integration/dist/lib/subscriptions' diff --git a/packages/live-status-gateway/src/generated/options.ts b/packages/live-status-gateway/src/generated/options.ts deleted file mode 100644 index 99d75b2319..0000000000 --- a/packages/live-status-gateway/src/generated/options.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint-disable */ -/** - * This file was automatically generated by json-schema-to-typescript. - * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, - * and run "yarn generate-schema-types" to regenerate this file. - */ - -export interface LiveStatusGatewayConfig { - /** - * Activate Debug Logging - */ - debugLogging?: boolean -} diff --git a/packages/mos-gateway/src/generated/devices.ts b/packages/mos-gateway/src/generated/devices.ts deleted file mode 100644 index f192cf7614..0000000000 --- a/packages/mos-gateway/src/generated/devices.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* eslint-disable */ -/** - * This file was automatically generated by json-schema-to-typescript. - * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, - * and run "yarn generate-schema-types" to regenerate this file. - */ - -export interface MosDeviceConfig { - primary: { - id: string - host: string - dontUseQueryPort?: boolean - timeout?: number - heartbeatInterval?: number - ports?: { - lower: number - upper: number - query: number - } - } - secondary?: { - id: string - host: string - dontUseQueryPort?: boolean - timeout?: number - heartbeatInterval?: number - openMediaHotStandby?: boolean - ports?: { - lower: number - upper: number - query: number - } - } -} diff --git a/packages/mos-gateway/src/generated/options.ts b/packages/mos-gateway/src/generated/options.ts deleted file mode 100644 index a5dcc78bd2..0000000000 --- a/packages/mos-gateway/src/generated/options.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable */ -/** - * This file was automatically generated by json-schema-to-typescript. - * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, - * and run "yarn generate-schema-types" to regenerate this file. - */ - -export interface MosGatewayConfig { - mosId: string - debugLogging?: boolean - strict?: boolean - ports?: { - lower: number - upper: number - query: number - } -} diff --git a/packages/mos-gateway/src/mosHandler.ts b/packages/mos-gateway/src/mosHandler.ts index 6a61e5c6fc..def8f15811 100644 --- a/packages/mos-gateway/src/mosHandler.ts +++ b/packages/mos-gateway/src/mosHandler.ts @@ -32,8 +32,8 @@ import { DEFAULT_MOS_TIMEOUT_TIME, DEFAULT_MOS_HEARTBEAT_INTERVAL, } from '@sofie-automation/shared-lib/dist/core/constants' -import { MosGatewayConfig } from './generated/options' -import { MosDeviceConfig } from './generated/devices' +import { MosGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/MosGatewayOptionsTypes' +import { MosDeviceConfig } from '@sofie-automation/shared-lib/dist/generated/MosGatewayDevicesTypes' import { PeripheralDeviceForDevice } from '@sofie-automation/server-core-integration' export interface MosConfig { diff --git a/packages/playout-gateway/src/coreHandler.ts b/packages/playout-gateway/src/coreHandler.ts index 1f73222973..2f63d2f0af 100644 --- a/packages/playout-gateway/src/coreHandler.ts +++ b/packages/playout-gateway/src/coreHandler.ts @@ -23,7 +23,7 @@ import { PLAYOUT_DEVICE_CONFIG } from './configManifest' import { BaseRemoteDeviceIntegration } from 'timeline-state-resolver/dist/service/remoteDeviceInstance' import { getVersions } from './versions' import { CoreConnectionChild } from '@sofie-automation/server-core-integration/dist/lib/CoreConnectionChild' -import { PlayoutGatewayConfig } from './generated/options' +import { PlayoutGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/PlayoutGatewayConfigTypes' import { PeripheralDeviceCommandId } from '@sofie-automation/shared-lib/dist/core/model/Ids' export interface CoreConfig { diff --git a/packages/playout-gateway/src/generated/options.ts b/packages/playout-gateway/src/generated/options.ts deleted file mode 100644 index d07edd8909..0000000000 --- a/packages/playout-gateway/src/generated/options.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* eslint-disable */ -/** - * This file was automatically generated by json-schema-to-typescript. - * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, - * and run "yarn generate-schema-types" to regenerate this file. - */ - -export interface PlayoutGatewayConfig { - /** - * Activate Debug Logging - */ - debugLogging?: boolean - debugState?: boolean - /** - * Activate Multi-Threading - */ - multiThreading?: boolean - /** - * Requires restart of Gateway to apply - */ - multiThreadedResolver?: boolean - /** - * Requires restart of Gateway to apply - */ - useCacheWhenResolving?: boolean - /** - * Report command timings on all commands - */ - reportAllCommands?: boolean - /** - * Adjust resolve-time estimation - */ - estimateResolveTimeMultiplier?: number -} diff --git a/packages/playout-gateway/src/tsrHandler.ts b/packages/playout-gateway/src/tsrHandler.ts index f1c647977d..02885afb5c 100644 --- a/packages/playout-gateway/src/tsrHandler.ts +++ b/packages/playout-gateway/src/tsrHandler.ts @@ -37,7 +37,7 @@ import { TimelineObjGeneric, } from '@sofie-automation/shared-lib/dist/core/model/Timeline' import { PLAYOUT_DEVICE_CONFIG } from './configManifest' -import { PlayoutGatewayConfig } from './generated/options' +import { PlayoutGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/PlayoutGatewayConfigTypes' import { assertNever, getSchemaDefaultValues, diff --git a/packages/scripts/schema-types.mjs b/packages/scripts/schema-types.mjs index 799bfbef18..8561e3c78e 100644 --- a/packages/scripts/schema-types.mjs +++ b/packages/scripts/schema-types.mjs @@ -22,7 +22,6 @@ try { bannerComment: '', }) - await fs.writeFile('./playout-gateway/src/generated/options.ts', BANNER + '\n' + schema) await fs.writeFile('./shared-lib/src/generated/PlayoutGatewayConfigTypes.ts', BANNER + '\n' + schema) } catch (e) { console.error('Error while generating playout-gateway options.json, continuing...') @@ -37,7 +36,6 @@ try { bannerComment: '', }) - await fs.writeFile('./mos-gateway/src/generated/options.ts', BANNER + '\n' + schema) await fs.writeFile('./shared-lib/src/generated/MosGatewayOptionsTypes.ts', BANNER + '\n' + schema) } catch (e) { console.error('Error while generating mos-gateway options.json, continuing...') @@ -50,7 +48,6 @@ try { bannerComment: '', }) - await fs.writeFile('./mos-gateway/src/generated/devices.ts', BANNER + '\n' + schema) await fs.writeFile('./shared-lib/src/generated/MosGatewayDevicesTypes.ts', BANNER + '\n' + schema) } catch (e) { console.error('Error while generating mos-gateway devices.json, continuing...') @@ -65,7 +62,6 @@ try { bannerComment: '', }) - await fs.writeFile('./live-status-gateway/src/generated/options.ts', BANNER + '\n' + schema) await fs.writeFile('./shared-lib/src/generated/LiveStatusGatewayOptionsTypes.ts', BANNER + '\n' + schema) } catch (e) { console.error('Error while generating live-status-gateway options.json, continuing...') From 1c126940032921a7e36bfca2807114856693e9c3 Mon Sep 17 00:00:00 2001 From: olzzon Date: Thu, 20 Feb 2025 09:30:17 +0100 Subject: [PATCH 037/293] fix: generate type for upstreal/release53 --- packages/shared-lib/src/generated/MosGatewayDevicesTypes.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/shared-lib/src/generated/MosGatewayDevicesTypes.ts b/packages/shared-lib/src/generated/MosGatewayDevicesTypes.ts index b6ebdc5665..f192cf7614 100644 --- a/packages/shared-lib/src/generated/MosGatewayDevicesTypes.ts +++ b/packages/shared-lib/src/generated/MosGatewayDevicesTypes.ts @@ -31,10 +31,4 @@ export interface MosDeviceConfig { query: number } } - statuses: MosDeviceStatusesConfig -} -export interface MosDeviceStatusesConfig { - enabled: boolean - sendInRehearsal?: boolean - onlySendPlay?: boolean } From 85fe434b6642c690c7561e7b126df9717c064b37 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 20 Feb 2025 00:30:17 +0100 Subject: [PATCH 038/293] feat(EAV-111): add current segment parts to LSG --- .../api/schemas/activePlaylist.yaml | 34 +++++++++++++- .../topics/__tests__/activePlaylist.spec.ts | 20 +++++++++ .../src/topics/activePlaylistTopic.ts | 10 ++++- .../src/topics/helpers/segmentParts.ts | 45 +++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 packages/live-status-gateway/src/topics/helpers/segmentParts.ts diff --git a/packages/live-status-gateway/api/schemas/activePlaylist.yaml b/packages/live-status-gateway/api/schemas/activePlaylist.yaml index 2624e4e684..eb07707b35 100644 --- a/packages/live-status-gateway/api/schemas/activePlaylist.yaml +++ b/packages/live-status-gateway/api/schemas/activePlaylist.yaml @@ -124,6 +124,32 @@ $defs: - $ref: '#/$defs/piece/examples/0' publicData: partType: 'intro' + currentSegmentPart: + type: object + properties: + id: + description: Unique id of the part + type: string + name: + description: User-presentable name of the part + type: string + autoNext: + description: If this part will progress to the next automatically + type: boolean + default: false + timing: + type: object + properties: + expectedDurationMs: + description: Expected duration of the part + type: number + required: [id, name, timing] + additionalProperties: false + examples: + - id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_' + name: 'Intro' + timing: + expectedDurationMs: 15000 part: oneOf: - $ref: '#/$defs/partBase' @@ -196,7 +222,11 @@ $defs: - part_expected_duration - segment_budget_duration required: [expectedDurationMs, projectedEndTime] - required: [id, timing] + parts: + type: array + items: + $ref: '#/$defs/currentSegmentPart' + required: [id, timing, parts] additionalProperties: false examples: - id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_' @@ -205,6 +235,8 @@ $defs: budgetDurationMs: 20000 projectedEndTime: 1600000075000 countdownType: segment_budget_duration + parts: + - $ref: '#/$defs/currentSegmentPart/examples/0' piece: type: object properties: 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 79d94fa7bb..5f068bbc68 100644 --- a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts @@ -135,6 +135,16 @@ describe('ActivePlaylistTopic', () => { expectedDurationMs: 10000, projectedEndTime: 1600000070000, }, + parts: [ + { + id: 'PART_1', + name: 'Test Part', + timing: { + expectedDurationMs: 10000, + }, + autoNext: undefined, + }, + ], }, rundownIds: unprotectStringArray(playlist.rundownIdsInOrder), publicData: { a: 'b' }, @@ -230,6 +240,16 @@ describe('ActivePlaylistTopic', () => { projectedEndTime: 1600000072300, countdownType: 'segment_budget_duration', }, + parts: [ + { + id: 'PART_1', + name: 'Test Part', + timing: { + expectedDurationMs: 10000, + }, + autoNext: undefined, + }, + ], }, rundownIds: unprotectStringArray(playlist.rundownIdsInOrder), publicData: { a: 'b' }, diff --git a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts index 88bba08f27..1d318555f7 100644 --- a/packages/live-status-gateway/src/topics/activePlaylistTopic.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -15,6 +15,7 @@ import { CurrentSegmentTiming, calculateCurrentSegmentTiming } from './helpers/s import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import _ = require('underscore') import { PartTiming, calculateCurrentPartTiming } from './helpers/partTiming' +import { CurrentSegmentPart, getCurrentSegmentParts } from './helpers/segmentParts' import { SelectedPieceInstances, PieceInstanceMin } from '../collections/pieceInstancesHandler' import { PieceStatus, toPieceStatus } from './helpers/pieceStatus' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' @@ -42,6 +43,7 @@ interface CurrentPartStatus extends PartStatus { interface CurrentSegmentStatus { id: string timing: CurrentSegmentTiming + parts: CurrentSegmentPart[] } interface ActivePlaylistQuickLoopMarker { @@ -135,6 +137,8 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket const currentPart = this._currentPartInstance ? this._currentPartInstance.part : null const nextPart = this._nextPartInstance ? this._nextPartInstance.part : null + const currentSegmentParts = + (currentPart && this._partsBySegmentId[unprotectString(currentPart.segmentId)]) ?? [] const message = this._activePlaylist ? literal({ @@ -169,7 +173,11 @@ export class ActivePlaylistTopic extends WebSocketTopicBase implements WebSocket this._currentPartInstance, this._firstInstanceInSegmentPlayout, this._partInstancesInCurrentSegment, - this._partsBySegmentId[unprotectString(currentPart.segmentId)] ?? [] + currentSegmentParts + ), + parts: getCurrentSegmentParts( + this._partInstancesInCurrentSegment, + currentSegmentParts ), }) : null, diff --git a/packages/live-status-gateway/src/topics/helpers/segmentParts.ts b/packages/live-status-gateway/src/topics/helpers/segmentParts.ts new file mode 100644 index 0000000000..dcf676d591 --- /dev/null +++ b/packages/live-status-gateway/src/topics/helpers/segmentParts.ts @@ -0,0 +1,45 @@ +import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { unprotectString } from '@sofie-automation/server-core-integration' +import _ = require('underscore') + +export interface CurrentSegmentPart { + id: string + name: string + autoNext: boolean | undefined + timing: { + expectedDurationMs?: number + } +} + +export function getCurrentSegmentParts( + segmentPartInstances: DBPartInstance[], + segmentParts: DBPart[] +): CurrentSegmentPart[] { + const partInstancesByPartId: Record = _.indexBy( + segmentPartInstances, + (partInstance) => unprotectString(partInstance.part._id) + ) + segmentParts.forEach((part) => { + const partId = unprotectString(part._id) + if (partInstancesByPartId[partId]) return + const partInstance = { + _id: partId, + part, + } + partInstancesByPartId[partId] = partInstance + }) + return Object.values<{ _id: string | PartInstanceId; part: DBPart }>(partInstancesByPartId) + .sort((a, b) => a.part._rank - b.part._rank) + .map( + (partInstance): CurrentSegmentPart => ({ + id: unprotectString(partInstance.part._id), + name: partInstance.part.title, + autoNext: partInstance.part.autoNext, + timing: { + expectedDurationMs: partInstance.part.expectedDuration, + }, + }) + ) +} From 4d991aa3fbe917e68a3f00976f575d3d71a74d47 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 24 Feb 2025 13:52:11 +0100 Subject: [PATCH 039/293] fix(EAV-450): missing null activePlaylist update when playlist gets deactivated --- .../live-status-gateway/src/collections/segmentHandler.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/live-status-gateway/src/collections/segmentHandler.ts b/packages/live-status-gateway/src/collections/segmentHandler.ts index 96a1b9d88e..f1129fdc32 100644 --- a/packages/live-status-gateway/src/collections/segmentHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentHandler.ts @@ -48,10 +48,8 @@ export class SegmentHandler extends PublicationCollection { From 7956f7bba509d892389bb3c564312da730c0495b Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 25 Feb 2025 10:13:27 +0000 Subject: [PATCH 040/293] fix: missing export --- packages/blueprints-integration/src/context/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/blueprints-integration/src/context/index.ts b/packages/blueprints-integration/src/context/index.ts index 843436ddd8..d1570ad738 100644 --- a/packages/blueprints-integration/src/context/index.ts +++ b/packages/blueprints-integration/src/context/index.ts @@ -5,6 +5,7 @@ export * from './fixUpConfigContext' export * from './onSetAsNextContext' export * from './onTakeContext' export * from './packageInfoContext' +export * from './playoutStore' export * from './processIngestDataContext' export * from './rundownContext' export * from './showStyleContext' From 929df95ecb91b3feee6cee83a1dd855f137ad3db Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 25 Feb 2025 13:28:07 +0100 Subject: [PATCH 041/293] chore: add check to install-and-build script to disallow yarn 1 This is because if one has a system that has yarn 1 / yarn classic installed, some weird errors pop up during instllatoin and build. This script will catch the issue early and provice a user friendlier feedback --- scripts/install-and-build.mjs | 37 +++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/scripts/install-and-build.mjs b/scripts/install-and-build.mjs index 5b1fa8124f..7dea221af9 100644 --- a/scripts/install-and-build.mjs +++ b/scripts/install-and-build.mjs @@ -1,3 +1,4 @@ +import cp from "child_process"; import process from "process"; import concurrently from "concurrently"; import { EXTRA_PACKAGES, config } from "./lib.js"; @@ -5,8 +6,36 @@ import { EXTRA_PACKAGES, config } from "./lib.js"; function hr() { // write regular dashes if this is a "simple" output stream () if (!process.stdout.hasColors || !process.stdout.hasColors()) - return '-'.repeat(process.stdout.columns ?? 40) - return '─'.repeat(process.stdout.columns ?? 40) + return "-".repeat(process.stdout.columns ?? 40); + return "─".repeat(process.stdout.columns ?? 40); +} +function exec(cmd) { + return new Promise((resolve, reject) => { + cp.exec(cmd, (err, stdout, stderr) => { + if (err) reject(err); + resolve({ stdout, stderr }); + }); + }); +} +const yarnVersion = await exec("yarn -v"); + +// Require yarn > 1: +if ( + yarnVersion.stdout.startsWith("0.") || + yarnVersion.stdout.startsWith("1.") +) { + console.error( + "It seems like you're using an old version of yarn. Please upgrade to yarn 2 or later" + ); + console.error(`Detected yarn version: ${yarnVersion.stdout.trim()}`); + console.error(`--`); + console.error(`Tip:`); + console.error( + `To uninstall yarn classic, you can find where it's installed by running 'which yarn' or 'where yarn'` + ); + console.error(`After you have uninstalled it, run 'corepack enable'`); + + process.exit(1); } try { @@ -41,9 +70,9 @@ try { console.log(" 🪛 Build packages..."); console.log(hr()); - const buildArgs = ['--ignore @sofie-automation/webui'] + const buildArgs = ["--ignore @sofie-automation/webui"]; if (config.uiOnly) { - buildArgs.push(...EXTRA_PACKAGES.map((pkg) => `--ignore ${pkg}`)) + buildArgs.push(...EXTRA_PACKAGES.map((pkg) => `--ignore ${pkg}`)); } await concurrently( From 22777374f9b106eaf9c368377a451e606595932b Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 26 Feb 2025 10:49:07 +0000 Subject: [PATCH 042/293] chore: update meteor to 3.1.2 --- .github/actions/setup-meteor/action.yaml | 2 +- .node-version | 2 +- meteor/.meteor/packages | 2 +- meteor/.meteor/release | 2 +- meteor/.meteor/versions | 4 ++-- meteor/Dockerfile | 2 +- meteor/package.json | 2 +- package.json | 2 +- packages/blueprints-integration/package.json | 2 +- packages/corelib/package.json | 2 +- packages/documentation/package.json | 2 +- packages/job-worker/package.json | 2 +- packages/live-status-gateway/package.json | 2 +- packages/meteor-lib/package.json | 2 +- packages/mos-gateway/package.json | 2 +- packages/playout-gateway/package.json | 2 +- packages/server-core-integration/package.json | 2 +- packages/shared-lib/package.json | 2 +- packages/webui/package.json | 2 +- 19 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/actions/setup-meteor/action.yaml b/.github/actions/setup-meteor/action.yaml index 68a7305e4c..04dddf3796 100644 --- a/.github/actions/setup-meteor/action.yaml +++ b/.github/actions/setup-meteor/action.yaml @@ -3,5 +3,5 @@ description: "Setup Meteor" runs: using: "composite" steps: - - run: curl "https://install.meteor.com/?release=3.1" | sh + - run: curl "https://install.meteor.com/?release=3.1.2" | sh shell: bash diff --git a/.node-version b/.node-version index 8b84b727be..d5b283a3ac 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.11 +22.13.1 diff --git a/meteor/.meteor/packages b/meteor/.meteor/packages index 5a1bc49c8a..3e586bdb11 100644 --- a/meteor/.meteor/packages +++ b/meteor/.meteor/packages @@ -9,7 +9,7 @@ # but you can also edit it by hand. meteor@2.1.0 -webapp@2.0.4 +webapp@2.0.5 ddp@1.4.2 mongo@2.1.0 # The database Meteor supports right now diff --git a/meteor/.meteor/release b/meteor/.meteor/release index eaae1a48e1..5f22892744 100644 --- a/meteor/.meteor/release +++ b/meteor/.meteor/release @@ -1 +1 @@ -METEOR@3.1.1 +METEOR@3.1.2 diff --git a/meteor/.meteor/versions b/meteor/.meteor/versions index ad14217784..e73648723f 100644 --- a/meteor/.meteor/versions +++ b/meteor/.meteor/versions @@ -26,7 +26,7 @@ inter-process-messaging@0.1.2 logging@1.3.5 meteor@2.1.0 minimongo@2.0.2 -modern-browsers@0.1.11 +modern-browsers@0.2.0 modules@0.20.3 modules-runtime@0.13.2 mongo@2.1.0 @@ -44,6 +44,6 @@ routepolicy@1.1.2 socket-stream-client@0.6.0 tracker@1.3.4 typescript@5.6.3 -webapp@2.0.4 +webapp@2.0.5 webapp-hashing@1.1.2 zodern:types@1.0.13 diff --git a/meteor/Dockerfile b/meteor/Dockerfile index 13c52fa295..06ce3325cd 100644 --- a/meteor/Dockerfile +++ b/meteor/Dockerfile @@ -15,7 +15,7 @@ RUN yarn install && yarn build # BUILD IMAGE FROM node:22 -RUN curl "https://install.meteor.com/?release=3.1" | sh +RUN curl "https://install.meteor.com/?release=3.1.2" | sh # Temporary change the NODE_ENV env variable, so that all libraries are installed: ENV NODE_ENV_TMP $NODE_ENV diff --git a/meteor/package.json b/meteor/package.json index 331f9a216f..c7291534bd 100644 --- a/meteor/package.json +++ b/meteor/package.json @@ -3,7 +3,7 @@ "version": "1.53.0-in-development", "private": true, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "scripts": { "preinstall": "node -v", diff --git a/package.json b/package.json index 2e6a756f25..0ba1371feb 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MIT", "private": true, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "scripts": { "postinstall": "run install:packages && run install:meteor", diff --git a/packages/blueprints-integration/package.json b/packages/blueprints-integration/package.json index fa824281fb..f0b127fb80 100644 --- a/packages/blueprints-integration/package.json +++ b/packages/blueprints-integration/package.json @@ -29,7 +29,7 @@ "license-validate": "run -T sofie-licensecheck" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "files": [ "/dist", diff --git a/packages/corelib/package.json b/packages/corelib/package.json index 3be36cc16b..891726be89 100644 --- a/packages/corelib/package.json +++ b/packages/corelib/package.json @@ -30,7 +30,7 @@ "license-validate": "run -T sofie-licensecheck" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "files": [ "/dist", diff --git a/packages/documentation/package.json b/packages/documentation/package.json index 138c998596..1679332a95 100644 --- a/packages/documentation/package.json +++ b/packages/documentation/package.json @@ -15,7 +15,7 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "devDependencies": { "@docusaurus/core": "3.7.0", diff --git a/packages/job-worker/package.json b/packages/job-worker/package.json index d17f87dad3..a90e552584 100644 --- a/packages/job-worker/package.json +++ b/packages/job-worker/package.json @@ -31,7 +31,7 @@ "license-validate": "run -T sofie-licensecheck" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "files": [ "/dist", diff --git a/packages/live-status-gateway/package.json b/packages/live-status-gateway/package.json index 6b287cbb9b..d9b09bac16 100644 --- a/packages/live-status-gateway/package.json +++ b/packages/live-status-gateway/package.json @@ -37,7 +37,7 @@ "license-validate": "run -T sofie-licensecheck" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "keywords": [ "broadcast", diff --git a/packages/meteor-lib/package.json b/packages/meteor-lib/package.json index 4343a8458b..6f9d133f4c 100644 --- a/packages/meteor-lib/package.json +++ b/packages/meteor-lib/package.json @@ -30,7 +30,7 @@ "license-validate": "run -T sofie-licensecheck" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "files": [ "/dist", diff --git a/packages/mos-gateway/package.json b/packages/mos-gateway/package.json index b8be483fa1..41b1f45a85 100644 --- a/packages/mos-gateway/package.json +++ b/packages/mos-gateway/package.json @@ -48,7 +48,7 @@ "license-validate": "run -T sofie-licensecheck" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "keywords": [ "mos", diff --git a/packages/playout-gateway/package.json b/packages/playout-gateway/package.json index 48f17065da..eda80f84a7 100644 --- a/packages/playout-gateway/package.json +++ b/packages/playout-gateway/package.json @@ -40,7 +40,7 @@ "license-validate": "run -T sofie-licensecheck" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "keywords": [ "broadcast", diff --git a/packages/server-core-integration/package.json b/packages/server-core-integration/package.json index 320e4b94ca..7dbcd1869c 100644 --- a/packages/server-core-integration/package.json +++ b/packages/server-core-integration/package.json @@ -48,7 +48,7 @@ "license-validate": "run -T sofie-licensecheck" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "files": [ "/dist", diff --git a/packages/shared-lib/package.json b/packages/shared-lib/package.json index 3975f5c868..6e5314b800 100644 --- a/packages/shared-lib/package.json +++ b/packages/shared-lib/package.json @@ -29,7 +29,7 @@ "license-validate": "run -T sofie-licensecheck" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" }, "files": [ "/dist", diff --git a/packages/webui/package.json b/packages/webui/package.json index 025a04b83e..822b140544 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -114,6 +114,6 @@ "xml2js": "^0.6.2" }, "engines": { - "node": ">=22.11" + "node": ">=22.13.1" } } From e7270281ccd3cde2ac6490f34055f039cf24404a Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Thu, 27 Feb 2025 16:28:17 +0000 Subject: [PATCH 043/293] feat: expose getSegment in blueprint context --- .../src/context/onSetAsNextContext.ts | 3 +++ .../src/context/partsAndPieceActionContext.ts | 3 +++ .../src/blueprints/context/OnSetAsNextContext.ts | 5 +++++ .../job-worker/src/blueprints/context/OnTakeContext.ts | 4 ++++ .../job-worker/src/blueprints/context/adlibActions.ts | 5 +++++ .../services/PartAndPieceInstanceActionService.ts | 10 ++++++++++ 6 files changed, 30 insertions(+) diff --git a/packages/blueprints-integration/src/context/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index c0aa830068..9f1149ddfe 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -6,6 +6,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, + IBlueprintSegment, IEventContext, IShowStyleUserContext, } from '..' @@ -49,6 +50,8 @@ export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContex getPartInstanceForPreviousPiece(piece: IBlueprintPieceInstance): Promise /** Gets the Part for a Piece retrieved from findLastScriptedPieceOnLayer. This primarily allows for accessing metadata of the Part */ getPartForPreviousPiece(piece: IBlueprintPieceDB): Promise + /** Gets the Segment. This primarily allows for accessing metadata */ + getSegment(segment: 'current' | 'next'): Promise /** * Creative actions diff --git a/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts b/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts index e2f3b43e39..ccc0c7e8c4 100644 --- a/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts +++ b/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts @@ -6,6 +6,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, + IBlueprintSegment, Time, } from '..' import { BlueprintQuickLookInfo } from './quickLoopInfo' @@ -44,6 +45,8 @@ export interface IPartAndPieceActionContext { getPartInstanceForPreviousPiece(piece: IBlueprintPieceInstance): Promise /** Gets the Part for a Piece retrieved from findLastScriptedPieceOnLayer. This primarily allows for accessing metadata of the Part */ getPartForPreviousPiece(piece: IBlueprintPieceDB): Promise + /** Gets the Segment. This primarily allows for accessing metadata */ + getSegment(segment: 'current' | 'next'): Promise /** * Creative actions diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 43a3b5f0d3..013ace4120 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -9,6 +9,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, + IBlueprintSegment, IEventContext, IOnSetAsNextContext, } from '@sofie-automation/blueprints-integration' @@ -67,6 +68,10 @@ export class OnSetAsNextContext return this.partAndPieceInstanceService.getResolvedPieceInstances(part) } + async getSegment(segment: 'current' | 'next'): Promise { + return this.partAndPieceInstanceService.getSegment(segment) + } + async findLastPieceOnLayer( sourceLayerId0: string | string[], options?: { diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index 45c7523a3d..4c74536128 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -12,6 +12,7 @@ import { TSR, IBlueprintPlayoutDevice, IOnTakeContext, + IBlueprintSegment, } from '@sofie-automation/blueprints-integration' import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' @@ -64,6 +65,9 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex async getResolvedPieceInstances(part: 'current' | 'next'): Promise { return this.partAndPieceInstanceService.getResolvedPieceInstances(part) } + async getSegment(segment: 'current' | 'next'): Promise { + return this.partAndPieceInstanceService.getSegment(segment) + } async findLastPieceOnLayer( sourceLayerId0: string | string[], diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index e42d6d1617..4d39906182 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -14,6 +14,7 @@ import { TSR, IBlueprintPlayoutDevice, StudioRouteSet, + IBlueprintSegment, } from '@sofie-automation/blueprints-integration' import { PartInstanceId, PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' @@ -113,6 +114,10 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct return this.partAndPieceInstanceService.getResolvedPieceInstances(part) } + async getSegment(segment: 'current' | 'next'): Promise { + return this.partAndPieceInstanceService.getSegment(segment) + } + async findLastPieceOnLayer( sourceLayerId0: string | string[], options?: { diff --git a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts index 4bac075301..3004f27e41 100644 --- a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts +++ b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts @@ -9,6 +9,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, + IBlueprintSegment, OmitId, SomeContent, Time, @@ -22,6 +23,7 @@ import { convertPieceInstanceToBlueprints, convertPieceToBlueprints, convertResolvedPieceInstanceToBlueprints, + convertSegmentToBlueprints, createBlueprintQuickLoopInfo, getMediaObjectDuration, } from '../lib' @@ -138,6 +140,14 @@ export class PartAndPieceInstanceActionService { ) return resolvedInstances.map(convertResolvedPieceInstanceToBlueprints) } + getSegment(segment: 'current' | 'next'): IBlueprintSegment | undefined { + const partInstance = this.#getPartInstance(segment) + if (!partInstance) return undefined + + const segmentModel = this._playoutModel.findSegment(partInstance.partInstance.segmentId) + + return segmentModel?.segment ? convertSegmentToBlueprints(segmentModel?.segment) : undefined + } async findLastPieceOnLayer( sourceLayerId0: string | string[], From eee202af9f067df21723df95d8cadb12f443a3eb Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Thu, 27 Feb 2025 16:34:45 +0000 Subject: [PATCH 044/293] chore: test getSegment --- .../__tests__/context-OnSetAsNextContext.test.ts | 8 ++++++++ .../blueprints/__tests__/context-OnTakeContext.test.ts | 8 ++++++++ .../src/blueprints/__tests__/context-adlibActions.test.ts | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts b/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts index a132dc5efb..a43f53e89c 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts @@ -53,6 +53,14 @@ describe('Test blueprint api context', () => { expect(mockActionService.getResolvedPieceInstances).toHaveBeenCalledWith('current') }) + test('getSegment', async () => { + const { context, mockActionService } = await getTestee() + + await context.getSegment('current') + expect(mockActionService.getSegment).toHaveBeenCalledTimes(1) + expect(mockActionService.getSegment).toHaveBeenCalledWith('current') + }) + test('findLastPieceOnLayer', async () => { const { context, mockActionService } = await getTestee() diff --git a/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts b/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts index 77f8a94d93..f2676ee09c 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts @@ -53,6 +53,14 @@ describe('Test blueprint api context', () => { expect(mockActionService.getResolvedPieceInstances).toHaveBeenCalledWith('current') }) + test('getSegment', async () => { + const { context, mockActionService } = await getTestee() + + await context.getSegment('current') + expect(mockActionService.getSegment).toHaveBeenCalledTimes(1) + expect(mockActionService.getSegment).toHaveBeenCalledWith('current') + }) + test('findLastPieceOnLayer', async () => { const { context, mockActionService } = await getTestee() diff --git a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts index 46e0fe1d6f..e08d6c2db1 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts @@ -55,6 +55,14 @@ describe('Test blueprint api context', () => { expect(mockActionService.getResolvedPieceInstances).toHaveBeenCalledWith('current') }) + test('getSegment', async () => { + const { context, mockActionService } = await getTestee() + + await context.getSegment('current') + expect(mockActionService.getSegment).toHaveBeenCalledTimes(1) + expect(mockActionService.getSegment).toHaveBeenCalledWith('current') + }) + test('findLastPieceOnLayer', async () => { const { context, mockActionService } = await getTestee() From e22680e4f77bccc5f68e79f10d94ab275daacf12 Mon Sep 17 00:00:00 2001 From: olzzon Date: Thu, 6 Mar 2025 10:10:16 +0100 Subject: [PATCH 045/293] wip: prepare for header on director screen --- .../src/client/styles/countdown/director.scss | 418 ++++++------------ .../client/ui/ClockView/DirectorScreen.tsx | 187 ++++---- 2 files changed, 235 insertions(+), 370 deletions(-) diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 09d6298b8a..3220109467 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -14,154 +14,7 @@ $hold-status-color: $liveline-timecode-color; } .director-screen { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - - font-size: 0.4vh; - - display: grid; - grid-template: 4fr 3fr fit-content(1em) / auto; - - overflow: hidden; - white-space: nowrap; - - .director-screen__part { - display: grid; - grid-template: - 10em - 4fr - 6fr / 13vw auto; // allow a fallback for CasparCG - grid-template: - 10em - 4fr - 6fr / #{'min(13vw, 27vh)'} auto; - - .director-screen__segment-name { - grid-row: 1; - grid-column: 1 / -1; - text-align: center; - font-size: 8em; - font-weight: bold; - - &.live { - background: $general-live-color; - color: #fff; - border-top: 0.1em solid #fff; - -webkit-text-stroke: black; - -webkit-text-stroke-width: 0.025em; - text-shadow: 0px 0px 20px #00000044; - } - - &.next { - background: $general-next-color; - color: #000; - border-top: 0.1em solid #fff; - } - } - - .director-screen__rundown-countdown { - grid-row: 2 / -1; - grid-column: 1 / -1; - - text-align: center; - display: flex; - align-items: center; - justify-content: center; - - font-size: 12vw; - } - - .director-screen__part__piece-icon { - grid-row: 2; - grid-column: 1; - padding: 0em; - - text-align: center; - display: flex; - align-items: center; - justify-content: center; - - > svg { - flex-grow: 1; - } - } - - .director-screen__part__piece-name { - grid-row: 2; - grid-column: 2; - text-align: left; - font-size: 13em; - overflow: hidden; - white-space: nowrap; - padding-left: 0.2em; - - display: flex; - align-items: center; - - .director-screen__part__auto-next-icon { - display: block; - min-width: 1em; - max-width: 1em; - } - } - - .director-screen__part__piece-countdown { - text-align: left; - - display: flex; - align-items: center; - font-size: 13em; // Allow a fallback for CasparCG - font-size: #{'min(13em, 8vw)'}; - padding: 0 0.2em; - line-height: 1em; - - > .overtime { - color: $general-late-color; - } - - > img.freeze-icon { - width: 0.9em; - height: 0.9em; - margin-left: -0.05em; - margin-top: -0.05em; - } - } - - .director-screen__part__part-countdown { - text-align: right; - - display: flex; - align-items: center; - justify-content: flex-end; - font-size: 13em; - padding: 0 0.2em; - line-height: 1em; - - > span { - font-size: 2em; // Allow a fallback for CasparCG - font-size: #{'min(2em, 20vw)'}; - } - } - - .director-screen__part__piece-countdown, - .director-screen__part__part-countdown { - grid-row: 3; - grid-column: 2; - color: $general-countdown-to-next-color; - } - - &.director-screen__part--next-part { - .director-screen__part__piece-icon, - .director-screen__part__piece-name { - grid-row: 2 / -1; - } - } - } - - .director-screen__rundown-status-bar { + .director-screen__header { display: grid; grid-template-columns: auto fit-content(5em); grid-template-rows: fit-content(1em); @@ -169,14 +22,14 @@ $hold-status-color: $liveline-timecode-color; color: #888; padding: 0 0.2em; - .director-screen__rundown-status-bar__rundown-name { + .director-screen__header__rundown-name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; line-height: 1.44em; } - .director-screen__rundown-status-bar__countdown { + .director-screen__header__countdown { white-space: nowrap; color: $general-countdown-to-next-color; @@ -190,158 +43,165 @@ $hold-status-color: $liveline-timecode-color; } } - .director-screen__part + .director-screen__part { - border-top: solid 0.8em #454545; - } - .clocks-segment-countdown-red { - color: $general-late-color; - } + .director-screen__body { + position: fixed; + top: 1; + bottom: 0; + left: 0; + right: 0; - .clocks-counter-heavy { - font-weight: 600; - } - - .dashboard { - .timing { - margin: 0 0; - min-width: auto; - width: 100%; - text-align: center; - - .timing-clock { - position: relative; - margin-right: 1em; - font-weight: 100; - color: $general-clock; - font-size: 1.5em; - margin-top: 0.8em; - word-break: keep-all; - white-space: nowrap; + font-size: 0.4vh; - &.visual-last-child { - margin-right: 0; + display: grid; + grid-template: 4fr 3fr fit-content(1em) / auto; + + overflow: hidden; + white-space: nowrap; + + .director-screen__body__part { + display: grid; + grid-template: + 16em + 4fr + 6fr / 13vw auto; // allow a fallback for CasparCG + grid-template: + 16em + 4fr + 6fr / #{'min(13vw, 27vh)'} auto; + + .director-screen__body__segment-name { + grid-row: 1; + grid-column: 1 / -1; + text-align: center; + font-size: 16em; + font-weight: bold; + + &.live { + background: $general-live-color; + color: #fff; + border-top: 0.1em solid #fff; + -webkit-text-stroke: black; + -webkit-text-stroke-width: 0.025em; + text-shadow: 0px 0px 20px #00000044; } - &.countdown { - font-weight: 400; + &.next { + background: $general-next-color; + color: #000; + border-top: 0.1em solid #fff; } + } - &.playback-started { - display: inline-block; - width: 25%; - } + .director-screen__body__rundown-countdown { + grid-row: 2 / -1; + grid-column: 1 / -1; - &.left { - text-align: left; - } + text-align: center; + display: flex; + align-items: center; + justify-content: center; - &.time-now { - position: absolute; - top: 0.05em; - left: 50%; - transform: translateX(-50%); - margin-top: 0px; - margin-right: 0; - font-size: 2.3em; - font-weight: 100; - text-align: center; - } + font-size: 12vw; + } + + .director-screen__body__part__piece-icon { + grid-row: 2; + grid-column: 1; + padding: 0em; - &.current-remaining { - position: absolute; - left: calc(50% + 3.5em); - text-align: left; - color: $liveline-timecode-color; - font-weight: 500; - - .overtime { - color: $general-fast-color; - text-shadow: 0px 0px 6px $general-fast-color--shadow; - } + text-align: center; + display: flex; + align-items: center; + justify-content: center; + + > svg { + flex-grow: 1; } + } - .timing-clock-label { - position: absolute; - top: -1em; - color: #b8b8b8; - text-transform: uppercase; - white-space: nowrap; - font-weight: 300; - font-size: 0.5em; - - &.left { - left: 0; - right: auto; - text-align: left; - } - - &.right { - right: 0; - left: auto; - text-align: right; - } - - &.hide-overflow { - overflow: hidden; - text-overflow: ellipsis; - width: 100%; - } - - &.rundown-name { - width: auto; - max-width: calc(40vw - 138px); - min-width: 100%; - - > strong { - margin-right: 0.4em; - } - - > svg.icon.looping { - width: 1.4em; - height: 1.4em; - } - } + .director-screen__body__part__piece-name { + grid-row: 2; + grid-column: 2; + text-align: left; + font-size: 13em; + overflow: hidden; + white-space: nowrap; + padding-left: 0.2em; + + display: flex; + align-items: center; + + .director-screen__part__auto-next-icon { + display: block; + min-width: 1em; + max-width: 1em; } + } + + .director-screen__body__part__piece-countdown { + text-align: left; - &.heavy-light { - font-weight: 600; + display: flex; + align-items: center; + font-size: 13em; // Allow a fallback for CasparCG + font-size: #{'min(13em, 8vw)'}; + padding: 0 0.2em; + line-height: 1em; - &.heavy { - // color: $general-late-color; - color: #ffe900; - background: none; - } + > .overtime { + color: $general-late-color; + } - &.light { - color: $general-fast-color; - text-shadow: 0px 0px 6px $general-fast-color--shadow; - background: none; - } + > img.freeze-icon { + width: 0.9em; + height: 0.9em; + margin-left: -0.05em; + margin-top: -0.05em; } } - .rundown__header-status { - position: absolute; - font-size: 0.7rem; - text-transform: uppercase; - background: #fff; - border-radius: 1rem; + .director-screen__body__part__part-countdown { + text-align: right; + + display: flex; + align-items: center; + justify-content: flex-end; + font-size: 13em; + padding: 0 0.2em; line-height: 1em; - font-weight: 700; - color: #000; - top: 2.4em; - left: 0; - padding: 2px 5px 1px; - - &.rundown__header-status--hold { - background: $hold-status-color; + + > span { + font-size: 2em; // Allow a fallback for CasparCG + font-size: #{'min(2em, 20vw)'}; } } - .timing-clock-header-label { - font-weight: 100px; + .director-screen__body__part__piece-countdown, + .director-screen__body__part__part-countdown { + grid-row: 3; + grid-column: 2; + color: $general-countdown-to-next-color; + } + + &.director-screen__body__part--next-part { + .director-screen__body__part__piece-icon, + .director-screen__body__part__piece-name { + grid-row: 2 / -1; + } } } + + .director-screen__body__part + .director-screen__body__part { + border-top: solid 0.8em #454545; + } + + .clocks-segment-countdown-red { + color: $general-late-color; + } + + .clocks-counter-heavy { + font-weight: 600; + } } -} +} \ No newline at end of file diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 1dc9063d47..3bf4de5295 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -345,118 +345,123 @@ function DirectorScreenRender({ timingDurations.remainingBudgetOnCurrentSegment ?? timingDurations.remainingTimeOnCurrentPart ?? 0 const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) + const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) + const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) + const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) ?? 0 return (
-
+
+ {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedStart || 0, true)} + {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedEnd || 0, true)} + {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedDuration || 0, true)}
= 0, })} > - {currentSegment?.name} + {RundownUtils.formatDiffToTimecode(overUnderClock, true, false, true, true, true, undefined, true, true)}
- {currentPartInstance && currentShowStyleBaseId ? ( - <> -
- -
-
- -
-
- {currentSegment?.segmentTiming?.countdownType === CountdownType.SEGMENT_BUDGET_DURATION ? ( - +
+
+
+ {currentSegment?.name} +
+ {currentPartInstance && currentShowStyleBaseId ? ( + <> +
+ - ) : ( - +
+ - )} +
+
+ {currentSegment?.segmentTiming?.countdownType === CountdownType.SEGMENT_BUDGET_DURATION ? ( + + ) : ( + + )} +
+
+ +
+ + ) : expectedStart ? ( +
+
-
- -
- - ) : expectedStart ? ( -
- -
- ) : null} -
-
-
- {nextSegment?._id !== currentSegment?._id ? nextSegment?.name : undefined} + ) : null}
- {nextPartInstance && nextShowStyleBaseId ? ( - <> -
- -
-
- {currentPartInstance && currentPartInstance.instance.part.autoNext ? ( - Autonext - ) : null} - {nextPartInstance && nextShowStyleBaseId && nextPartInstance.instance.part.title ? ( - +
+ {nextSegment?._id !== currentSegment?._id ? nextSegment?.name : undefined} +
+ {nextPartInstance && nextShowStyleBaseId ? ( + <> +
+ - ) : ( - '_' - )} -
- - ) : null} -
-
-
- {playlist ? playlist.name : 'UNKNOWN'} -
-
= 0, - })} - > - {RundownUtils.formatDiffToTimecode(overUnderClock, true, false, true, true, true, undefined, true, true)} +
+
+ {currentPartInstance && currentPartInstance.instance.part.autoNext ? ( + Autonext + ) : null} + {nextPartInstance && nextShowStyleBaseId && nextPartInstance.instance.part.title ? ( + + ) : ( + '_' + )} +
+ + ) : null}
From 03dda2815e3e83366c9a8ebe4f38b0fb294ef671 Mon Sep 17 00:00:00 2001 From: olzzon Date: Thu, 6 Mar 2025 11:17:43 +0100 Subject: [PATCH 046/293] wip: prepare timers --- .../src/client/styles/countdown/director.scss | 11 ++++---- .../client/ui/ClockView/DirectorScreen.tsx | 26 ++++++++++++------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 3220109467..5467aa5e38 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -15,10 +15,11 @@ $hold-status-color: $liveline-timecode-color; .director-screen { .director-screen__header { - display: grid; - grid-template-columns: auto fit-content(5em); - grid-template-rows: fit-content(1em); - font-size: 6em; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + font-size: 2em; color: #888; padding: 0 0.2em; @@ -46,7 +47,7 @@ $hold-status-color: $liveline-timecode-color; .director-screen__body { position: fixed; - top: 1; + top: 20vh; bottom: 0; left: 0; right: 0; diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 3bf4de5295..447b329ffc 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -353,15 +353,23 @@ function DirectorScreenRender({ return (
- {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedStart || 0, true)} - {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedEnd || 0, true)} - {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedDuration || 0, true)} -
= 0, - })} - > - {RundownUtils.formatDiffToTimecode(overUnderClock, true, false, true, true, true, undefined, true, true)} +
+
{RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedEnd || 0, true)}
+ PLANNED END +
+
+
{RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedDuration || 0, true)}
+ TIME TO PLANNED END +
+
+
= 0, + })} + > + {RundownUtils.formatDiffToTimecode(overUnderClock, true, false, true, true, true, undefined, true, true)} +
+ OVER/UNDER
From 2e9ff13db3e56a82c21d1b3688cd3bdb90b43818 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 7 Mar 2025 07:36:57 +0100 Subject: [PATCH 047/293] fix: abreviation should be used even if it's an empty string --- .../webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx | 2 +- .../src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx | 2 +- .../src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx | 2 +- .../src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx | 2 +- .../src/client/ui/PieceIcons/Renderers/RemoteSpeakInputIcon.tsx | 2 +- .../webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx index 88e0126e04..3ddc90c3fc 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx @@ -32,7 +32,7 @@ export function CamInputIcon({ style={{ fill: '#ffffff', fontFamily: 'Roboto', fontSize: '75px', fontWeight: 100 }} className="label" > - {abbreviation ? abbreviation : 'C'} + {abbreviation !== undefined ? abbreviation : 'C'} {inputIndex !== undefined ? inputIndex : ''} diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx index 969db38be2..a72d8bcd5c 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx @@ -22,7 +22,7 @@ export function GraphicsInputIcon({ abbreviation }: { abbreviation?: string }): style={{ fill: '#ffffff', fontFamily: 'Roboto', fontSize: '75px', fontWeight: 100 }} className="label" > - {abbreviation ? abbreviation : 'G'} + {abbreviation !== undefined ? abbreviation : 'G'} diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx index e623d132e1..02855baadb 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx @@ -27,7 +27,7 @@ export function LiveSpeakInputIcon({ abbreviation }: { abbreviation?: string }): style={{ fill: '#ffffff', fontFamily: 'Roboto', fontSize: '62px', fontWeight: 100 }} className="label" > - {abbreviation ? abbreviation : 'LSK'} + {abbreviation !== undefined ? abbreviation : 'LSK'} diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx index b0bdd7e581..ef69f683e9 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx @@ -29,7 +29,7 @@ export function RemoteInputIcon({ }): JSX.Element { return ( - {abbreviation ? abbreviation : 'LIVE'} + {abbreviation !== undefined ? abbreviation : 'LIVE'} {inputIndex ?? ''} ) diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteSpeakInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteSpeakInputIcon.tsx index 142e097883..35f4376125 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteSpeakInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteSpeakInputIcon.tsx @@ -27,7 +27,7 @@ export function RemoteSpeakInputIcon({ abbreviation }: { abbreviation?: string } style={{ fill: '#ffffff', fontFamily: 'Roboto', fontSize: '62px', fontWeight: 100 }} className="label" > - {abbreviation ? abbreviation : 'RSK'} + {abbreviation !== undefined ? abbreviation : 'RSK'} diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx index d44ffe0311..77309b0b23 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx @@ -22,7 +22,7 @@ export function VTInputIcon({ abbreviation }: { abbreviation?: string }): JSX.El y="71.513954" style={{ fill: '#ffffff', fontFamily: 'Roboto', fontSize: '75px', fontWeight: 100 }} > - {abbreviation ? abbreviation : 'VT'} + {abbreviation !== undefined ? abbreviation : 'VT'} From c6910d94b589b5416ba15ff373ca56537c5fa469 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 7 Mar 2025 08:18:29 +0100 Subject: [PATCH 048/293] wip: prepare counter components --- .../lib/Components/CounterComponents.tsx | 33 +++++++++++++++++++ .../src/client/styles/counterComponents.scss | 11 +++++++ packages/webui/src/client/styles/main.scss | 1 + .../client/ui/ClockView/DirectorScreen.tsx | 17 +++++++--- 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 packages/webui/src/client/lib/Components/CounterComponents.tsx create mode 100644 packages/webui/src/client/styles/counterComponents.scss diff --git a/packages/webui/src/client/lib/Components/CounterComponents.tsx b/packages/webui/src/client/lib/Components/CounterComponents.tsx new file mode 100644 index 0000000000..98a9872c82 --- /dev/null +++ b/packages/webui/src/client/lib/Components/CounterComponents.tsx @@ -0,0 +1,33 @@ +import { RundownUtils } from '../rundown' + +interface OverUnderProps { + value: number +} + +export const OverUnderClockComponent = (props: OverUnderProps): JSX.Element => { + const overUnder = props.value > 0 ? 'Over' : 'Under' + return ( +
+ {overUnder} + + {RundownUtils.formatDiffToTimecode(props.value, true, false, true, true, true, undefined, true, true)} + +
+ ) +} + +export const PlannedEndComponent = (props: OverUnderProps): JSX.Element => { + return ( + + {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, props.value, true)} + + ) +} + +export const TimeToPlannedEndComponent = (props: OverUnderProps): JSX.Element => { + return ( + + {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, props.value, true)} + + ) +} diff --git a/packages/webui/src/client/styles/counterComponents.scss b/packages/webui/src/client/styles/counterComponents.scss new file mode 100644 index 0000000000..f99851ab00 --- /dev/null +++ b/packages/webui/src/client/styles/counterComponents.scss @@ -0,0 +1,11 @@ +.counter-component__over-under { +background-color: aqua; +} + +.counter-component__planned-end { +background-color: purple; +} + +.counter-component__time-to-planned-end { +background-color: pink; +} \ No newline at end of file diff --git a/packages/webui/src/client/styles/main.scss b/packages/webui/src/client/styles/main.scss index 5fb37b9e0d..76b899ace1 100644 --- a/packages/webui/src/client/styles/main.scss +++ b/packages/webui/src/client/styles/main.scss @@ -49,6 +49,7 @@ input { @import 'countdown/overlay'; @import 'countdown/presenter'; @import 'countdown/director'; +@import 'counterComponents'; @import 'customizations/nrk/shelf/taPanel'; diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 447b329ffc..30cecff078 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -36,6 +36,11 @@ import { useSetDocumentClass } from '../util/useSetDocumentClass' import { useRundownAndShowStyleIdsForPlaylist } from '../util/useRundownAndShowStyleIdsForPlaylist' import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/CurrentPartOrSegmentRemaining' +import { + OverUnderClockComponent, + PlannedEndComponent, + TimeToPlannedEndComponent, +} from '../../lib/Components/CounterComponents' interface SegmentUi extends DBSegment { items: Array @@ -345,7 +350,7 @@ function DirectorScreenRender({ timingDurations.remainingBudgetOnCurrentSegment ?? timingDurations.remainingTimeOnCurrentPart ?? 0 const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) - const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) + const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) || 0 const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) ?? 0 @@ -354,11 +359,15 @@ function DirectorScreenRender({
-
{RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedEnd || 0, true)}
+
+ +
PLANNED END
-
{RundownUtils.formatTimeToTimecode({ frameRate: 25 }, expectedDuration || 0, true)}
+
+ +
TIME TO PLANNED END
@@ -367,7 +376,7 @@ function DirectorScreenRender({ over: Math.floor(overUnderClock / 1000) >= 0, })} > - {RundownUtils.formatDiffToTimecode(overUnderClock, true, false, true, true, true, undefined, true, true)} +
OVER/UNDER
From 5a01c82b4b2767f2895539f1f57445787da8c24a Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 10 Mar 2025 09:18:51 +0100 Subject: [PATCH 049/293] wip: prepare overall css --- .../lib/Components/CounterComponents.tsx | 18 ++++--- .../src/client/styles/countdown/director.scss | 38 ++++---------- .../src/client/styles/counterComponents.scss | 52 +++++++++++++++++-- .../client/ui/ClockView/DirectorScreen.tsx | 28 ++++++---- 4 files changed, 88 insertions(+), 48 deletions(-) diff --git a/packages/webui/src/client/lib/Components/CounterComponents.tsx b/packages/webui/src/client/lib/Components/CounterComponents.tsx index 98a9872c82..323f9f2720 100644 --- a/packages/webui/src/client/lib/Components/CounterComponents.tsx +++ b/packages/webui/src/client/lib/Components/CounterComponents.tsx @@ -5,12 +5,10 @@ interface OverUnderProps { } export const OverUnderClockComponent = (props: OverUnderProps): JSX.Element => { - const overUnder = props.value > 0 ? 'Over' : 'Under' return ( -
- {overUnder} - - {RundownUtils.formatDiffToTimecode(props.value, true, false, true, true, true, undefined, true, true)} +
+ + {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, props.value, true, false, true)}
) @@ -19,7 +17,7 @@ export const OverUnderClockComponent = (props: OverUnderProps): JSX.Element => { export const PlannedEndComponent = (props: OverUnderProps): JSX.Element => { return ( - {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, props.value, true)} + {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, props.value, false, false, true)} ) } @@ -31,3 +29,11 @@ export const TimeToPlannedEndComponent = (props: OverUnderProps): JSX.Element =>
) } + +export const TimesSincePlannedEndComponent = (props: OverUnderProps): JSX.Element => { + return ( + + {RundownUtils.formatTimeToTimecode({ frameRate: 25 }, props.value, true, false, true)} + + ) +} diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 5467aa5e38..3674eb0476 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -22,26 +22,8 @@ $hold-status-color: $liveline-timecode-color; font-size: 2em; color: #888; padding: 0 0.2em; + padding-left: 10px; - .director-screen__header__rundown-name { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - line-height: 1.44em; - } - - .director-screen__header__countdown { - white-space: nowrap; - - color: $general-countdown-to-next-color; - - font-weight: 600; - font-size: 1.2em; - - &.over { - color: $general-late-color; - } - } } @@ -65,7 +47,7 @@ $hold-status-color: $liveline-timecode-color; grid-template: 16em 4fr - 6fr / 13vw auto; // allow a fallback for CasparCG + 6fr / 13vw auto; grid-template: 16em 4fr @@ -74,23 +56,23 @@ $hold-status-color: $liveline-timecode-color; .director-screen__body__segment-name { grid-row: 1; grid-column: 1 / -1; - text-align: center; + text-align: left; + padding-left: 10px; font-size: 16em; - font-weight: bold; + font-weight: 500; + line-height: 100%; + letter-spacing: 0%; + vertical-align: middle; &.live { background: $general-live-color; color: #fff; - border-top: 0.1em solid #fff; - -webkit-text-stroke: black; - -webkit-text-stroke-width: 0.025em; - text-shadow: 0px 0px 20px #00000044; + text-shadow: 0px 0px 6px #000000; } &.next { background: $general-next-color; color: #000; - border-top: 0.1em solid #fff; } } @@ -173,7 +155,7 @@ $hold-status-color: $liveline-timecode-color; line-height: 1em; > span { - font-size: 2em; // Allow a fallback for CasparCG + font-size: 2em; font-size: #{'min(2em, 20vw)'}; } } diff --git a/packages/webui/src/client/styles/counterComponents.scss b/packages/webui/src/client/styles/counterComponents.scss index f99851ab00..81bad70b0c 100644 --- a/packages/webui/src/client/styles/counterComponents.scss +++ b/packages/webui/src/client/styles/counterComponents.scss @@ -1,11 +1,57 @@ .counter-component__over-under { -background-color: aqua; + color: black; + font-family: Roboto Flex; + font-weight: 400; + font-size: 2em; + line-height: 100%; + letter-spacing: 0%; + + .under { + background-color: #ff0; + border-radius: 139px; + align-self: stretch; + gap: 10px; + min-width: 240px; + padding-left: 22px; + padding-right: 22px; + } + .over { + background-color: #FF5218; + border-radius: 139px; + align-self: stretch; + gap: 10px; + min-width: 240px; + padding-left: 22px; + padding-right: 22px; + } } .counter-component__planned-end { -background-color: purple; + color: #fff; + font-family: Roboto Flex; + font-weight: 400; + font-style: italic; + font-size: 2em; + line-height: 100%; + letter-spacing: 0%; } .counter-component__time-to-planned-end { -background-color: pink; + color:#ff0; + font-family: Roboto Flex; + font-weight: 500; + font-size: 2em; + line-height: 100%; + letter-spacing: 0%; + text-align: center; +} + +.counter-component__time-since-planned-end { + color: #FF5218; + font-family: Roboto Flex; + font-weight: 630; + font-size: 2em; + line-height: 100%; + letter-spacing: -3.5%; + text-align: center; } \ No newline at end of file diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 30cecff078..cc8881aa85 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -39,6 +39,7 @@ import { CurrentPartOrSegmentRemaining } from '../RundownView/RundownTiming/Curr import { OverUnderClockComponent, PlannedEndComponent, + TimesSincePlannedEndComponent, TimeToPlannedEndComponent, } from '../../lib/Components/CounterComponents' @@ -364,25 +365,30 @@ function DirectorScreenRender({
PLANNED END
-
+ {expectedEnd - overUnderClock < 0 ? (
- +
+ +
+ TIME TO PLANNED END
- TIME TO PLANNED END -
-
-
= 0, - })} - > + ) : ( +
+
+ +
+ TIME SINCE PLANNED END +
+ )} +
+
OVER/UNDER
-
+
Date: Mon, 10 Mar 2025 12:45:58 +0100 Subject: [PATCH 050/293] wip: segment timing --- .../webui/src/client/styles/countdown/director.scss | 12 +++++++++--- .../webui/src/client/ui/ClockView/DirectorScreen.tsx | 10 ++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 3674eb0476..a05cb6239f 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -14,6 +14,8 @@ $hold-status-color: $liveline-timecode-color; } .director-screen { + font-family: Roboto Flex; + .director-screen__header { display: flex; flex-direction: row; @@ -45,11 +47,11 @@ $hold-status-color: $liveline-timecode-color; .director-screen__body__part { display: grid; grid-template: - 16em + 22em 4fr 6fr / 13vw auto; grid-template: - 16em + 22em 4fr 6fr / #{'min(13vw, 27vh)'} auto; @@ -58,7 +60,7 @@ $hold-status-color: $liveline-timecode-color; grid-column: 1 / -1; text-align: left; padding-left: 10px; - font-size: 16em; + font-size: 20em; font-weight: 500; line-height: 100%; letter-spacing: 0%; @@ -75,6 +77,10 @@ $hold-status-color: $liveline-timecode-color; color: #000; } } + .director-screen__body__segment__countdown { + float: right; + margin-right: 10px; + } .director-screen__body__rundown-countdown { grid-row: 2 / -1; diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index cc8881aa85..4e99df593a 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -352,7 +352,6 @@ function DirectorScreenRender({ const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) || 0 - const expectedDuration = PlaylistTiming.getExpectedDuration(playlist.timing) const overUnderClock = getPlaylistTimingDiff(playlist, timingDurations) ?? 0 @@ -394,7 +393,14 @@ function DirectorScreenRender({ live: currentSegment !== undefined, })} > - {currentSegment?.name} + {currentSegment?.name} + + +
{currentPartInstance && currentShowStyleBaseId ? ( <> From efb9c4224add897528661aff8d2420c1574a6311 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 18 Feb 2025 12:24:28 +0000 Subject: [PATCH 051/293] feat: improve ab notifications SOFIE-207 --- .../abPlayback/__tests__/abPlayback.spec.ts | 125 +++++++++++++----- .../__tests__/abPlaybackResolver.spec.ts | 83 +++++++++++- .../playout/abPlayback/abPlaybackResolver.ts | 14 +- .../playout/abPlayback/abPlaybackSessions.ts | 3 + .../src/playout/abPlayback/index.ts | 34 +++-- 5 files changed, 208 insertions(+), 51 deletions(-) diff --git a/packages/job-worker/src/playout/abPlayback/__tests__/abPlayback.spec.ts b/packages/job-worker/src/playout/abPlayback/__tests__/abPlayback.spec.ts index e6ac20f779..920eab0867 100644 --- a/packages/job-worker/src/playout/abPlayback/__tests__/abPlayback.spec.ts +++ b/packages/job-worker/src/playout/abPlayback/__tests__/abPlayback.spec.ts @@ -33,7 +33,7 @@ function createBasicResolvedPieceInstance( const piece = literal({ _id: protectString(id), externalId: id, - name: id, + name: `name-${id}`, enable: { start, }, @@ -142,13 +142,20 @@ describe('resolveMediaPlayers', () => { [1, 2], 4500 ) - expect(assignments.failedRequired).toEqual(['inst_2_clip_ghi']) + expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', pieceNames: ['name-2'] }]) expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false }, - { end: 5400, id: 'inst_1_clip_def', playerId: 2, start: 400, optional: false }, - { end: 4800, id: 'inst_2_clip_ghi', playerId: undefined, start: 800, optional: false }, // Massive overlap + { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, + { end: 5400, id: 'inst_1_clip_def', playerId: 2, start: 400, optional: false, pieceNames: ['name-1'] }, + { + end: 4800, + id: 'inst_2_clip_ghi', + playerId: undefined, + start: 800, + optional: false, + pieceNames: ['name-2'], + }, // Massive overlap ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -189,13 +196,34 @@ describe('resolveMediaPlayers', () => { ['player1', 'player2'], 4500 ) - expect(assignments.failedRequired).toEqual(['inst_2_clip_ghi']) + expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', pieceNames: ['name-2'] }]) expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 'player1', start: 400, optional: false }, - { end: 5400, id: 'inst_1_clip_def', playerId: 'player2', start: 400, optional: false }, - { end: 4800, id: 'inst_2_clip_ghi', playerId: undefined, start: 800, optional: false }, // Massive overlap + { + end: 5400, + id: 'inst_0_clip_abc', + playerId: 'player1', + start: 400, + optional: false, + pieceNames: ['name-0'], + }, + { + end: 5400, + id: 'inst_1_clip_def', + playerId: 'player2', + start: 400, + optional: false, + pieceNames: ['name-1'], + }, + { + end: 4800, + id: 'inst_2_clip_ghi', + playerId: undefined, + start: 800, + optional: false, + pieceNames: ['name-2'], + }, // Massive overlap ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -236,13 +264,27 @@ describe('resolveMediaPlayers', () => { [1, 'player2'], 4500 ) - expect(assignments.failedRequired).toEqual(['inst_2_clip_ghi']) + expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', pieceNames: ['name-2'] }]) expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false }, - { end: 5400, id: 'inst_1_clip_def', playerId: 'player2', start: 400, optional: false }, - { end: 4800, id: 'inst_2_clip_ghi', playerId: undefined, start: 800, optional: false }, // Massive overlap + { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, + { + end: 5400, + id: 'inst_1_clip_def', + playerId: 'player2', + start: 400, + optional: false, + pieceNames: ['name-1'], + }, + { + end: 4800, + id: 'inst_2_clip_ghi', + playerId: undefined, + start: 800, + optional: false, + pieceNames: ['name-2'], + }, // Massive overlap ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -286,7 +328,14 @@ describe('resolveMediaPlayers', () => { expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(1) expect(assignments.requests).toEqual([ - { end: 7400, id: 'tmp_clip_abc', playerId: 1, start: 400, optional: false }, + { + end: 7400, + id: 'tmp_clip_abc', + playerId: 1, + start: 400, + optional: false, + pieceNames: ['name-0', 'name-1', 'name-2', 'name-3'], + }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(4) @@ -335,9 +384,9 @@ describe('resolveMediaPlayers', () => { expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false }, - { end: 4800, id: 'inst_1_clip_def', playerId: 2, start: 800, optional: false }, - { end: 7400, id: 'inst_3_clip_ghi', playerId: 2, start: 6400, optional: false }, + { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, + { end: 4800, id: 'inst_1_clip_def', playerId: 2, start: 800, optional: false, pieceNames: ['name-1'] }, + { end: 7400, id: 'inst_3_clip_ghi', playerId: 2, start: 6400, optional: false, pieceNames: ['name-3'] }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -382,9 +431,9 @@ describe('resolveMediaPlayers', () => { expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false }, - { end: 6800, id: 'inst_1_clip_def', playerId: 2, start: 800, optional: false }, - { end: 6400, id: 'inst_3_clip_ghi', playerId: 1, start: 5400, optional: false }, + { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, + { end: 6800, id: 'inst_1_clip_def', playerId: 2, start: 800, optional: false, pieceNames: ['name-1'] }, + { end: 6400, id: 'inst_3_clip_ghi', playerId: 1, start: 5400, optional: false, pieceNames: ['name-3'] }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -429,9 +478,9 @@ describe('resolveMediaPlayers', () => { expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false }, - { end: 6800, id: 'inst_1_clip_def', playerId: 2, start: 800, optional: false }, - { end: 6400, id: 'inst_3_clip_ghi', playerId: 1, start: 5400, optional: false }, + { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, + { end: 6800, id: 'inst_1_clip_def', playerId: 2, start: 800, optional: false, pieceNames: ['name-1'] }, + { end: 6400, id: 'inst_3_clip_ghi', playerId: 1, start: 5400, optional: false, pieceNames: ['name-3'] }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -483,13 +532,20 @@ describe('resolveMediaPlayers', () => { [1, 2], 0 ) - expect(assignments.failedRequired).toEqual(['inst_2_clip_ghi']) + expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', pieceNames: ['name-2'] }]) expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 7400, id: 'inst_0_clip_abc', playerId: 2, start: 2400, optional: false }, - { end: 7400, id: 'inst_1_clip_def', playerId: 1, start: 2400, optional: false }, - { end: 6800, id: 'inst_2_clip_ghi', playerId: undefined, start: 2800, optional: false }, + { end: 7400, id: 'inst_0_clip_abc', playerId: 2, start: 2400, optional: false, pieceNames: ['name-0'] }, + { end: 7400, id: 'inst_1_clip_def', playerId: 1, start: 2400, optional: false, pieceNames: ['name-1'] }, + { + end: 6800, + id: 'inst_2_clip_ghi', + playerId: undefined, + start: 2800, + optional: false, + pieceNames: ['name-2'], + }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -542,12 +598,19 @@ describe('resolveMediaPlayers', () => { 0 ) expect(assignments.failedRequired).toHaveLength(0) - expect(assignments.failedOptional).toEqual(['inst_1_clip_def']) + expect(assignments.failedOptional).toEqual([{ id: 'inst_1_clip_def', pieceNames: ['name-1'] }]) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 7400, id: 'inst_0_clip_abc', playerId: 2, start: 2400, optional: false }, - { end: 7400, id: 'inst_1_clip_def', playerId: undefined, start: 2400, optional: true }, - { end: 6800, id: 'inst_2_clip_ghi', playerId: 1, start: 2800, optional: false }, + { end: 7400, id: 'inst_0_clip_abc', playerId: 2, start: 2400, optional: false, pieceNames: ['name-0'] }, + { + end: 7400, + id: 'inst_1_clip_def', + playerId: undefined, + start: 2400, + optional: true, + pieceNames: ['name-1'], + }, + { end: 6800, id: 'inst_2_clip_ghi', playerId: 1, start: 2800, optional: false, pieceNames: ['name-2'] }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) diff --git a/packages/job-worker/src/playout/abPlayback/__tests__/abPlaybackResolver.spec.ts b/packages/job-worker/src/playout/abPlayback/__tests__/abPlaybackResolver.spec.ts index cf961f0490..9a6390fe86 100644 --- a/packages/job-worker/src/playout/abPlayback/__tests__/abPlaybackResolver.spec.ts +++ b/packages/job-worker/src/playout/abPlayback/__tests__/abPlaybackResolver.spec.ts @@ -24,18 +24,21 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: undefined, playerId: 1, + pieceNames: [], }, { id: 'b', start: 1000, end: undefined, playerId: 1, + pieceNames: [], }, { id: 'c', start: 1000, end: undefined, playerId: 1, + pieceNames: [], }, ] @@ -52,11 +55,13 @@ describe('resolveAbAssignmentsFromRequests', () => { id: 'a', start: 1000, end: undefined, + pieceNames: [], }, { id: 'b', start: 2000, end: undefined, + pieceNames: [], }, ] @@ -75,11 +80,13 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: undefined, playerId: 2, + pieceNames: [], }, { id: 'b', start: 2000, end: undefined, + pieceNames: [], }, ] @@ -97,23 +104,26 @@ describe('resolveAbAssignmentsFromRequests', () => { id: 'a', start: 1000, end: undefined, + pieceNames: [], }, { id: 'b', start: 2000, end: undefined, + pieceNames: [], }, { id: 'c', start: 3000, end: undefined, + pieceNames: [], }, ] const res = resolveAbAssignmentsFromRequests(resolverOptions, TWO_SLOTS, requests, 10000) expect(res).toBeTruthy() expect(res.failedOptional).toEqual([]) - expect(res.failedRequired).toEqual(['c']) + expect(res.failedRequired).toEqual([{ id: 'c', pieceNames: [] }]) expectGotPlayer(res, 'a', 1) expectGotPlayer(res, 'b', 2) expectGotPlayer(res, 'c', undefined) @@ -126,24 +136,27 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: undefined, playerId: 2, + pieceNames: [], }, { id: 'b', start: 2000, end: undefined, + pieceNames: [], }, { id: 'c', start: 3000, end: undefined, playerId: 1, + pieceNames: [], }, ] const res = resolveAbAssignmentsFromRequests(resolverOptions, TWO_SLOTS, requests, 10000) expect(res).toBeTruthy() expect(res.failedOptional).toEqual([]) - expect(res.failedRequired).toEqual(['b']) + expect(res.failedRequired).toEqual([{ id: 'b', pieceNames: [] }]) expectGotPlayer(res, 'a', 2) expectGotPlayer(res, 'b', undefined) expectGotPlayer(res, 'c', 1) @@ -156,22 +169,26 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: 10000, playerId: 2, + pieceNames: [], }, { id: 'b', start: 2000, end: 10500, playerId: 1, + pieceNames: [], }, { id: 'c', start: 10900, end: undefined, + pieceNames: [], }, { id: 'd', start: 10950, end: undefined, + pieceNames: [], }, ] @@ -192,22 +209,26 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: 9000, playerId: 2, + pieceNames: [], }, { id: 'b', start: 2000, end: 8500, playerId: 1, + pieceNames: [], }, { id: 'c', start: 10900, end: undefined, + pieceNames: [], }, { id: 'd', start: 10950, end: undefined, + pieceNames: [], }, ] @@ -228,28 +249,33 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: 15000, playerId: 3, + pieceNames: [], }, { id: 'b', start: 2000, end: 16000, playerId: 1, + pieceNames: [], }, { id: 'c', start: 20000, end: 40000, + pieceNames: [], }, { id: 'd', start: 30000, end: undefined, playerId: 1, + pieceNames: [], }, { id: 'e', start: 35000, end: undefined, + pieceNames: [], }, ] @@ -272,12 +298,14 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: undefined, playerId: 2, + pieceNames: [], }, // adlib { id: 'b', start: 10000, end: undefined, + pieceNames: [], }, // lookaheads { @@ -286,12 +314,14 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, playerId: 1, lookaheadRank: 1, + pieceNames: [], }, { id: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 2, + pieceNames: [], }, ] @@ -313,12 +343,14 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: 10500, playerId: 2, + pieceNames: [], }, // adlib { id: 'b', start: 10000, end: undefined, + pieceNames: [], }, // lookaheads (in order of future use) { @@ -327,6 +359,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, playerId: 1, lookaheadRank: 1, + pieceNames: [], }, { id: 'd', @@ -334,6 +367,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, playerId: 2, lookaheadRank: 2, + pieceNames: [], }, ] @@ -354,18 +388,21 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: 9500, playerId: 2, + pieceNames: [], }, // adlib { id: 'b', start: 10000, end: undefined, + pieceNames: [], }, // adlib { id: 'e', start: 10000, end: undefined, + pieceNames: [], }, // lookaheads (in order of future use) { @@ -374,6 +411,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, playerId: 1, lookaheadRank: 1, + pieceNames: [], }, { id: 'd', @@ -381,6 +419,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, playerId: 2, lookaheadRank: 2, + pieceNames: [], }, ] @@ -402,12 +441,14 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: 10500, playerId: 2, + pieceNames: [], }, // adlib { id: 'b', start: 10000, end: 15000, + pieceNames: [], }, // lookaheads (in order of future use) { @@ -416,6 +457,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, playerId: 1, lookaheadRank: 1, + pieceNames: [], }, { id: 'd', @@ -423,6 +465,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, playerId: 2, lookaheadRank: 2, + pieceNames: [], }, ] @@ -444,12 +487,14 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: 10500, playerId: 2, + pieceNames: [], }, // adlib { id: 'b', start: 10000, end: 20500, + pieceNames: [], }, // next part { @@ -457,6 +502,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 20000, end: undefined, playerId: 1, + pieceNames: [], }, // lookaheads (in order of future use) { @@ -465,12 +511,14 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, playerId: 2, lookaheadRank: 1, + pieceNames: [], }, { id: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 2, + pieceNames: [], }, ] @@ -492,12 +540,14 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: 10500, playerId: 2, + pieceNames: [], }, // adlib { id: 'b', start: 10000, end: 20500, + pieceNames: [], }, // next part { @@ -505,6 +555,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 20000, end: undefined, playerId: 1, + pieceNames: [], }, // lookaheads (in order of future use) { @@ -513,12 +564,14 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, playerId: 2, lookaheadRank: 1, + pieceNames: [], }, { id: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 2, + pieceNames: [], }, ] @@ -541,6 +594,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: undefined, playerId: 2, + pieceNames: [], }, // bak { @@ -549,12 +603,14 @@ describe('resolveAbAssignmentsFromRequests', () => { optional: true, playerId: 1, end: undefined, + pieceNames: [], }, // adlib { id: 'c', start: 10000, end: undefined, + pieceNames: [], }, // lookaheads { @@ -562,19 +618,21 @@ describe('resolveAbAssignmentsFromRequests', () => { start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 1, + pieceNames: [], }, { id: 'e', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 2, + pieceNames: [], }, ] const res = resolveAbAssignmentsFromRequests(resolverOptions, TWO_SLOTS, requests, 10000) expect(res).toBeTruthy() expect(res.failedOptional).toEqual([]) - expect(res.failedRequired).toEqual(['c']) + expect(res.failedRequired).toEqual([{ id: 'c', pieceNames: [] }]) expectGotPlayer(res, 'a', 2) expectGotPlayer(res, 'b', 1) expectGotPlayer(res, 'c', undefined) @@ -588,6 +646,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: undefined, playerId: 2, + pieceNames: [], }, // previous clip { @@ -595,6 +654,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 0, playerId: 1, end: 5000, + pieceNames: [], }, // lookaheads { @@ -602,6 +662,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 1, + pieceNames: [], }, { id: 'e', @@ -609,12 +670,14 @@ describe('resolveAbAssignmentsFromRequests', () => { playerId: 3, // From before end: undefined, lookaheadRank: 2, + pieceNames: [], }, { id: 'f', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 3, + pieceNames: [], }, ] @@ -638,6 +701,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: undefined, playerId: 2, + pieceNames: [], }, // previous clip { @@ -645,6 +709,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 0, playerId: 1, end: 5000, + pieceNames: [], }, // lookaheads { @@ -653,6 +718,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, lookaheadRank: 1, playerId: 1, + pieceNames: [], }, { id: 'e', @@ -660,6 +726,7 @@ describe('resolveAbAssignmentsFromRequests', () => { playerId: 3, // From before end: undefined, lookaheadRank: 2, + pieceNames: [], }, { id: 'f', @@ -667,6 +734,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, lookaheadRank: 3, playerId: 2, + pieceNames: [], }, ] @@ -689,6 +757,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: undefined, playerId: 3, + pieceNames: [], }, // previous clip { @@ -696,6 +765,7 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 0, playerId: 1, end: 5000, + pieceNames: [], }, // lookaheads { @@ -704,6 +774,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, lookaheadRank: 1, playerId: 1, + pieceNames: [], }, { id: 'e', @@ -711,6 +782,7 @@ describe('resolveAbAssignmentsFromRequests', () => { playerId: 2, end: undefined, lookaheadRank: 2, + pieceNames: [], }, ] @@ -732,18 +804,21 @@ describe('resolveAbAssignmentsFromRequests', () => { start: 1000, end: 11000, playerId: 1, + pieceNames: [], }, { id: 'b', start: 13000, // soon end: undefined, playerId: 1, + pieceNames: [], }, { id: 'c', start: 1000, end: undefined, playerId: 2, + pieceNames: [], }, // lookaheads { @@ -752,6 +827,7 @@ describe('resolveAbAssignmentsFromRequests', () => { end: undefined, lookaheadRank: 1, playerId: 1, + pieceNames: [], }, { id: 'e', @@ -759,6 +835,7 @@ describe('resolveAbAssignmentsFromRequests', () => { playerId: 2, end: undefined, lookaheadRank: 2, + pieceNames: [], }, ] diff --git a/packages/job-worker/src/playout/abPlayback/abPlaybackResolver.ts b/packages/job-worker/src/playout/abPlayback/abPlaybackResolver.ts index 217f9bab51..5048462060 100644 --- a/packages/job-worker/src/playout/abPlayback/abPlaybackResolver.ts +++ b/packages/job-worker/src/playout/abPlayback/abPlaybackResolver.ts @@ -9,13 +9,19 @@ export interface SessionRequest { readonly optional?: boolean readonly lookaheadRank?: number playerId?: AbPlayerId + pieceNames: string[] +} + +export interface FailedSession { + id: string + pieceNames: string[] } export interface AssignmentResult { /** Any non-optional sessions which were not assigned a player */ - failedRequired: string[] + failedRequired: FailedSession[] /** Any optional sessions which were not assigned a player */ - failedOptional: string[] + failedOptional: FailedSession[] /** All of the requests with their player assignments set */ requests: Readonly } @@ -312,9 +318,9 @@ export function resolveAbAssignmentsFromRequests( if (req.lookaheadRank !== undefined) { // ignore } else if (req.optional) { - res.failedOptional.push(req.id) + res.failedOptional.push({ id: req.id, pieceNames: req.pieceNames }) } else { - res.failedRequired.push(req.id) + res.failedRequired.push({ id: req.id, pieceNames: req.pieceNames }) } } } diff --git a/packages/job-worker/src/playout/abPlayback/abPlaybackSessions.ts b/packages/job-worker/src/playout/abPlayback/abPlaybackSessions.ts index 1a9fd75e50..5ec7a7007b 100644 --- a/packages/job-worker/src/playout/abPlayback/abPlaybackSessions.ts +++ b/packages/job-worker/src/playout/abPlayback/abPlaybackSessions.ts @@ -49,6 +49,7 @@ export function calculateSessionTimeRanges( optional: val.optional && (session.optional ?? false), lookaheadRank: undefined, playerId: previousAssignmentMap?.[sessionId]?.playerId, // Persist previous assignments + pieceNames: [...(val.pieceNames || []), p.instance.piece.name], } } else { // New session @@ -59,6 +60,7 @@ export function calculateSessionTimeRanges( optional: session.optional ?? false, lookaheadRank: undefined, playerId: previousAssignmentMap?.[sessionId]?.playerId, // Persist previous assignments + pieceNames: [p.instance.piece.name], } } } @@ -104,6 +106,7 @@ export function calculateSessionTimeRanges( end: undefined, lookaheadRank: i + 1, // This is so that we can easily work out which to use first playerId: previousAssignmentMap?.[grp.id]?.playerId, + pieceNames: [], }) } }) diff --git a/packages/job-worker/src/playout/abPlayback/index.ts b/packages/job-worker/src/playout/abPlayback/index.ts index f3b0c80c8e..dfb07606bf 100644 --- a/packages/job-worker/src/playout/abPlayback/index.ts +++ b/packages/job-worker/src/playout/abPlayback/index.ts @@ -20,6 +20,7 @@ import { ABPlayerDefinition, NoteSeverity } from '@sofie-automation/blueprints-i import { abPoolFilterDisabled, findPlayersInRouteSets } from './routeSetDisabling' import type { INotification } from '../../notifications/NotificationsModel' import { generateTranslation } from '@sofie-automation/corelib/dist/lib' +import _ = require('underscore') export interface ABPlaybackResult { assignments: Record @@ -114,29 +115,36 @@ export function applyAbPlaybackForTimeline( }" (${JSON.stringify(assignment)})` ) } - if (assignments.failedRequired.length > 0) { - logger.warn( - `ABPlayback failed to assign sessions for "${poolName}": ${JSON.stringify(assignments.failedRequired)}` - ) + for (const failedSession of assignments.failedRequired) { + const uniqueNames = _.uniq(failedSession.pieceNames).join(', ') + logger.warn(`ABPlayback failed to assign session for "${poolName}"-"${failedSession.id}": ${uniqueNames}`) + + // Ignore lookahead + if (failedSession.pieceNames.length === 0) continue + notifications.push({ id: `failedRequired-${poolName}`, severity: NoteSeverity.ERROR, - message: generateTranslation('Failed to assign players for {{count}} sessions', { - count: assignments.failedRequired.length, + message: generateTranslation('Failed to assign AB player for {{pieceNames}}', { + pieceNames: uniqueNames, }), }) } - if (assignments.failedOptional.length > 0) { + + for (const failedSession of assignments.failedOptional) { + const uniqueNames = _.uniq(failedSession.pieceNames).join(', ') logger.info( - `ABPlayback failed to assign optional sessions for "${poolName}": ${JSON.stringify( - assignments.failedOptional - )}` + `ABPlayback failed to assign optional session for "${poolName}"-"${failedSession.id}": ${uniqueNames}` ) + + // Ignore lookahead + if (failedSession.pieceNames.length === 0) continue + notifications.push({ - id: `failedOptional-${poolName}`, + id: `failedRequired-${poolName}`, severity: NoteSeverity.WARNING, - message: generateTranslation('Failed to assign players for {{count}} non-critical sessions', { - count: assignments.failedOptional.length, + message: generateTranslation('Failed to assign non-critical AB player for {{pieceNames}}', { + pieceNames: uniqueNames, }), }) } From efb819e5c63153c151aa3acd1aefaabca4500f26 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 19 Feb 2025 12:25:33 +0000 Subject: [PATCH 052/293] feat: add ab session names to logging SOFIE-213 --- .../corelib/src/dataModel/RundownPlaylist.ts | 1 + .../playout/abPlayback/abPlaybackResolver.ts | 6 ++- .../playout/abPlayback/abPlaybackSessions.ts | 20 ++++++--- .../playout/abPlayback/applyAssignments.ts | 42 ++++++++++++------- .../src/playout/abPlayback/index.ts | 10 +++-- 5 files changed, 54 insertions(+), 25 deletions(-) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index f9ff938c02..5ffa754f78 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -31,6 +31,7 @@ export interface ABSessionInfo { export interface ABSessionAssignment { sessionId: string + sessionName: string playerId: number | string lookahead: boolean // purely informational for debugging } diff --git a/packages/job-worker/src/playout/abPlayback/abPlaybackResolver.ts b/packages/job-worker/src/playout/abPlayback/abPlaybackResolver.ts index 5048462060..a7f1de5766 100644 --- a/packages/job-worker/src/playout/abPlayback/abPlaybackResolver.ts +++ b/packages/job-worker/src/playout/abPlayback/abPlaybackResolver.ts @@ -4,6 +4,7 @@ import * as _ from 'underscore' export interface SessionRequest { readonly id: string + readonly name: string readonly start: number readonly end: number | undefined readonly optional?: boolean @@ -14,6 +15,7 @@ export interface SessionRequest { export interface FailedSession { id: string + name: string pieceNames: string[] } @@ -318,9 +320,9 @@ export function resolveAbAssignmentsFromRequests( if (req.lookaheadRank !== undefined) { // ignore } else if (req.optional) { - res.failedOptional.push({ id: req.id, pieceNames: req.pieceNames }) + res.failedOptional.push({ id: req.id, name: req.name, pieceNames: req.pieceNames }) } else { - res.failedRequired.push({ id: req.id, pieceNames: req.pieceNames }) + res.failedRequired.push({ id: req.id, name: req.name, pieceNames: req.pieceNames }) } } } diff --git a/packages/job-worker/src/playout/abPlayback/abPlaybackSessions.ts b/packages/job-worker/src/playout/abPlayback/abPlaybackSessions.ts index 5ec7a7007b..f50b852819 100644 --- a/packages/job-worker/src/playout/abPlayback/abPlaybackSessions.ts +++ b/packages/job-worker/src/playout/abPlayback/abPlaybackSessions.ts @@ -44,6 +44,7 @@ export function calculateSessionTimeRanges( // This session is already known, so extend the session to cover all the pieces sessionRequests[sessionId] = { id: sessionId, + name: session.sessionName, start: Math.min(val.start, start), end: val.end === undefined || end === undefined ? undefined : Math.max(val.end, end), optional: val.optional && (session.optional ?? false), @@ -55,6 +56,7 @@ export function calculateSessionTimeRanges( // New session sessionRequests[sessionId] = { id: sessionId, + name: session.sessionName, start, end, optional: session.optional ?? false, @@ -69,7 +71,10 @@ export function calculateSessionTimeRanges( const result = _.compact(Object.values(sessionRequests)) // Include lookaheads, as they dont have pieces - const groupedLookaheadMap = new Map>>() + const groupedLookaheadMap = new Map< + string, + { name: string; objs: Array> } + >() for (const obj of timelineObjects) { if ( !!obj.isLookahead && @@ -82,16 +87,20 @@ export function calculateSessionTimeRanges( for (const session of obj.abSessions) { if (session.poolName === poolName) { const sessionId = abSessionHelper.getTimelineObjectAbSessionId(obj, session) - if (sessionId) { - const existing = groupedLookaheadMap.get(sessionId) - groupedLookaheadMap.set(sessionId, existing ? [...existing, obj] : [obj]) + if (!sessionId) continue + + const existing = groupedLookaheadMap.get(sessionId) + if (existing) { + existing.objs.push(obj) + } else { + groupedLookaheadMap.set(sessionId, { name: session.sessionName, objs: [obj] }) } } } } } - const groupedLookahead = Array.from(groupedLookaheadMap.entries()).map(([id, objs]) => ({ objs, id })) + const groupedLookahead = Array.from(groupedLookaheadMap.entries()).map(([id, info]) => ({ id, ...info })) const sortedLookaheadGroups = _.sortBy(groupedLookahead, (grp) => { // Find the highest priority of the objects const r = _.max(_.pluck(grp.objs, 'priority')) || -900 @@ -102,6 +111,7 @@ export function calculateSessionTimeRanges( if (!sessionRequests[grp.id]) { result.push({ id: grp.id, + name: grp.name, start: Number.MAX_SAFE_INTEGER, // Distant future end: undefined, lookaheadRank: i + 1, // This is so that we can easily work out which to use first diff --git a/packages/job-worker/src/playout/abPlayback/applyAssignments.ts b/packages/job-worker/src/playout/abPlayback/applyAssignments.ts index 62cae611fc..2c8c24d4be 100644 --- a/packages/job-worker/src/playout/abPlayback/applyAssignments.ts +++ b/packages/job-worker/src/playout/abPlayback/applyAssignments.ts @@ -35,24 +35,28 @@ export function applyAbPlayerObjectAssignments( poolName: string ): ABSessionAssignments { const newAssignments: ABSessionAssignments = {} - const persistAssignment = (sessionId: string, playerId: AbPlayerId, lookahead: boolean): void => { + const persistAssignment = (session: ABSessionAssignment): void => { // Track the assignment, so that the next onTimelineGenerate can try to reuse the same session - if (newAssignments[sessionId]) { + if (newAssignments[session.sessionId]) { // TODO - warn? } - newAssignments[sessionId] = { sessionId, playerId, lookahead } + newAssignments[session.sessionId] = session } // collect objects by their sessionId - const groupedObjectsMap = new Map>() + const groupedObjectsMap = new Map }>() for (const obj of timelineObjs) { if (obj.abSessions && obj.pieceInstanceId) { for (const session of obj.abSessions) { if (session.poolName === poolName) { const sessionId = abSessionHelper.getTimelineObjectAbSessionId(obj, session) - if (sessionId) { - const existing = groupedObjectsMap.get(sessionId) - groupedObjectsMap.set(sessionId, existing ? [...existing, obj] : [obj]) + if (!sessionId) continue + + const existing = groupedObjectsMap.get(sessionId) + if (existing) { + existing.objs.push(obj) + } else { + groupedObjectsMap.set(sessionId, { name: session.sessionName, objs: [obj] }) } } } @@ -63,7 +67,7 @@ export function applyAbPlayerObjectAssignments( const unexpectedSessions: string[] = [] // Apply the known assignments - for (const [sessionId, objs] of groupedObjectsMap.entries()) { + for (const [sessionId, info] of groupedObjectsMap.entries()) { if (sessionId === 'undefined') continue const matchingAssignment = resolvedAssignments.find((req) => req.id === sessionId) @@ -76,24 +80,34 @@ export function applyAbPlayerObjectAssignments( abConfiguration, poolName, matchingAssignment.playerId, - objs + info.objs ) ) - persistAssignment(sessionId, matchingAssignment.playerId, !!matchingAssignment.lookaheadRank) + persistAssignment({ + sessionId, + sessionName: matchingAssignment.name, + playerId: matchingAssignment.playerId, + lookahead: !!matchingAssignment.lookaheadRank, + }) } else { // A warning will already have been raised about this having no player } } else { // This is a group that shouldn't exist, so are likely a bug. There isnt a lot we can do beyond warn about the issue - unexpectedSessions.push(`${sessionId}(${objs.map((obj) => obj.id).join(',')})`) + unexpectedSessions.push(`${sessionId}(${info.objs.map((obj) => obj.id).join(',')})`) // If there was a previous assignment, hopefully that is better than nothing const prev = previousAssignmentMap?.[sessionId] if (prev) { failedObjects.push( - ...updateObjectsToAbPlayer(blueprintContext, abConfiguration, poolName, prev.playerId, objs) + ...updateObjectsToAbPlayer(blueprintContext, abConfiguration, poolName, prev.playerId, info.objs) ) - persistAssignment(sessionId, prev.playerId, false) + persistAssignment({ + sessionId, + sessionName: '?', + playerId: prev.playerId, + lookahead: false, + }) } } } @@ -110,7 +124,7 @@ export function applyAbPlayerObjectAssignments( for (const assignment of Object.values(newAssignments)) { if (!assignment) continue logger.silly( - `ABPlayback: Assigned session "${poolName}"-"${assignment.sessionId}" to player "${assignment.playerId}" (lookahead: ${assignment.lookahead})` + `ABPlayback: Assigned session "${poolName}"-"${assignment.sessionId}" (${assignment.sessionName}) to player "${assignment.playerId}" (lookahead: ${assignment.lookahead})` ) } diff --git a/packages/job-worker/src/playout/abPlayback/index.ts b/packages/job-worker/src/playout/abPlayback/index.ts index dfb07606bf..1f623eb959 100644 --- a/packages/job-worker/src/playout/abPlayback/index.ts +++ b/packages/job-worker/src/playout/abPlayback/index.ts @@ -69,7 +69,7 @@ export function applyAbPlaybackForTimeline( for (const assignment of Object.values(assignments)) { if (assignment) { logger.silly( - `ABPlayback: Previous assignment "${pool}"-"${assignment.sessionId}" to player "${assignment.playerId}"` + `ABPlayback: Previous assignment "${pool}"-"${assignment.sessionId}" (${assignment.sessionName}) to player "${assignment.playerId}"` ) } } @@ -110,14 +110,16 @@ export function applyAbPlaybackForTimeline( for (const assignment of Object.values(assignments.requests)) { logger.silly( - `ABPlayback resolved session "${poolName}"-"${assignment.id}" to player "${ + `ABPlayback resolved session "${poolName}"-"${assignment.id}" (${assignment.name}) to player "${ assignment.playerId }" (${JSON.stringify(assignment)})` ) } for (const failedSession of assignments.failedRequired) { const uniqueNames = _.uniq(failedSession.pieceNames).join(', ') - logger.warn(`ABPlayback failed to assign session for "${poolName}"-"${failedSession.id}": ${uniqueNames}`) + logger.warn( + `ABPlayback failed to assign session for "${poolName}"-"${failedSession.id}" (${failedSession.name}): ${uniqueNames}` + ) // Ignore lookahead if (failedSession.pieceNames.length === 0) continue @@ -134,7 +136,7 @@ export function applyAbPlaybackForTimeline( for (const failedSession of assignments.failedOptional) { const uniqueNames = _.uniq(failedSession.pieceNames).join(', ') logger.info( - `ABPlayback failed to assign optional session for "${poolName}"-"${failedSession.id}": ${uniqueNames}` + `ABPlayback failed to assign optional session for "${poolName}"-"${failedSession.id}" (${failedSession.name}): ${uniqueNames}` ) // Ignore lookahead From e054aaa0f37fae99d7bea0fc752d973f9f5309df Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 19 Feb 2025 12:48:50 +0000 Subject: [PATCH 053/293] chore: fix tests SOFIE-213 --- .../abPlayback/__tests__/abPlayback.spec.ts | 183 ++++++++++++++++-- .../__tests__/abPlaybackResolver.spec.ts | 83 +++++++- .../__tests__/applyAssignments.spec.ts | 3 + 3 files changed, 245 insertions(+), 24 deletions(-) diff --git a/packages/job-worker/src/playout/abPlayback/__tests__/abPlayback.spec.ts b/packages/job-worker/src/playout/abPlayback/__tests__/abPlayback.spec.ts index 920eab0867..d110df3df2 100644 --- a/packages/job-worker/src/playout/abPlayback/__tests__/abPlayback.spec.ts +++ b/packages/job-worker/src/playout/abPlayback/__tests__/abPlayback.spec.ts @@ -142,15 +142,32 @@ describe('resolveMediaPlayers', () => { [1, 2], 4500 ) - expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', pieceNames: ['name-2'] }]) + expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', name: 'ghi', pieceNames: ['name-2'] }]) expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, - { end: 5400, id: 'inst_1_clip_def', playerId: 2, start: 400, optional: false, pieceNames: ['name-1'] }, + { + end: 5400, + id: 'inst_0_clip_abc', + name: 'abc', + playerId: 1, + start: 400, + optional: false, + pieceNames: ['name-0'], + }, + { + end: 5400, + id: 'inst_1_clip_def', + name: 'def', + playerId: 2, + start: 400, + optional: false, + pieceNames: ['name-1'], + }, { end: 4800, id: 'inst_2_clip_ghi', + name: 'ghi', playerId: undefined, start: 800, optional: false, @@ -196,13 +213,14 @@ describe('resolveMediaPlayers', () => { ['player1', 'player2'], 4500 ) - expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', pieceNames: ['name-2'] }]) + expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', name: 'ghi', pieceNames: ['name-2'] }]) expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ { end: 5400, id: 'inst_0_clip_abc', + name: 'abc', playerId: 'player1', start: 400, optional: false, @@ -211,6 +229,7 @@ describe('resolveMediaPlayers', () => { { end: 5400, id: 'inst_1_clip_def', + name: 'def', playerId: 'player2', start: 400, optional: false, @@ -219,6 +238,7 @@ describe('resolveMediaPlayers', () => { { end: 4800, id: 'inst_2_clip_ghi', + name: 'ghi', playerId: undefined, start: 800, optional: false, @@ -264,14 +284,23 @@ describe('resolveMediaPlayers', () => { [1, 'player2'], 4500 ) - expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', pieceNames: ['name-2'] }]) + expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', name: 'ghi', pieceNames: ['name-2'] }]) expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, + { + end: 5400, + id: 'inst_0_clip_abc', + name: 'abc', + playerId: 1, + start: 400, + optional: false, + pieceNames: ['name-0'], + }, { end: 5400, id: 'inst_1_clip_def', + name: 'def', playerId: 'player2', start: 400, optional: false, @@ -280,6 +309,7 @@ describe('resolveMediaPlayers', () => { { end: 4800, id: 'inst_2_clip_ghi', + name: 'ghi', playerId: undefined, start: 800, optional: false, @@ -331,6 +361,7 @@ describe('resolveMediaPlayers', () => { { end: 7400, id: 'tmp_clip_abc', + name: 'abc', playerId: 1, start: 400, optional: false, @@ -384,9 +415,33 @@ describe('resolveMediaPlayers', () => { expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, - { end: 4800, id: 'inst_1_clip_def', playerId: 2, start: 800, optional: false, pieceNames: ['name-1'] }, - { end: 7400, id: 'inst_3_clip_ghi', playerId: 2, start: 6400, optional: false, pieceNames: ['name-3'] }, + { + end: 5400, + id: 'inst_0_clip_abc', + name: 'abc', + playerId: 1, + start: 400, + optional: false, + pieceNames: ['name-0'], + }, + { + end: 4800, + id: 'inst_1_clip_def', + name: 'def', + playerId: 2, + start: 800, + optional: false, + pieceNames: ['name-1'], + }, + { + end: 7400, + id: 'inst_3_clip_ghi', + name: 'ghi', + playerId: 2, + start: 6400, + optional: false, + pieceNames: ['name-3'], + }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -431,9 +486,33 @@ describe('resolveMediaPlayers', () => { expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, - { end: 6800, id: 'inst_1_clip_def', playerId: 2, start: 800, optional: false, pieceNames: ['name-1'] }, - { end: 6400, id: 'inst_3_clip_ghi', playerId: 1, start: 5400, optional: false, pieceNames: ['name-3'] }, + { + end: 5400, + id: 'inst_0_clip_abc', + name: 'abc', + playerId: 1, + start: 400, + optional: false, + pieceNames: ['name-0'], + }, + { + end: 6800, + id: 'inst_1_clip_def', + name: 'def', + playerId: 2, + start: 800, + optional: false, + pieceNames: ['name-1'], + }, + { + end: 6400, + id: 'inst_3_clip_ghi', + name: 'ghi', + playerId: 1, + start: 5400, + optional: false, + pieceNames: ['name-3'], + }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -478,9 +557,33 @@ describe('resolveMediaPlayers', () => { expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 5400, id: 'inst_0_clip_abc', playerId: 1, start: 400, optional: false, pieceNames: ['name-0'] }, - { end: 6800, id: 'inst_1_clip_def', playerId: 2, start: 800, optional: false, pieceNames: ['name-1'] }, - { end: 6400, id: 'inst_3_clip_ghi', playerId: 1, start: 5400, optional: false, pieceNames: ['name-3'] }, + { + end: 5400, + id: 'inst_0_clip_abc', + name: 'abc', + playerId: 1, + start: 400, + optional: false, + pieceNames: ['name-0'], + }, + { + end: 6800, + id: 'inst_1_clip_def', + name: 'def', + playerId: 2, + start: 800, + optional: false, + pieceNames: ['name-1'], + }, + { + end: 6400, + id: 'inst_3_clip_ghi', + name: 'ghi', + playerId: 1, + start: 5400, + optional: false, + pieceNames: ['name-3'], + }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) @@ -503,11 +606,13 @@ describe('resolveMediaPlayers', () => { const previousAssignments: ABSessionAssignments = { inst_0_clip_abc: { sessionId: 'inst_0_clip_abc', + sessionName: 'abc', playerId: 5, lookahead: false, }, inst_1_clip_def: { sessionId: 'inst_1_clip_def', + sessionName: 'def', playerId: 1, lookahead: true, }, @@ -532,15 +637,32 @@ describe('resolveMediaPlayers', () => { [1, 2], 0 ) - expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', pieceNames: ['name-2'] }]) + expect(assignments.failedRequired).toEqual([{ id: 'inst_2_clip_ghi', name: 'ghi', pieceNames: ['name-2'] }]) expect(assignments.failedOptional).toHaveLength(0) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 7400, id: 'inst_0_clip_abc', playerId: 2, start: 2400, optional: false, pieceNames: ['name-0'] }, - { end: 7400, id: 'inst_1_clip_def', playerId: 1, start: 2400, optional: false, pieceNames: ['name-1'] }, + { + end: 7400, + id: 'inst_0_clip_abc', + name: 'abc', + playerId: 2, + start: 2400, + optional: false, + pieceNames: ['name-0'], + }, + { + end: 7400, + id: 'inst_1_clip_def', + name: 'def', + playerId: 1, + start: 2400, + optional: false, + pieceNames: ['name-1'], + }, { end: 6800, id: 'inst_2_clip_ghi', + name: 'ghi', playerId: undefined, start: 2800, optional: false, @@ -568,11 +690,13 @@ describe('resolveMediaPlayers', () => { const previousAssignments: ABSessionAssignments = { inst_0_clip_abc: { sessionId: 'inst_0_clip_abc', + sessionName: 'abc', playerId: 2, lookahead: false, }, inst_1_clip_def: { sessionId: 'inst_1_clip_def', + sessionName: 'def', playerId: 1, lookahead: false, }, @@ -598,19 +722,36 @@ describe('resolveMediaPlayers', () => { 0 ) expect(assignments.failedRequired).toHaveLength(0) - expect(assignments.failedOptional).toEqual([{ id: 'inst_1_clip_def', pieceNames: ['name-1'] }]) + expect(assignments.failedOptional).toEqual([{ id: 'inst_1_clip_def', name: 'def', pieceNames: ['name-1'] }]) expect(assignments.requests).toHaveLength(3) expect(assignments.requests).toEqual([ - { end: 7400, id: 'inst_0_clip_abc', playerId: 2, start: 2400, optional: false, pieceNames: ['name-0'] }, + { + end: 7400, + id: 'inst_0_clip_abc', + name: 'abc', + playerId: 2, + start: 2400, + optional: false, + pieceNames: ['name-0'], + }, { end: 7400, id: 'inst_1_clip_def', + name: 'def', playerId: undefined, start: 2400, optional: true, pieceNames: ['name-1'], }, - { end: 6800, id: 'inst_2_clip_ghi', playerId: 1, start: 2800, optional: false, pieceNames: ['name-2'] }, + { + end: 6800, + id: 'inst_2_clip_ghi', + name: 'ghi', + playerId: 1, + start: 2800, + optional: false, + pieceNames: ['name-2'], + }, ]) expect(mockGetPieceSessionId).toHaveBeenCalledTimes(3) diff --git a/packages/job-worker/src/playout/abPlayback/__tests__/abPlaybackResolver.spec.ts b/packages/job-worker/src/playout/abPlayback/__tests__/abPlaybackResolver.spec.ts index 9a6390fe86..edbc0bcccd 100644 --- a/packages/job-worker/src/playout/abPlayback/__tests__/abPlaybackResolver.spec.ts +++ b/packages/job-worker/src/playout/abPlayback/__tests__/abPlaybackResolver.spec.ts @@ -21,6 +21,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // Note: these should all collide { id: 'a', + name: 'a', start: 1000, end: undefined, playerId: 1, @@ -28,6 +29,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'b', + name: 'b', start: 1000, end: undefined, playerId: 1, @@ -35,6 +37,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'c', + name: 'c', start: 1000, end: undefined, playerId: 1, @@ -53,12 +56,14 @@ describe('resolveAbAssignmentsFromRequests', () => { const requests: SessionRequest[] = [ { id: 'a', + name: 'a', start: 1000, end: undefined, pieceNames: [], }, { id: 'b', + name: 'b', start: 2000, end: undefined, pieceNames: [], @@ -77,6 +82,7 @@ describe('resolveAbAssignmentsFromRequests', () => { const requests: SessionRequest[] = [ { id: 'a', + name: 'a', start: 1000, end: undefined, playerId: 2, @@ -84,6 +90,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'b', + name: 'b', start: 2000, end: undefined, pieceNames: [], @@ -102,18 +109,21 @@ describe('resolveAbAssignmentsFromRequests', () => { const requests: SessionRequest[] = [ { id: 'a', + name: 'a', start: 1000, end: undefined, pieceNames: [], }, { id: 'b', + name: 'b', start: 2000, end: undefined, pieceNames: [], }, { id: 'c', + name: 'c', start: 3000, end: undefined, pieceNames: [], @@ -123,7 +133,7 @@ describe('resolveAbAssignmentsFromRequests', () => { const res = resolveAbAssignmentsFromRequests(resolverOptions, TWO_SLOTS, requests, 10000) expect(res).toBeTruthy() expect(res.failedOptional).toEqual([]) - expect(res.failedRequired).toEqual([{ id: 'c', pieceNames: [] }]) + expect(res.failedRequired).toEqual([{ id: 'c', name: 'c', pieceNames: [] }]) expectGotPlayer(res, 'a', 1) expectGotPlayer(res, 'b', 2) expectGotPlayer(res, 'c', undefined) @@ -133,6 +143,7 @@ describe('resolveAbAssignmentsFromRequests', () => { const requests: SessionRequest[] = [ { id: 'a', + name: 'a', start: 1000, end: undefined, playerId: 2, @@ -140,12 +151,14 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'b', + name: 'b', start: 2000, end: undefined, pieceNames: [], }, { id: 'c', + name: 'c', start: 3000, end: undefined, playerId: 1, @@ -156,7 +169,7 @@ describe('resolveAbAssignmentsFromRequests', () => { const res = resolveAbAssignmentsFromRequests(resolverOptions, TWO_SLOTS, requests, 10000) expect(res).toBeTruthy() expect(res.failedOptional).toEqual([]) - expect(res.failedRequired).toEqual([{ id: 'b', pieceNames: [] }]) + expect(res.failedRequired).toEqual([{ id: 'b', name: 'b', pieceNames: [] }]) expectGotPlayer(res, 'a', 2) expectGotPlayer(res, 'b', undefined) expectGotPlayer(res, 'c', 1) @@ -166,6 +179,7 @@ describe('resolveAbAssignmentsFromRequests', () => { const requests: SessionRequest[] = [ { id: 'a', + name: 'a', start: 1000, end: 10000, playerId: 2, @@ -173,6 +187,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'b', + name: 'b', start: 2000, end: 10500, playerId: 1, @@ -180,12 +195,14 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'c', + name: 'c', start: 10900, end: undefined, pieceNames: [], }, { id: 'd', + name: 'd', start: 10950, end: undefined, pieceNames: [], @@ -206,6 +223,7 @@ describe('resolveAbAssignmentsFromRequests', () => { const requests: SessionRequest[] = [ { id: 'a', + name: 'a', start: 1000, end: 9000, playerId: 2, @@ -213,6 +231,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'b', + name: 'b', start: 2000, end: 8500, playerId: 1, @@ -220,12 +239,14 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'c', + name: 'c', start: 10900, end: undefined, pieceNames: [], }, { id: 'd', + name: 'd', start: 10950, end: undefined, pieceNames: [], @@ -246,6 +267,7 @@ describe('resolveAbAssignmentsFromRequests', () => { const requests: SessionRequest[] = [ { id: 'a', + name: 'a', start: 1000, end: 15000, playerId: 3, @@ -253,6 +275,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'b', + name: 'b', start: 2000, end: 16000, playerId: 1, @@ -260,12 +283,14 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'c', + name: 'c', start: 20000, end: 40000, pieceNames: [], }, { id: 'd', + name: 'd', start: 30000, end: undefined, playerId: 1, @@ -273,6 +298,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'e', + name: 'e', start: 35000, end: undefined, pieceNames: [], @@ -295,6 +321,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current part { id: 'a', + name: 'a', start: 1000, end: undefined, playerId: 2, @@ -303,6 +330,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // adlib { id: 'b', + name: 'b', start: 10000, end: undefined, pieceNames: [], @@ -310,6 +338,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads { id: 'c', + name: 'c', start: Number.POSITIVE_INFINITY, end: undefined, playerId: 1, @@ -318,6 +347,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 2, @@ -340,6 +370,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current part { id: 'a', + name: 'a', start: 1000, end: 10500, playerId: 2, @@ -348,6 +379,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // adlib { id: 'b', + name: 'b', start: 10000, end: undefined, pieceNames: [], @@ -355,6 +387,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads (in order of future use) { id: 'c', + name: 'c', start: Number.POSITIVE_INFINITY, end: undefined, playerId: 1, @@ -363,6 +396,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, playerId: 2, @@ -385,6 +419,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current part { id: 'a', + name: 'a', start: 1000, end: 9500, playerId: 2, @@ -393,6 +428,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // adlib { id: 'b', + name: 'b', start: 10000, end: undefined, pieceNames: [], @@ -400,6 +436,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // adlib { id: 'e', + name: 'e', start: 10000, end: undefined, pieceNames: [], @@ -407,6 +444,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads (in order of future use) { id: 'c', + name: 'c', start: Number.POSITIVE_INFINITY, end: undefined, playerId: 1, @@ -415,6 +453,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, playerId: 2, @@ -438,6 +477,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current part { id: 'a', + name: 'a', start: 1000, end: 10500, playerId: 2, @@ -446,6 +486,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // adlib { id: 'b', + name: 'b', start: 10000, end: 15000, pieceNames: [], @@ -453,6 +494,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads (in order of future use) { id: 'c', + name: 'c', start: Number.POSITIVE_INFINITY, end: undefined, playerId: 1, @@ -461,6 +503,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, playerId: 2, @@ -484,6 +527,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current part { id: 'a', + name: 'a', start: 1000, end: 10500, playerId: 2, @@ -492,6 +536,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // adlib { id: 'b', + name: 'b', start: 10000, end: 20500, pieceNames: [], @@ -499,6 +544,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // next part { id: 'a', + name: 'a', start: 20000, end: undefined, playerId: 1, @@ -507,6 +553,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads (in order of future use) { id: 'c', + name: 'c', start: Number.POSITIVE_INFINITY, end: undefined, playerId: 2, @@ -515,6 +562,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 2, @@ -537,6 +585,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current part { id: 'a', + name: 'a', start: 1000, end: 10500, playerId: 2, @@ -545,6 +594,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // adlib { id: 'b', + name: 'b', start: 10000, end: 20500, pieceNames: [], @@ -552,6 +602,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // next part { id: 'e', + name: 'e', start: 20000, end: undefined, playerId: 1, @@ -560,6 +611,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads (in order of future use) { id: 'c', + name: 'c', start: Number.POSITIVE_INFINITY, end: undefined, playerId: 2, @@ -568,6 +620,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 2, @@ -591,6 +644,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current part { id: 'a', + name: 'a', start: 1000, end: undefined, playerId: 2, @@ -599,6 +653,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // bak { id: 'b', + name: 'b', start: 5000, optional: true, playerId: 1, @@ -608,6 +663,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // adlib { id: 'c', + name: 'c', start: 10000, end: undefined, pieceNames: [], @@ -615,6 +671,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 1, @@ -622,6 +679,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'e', + name: 'e', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 2, @@ -632,7 +690,7 @@ describe('resolveAbAssignmentsFromRequests', () => { const res = resolveAbAssignmentsFromRequests(resolverOptions, TWO_SLOTS, requests, 10000) expect(res).toBeTruthy() expect(res.failedOptional).toEqual([]) - expect(res.failedRequired).toEqual([{ id: 'c', pieceNames: [] }]) + expect(res.failedRequired).toEqual([{ id: 'c', name: 'c', pieceNames: [] }]) expectGotPlayer(res, 'a', 2) expectGotPlayer(res, 'b', 1) expectGotPlayer(res, 'c', undefined) @@ -643,6 +701,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current clip { id: 'a', + name: 'a', start: 1000, end: undefined, playerId: 2, @@ -651,6 +710,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // previous clip { id: 'b', + name: 'b', start: 0, playerId: 1, end: 5000, @@ -659,6 +719,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 1, @@ -666,6 +727,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'e', + name: 'e', start: Number.POSITIVE_INFINITY, playerId: 3, // From before end: undefined, @@ -674,6 +736,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'f', + name: 'f', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 3, @@ -698,6 +761,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current clip { id: 'a', + name: 'a', start: 1000, end: undefined, playerId: 2, @@ -706,6 +770,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // previous clip { id: 'b', + name: 'b', start: 0, playerId: 1, end: 5000, @@ -714,6 +779,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 1, @@ -722,6 +788,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'e', + name: 'e', start: Number.POSITIVE_INFINITY, playerId: 3, // From before end: undefined, @@ -730,6 +797,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'f', + name: 'f', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 3, @@ -754,6 +822,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current clip { id: 'a', + name: 'a', start: 1000, end: undefined, playerId: 3, @@ -762,6 +831,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // previous clip { id: 'b', + name: 'b', start: 0, playerId: 1, end: 5000, @@ -770,6 +840,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 1, @@ -778,6 +849,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'e', + name: 'e', start: Number.POSITIVE_INFINITY, playerId: 2, end: undefined, @@ -801,6 +873,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // current clip { id: 'a', + name: 'a', start: 1000, end: 11000, playerId: 1, @@ -808,6 +881,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'b', + name: 'b', start: 13000, // soon end: undefined, playerId: 1, @@ -815,6 +889,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'c', + name: 'c', start: 1000, end: undefined, playerId: 2, @@ -823,6 +898,7 @@ describe('resolveAbAssignmentsFromRequests', () => { // lookaheads { id: 'd', + name: 'd', start: Number.POSITIVE_INFINITY, end: undefined, lookaheadRank: 1, @@ -831,6 +907,7 @@ describe('resolveAbAssignmentsFromRequests', () => { }, { id: 'e', + name: 'e', start: Number.POSITIVE_INFINITY, playerId: 2, end: undefined, diff --git a/packages/job-worker/src/playout/abPlayback/__tests__/applyAssignments.spec.ts b/packages/job-worker/src/playout/abPlayback/__tests__/applyAssignments.spec.ts index af6a9a4e0b..04e21c08c7 100644 --- a/packages/job-worker/src/playout/abPlayback/__tests__/applyAssignments.spec.ts +++ b/packages/job-worker/src/playout/abPlayback/__tests__/applyAssignments.spec.ts @@ -42,11 +42,13 @@ describe('applyMediaPlayersAssignments', () => { const previousAssignments: ABSessionAssignments = { abc: { sessionId: 'abc', + sessionName: 'abc', playerId: 5, lookahead: false, }, def: { sessionId: 'def', + sessionName: 'def', playerId: 3, lookahead: true, }, @@ -68,6 +70,7 @@ describe('applyMediaPlayersAssignments', () => { const previousAssignments: ABSessionAssignments = { piece0_clip_def: { sessionId: 'piece0_clip_def', + sessionName: 'def', playerId: 3, lookahead: false, }, From 283dfb5866a24fc660940e2c132a3270bf771c34 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 11 Mar 2025 10:29:16 +0100 Subject: [PATCH 054/293] fix: piece icon cam squashed --- .../ui/PieceIcons/Renderers/CamInputIcon.tsx | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx index 3ddc90c3fc..529cc972c4 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx @@ -7,35 +7,19 @@ export function CamInputIcon({ abbreviation?: string }): JSX.Element { return ( - + - + {abbreviation !== undefined ? abbreviation : 'C'} - - {inputIndex !== undefined ? inputIndex : ''} - + {inputIndex !== undefined ? inputIndex : ''} From 84740803ef68f29d8bb35362f4038530999b764e Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 11 Mar 2025 10:30:25 +0100 Subject: [PATCH 055/293] wip: implement adjustable label length and size --- .../src/client/styles/countdown/director.scss | 33 ++++++++++++------- .../client/ui/ClockView/DirectorScreen.tsx | 19 +++++++++-- .../src/client/ui/PieceIcons/PieceName.tsx | 20 +++++++---- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index a05cb6239f..99b40e4130 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -16,22 +16,28 @@ $hold-status-color: $liveline-timecode-color; .director-screen { font-family: Roboto Flex; - .director-screen__header { + .director-screen__top { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 14vh; + padding-left: 10px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; - font-size: 2em; + background-color: #3F3F3F; color: #888; + font-size: 2em; padding: 0 0.2em; - padding-left: 10px; } .director-screen__body { position: fixed; - top: 20vh; + top: 14vh; bottom: 0; left: 0; right: 0; @@ -65,21 +71,27 @@ $hold-status-color: $liveline-timecode-color; line-height: 100%; letter-spacing: 0%; vertical-align: middle; + color: #fff; &.live { background: $general-live-color; - color: #fff; text-shadow: 0px 0px 6px #000000; } - + &.next { background: $general-next-color; - color: #000; + text-shadow: 0px 0px 6px #000000; } } .director-screen__body__segment__countdown { + height: 100%; + width: 30vw; + color: #FF5218; float: right; - margin-right: 10px; + text-align: right; + padding-right: 10px; + background: linear-gradient(90deg, rgba(223, 0, 0, 0) 0%, #DF0000 7.86%, rgba(116, 0, 0, 0.808) 16.21%, rgba(0, 0, 0, 0.6) 24.94%); + } .director-screen__body__rundown-countdown { @@ -174,6 +186,7 @@ $hold-status-color: $liveline-timecode-color; } &.director-screen__body__part--next-part { + border-top: solid 2em $general-next-color; .director-screen__body__part__piece-icon, .director-screen__body__part__piece-name { grid-row: 2 / -1; @@ -181,10 +194,6 @@ $hold-status-color: $liveline-timecode-color; } } - .director-screen__body__part + .director-screen__body__part { - border-top: solid 0.8em #454545; - } - .clocks-segment-countdown-red { color: $general-late-color; } diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 4e99df593a..17a2085598 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -357,8 +357,8 @@ function DirectorScreenRender({ return (
-
-
+
+
@@ -387,6 +387,9 @@ function DirectorScreenRender({
+ { + // Current Part: + }
@@ -451,6 +463,9 @@ function DirectorScreenRender({
) : null}
+ { + // Next Part: + }
): JSX.Element | null { +function getLocalPieceLabel(piece: ReadonlyDeep, autowidth?: AdjustLabelFitProps): JSX.Element | null { const { color } = piece.content as EvsContent return ( <> @@ -30,17 +32,21 @@ function getLocalPieceLabel(piece: ReadonlyDeep): JSX.Element | nu · )} - {piece.name} + ) } -function getPieceLabel(piece: ReadonlyDeep, type: SourceLayerType): JSX.Element | null { +function getPieceLabel( + piece: ReadonlyDeep, + type: SourceLayerType, + autowidth?: AdjustLabelFitProps +): JSX.Element | null { switch (type) { case SourceLayerType.LOCAL: - return getLocalPieceLabel(piece) + return getLocalPieceLabel(piece, autowidth) default: - return <>{piece.name} + return } } @@ -59,7 +65,7 @@ export function PieceNameContainer(props: Readonly): JSX.Eleme useSubscription(MeteorPubSub.uiShowStyleBase, props.showStyleBaseId) if (pieceInstance && sourceLayer && supportedLayers.has(sourceLayer.type)) { - return getPieceLabel(pieceInstance.piece, sourceLayer.type) + return getPieceLabel(pieceInstance.piece, sourceLayer.type, props.autowidth) } - return <>{props.partName || ''} + return } From 063d81a0bcb36ce9d0c7f64bf3b6c72095b0e480 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 11 Mar 2025 12:26:45 +0100 Subject: [PATCH 056/293] wip: scaling of labels --- .../src/client/styles/countdown/director.scss | 25 +- .../client/ui/ClockView/DirectorScreen.tsx | 22 +- .../src/client/ui/util/AdjustLabelFit.tsx | 260 ++++++++++++++++++ 3 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 packages/webui/src/client/ui/util/AdjustLabelFit.tsx diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 99b40e4130..56f301edca 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -60,6 +60,8 @@ $hold-status-color: $liveline-timecode-color; 22em 4fr 6fr / #{'min(13vw, 27vh)'} auto; + white-space: nowrap; + .director-screen__body__segment-name { grid-row: 1; @@ -72,6 +74,7 @@ $hold-status-color: $liveline-timecode-color; letter-spacing: 0%; vertical-align: middle; color: #fff; + position: relative; &.live { background: $general-live-color; @@ -82,16 +85,18 @@ $hold-status-color: $liveline-timecode-color; background: $general-next-color; text-shadow: 0px 0px 6px #000000; } - } - .director-screen__body__segment__countdown { - height: 100%; - width: 30vw; - color: #FF5218; - float: right; - text-align: right; - padding-right: 10px; - background: linear-gradient(90deg, rgba(223, 0, 0, 0) 0%, #DF0000 7.86%, rgba(116, 0, 0, 0.808) 16.21%, rgba(0, 0, 0, 0.6) 24.94%); - + .director-screen__body__segment__countdown { + position: absolute; + top: 0; + right: 0; + height: 100%; + width: 20vw; + color: #FF5218; + float: right; + text-align: right; + padding-right: 10px; + background: linear-gradient(90deg, rgba(223, 0, 0, 0) 0%, #DF0000 7.86%, rgba(116, 0, 0, 0.808) 16.21%, rgba(0, 0, 0, 0.6) 24.94%); + } } .director-screen__body__rundown-countdown { diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 17a2085598..6d6db4c98f 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -42,6 +42,7 @@ import { TimesSincePlannedEndComponent, TimeToPlannedEndComponent, } from '../../lib/Components/CounterComponents' +import { AdjustLabelFit } from '../util/AdjustLabelFit' interface SegmentUi extends DBSegment { items: Array @@ -396,7 +397,15 @@ function DirectorScreenRender({ live: currentSegment !== undefined, })} > - {currentSegment?.name} + ) : ( '_' diff --git a/packages/webui/src/client/ui/util/AdjustLabelFit.tsx b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx new file mode 100644 index 0000000000..48b936e5c6 --- /dev/null +++ b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx @@ -0,0 +1,260 @@ +import React, { useEffect, useRef, CSSProperties } from 'react' + +export interface AdjustLabelFitProps { + /** + * The text label to display and adjust + */ + label: string + + /** + * The available width for the text in any valid CSS width format (px, vw, %, etc.) + * If not specified, it will use the parent container's width + */ + width?: string | number + + /** + * Optional font family (defaults to the parent element's font) + */ + fontFamily?: string + + /** + * Initial font size in any valid CSS unit (px, pt, rem, etc.) + * Default is inherited from parent + */ + fontSize?: string | number + + /** + * Minimum font size in pixels (for auto-scaling) + * Default is 10px + */ + minFontSize?: number + + /** + * Maximum font size in pixels (for auto-scaling) + * Default is 100px + */ + maxFontSize?: number + + /** + * Minimum letter spacing in pixels + * Default is -1px + */ + minLetterSpacing?: number + + /** + * Additional CSS styles for the container + */ + containerStyle?: CSSProperties + + /** + * Additional CSS styles for the label + */ + labelStyle?: CSSProperties + + /** + * Additional class name for the container + */ + className?: string + + /** + * Whether to use font variation settings for adjustment (requires variable font) + * If false, will only use letter-spacing + */ + useVariableFont?: boolean + + /** + * Whether to adjust font size to fill the container width + * Default is true + */ + adjustFontSize?: boolean + + /** + * Hard cut length of the text if it doesn't fit + */ + hardCutText?: boolean +} + +/** + * A component that automatically adjusts text to fit within a specified width + * using font size scaling, variable font width adjustment, and letter spacing. + */ +export const AdjustLabelFit: React.FC = ({ + label, + width, + fontFamily, + fontSize, + minFontSize = 10, + maxFontSize = 100, + minLetterSpacing = -1, + containerStyle = {}, + labelStyle = {}, + className = '', + useVariableFont = true, + adjustFontSize = true, + hardCutText = false, +}) => { + const labelRef = useRef(null) + const containerRef = useRef(null) + + // Convert to CSS values: + const widthValue = typeof width === 'number' ? `${width}px` : width + const fontSizeValue = typeof fontSize === 'number' ? `${fontSize}px` : fontSize + const finalContainerStyle: CSSProperties = { + display: 'block', + overflow: 'hidden', + ...containerStyle, + ...(widthValue ? { width: widthValue } : {}), + } + + // Label style - add optional font settings + const finalLabelStyle: CSSProperties = { + display: 'inline-block', + ...labelStyle, + ...(fontFamily ? { fontFamily } : {}), + ...(fontSizeValue ? { fontSize: fontSizeValue } : {}), + } + + const adjustTextToFit = () => { + const labelElement = labelRef.current + const containerElement = containerRef.current + + if (!labelElement || !containerElement) return + + const DEFAULT_WIDTH = 100 + labelElement.style.letterSpacing = '0px' + + if (useVariableFont) { + labelElement.style.fontVariationSettings = `'wdth' ${DEFAULT_WIDTH}` + } + + // Reset label content if it was cut + labelElement.textContent = label + + // Reset font size to initial value if specified, or to computed style if not + if (adjustFontSize) { + if (fontSizeValue) { + labelElement.style.fontSize = fontSizeValue + } else { + // Use computed style if no fontSize was specified + const computedStyle = window.getComputedStyle(labelElement) + const initialFontSize = computedStyle.fontSize + labelElement.style.fontSize = initialFontSize + } + } + + // Force reflow to ensure measurements are accurate + void labelElement.offsetWidth + + // Measure the container and text widths + const containerWidth = containerElement.clientWidth + const textWidth = labelElement.getBoundingClientRect().width + + if (textWidth <= containerWidth) { + // If text fits but we want to expand it to fill the width + if (adjustFontSize) { + const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) + const scaleFactor = containerWidth / textWidth + const newFontSize = Math.min(currentFontSize * scaleFactor, maxFontSize) + + labelElement.style.fontSize = `${newFontSize}px` + + // Re-center text vertically if needed + labelElement.style.lineHeight = '1' + } + return + } + + // Text doesn't fit - adjust size first if enabled + if (adjustFontSize) { + const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) + const scaleFactor = containerWidth / textWidth + const newFontSize = Math.max(currentFontSize * scaleFactor, minFontSize) + + labelElement.style.fontSize = `${newFontSize}px` + + // Remeasure after font size adjustment + void labelElement.offsetWidth + const newTextWidth = labelElement.getBoundingClientRect().width + + // If text now fits with font size adjustment alone, we're done + if (newTextWidth <= containerWidth) return + } + + // Further adjustments if still needed + if (useVariableFont) { + const textWidth = labelElement.getBoundingClientRect().width + const widthRatio = containerWidth / textWidth + let currentWidth = DEFAULT_WIDTH * widthRatio + + // Use a reasonable range for width variation + currentWidth = Math.max(currentWidth, 75) // minimum 75% + currentWidth = Math.min(currentWidth, 110) // maximum 110% + + labelElement.style.fontVariationSettings = `'wdth' ${currentWidth}` + + // Remeasure text width after adjustment: + void labelElement.offsetWidth + const adjustedTextWidth = labelElement.getBoundingClientRect().width + + // Letter spacing if text still overflows + if (adjustedTextWidth > containerWidth) { + const overflow = adjustedTextWidth - containerWidth + const letterCount = label.length - 1 // Spaces between letters + let letterSpacing = letterCount > 0 ? -overflow / letterCount : 0 + + letterSpacing = Math.max(letterSpacing, minLetterSpacing) + labelElement.style.letterSpacing = `${letterSpacing}px` + + // Hard cut text if enabled and letterspacing is not enough: + if (hardCutText) { + void labelElement.offsetWidth + const finalTextWidth = labelElement.getBoundingClientRect().width + if (finalTextWidth > containerWidth) { + const ratio = containerWidth / finalTextWidth + const visibleChars = Math.floor(label.length * ratio) - 1 + labelElement.textContent = label.slice(0, Math.max(visibleChars, 1)) + } + } + } + } else { + // No variable font type + const textWidth = labelElement.getBoundingClientRect().width + const overflow = textWidth - containerWidth + const letterCount = label.length - 1 + let letterSpacing = letterCount > 0 ? -overflow / letterCount : 0 + + // Limit by minLetterSpacing + letterSpacing = Math.max(letterSpacing, minLetterSpacing) + labelElement.style.letterSpacing = `${letterSpacing}px` + + // Hard cut text if enabled and letterspacing is not enough: + if (hardCutText) { + void labelElement.offsetWidth + const finalTextWidth = labelElement.getBoundingClientRect().width + if (finalTextWidth > containerWidth) { + const ratio = containerWidth / finalTextWidth + const visibleChars = Math.floor(label.length * ratio) - 1 + labelElement.textContent = label.slice(0, Math.max(visibleChars, 1)) + } + } + } + } + + useEffect(() => { + adjustTextToFit() + + // Adjust on window resize + window.addEventListener('resize', adjustTextToFit) + return () => { + window.removeEventListener('resize', adjustTextToFit) + } + }, [label, width, fontFamily, fontSize, minFontSize, maxFontSize, minLetterSpacing, useVariableFont, adjustFontSize]) + + return ( +
+ + {label} + +
+ ) +} From 16f2e4ccfd75e020b8e0baf4db5d3ee4adf69260 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 11 Mar 2025 14:01:21 +0100 Subject: [PATCH 057/293] wip: wordwrap on very long label --- .../client/ui/ClockView/DirectorScreen.tsx | 6 ++ .../src/client/ui/util/AdjustLabelFit.tsx | 64 +++++++++++++++++-- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 6d6db4c98f..42206f14a8 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -402,6 +402,8 @@ function DirectorScreenRender({ width={'80vw'} fontFamily="Roboto" fontSize="1em" + minFontSize={70} + maxFontSize={100} minLetterSpacing={0} useVariableFont={true} hardCutText={true} @@ -436,6 +438,8 @@ function DirectorScreenRender({ width: '90vw', fontFamily: 'Roboto Flex', fontSize: '2em', + minFontSize: 70, + maxFontSize: 120, minLetterSpacing: 2, useVariableFont: true, }} @@ -512,6 +516,8 @@ function DirectorScreenRender({ width: '90vw', fontFamily: 'Roboto Flex', fontSize: '2em', + minFontSize: 70, + maxFontSize: 120, minLetterSpacing: 2, useVariableFont: true, }} diff --git a/packages/webui/src/client/ui/util/AdjustLabelFit.tsx b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx index 48b936e5c6..c110bcb508 100644 --- a/packages/webui/src/client/ui/util/AdjustLabelFit.tsx +++ b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx @@ -24,14 +24,14 @@ export interface AdjustLabelFitProps { fontSize?: string | number /** - * Minimum font size in pixels (for auto-scaling) - * Default is 10px + * Minimum font size in percentage relative to fontSize (for auto-scaling) + * Default is 50 */ minFontSize?: number /** - * Maximum font size in pixels (for auto-scaling) - * Default is 100px + * Maximum font size in percentage relative to fontSize (for auto-scaling) + * Default is 120 */ maxFontSize?: number @@ -70,6 +70,7 @@ export interface AdjustLabelFitProps { /** * Hard cut length of the text if it doesn't fit + * When unset, it will wrap text per letter */ hardCutText?: boolean } @@ -83,8 +84,8 @@ export const AdjustLabelFit: React.FC = ({ width, fontFamily, fontSize, - minFontSize = 10, - maxFontSize = 100, + minFontSize = 50, + maxFontSize = 120, minLetterSpacing = -1, containerStyle = {}, labelStyle = {}, @@ -104,6 +105,7 @@ export const AdjustLabelFit: React.FC = ({ overflow: 'hidden', ...containerStyle, ...(widthValue ? { width: widthValue } : {}), + ...(hardCutText ? {} : { wordBreak: 'break-all' }), } // Label style - add optional font settings @@ -169,7 +171,6 @@ export const AdjustLabelFit: React.FC = ({ const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) const scaleFactor = containerWidth / textWidth const newFontSize = Math.max(currentFontSize * scaleFactor, minFontSize) - labelElement.style.fontSize = `${newFontSize}px` // Remeasure after font size adjustment @@ -214,6 +215,30 @@ export const AdjustLabelFit: React.FC = ({ const visibleChars = Math.floor(label.length * ratio) - 1 labelElement.textContent = label.slice(0, Math.max(visibleChars, 1)) } + } else { + // Apply line wrapping per letter if hardCutText is not set + void labelElement.offsetWidth + const finalTextWidth = labelElement.getBoundingClientRect().width + if (finalTextWidth > containerWidth) { + const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) + const minFontSizeValue = currentFontSize * (minFontSize / 100) + labelElement.style.fontSize = `${minFontSizeValue}px` + + labelElement.textContent = '' + + for (let i = 0; i < label.length; i++) { + const charSpan = document.createElement('span') + charSpan.textContent = label[i] + charSpan.style.display = 'inline-block' + charSpan.style.wordBreak = 'break-all' + charSpan.style.whiteSpace = 'normal' + labelElement.appendChild(charSpan) + } + + // Apply wrapping styles + labelElement.style.wordBreak = 'break-all' + labelElement.style.whiteSpace = 'normal' + } } } } else { @@ -236,6 +261,31 @@ export const AdjustLabelFit: React.FC = ({ const visibleChars = Math.floor(label.length * ratio) - 1 labelElement.textContent = label.slice(0, Math.max(visibleChars, 1)) } + } else { + // Apply line wrapping per letter if hardCutText is not set + void labelElement.offsetWidth + const finalTextWidth = labelElement.getBoundingClientRect().width + if (finalTextWidth > containerWidth) { + const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) + // Use minFontSize as a percentage relative to fontSize + const minFontSizeValue = (fontSize ? parseFloat(fontSizeValue || '0') : currentFontSize) * (minFontSize / 100) + //labelElement.style.fontSize = `${minFontSizeValue}px` + + labelElement.textContent = '' + + for (let i = 0; i < label.length; i++) { + const charSpan = document.createElement('span') + charSpan.textContent = label[i] + charSpan.style.display = 'inline-block' + charSpan.style.wordBreak = 'break-all' + charSpan.style.whiteSpace = 'normal' + labelElement.appendChild(charSpan) + } + + // Apply wrapping styles + labelElement.style.wordBreak = 'break-all' + labelElement.style.whiteSpace = 'normal' + } } } } From 0d78b7793fb5963ee4e4d49f1ff0ef55d9f35012 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 11 Mar 2025 14:10:49 +0100 Subject: [PATCH 058/293] wip: place part timers just beneath title --- packages/webui/src/client/styles/countdown/director.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 56f301edca..8badf4eff0 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -147,9 +147,6 @@ $hold-status-color: $liveline-timecode-color; .director-screen__body__part__piece-countdown { text-align: left; - - display: flex; - align-items: center; font-size: 13em; // Allow a fallback for CasparCG font-size: #{'min(13em, 8vw)'}; padding: 0 0.2em; @@ -170,9 +167,7 @@ $hold-status-color: $liveline-timecode-color; .director-screen__body__part__part-countdown { text-align: right; - display: flex; align-items: center; - justify-content: flex-end; font-size: 13em; padding: 0 0.2em; line-height: 1em; From 53405eeb0ad12da60ad239f6a2518f0abf33bfbf Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 12 Mar 2025 09:19:55 +0100 Subject: [PATCH 059/293] wip: piece icons placement --- .../src/client/styles/countdown/director.scss | 4 +++- .../src/client/ui/ClockView/DirectorScreen.tsx | 4 ++-- .../webui/src/client/ui/PieceIcons/PieceIcons.scss | 14 -------------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 8badf4eff0..b14d199199 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -62,7 +62,6 @@ $hold-status-color: $liveline-timecode-color; 6fr / #{'min(13vw, 27vh)'} auto; white-space: nowrap; - .director-screen__body__segment-name { grid-row: 1; grid-column: 1 / -1; @@ -75,6 +74,8 @@ $hold-status-color: $liveline-timecode-color; vertical-align: middle; color: #fff; position: relative; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.5); + z-index: 1; &.live { background: $general-live-color; @@ -115,6 +116,7 @@ $hold-status-color: $liveline-timecode-color; grid-row: 2; grid-column: 1; padding: 0em; + margin-left: 10px; text-align: center; display: flex; diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 42206f14a8..ffdf98ea6e 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -439,7 +439,7 @@ function DirectorScreenRender({ fontFamily: 'Roboto Flex', fontSize: '2em', minFontSize: 70, - maxFontSize: 120, + maxFontSize: 100, minLetterSpacing: 2, useVariableFont: true, }} @@ -517,7 +517,7 @@ function DirectorScreenRender({ fontFamily: 'Roboto Flex', fontSize: '2em', minFontSize: 70, - maxFontSize: 120, + maxFontSize: 100, minLetterSpacing: 2, useVariableFont: true, }} diff --git a/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss index 0e109df757..70a25b3f68 100644 --- a/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss +++ b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss @@ -8,45 +8,31 @@ $letter-spacing: 0.02em; .piece-icon { .camera { @include item-type-colors-svg(); - rx: 4; - ry: 4; } .live-speak { fill: url(#background-gradient); - rx: 4; - ry: 4; } .graphics { @include item-type-colors-svg(); - rx: 4; - ry: 4; } .local { @include item-type-colors-svg(); - rx: 4; - ry: 4; } .remote { @include item-type-colors-svg(); - rx: 4; - ry: 4; } .vt { @include item-type-colors-svg(); - rx: 4; - ry: 4; } .unknown { @include item-type-colors-svg(); - rx: 4; - ry: 4; } // Gradient styles for live-speak From f7b37adcf13415883ce98e66945aaed1d65b6232 Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 12 Mar 2025 10:35:51 +0100 Subject: [PATCH 060/293] wip: add roboto flex regular font --- .../webui/src/client/styles/fonts/roboto.scss | 1 + .../client/styles/fonts/sass/_RobotoFlex.scss | 8 ++++++++ .../src/client/styles/fonts/sass/roboto.scss | 1 + .../fonts/Regular/RobotoFlex-Regular.ttf | Bin 0 -> 91208 bytes .../fonts/Regular/RobotoFlex-Regular.woff | Bin 0 -> 106564 bytes .../fonts/Regular/RobotoFlex-Regular.woff2 | Bin 0 -> 66464 bytes 6 files changed, 10 insertions(+) create mode 100644 packages/webui/src/client/styles/fonts/sass/_RobotoFlex.scss create mode 100644 packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.ttf create mode 100644 packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.woff create mode 100644 packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.woff2 diff --git a/packages/webui/src/client/styles/fonts/roboto.scss b/packages/webui/src/client/styles/fonts/roboto.scss index edbf0c50f7..fd17669454 100644 --- a/packages/webui/src/client/styles/fonts/roboto.scss +++ b/packages/webui/src/client/styles/fonts/roboto.scss @@ -6,6 +6,7 @@ @import 'sass/Light'; @import 'sass/LightItalic'; @import 'sass/Regular'; +@import 'sass/RobotoFlex'; @import 'sass/Italic'; @import 'sass/Medium'; @import 'sass/MediumItalic'; diff --git a/packages/webui/src/client/styles/fonts/sass/_RobotoFlex.scss b/packages/webui/src/client/styles/fonts/sass/_RobotoFlex.scss new file mode 100644 index 0000000000..7f38e79982 --- /dev/null +++ b/packages/webui/src/client/styles/fonts/sass/_RobotoFlex.scss @@ -0,0 +1,8 @@ +/* BEGIN Roboto Flex */ +@font-face { + font-family: 'Roboto Flex'; + @include fontdef-woff($FontPath, 'RobotoFlex', $FontVersion, 'Regular'); + font-weight: 400; + font-style: normal; + } + /* END Roboto Flex */ \ No newline at end of file diff --git a/packages/webui/src/client/styles/fonts/sass/roboto.scss b/packages/webui/src/client/styles/fonts/sass/roboto.scss index 3f7d896a16..a39eba8d2f 100644 --- a/packages/webui/src/client/styles/fonts/sass/roboto.scss +++ b/packages/webui/src/client/styles/fonts/sass/roboto.scss @@ -6,6 +6,7 @@ @import 'Light'; @import 'LightItalic'; @import 'Regular'; +@import 'RobotoFlex'; @import 'Italic'; @import 'Medium'; @import 'MediumItalic'; diff --git a/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8f50dd93c7fb8e1765a66c1b2813bc6f2c9828b6 GIT binary patch literal 91208 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 literal 0 HcmV?d00001 diff --git a/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..424c9f127d2fb37777b864b1106e682f63332a2e GIT binary patch literal 106564 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 f0000100000;-4!<00000#PAU=00000;KYre-bpHf literal 0 HcmV?d00001 diff --git a/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3380f3bf719228f731f80bf7ba94a17899206b88 GIT binary patch literal 66464 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 H1_1m&xDouf literal 0 HcmV?d00001 From 5511c5ad57c163d2320f76b4d176dc0ccb514586 Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 12 Mar 2025 11:24:46 +0100 Subject: [PATCH 061/293] wip: cleanup AdjustLabelFit for unused properties --- .../src/client/ui/util/AdjustLabelFit.tsx | 161 +++++------------- 1 file changed, 44 insertions(+), 117 deletions(-) diff --git a/packages/webui/src/client/ui/util/AdjustLabelFit.tsx b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx index c110bcb508..da758de564 100644 --- a/packages/webui/src/client/ui/util/AdjustLabelFit.tsx +++ b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx @@ -56,18 +56,6 @@ export interface AdjustLabelFitProps { */ className?: string - /** - * Whether to use font variation settings for adjustment (requires variable font) - * If false, will only use letter-spacing - */ - useVariableFont?: boolean - - /** - * Whether to adjust font size to fill the container width - * Default is true - */ - adjustFontSize?: boolean - /** * Hard cut length of the text if it doesn't fit * When unset, it will wrap text per letter @@ -90,8 +78,6 @@ export const AdjustLabelFit: React.FC = ({ containerStyle = {}, labelStyle = {}, className = '', - useVariableFont = true, - adjustFontSize = true, hardCutText = false, }) => { const labelRef = useRef(null) @@ -125,23 +111,19 @@ export const AdjustLabelFit: React.FC = ({ const DEFAULT_WIDTH = 100 labelElement.style.letterSpacing = '0px' - if (useVariableFont) { - labelElement.style.fontVariationSettings = `'wdth' ${DEFAULT_WIDTH}` - } + labelElement.style.fontVariationSettings = `'wdth' ${DEFAULT_WIDTH}` // Reset label content if it was cut labelElement.textContent = label // Reset font size to initial value if specified, or to computed style if not - if (adjustFontSize) { - if (fontSizeValue) { - labelElement.style.fontSize = fontSizeValue - } else { - // Use computed style if no fontSize was specified - const computedStyle = window.getComputedStyle(labelElement) - const initialFontSize = computedStyle.fontSize - labelElement.style.fontSize = initialFontSize - } + if (fontSizeValue) { + labelElement.style.fontSize = fontSizeValue + } else { + // Use computed style if no fontSize was specified + const computedStyle = window.getComputedStyle(labelElement) + const initialFontSize = computedStyle.fontSize + labelElement.style.fontSize = initialFontSize } // Force reflow to ensure measurements are accurate @@ -152,103 +134,49 @@ export const AdjustLabelFit: React.FC = ({ const textWidth = labelElement.getBoundingClientRect().width if (textWidth <= containerWidth) { - // If text fits but we want to expand it to fill the width - if (adjustFontSize) { - const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) - const scaleFactor = containerWidth / textWidth - const newFontSize = Math.min(currentFontSize * scaleFactor, maxFontSize) + const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) + const scaleFactor = containerWidth / textWidth + const newFontSize = Math.min(currentFontSize * scaleFactor, maxFontSize) - labelElement.style.fontSize = `${newFontSize}px` + labelElement.style.fontSize = `${newFontSize}px` + + // Re-center text vertically if needed + labelElement.style.lineHeight = '1' - // Re-center text vertically if needed - labelElement.style.lineHeight = '1' - } return } - // Text doesn't fit - adjust size first if enabled - if (adjustFontSize) { - const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) - const scaleFactor = containerWidth / textWidth - const newFontSize = Math.max(currentFontSize * scaleFactor, minFontSize) - labelElement.style.fontSize = `${newFontSize}px` + const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) + const scaleFactor = containerWidth / textWidth + const newFontSize = Math.max(currentFontSize * scaleFactor, minFontSize) + labelElement.style.fontSize = `${newFontSize}px` - // Remeasure after font size adjustment - void labelElement.offsetWidth - const newTextWidth = labelElement.getBoundingClientRect().width + // Remeasure after font size adjustment + void labelElement.offsetWidth + const newTextWidth = labelElement.getBoundingClientRect().width - // If text now fits with font size adjustment alone, we're done - if (newTextWidth <= containerWidth) return - } + // If text now fits with font size adjustment alone, we're done + if (newTextWidth <= containerWidth) return - // Further adjustments if still needed - if (useVariableFont) { - const textWidth = labelElement.getBoundingClientRect().width - const widthRatio = containerWidth / textWidth - let currentWidth = DEFAULT_WIDTH * widthRatio - - // Use a reasonable range for width variation - currentWidth = Math.max(currentWidth, 75) // minimum 75% - currentWidth = Math.min(currentWidth, 110) // maximum 110% - - labelElement.style.fontVariationSettings = `'wdth' ${currentWidth}` - - // Remeasure text width after adjustment: - void labelElement.offsetWidth - const adjustedTextWidth = labelElement.getBoundingClientRect().width - - // Letter spacing if text still overflows - if (adjustedTextWidth > containerWidth) { - const overflow = adjustedTextWidth - containerWidth - const letterCount = label.length - 1 // Spaces between letters - let letterSpacing = letterCount > 0 ? -overflow / letterCount : 0 - - letterSpacing = Math.max(letterSpacing, minLetterSpacing) - labelElement.style.letterSpacing = `${letterSpacing}px` - - // Hard cut text if enabled and letterspacing is not enough: - if (hardCutText) { - void labelElement.offsetWidth - const finalTextWidth = labelElement.getBoundingClientRect().width - if (finalTextWidth > containerWidth) { - const ratio = containerWidth / finalTextWidth - const visibleChars = Math.floor(label.length * ratio) - 1 - labelElement.textContent = label.slice(0, Math.max(visibleChars, 1)) - } - } else { - // Apply line wrapping per letter if hardCutText is not set - void labelElement.offsetWidth - const finalTextWidth = labelElement.getBoundingClientRect().width - if (finalTextWidth > containerWidth) { - const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) - const minFontSizeValue = currentFontSize * (minFontSize / 100) - labelElement.style.fontSize = `${minFontSizeValue}px` - - labelElement.textContent = '' - - for (let i = 0; i < label.length; i++) { - const charSpan = document.createElement('span') - charSpan.textContent = label[i] - charSpan.style.display = 'inline-block' - charSpan.style.wordBreak = 'break-all' - charSpan.style.whiteSpace = 'normal' - labelElement.appendChild(charSpan) - } - - // Apply wrapping styles - labelElement.style.wordBreak = 'break-all' - labelElement.style.whiteSpace = 'normal' - } - } - } - } else { - // No variable font type - const textWidth = labelElement.getBoundingClientRect().width - const overflow = textWidth - containerWidth - const letterCount = label.length - 1 + const widthRatio = containerWidth / newTextWidth + let currentWidth = DEFAULT_WIDTH * widthRatio + + // Use a reasonable range for width variation + currentWidth = Math.max(currentWidth, 75) // minimum 75% + currentWidth = Math.min(currentWidth, 110) // maximum 110% + + labelElement.style.fontVariationSettings = `'wdth' ${currentWidth}` + + // Remeasure text width after adjustment: + void labelElement.offsetWidth + const adjustedTextWidth = labelElement.getBoundingClientRect().width + + // Letter spacing if text still overflows + if (adjustedTextWidth > containerWidth) { + const overflow = adjustedTextWidth - containerWidth + const letterCount = label.length - 1 // Spaces between letters let letterSpacing = letterCount > 0 ? -overflow / letterCount : 0 - // Limit by minLetterSpacing letterSpacing = Math.max(letterSpacing, minLetterSpacing) labelElement.style.letterSpacing = `${letterSpacing}px` @@ -267,9 +195,8 @@ export const AdjustLabelFit: React.FC = ({ const finalTextWidth = labelElement.getBoundingClientRect().width if (finalTextWidth > containerWidth) { const currentFontSize = parseFloat(window.getComputedStyle(labelElement).fontSize) - // Use minFontSize as a percentage relative to fontSize - const minFontSizeValue = (fontSize ? parseFloat(fontSizeValue || '0') : currentFontSize) * (minFontSize / 100) - //labelElement.style.fontSize = `${minFontSizeValue}px` + const minFontSizeValue = currentFontSize * (minFontSize / 100) + labelElement.style.fontSize = `${minFontSizeValue}px` labelElement.textContent = '' @@ -298,7 +225,7 @@ export const AdjustLabelFit: React.FC = ({ return () => { window.removeEventListener('resize', adjustTextToFit) } - }, [label, width, fontFamily, fontSize, minFontSize, maxFontSize, minLetterSpacing, useVariableFont, adjustFontSize]) + }, [label, width, fontFamily, fontSize, minFontSize, maxFontSize, minLetterSpacing]) return (
From cd47828b09a2254d41869eeeccd7896571324b8b Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 12 Mar 2025 11:25:35 +0100 Subject: [PATCH 062/293] wip: cleanup directors screen for unused properties --- packages/webui/src/client/ui/ClockView/DirectorScreen.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index ffdf98ea6e..961f799828 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -400,12 +400,11 @@ function DirectorScreenRender({ @@ -441,7 +440,6 @@ function DirectorScreenRender({ minFontSize: 70, maxFontSize: 100, minLetterSpacing: 2, - useVariableFont: true, }} />
@@ -465,9 +463,6 @@ function DirectorScreenRender({ /> )}
-
- -
) : expectedStart ? (
@@ -519,7 +514,6 @@ function DirectorScreenRender({ minFontSize: 70, maxFontSize: 100, minLetterSpacing: 2, - useVariableFont: true, }} /> ) : ( From e6b468237f7eaab916aa6db017c4f2b345f481e0 Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 12 Mar 2025 11:56:33 +0100 Subject: [PATCH 063/293] wip: reset adjustLabelFit upon resizing window --- .../src/client/ui/util/AdjustLabelFit.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/webui/src/client/ui/util/AdjustLabelFit.tsx b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx index da758de564..b2c1393fa9 100644 --- a/packages/webui/src/client/ui/util/AdjustLabelFit.tsx +++ b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx @@ -218,12 +218,29 @@ export const AdjustLabelFit: React.FC = ({ } useEffect(() => { - adjustTextToFit() + // Add debouncing for resize events + let resizeTimer: number + const handleResize = () => { + cancelAnimationFrame(resizeTimer) + resizeTimer = requestAnimationFrame(() => { + // Reset all styles first before recalculating + if (labelRef.current) { + labelRef.current.style.letterSpacing = '0px' + labelRef.current.style.fontVariationSettings = '' + labelRef.current.textContent = label + // Reset the word break and white space properties + labelRef.current.style.wordBreak = 'normal' + labelRef.current.style.whiteSpace = 'nowrap' + } + adjustTextToFit() + }) + } // Adjust on window resize - window.addEventListener('resize', adjustTextToFit) + window.addEventListener('resize', handleResize) return () => { - window.removeEventListener('resize', adjustTextToFit) + window.removeEventListener('resize', handleResize) + cancelAnimationFrame(resizeTimer) } }, [label, width, fontFamily, fontSize, minFontSize, maxFontSize, minLetterSpacing]) From fbaf9e09805b08ede84db601b1a0f20885ee024c Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 12 Mar 2025 12:51:44 +0100 Subject: [PATCH 064/293] wip: convert piece icons to css text icons --- .../src/client/ui/PieceIcons/PieceIcons.scss | 71 ++++++++----------- .../ui/PieceIcons/Renderers/CamInputIcon.tsx | 22 ++---- .../Renderers/GraphicsInputIcon.tsx | 29 +------- .../Renderers/LiveSpeakInputIcon.tsx | 34 +-------- .../PieceIcons/Renderers/LocalInputIcon.tsx | 2 +- .../PieceIcons/Renderers/RemoteInputIcon.tsx | 26 ++----- .../PieceIcons/Renderers/UnknownInputIcon.tsx | 29 +------- .../ui/PieceIcons/Renderers/VTInputIcon.tsx | 29 +------- .../Parts/SegmentTimelinePart.tsx | 6 +- 9 files changed, 57 insertions(+), 191 deletions(-) diff --git a/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss index 70a25b3f68..44a377ad12 100644 --- a/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss +++ b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss @@ -4,35 +4,41 @@ $font-size-base: 70px; $text-shadow: 0 2px 9px rgba(0, 0, 0, 0.5); $letter-spacing: 0.02em; -// Base icon styles -.piece-icon { - .camera { - @include item-type-colors-svg(); - } - - .live-speak { - fill: url(#background-gradient); - } +@mixin layer-type-backgrounds { + @each $layer-type in $layer-types { + &.#{$layer-type} { + // Background: + display: inline-block; + width: 180px; + height: 140px; + line-height: 140px; - - .graphics { - @include item-type-colors-svg(); - } - - .local { - @include item-type-colors-svg(); + background-color: var(--segment-layer-background-#{$layer-type}); + + &.second { + background-color: var(--segment-layer-background-#{$layer-type}--second); + } + } } +} - .remote { - @include item-type-colors-svg(); - } +// Base icon styles +.piece-icon { + // Text styles: + fill: $text-color; + font-family: Roboto Flex; + filter: drop-shadow($text-shadow); + font-size: $font-size-base; + font-weight: 300; + letter-spacing: $letter-spacing; - .vt { - @include item-type-colors-svg(); + span { + // Common styles + @include layer-type-backgrounds; // Apply to all span elements that have layer type classes } - .unknown { - @include item-type-colors-svg(); + .live-speak { + fill: url(#background-gradient); } // Gradient styles for live-speak @@ -47,8 +53,6 @@ $letter-spacing: 0.02em; // Split view specific styles .upper, .lower { - rx: 4; - ry: 4; &.camera { @include item-type-colors-svg(); @@ -60,21 +64,4 @@ $letter-spacing: 0.02em; @include item-type-colors-svg(); } } - - // Common text styles - .piece-icon-text { - fill: $text-color; - font-family: Roboto Condensed, Roboto, sans-serif; - font-size: 40px; - - filter: drop-shadow($text-shadow); - - .label { - fill: $text-color; - font-family: Roboto Condensed, Roboto, sans-serif; - font-size: $font-size-base; - font-weight: 300; - letter-spacing: $letter-spacing; - } - } } \ No newline at end of file diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx index 529cc972c4..9b749dff8c 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx @@ -7,21 +7,11 @@ export function CamInputIcon({ abbreviation?: string }): JSX.Element { return ( - - - - - {abbreviation !== undefined ? abbreviation : 'C'} - {inputIndex !== undefined ? inputIndex : ''} - - - +
+ + {abbreviation !== undefined ? abbreviation : 'C'} + {inputIndex !== undefined ? inputIndex : ''} + +
) } diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx index a72d8bcd5c..4a5cf8c017 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx @@ -1,30 +1,7 @@ export function GraphicsInputIcon({ abbreviation }: { abbreviation?: string }): JSX.Element { return ( - - - - - {abbreviation !== undefined ? abbreviation : 'G'} - - - +
+ {abbreviation !== undefined ? abbreviation : 'G'} +
) } diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx index 02855baadb..6c109cbb95 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx @@ -1,35 +1,7 @@ export function LiveSpeakInputIcon({ abbreviation }: { abbreviation?: string }): JSX.Element { return ( - - - - - - - - - {abbreviation !== undefined ? abbreviation : 'LSK'} - - - +
+ {abbreviation !== undefined ? abbreviation : 'LSK'} +
) } diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx index 4c9a166df9..0a8f5606aa 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx @@ -4,7 +4,7 @@ export default function LocalInputIcon(props: Readonly<{ inputIndex?: string; ab return ( {props.abbreviation !== undefined ? props.abbreviation : 'EVS'} - {props.inputIndex ?? ''} + {props.inputIndex ?? ''} ) } diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx index ef69f683e9..a887aaf426 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx @@ -1,23 +1,7 @@ import React from 'react' export function BaseRemoteInputIcon(props: Readonly>): JSX.Element { - return ( - - - - - {props.children} - - - - ) + return
{props.children}
} export function RemoteInputIcon({ @@ -28,9 +12,11 @@ export function RemoteInputIcon({ abbreviation?: string }): JSX.Element { return ( - - {abbreviation !== undefined ? abbreviation : 'LIVE'} - {inputIndex ?? ''} + + + {abbreviation !== undefined ? abbreviation : 'LIVE'} + {inputIndex ?? ''} + ) } diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx index e78367d200..74e028456a 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx @@ -1,30 +1,7 @@ export function UnknownInputIcon(): JSX.Element { return ( - - - - - ? - - - +
+ ? +
) } diff --git a/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx index 77309b0b23..07e0779d7f 100644 --- a/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx @@ -1,30 +1,7 @@ export function VTInputIcon({ abbreviation }: { abbreviation?: string }): JSX.Element { return ( - - - - - {abbreviation !== undefined ? abbreviation : 'VT'} - - - +
+ {abbreviation !== undefined ? abbreviation : 'VT'} +
) } diff --git a/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 70af5491e0..87c97e347e 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -591,7 +591,7 @@ export class SegmentTimelinePartClass extends React.Component - {innerPart.autoNext ? t('Auto') : this.state.isLive ? t('Next') : null} + {innerPart.autoNext ? t('Auto') : this.state.isLive ? 'cc' : null} {isEndOfLoopingShow && }
@@ -766,7 +766,7 @@ export class SegmentTimelinePartClass extends React.Component )} @@ -801,7 +801,7 @@ export class SegmentTimelinePartClass extends React.Component )} From 071e8be8fd82baf4958eb1cb0b397d2ce2ccbbb9 Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 12 Mar 2025 12:53:55 +0100 Subject: [PATCH 065/293] wip: ensure css prior to calculation --- packages/webui/src/client/ui/util/AdjustLabelFit.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/webui/src/client/ui/util/AdjustLabelFit.tsx b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx index b2c1393fa9..08c30f58db 100644 --- a/packages/webui/src/client/ui/util/AdjustLabelFit.tsx +++ b/packages/webui/src/client/ui/util/AdjustLabelFit.tsx @@ -218,6 +218,10 @@ export const AdjustLabelFit: React.FC = ({ } useEffect(() => { + const adjustmentTimer = requestAnimationFrame(() => { + adjustTextToFit() + }) + // Add debouncing for resize events let resizeTimer: number const handleResize = () => { @@ -240,6 +244,7 @@ export const AdjustLabelFit: React.FC = ({ window.addEventListener('resize', handleResize) return () => { window.removeEventListener('resize', handleResize) + cancelAnimationFrame(adjustmentTimer) cancelAnimationFrame(resizeTimer) } }, [label, width, fontFamily, fontSize, minFontSize, maxFontSize, minLetterSpacing]) From 0b1f050bc3dcec9e1442975039a4360555e1098a Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 12 Mar 2025 13:24:33 +0100 Subject: [PATCH 066/293] wip: css rough alignments --- .../src/client/styles/countdown/director.scss | 17 ++++++----------- .../src/client/ui/ClockView/DirectorScreen.tsx | 4 ++-- .../src/client/ui/PieceIcons/PieceIcons.scss | 6 +++--- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index b14d199199..813e7233e9 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -53,13 +53,9 @@ $hold-status-color: $liveline-timecode-color; .director-screen__body__part { display: grid; grid-template: - 22em - 4fr - 6fr / 13vw auto; - grid-template: - 22em - 4fr - 6fr / #{'min(13vw, 27vh)'} auto; + 24em + 60em + 30em / #{'min(13vw, 27vh)'} auto; white-space: nowrap; .director-screen__body__segment-name { @@ -120,7 +116,7 @@ $hold-status-color: $liveline-timecode-color; text-align: center; display: flex; - align-items: center; + align-items: top; justify-content: center; > svg { @@ -138,7 +134,8 @@ $hold-status-color: $liveline-timecode-color; padding-left: 0.2em; display: flex; - align-items: center; + align-items: top; + margin-top: 20px; .director-screen__part__auto-next-icon { display: block; @@ -182,13 +179,11 @@ $hold-status-color: $liveline-timecode-color; .director-screen__body__part__piece-countdown, .director-screen__body__part__part-countdown { - grid-row: 3; grid-column: 2; color: $general-countdown-to-next-color; } &.director-screen__body__part--next-part { - border-top: solid 2em $general-next-color; .director-screen__body__part__piece-icon, .director-screen__body__part__piece-name { grid-row: 2 / -1; diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index 961f799828..afd5fff859 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -348,8 +348,8 @@ function DirectorScreenRender({ useSetDocumentClass('dark', 'xdark') if (playlist && playlistId && segments) { - const currentPartOrSegmentCountdown = - timingDurations.remainingBudgetOnCurrentSegment ?? timingDurations.remainingTimeOnCurrentPart ?? 0 + // const currentPartOrSegmentCountdown = + // timingDurations.remainingBudgetOnCurrentSegment ?? timingDurations.remainingTimeOnCurrentPart ?? 0 const expectedStart = PlaylistTiming.getExpectedStart(playlist.timing) const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing) || 0 diff --git a/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss index 44a377ad12..eab87fe873 100644 --- a/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss +++ b/packages/webui/src/client/ui/PieceIcons/PieceIcons.scss @@ -9,9 +9,9 @@ $letter-spacing: 0.02em; &.#{$layer-type} { // Background: display: inline-block; - width: 180px; - height: 140px; - line-height: 140px; + width: 207px; + height: 126px; + line-height: 126px; background-color: var(--segment-layer-background-#{$layer-type}); From 0117ef6263bbcfc7aea5dfd51440c86918b01728 Mon Sep 17 00:00:00 2001 From: Kasper Olsson Hans Date: Wed, 12 Mar 2025 17:45:21 +0100 Subject: [PATCH 067/293] Update packages/webui/src/client/ui/TestTools/Timeline.tsx Co-authored-by: Jan Starzak --- packages/webui/src/client/ui/TestTools/Timeline.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/webui/src/client/ui/TestTools/Timeline.tsx b/packages/webui/src/client/ui/TestTools/Timeline.tsx index 084ae0b933..3d66e0ed03 100644 --- a/packages/webui/src/client/ui/TestTools/Timeline.tsx +++ b/packages/webui/src/client/ui/TestTools/Timeline.tsx @@ -258,12 +258,10 @@ function renderTimelineState(state: TimelineState, filter: RegExp | string | und {(o.classes ?? []).join('
')}
{JSON.stringify(o.content, undefined, '\t')}
-
-					{
+				{o.abSessions && 
{
 						//@ts-expect-error - abSessions is not in the type but are still in the object if used:
-						o.abSessions && 'AB-Sessions:' + '\n' + JSON.stringify(o.abSessions, undefined, '\t')
-					}
-				
+ 'AB-Sessions:' + '\n' + JSON.stringify(o.abSessions, undefined, '\t') + }
} )) From bf14378b5d45c2c316911b7ae6d54d6d1a87d3b0 Mon Sep 17 00:00:00 2001 From: olzzon Date: Thu, 13 Mar 2025 07:17:31 +0100 Subject: [PATCH 068/293] wip: align top text --- .../src/client/styles/countdown/director.scss | 18 +++++++++++++++++- .../src/client/ui/ClockView/DirectorScreen.tsx | 6 +++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 813e7233e9..991c2c8b21 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -32,6 +32,22 @@ $hold-status-color: $liveline-timecode-color; font-size: 2em; padding: 0 0.2em; + .director-screen__top__planned-end { + text-align: left; + } + + .director-screen__top__planned-to { + text-align: left; + } + .director-screen__top__planned-since { + margin-left: -50px; + } + + .director-screen__top__over-under { + margin-left: 120px; + } + + } @@ -54,7 +70,7 @@ $hold-status-color: $liveline-timecode-color; display: grid; grid-template: 24em - 60em + 50em 30em / #{'min(13vw, 27vh)'} auto; white-space: nowrap; diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index afd5fff859..e8261ed84e 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -370,21 +370,21 @@ function DirectorScreenRender({
- TIME TO PLANNED END + TIME TO PLANNED END
) : (
- TIME SINCE PLANNED END + TIME SINCE PLANNED END
)}
- OVER/UNDER + OVER/UNDER
From e45951ea53cc9b66a9672ff0cd3c5381e7ae94c2 Mon Sep 17 00:00:00 2001 From: olzzon Date: Thu, 13 Mar 2025 11:20:42 +0100 Subject: [PATCH 069/293] wip: Roboto Flex use ttf with support for variantStyles --- .../src/client/styles/countdown/director.scss | 2 +- .../client/styles/fonts/sass/_RobotoFlex.scss | 2 +- .../src/client/styles/fonts/sass/_mixins.scss | 4 ++++ .../client/ui/ClockView/DirectorScreen.tsx | 13 ++++++++++++- .../fonts/Regular/RobotoFlex-Regular.ttf | Bin 91208 -> 1787292 bytes .../fonts/Regular/RobotoFlex-Regular.woff | Bin 106564 -> 0 bytes .../fonts/Regular/RobotoFlex-Regular.woff2 | Bin 66464 -> 0 bytes 7 files changed, 18 insertions(+), 3 deletions(-) delete mode 100644 packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.woff delete mode 100644 packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.woff2 diff --git a/packages/webui/src/client/styles/countdown/director.scss b/packages/webui/src/client/styles/countdown/director.scss index 991c2c8b21..218346a6b2 100644 --- a/packages/webui/src/client/styles/countdown/director.scss +++ b/packages/webui/src/client/styles/countdown/director.scss @@ -81,7 +81,7 @@ $hold-status-color: $liveline-timecode-color; padding-left: 10px; font-size: 20em; font-weight: 500; - line-height: 100%; + line-height: 120%; letter-spacing: 0%; vertical-align: middle; color: #fff; diff --git a/packages/webui/src/client/styles/fonts/sass/_RobotoFlex.scss b/packages/webui/src/client/styles/fonts/sass/_RobotoFlex.scss index 7f38e79982..a3b95594ea 100644 --- a/packages/webui/src/client/styles/fonts/sass/_RobotoFlex.scss +++ b/packages/webui/src/client/styles/fonts/sass/_RobotoFlex.scss @@ -1,7 +1,7 @@ /* BEGIN Roboto Flex */ @font-face { font-family: 'Roboto Flex'; - @include fontdef-woff($FontPath, 'RobotoFlex', $FontVersion, 'Regular'); + @include fontdef-ttf($FontPath, 'RobotoFlex', $FontVersion, 'Regular'); font-weight: 400; font-style: normal; } diff --git a/packages/webui/src/client/styles/fonts/sass/_mixins.scss b/packages/webui/src/client/styles/fonts/sass/_mixins.scss index 44259e3bb6..04287aee59 100644 --- a/packages/webui/src/client/styles/fonts/sass/_mixins.scss +++ b/packages/webui/src/client/styles/fonts/sass/_mixins.scss @@ -2,3 +2,7 @@ src: url('#{$FontPath}/#{$FontType}/#{$FontName}-#{$FontType}.woff2?v=#{$FontVersion}') format('woff2'), url('#{$FontPath}/#{$FontType}/#{$FontName}-#{$FontType}.woff?v=#{$FontVersion}') format('woff'); } + +@mixin fontdef-ttf($FontPath, $FontName, $FontVersion: '1.0.0', $FontType: 'Regular') { + src: url('#{$FontPath}/#{$FontType}/#{$FontName}-#{$FontType}.ttf?v=#{$FontVersion}') format('truetype'); + } \ No newline at end of file diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx index e8261ed84e..08a867e2f2 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen.tsx @@ -479,7 +479,18 @@ function DirectorScreenRender({ next: nextSegment !== undefined && nextSegment?._id !== currentSegment?._id, })} > - {nextSegment?._id !== currentSegment?._id ? nextSegment?.name : undefined} + {nextSegment?._id !== currentSegment?._id ? ( + + ) : undefined}
{nextPartInstance && nextShowStyleBaseId ? ( <> diff --git a/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/RobotoFlex-Regular.ttf index 8f50dd93c7fb8e1765a66c1b2813bc6f2c9828b6..c1d8ff7c8bdf3adba3c68e1492cf65544d54c547 100644 GIT binary patch literal 1787292 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

!w1N3Y8%DVHM*~06 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