From f9427d075be7a8f56257366f548b8d2546213619 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Thu, 3 Jun 2021 17:26:51 +0100 Subject: [PATCH 001/112] feat: Playlist/Rundown expectedEnd display in lobby / header --- meteor/client/styles/rundownList.scss | 10 ++++++---- meteor/client/ui/ClockView/PresenterScreen.tsx | 1 + meteor/client/ui/RundownList.tsx | 3 +++ .../client/ui/RundownList/RundownListItemView.tsx | 11 +++++++++++ meteor/client/ui/RundownList/RundownPlaylistUi.tsx | 11 +++++++++++ meteor/client/ui/RundownView.tsx | 13 +++++++++++-- .../ui/StudioScreenSaver/StudioScreenSaver.tsx | 1 + meteor/lib/collections/RundownPlaylists.ts | 4 ++++ meteor/lib/collections/Rundowns.ts | 1 + meteor/server/api/rundownPlaylist.ts | 3 +++ .../server/migration/deprecatedDataTypes/1_0_1.ts | 2 ++ packages/blueprints-integration/src/rundown.ts | 4 ++++ 12 files changed, 58 insertions(+), 6 deletions(-) diff --git a/meteor/client/styles/rundownList.scss b/meteor/client/styles/rundownList.scss index 9f4fbe7e97..89fa18937d 100644 --- a/meteor/client/styles/rundownList.scss +++ b/meteor/client/styles/rundownList.scss @@ -10,7 +10,7 @@ * Note that the standard size is used for several columns, which is why these * percentages do not add up to a perfect 100% (ie. 1.0) */ - --nameColumnSize: 0.4; + --nameColumnSize: 0.3; --showStyleColumnSize: 0.17; --standardColumnSize: 0.11; --layoutSelectionColumnSize: 0.075; @@ -23,6 +23,7 @@ --showStyleColumnWidth: calc(var(--componentWidth) * var(--showStyleColumnSize)); --airTimeColumnWidth: calc(var(--componentWidth) * var(--standardColumnSize)); --durationColumnWidth: calc(var(--componentWidth) * var(--standardColumnSize)); + --expectedEndColumnWidth: calc(var(--componentWidth) * var(--standardColumnSize)); --lastModifiedColumnWidth: calc(var(--componentWidth) * var(--standardColumnSize)); --layoutSelectionColumnWidth: calc(var(--componentWidth) * var(--layoutSelectionColumnSize)); --actionsColumnWidth: calc(var(--componentWidth) * var(--actionsColumnSize)); @@ -36,7 +37,7 @@ display: grid; grid-template-columns: var(--nameColumnWidth) var(--showStyleColumnWidth) var(--airTimeColumnWidth) - var(--durationColumnWidth) var(--lastModifiedColumnWidth) var(--layoutSelectionColumnWidth); + var(--durationColumnWidth) var(--expectedEndColumnWidth) var(--lastModifiedColumnWidth) var(--layoutSelectionColumnWidth); background-color: #898989; color: #fff; @@ -173,7 +174,7 @@ // for the header, the first column spans the first four item columns grid-template-columns: calc(var(--nameColumnWidth) + var(--showStyleColumnWidth)) var(--airTimeColumnWidth) - var(--durationColumnWidth) var(--lastModifiedColumnWidth); + var(--durationColumnWidth) var(--expectedEndColumnWidth) var(--lastModifiedColumnWidth); > span { align-self: center; @@ -246,6 +247,7 @@ --showStyleColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--showStyleColumnSize)); --airTimeColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--standardColumnSize)); --durationColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--standardColumnSize)); + --expectedEndColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--standardColumnSize)); --lastModifiedColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--standardColumnSize)); --actionsColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--actionsColumnSize)); @@ -304,7 +306,7 @@ display: grid; grid-template-columns: var(--nameColumnWidth) var(--showStyleColumnWidth) var(--airTimeColumnWidth) - var(--durationColumnWidth) var(--lastModifiedColumnWidth) var(--layoutSelectionColumnWidth); + var(--durationColumnWidth) var(--expectedEndColumnWidth) var(--lastModifiedColumnWidth) var(--layoutSelectionColumnWidth); .rundown-list-item__actions { padding: 0 0 3px; // cater for icons being larger than the font diff --git a/meteor/client/ui/ClockView/PresenterScreen.tsx b/meteor/client/ui/ClockView/PresenterScreen.tsx index d401c4b376..cea32acc2f 100644 --- a/meteor/client/ui/ClockView/PresenterScreen.tsx +++ b/meteor/client/ui/ClockView/PresenterScreen.tsx @@ -74,6 +74,7 @@ function getShowStyleBaseIdSegmentPartUi( name: 1, expectedStart: 1, expectedDuration: 1, + expectedEnd: 1, }, }) showStyleBaseId = currentRundown?.showStyleBaseId diff --git a/meteor/client/ui/RundownList.tsx b/meteor/client/ui/RundownList.tsx index fff39a694b..ab4d11e095 100644 --- a/meteor/client/ui/RundownList.tsx +++ b/meteor/client/ui/RundownList.tsx @@ -303,6 +303,9 @@ export const RundownList = translateWithTracker((): IRundownsListProps => { {t('Show Style')} {t('On Air Start Time')} {t('Duration')} + {this.props.rundownPlaylists.some( + (p) => !!p.expectedEnd || p.rundowns.some((r) => r.expectedEnd) + ) && {t('Expected End Time')}} {t('Last Updated')} {this.props.rundownLayouts.some((l) => l.exposeAsShelf || l.exposeAsStandalone) && ( {t('Shelf Layout')} diff --git a/meteor/client/ui/RundownList/RundownListItemView.tsx b/meteor/client/ui/RundownList/RundownListItemView.tsx index 88fb141dba..0694343135 100644 --- a/meteor/client/ui/RundownList/RundownListItemView.tsx +++ b/meteor/client/ui/RundownList/RundownListItemView.tsx @@ -109,6 +109,8 @@ export default withTranslation()(function RundownListItemView(props: Translated< {rundown.expectedStart ? ( + ) : rundown.expectedEnd && rundown.expectedDuration ? ( + ) : ( {t('Not set')} )} @@ -138,6 +140,15 @@ export default withTranslation()(function RundownListItemView(props: Translated< {t('Not set')} )} + + {rundown.expectedEnd ? ( + + ) : rundown.expectedStart && rundown.expectedDuration ? ( + + ) : ( + {t('Not set')} + )} + diff --git a/meteor/client/ui/RundownList/RundownPlaylistUi.tsx b/meteor/client/ui/RundownList/RundownPlaylistUi.tsx index 85ddb98d60..40155473fd 100644 --- a/meteor/client/ui/RundownList/RundownPlaylistUi.tsx +++ b/meteor/client/ui/RundownList/RundownPlaylistUi.tsx @@ -341,6 +341,8 @@ export const RundownPlaylistUi = DropTarget( {playlist.expectedStart ? ( + ) : playlist.expectedEnd && playlist.expectedDuration ? ( + ) : ( {t('Not set')} )} @@ -356,6 +358,15 @@ export const RundownPlaylistUi = DropTarget( {t('Not set')} )} + + {playlist.expectedEnd ? ( + + ) : playlist.expectedStart && playlist.expectedDuration ? ( + + ) : ( + {t('Not set')} + )} + diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 66eb8b8e22..3f7ba38268 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -340,7 +340,7 @@ const TimingDisplay = withTranslation()( ) : null} )} - {rundownPlaylist.expectedDuration ? ( + {rundownPlaylist.expectedEnd || rundownPlaylist.expectedDuration ? ( {!rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( @@ -351,6 +351,11 @@ const TimingDisplay = withTranslation()( date={rundownPlaylist.expectedStart + rundownPlaylist.expectedDuration} /> + ) : !rundownPlaylist.loop && rundownPlaylist.expectedEnd ? ( + + {t('Planned End')} + + ) : null} {!rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( @@ -389,13 +394,17 @@ const TimingDisplay = withTranslation()( ) : ( + {console.log(this.props.rundownPlaylist.expectedEnd)} {!rundownPlaylist.loop && this.props.timingDurations ? ( {t('Expected End')} ) : null} diff --git a/meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx b/meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx index 027cfe1aa4..2090bdeeed 100644 --- a/meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx +++ b/meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx @@ -58,6 +58,7 @@ export const findNextPlaylist = (props: IProps) => { name: 1, expectedStart: 1, expectedDuration: 1, + expectedEnd: 1, studioId: 1, }, } diff --git a/meteor/lib/collections/RundownPlaylists.ts b/meteor/lib/collections/RundownPlaylists.ts index d48d987b72..985b1a8b03 100644 --- a/meteor/lib/collections/RundownPlaylists.ts +++ b/meteor/lib/collections/RundownPlaylists.ts @@ -65,6 +65,8 @@ export interface DBRundownPlaylist { expectedStart?: Time /** How long the playlist is expected to take ON AIR */ expectedDuration?: number + /** When the playlist is expected to end */ + expectedEnd?: Time /** Is the playlist in rehearsal mode (can be used, when active: true) */ rehearsal?: boolean /** Playout hold state */ @@ -120,6 +122,7 @@ export class RundownPlaylist implements DBRundownPlaylist { public rundownsStartedPlayback?: Record public expectedStart?: Time public expectedDuration?: number + public expectedEnd?: Time public rehearsal?: boolean public holdState?: RundownHoldState public activationId?: RundownPlaylistActivationId @@ -210,6 +213,7 @@ export class RundownPlaylist implements DBRundownPlaylist { playlistId: 1, expectedStart: 1, expectedDuration: 1, + expectedEnd: 1, showStyleBaseId: 1, }, }) diff --git a/meteor/lib/collections/Rundowns.ts b/meteor/lib/collections/Rundowns.ts index c8013fe2f5..df1335204d 100644 --- a/meteor/lib/collections/Rundowns.ts +++ b/meteor/lib/collections/Rundowns.ts @@ -86,6 +86,7 @@ export class Rundown implements DBRundown { public description?: string public expectedStart?: Time public expectedDuration?: number + public expectedEnd?: Time public metaData?: unknown // From IBlueprintRundownDB: public _id: RundownId diff --git a/meteor/server/api/rundownPlaylist.ts b/meteor/server/api/rundownPlaylist.ts index 0c645337ce..21c414645e 100644 --- a/meteor/server/api/rundownPlaylist.ts +++ b/meteor/server/api/rundownPlaylist.ts @@ -171,6 +171,7 @@ export function produceRundownPlaylistInfoFromRundown( name: playlistInfo.playlist.name, expectedStart: playlistInfo.playlist.expectedStart, expectedDuration: playlistInfo.playlist.expectedDuration, + expectedEnd: playlistInfo.playlist.expectedEnd, loop: playlistInfo.playlist.loop, @@ -212,6 +213,7 @@ function defaultPlaylistForRundown( name: rundown.name, expectedStart: rundown.expectedStart, expectedDuration: rundown.expectedDuration, + expectedEnd: rundown.expectedEnd, modified: getCurrentTime(), } @@ -485,6 +487,7 @@ function sortDefaultRundownInPlaylistOrder(rundowns: ReadonlyDeep, ReadonlyDeep>(rundowns, { sort: { expectedStart: 1, + expectedEnd: 1, name: 1, _id: 1, }, diff --git a/meteor/server/migration/deprecatedDataTypes/1_0_1.ts b/meteor/server/migration/deprecatedDataTypes/1_0_1.ts index 7c7174f357..8c386680ad 100644 --- a/meteor/server/migration/deprecatedDataTypes/1_0_1.ts +++ b/meteor/server/migration/deprecatedDataTypes/1_0_1.ts @@ -14,6 +14,7 @@ export interface Rundown { name: string expectedStart?: Time expectedDuration?: number + expectedEnd?: Time metaData?: { [key: string]: any } @@ -61,6 +62,7 @@ export function makePlaylistFromRundown_1_0_0( nextPartInstanceId: null, expectedDuration: rundown.expectedDuration, expectedStart: rundown.expectedStart, + expectedEnd: rundown.expectedEnd, holdState: rundown.holdState, name: rundown.name, nextPartManual: rundown.nextPartManual, diff --git a/packages/blueprints-integration/src/rundown.ts b/packages/blueprints-integration/src/rundown.ts index a6322d3c11..845e6ea758 100644 --- a/packages/blueprints-integration/src/rundown.ts +++ b/packages/blueprints-integration/src/rundown.ts @@ -13,6 +13,8 @@ export interface IBlueprintRundownPlaylistInfo { expectedStart?: Time /** Expected duration of the rundown playlist */ expectedDuration?: number + /** Expected end time of the rundown playlist */ + expectedEnd?: Time /** Should the rundown playlist use out-of-order timing mode (unplayed content will be played eventually) as opposed to normal timing mode (unplayed content behind the OnAir line has been skipped) */ outOfOrderTiming?: boolean /** Should the rundown playlist loop at the end */ @@ -32,6 +34,8 @@ export interface IBlueprintRundown { expectedStart?: Time /** Expected duration of the rundown */ expectedDuration?: number + /** Expected end time of the rundown */ + expectedEnd?: Time /** Arbitrary data storage for plugins */ metaData?: TMetadata From 55cec52eb8e2bb6a09b4f9d4b1f988d4df32d8e3 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Fri, 4 Jun 2021 13:33:04 +0100 Subject: [PATCH 002/112] feat: End time diff --- meteor/client/ui/RundownView.tsx | 72 ++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 3f7ba38268..bd5148acf1 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -270,6 +270,20 @@ const TimingDisplay = withTranslation()( ) } + + private getEndTimeDiff(expectedDuration: number) { + let diff = 0 + if (this.props.rundownPlaylist.expectedEnd) { + let nowDiff = getCurrentTime() - this.props.rundownPlaylist.expectedEnd + let durationDiff = expectedDuration - (this.props.timingDurations.asPlayedRundownDuration ?? 0) + diff = nowDiff + durationDiff + } else { + diff = (this.props.timingDurations.asPlayedRundownDuration || 0) - expectedDuration + } + + return diff + } + render() { const { t, rundownPlaylist } = this.props @@ -342,7 +356,12 @@ const TimingDisplay = withTranslation()( )} {rundownPlaylist.expectedEnd || rundownPlaylist.expectedDuration ? ( - {!rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( + {!rundownPlaylist.loop && rundownPlaylist.expectedEnd ? ( + + {t('Planned End')} + + + ) : !rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( {t('Planned End')} - ) : !rundownPlaylist.loop && rundownPlaylist.expectedEnd ? ( - - {t('Planned End')} - - ) : null} - {!rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( - - {RundownUtils.formatDiffToTimecode( - getCurrentTime() - (rundownPlaylist.expectedStart + rundownPlaylist.expectedDuration), - true, - true, - true - )} - - ) : null} - {rundownPlaylist.expectedDuration && this.props.rundownCount < 2 ? ( // TEMPORARY: disable the diff counter for playlists longer than one rundown -- Jan Starzak, 2021-05-06 + {!rundownPlaylist.loop && + (rundownPlaylist.expectedEnd ? ( + + {RundownUtils.formatDiffToTimecode( + getCurrentTime() - rundownPlaylist.expectedEnd, + true, + true, + true + )} + + ) : rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( + + {RundownUtils.formatDiffToTimecode( + getCurrentTime() - (rundownPlaylist.expectedStart + rundownPlaylist.expectedDuration), + true, + true, + true + )} + + ) : null)} + {rundownPlaylist.expectedDuration ? ( {t('Diff')} {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedRundownDuration || 0) - rundownPlaylist.expectedDuration, + this.getEndTimeDiff(rundownPlaylist.expectedDuration), true, false, true, @@ -394,20 +418,22 @@ const TimingDisplay = withTranslation()( ) : ( - {console.log(this.props.rundownPlaylist.expectedEnd)} {!rundownPlaylist.loop && this.props.timingDurations ? ( {t('Expected End')} ) : null} + {!rundownPlaylist.loop && this.props.rundownPlaylist.expectedEnd ? ( + + {t('Planned End')} + + + ) : null} {this.props.timingDurations && this.props.rundownCount < 2 ? ( // TEMPORARY: disable the diff counter for playlists longer than one rundown -- Jan Starzak, 2021-05-06 Date: Fri, 4 Jun 2021 14:33:21 +0100 Subject: [PATCH 003/112] fix: End time diff --- meteor/client/ui/RundownView.tsx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index bd5148acf1..897b17abad 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -271,19 +271,6 @@ const TimingDisplay = withTranslation()( ) } - private getEndTimeDiff(expectedDuration: number) { - let diff = 0 - if (this.props.rundownPlaylist.expectedEnd) { - let nowDiff = getCurrentTime() - this.props.rundownPlaylist.expectedEnd - let durationDiff = expectedDuration - (this.props.timingDurations.asPlayedRundownDuration ?? 0) - diff = nowDiff + durationDiff - } else { - diff = (this.props.timingDurations.asPlayedRundownDuration || 0) - expectedDuration - } - - return diff - } - render() { const { t, rundownPlaylist } = this.props @@ -375,7 +362,9 @@ const TimingDisplay = withTranslation()( (rundownPlaylist.expectedEnd ? ( {RundownUtils.formatDiffToTimecode( - getCurrentTime() - rundownPlaylist.expectedEnd, + getCurrentTime() - + rundownPlaylist.expectedEnd + + (this.props.timingDurations.asPlayedRundownDuration ?? 0), true, true, true @@ -404,7 +393,7 @@ const TimingDisplay = withTranslation()( > {t('Diff')} {RundownUtils.formatDiffToTimecode( - this.getEndTimeDiff(rundownPlaylist.expectedDuration), + (this.props.timingDurations.asPlayedRundownDuration || 0) - rundownPlaylist.expectedDuration, true, false, true, From a6c0bc2543fe7c4664728f4d39b994485ebe804a Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Wed, 9 Jun 2021 15:54:35 +0100 Subject: [PATCH 004/112] wip: Move rundown timing to lib and add tests --- .../RundownTiming/RundownTiming.ts | 35 -- .../RundownTiming/RundownTimingProvider.tsx | 342 +------------ .../SegmentTimelineContainer.tsx | 11 +- .../rundown/__tests__/rundownTiming.test.ts | 377 ++++++++++++++ meteor/lib/rundown/rundownTiming.ts | 458 ++++++++++++++++++ 5 files changed, 849 insertions(+), 374 deletions(-) create mode 100644 meteor/lib/rundown/__tests__/rundownTiming.test.ts create mode 100644 meteor/lib/rundown/rundownTiming.ts diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts b/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts index 98fe3c934d..16524cf1e0 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts @@ -87,38 +87,3 @@ export namespace RundownTiming { timingDurations: RundownTimingContext } } - -/** - * Computes the actual (as-played fallbacking to expected) duration of a segment, consisting of given parts - * @export - * @param {RundownTiming.RundownTimingContext} timingDurations The timing durations calculated for the Rundown - * @param {Array} partIds The IDs of parts that are members of the segment - * @return number - */ -export function computeSegmentDuration( - timingDurations: RundownTiming.RundownTimingContext, - partIds: PartId[], - display?: boolean -): number { - const partDurations = timingDurations.partDurations - - if (partDurations === undefined) return 0 - - return partIds.reduce((memo, partId) => { - const pId = unprotectString(partId) - const partDuration = - (partDurations ? (partDurations[pId] !== undefined ? partDurations[pId] : 0) : 0) || - (display ? Settings.defaultDisplayDuration : 0) - return memo + partDuration - }, 0) -} - -export function computeSegmentDisplayDuration( - timingDurations: RundownTiming.RundownTimingContext, - parts: PartUi[] -): number { - return parts.reduce( - (memo, part) => memo + SegmentTimelinePartClass.getPartDisplayDuration(part, timingDurations), - 0 - ) -} diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index 248c5dd3fc..fcfad1a16b 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -4,19 +4,12 @@ import * as PropTypes from 'prop-types' import * as _ from 'underscore' import { withTracker } from '../../../lib/ReactMeteorData/react-meteor-data' import { Part, PartId } from '../../../../lib/collections/Parts' -import { getCurrentTime, literal, protectString, unprotectString } from '../../../../lib/lib' +import { getCurrentTime } from '../../../../lib/lib' import { MeteorReactComponent } from '../../../lib/MeteorReactComponent' import { RundownPlaylist } from '../../../../lib/collections/RundownPlaylists' -import { - PartInstance, - wrapPartToTemporaryInstance, - findPartInstanceInMapOrWrapToTemporary, -} from '../../../../lib/collections/PartInstances' -import { Settings } from '../../../../lib/Settings' +import { PartInstance } from '../../../../lib/collections/PartInstances' import { RundownTiming, TimeEventArgs } from './RundownTiming' - -// Minimum duration that a part can be assigned. Used by gap parts to allow them to "compress" to indicate time running out. -const MINIMAL_NONZERO_DURATION = 1 +import { RundownTimingCalculator } from '../../../../lib/rundown/rundownTiming' const TIMING_DEFAULT_REFRESH_INTERVAL = 1000 / 60 // the interval for high-resolution events (timeupdateHR) const LOW_RESOLUTION_TIMING_DECIMATOR = 15 // the low-resolution events will be called every @@ -125,18 +118,7 @@ export const RundownTimingProvider = withTracker< refreshTimerInterval: number refreshDecimator: number - private temporaryPartInstances: Map = new Map() - - private linearParts: Array<[PartId, number | null]> = [] - // look at the comments on RundownTimingContext to understand what these do - private partDurations: Record = {} - private partExpectedDurations: Record = {} - private partPlayed: Record = {} - private partStartsAt: Record = {} - private partDisplayStartsAt: Record = {} - private partDisplayDurations: Record = {} - private partDisplayDurationsNoPlayback: Record = {} - private displayDurationGroups: Record = {} + private timingCalculator: RundownTimingCalculator = new RundownTimingCalculator() constructor(props: IRundownTimingProviderProps & IRundownTimingProviderTrackedProps) { super(props) @@ -180,7 +162,7 @@ export const RundownTimingProvider = withTracker< } if (prevProps.parts !== this.props.parts) { // empty the temporary Part Instances cache - this.temporaryPartInstances.clear() + this.timingCalculator.clearTempPartInstances() this.onRefreshTimer() } } @@ -211,318 +193,18 @@ export const RundownTimingProvider = withTracker< window.dispatchEvent(event) } - private getPartInstanceOrGetCachedTemp(partInstancesMap: Map, part: Part): PartInstance { - const origPartId = part._id - const partInstance = partInstancesMap.get(origPartId) - if (partInstance !== undefined) { - return partInstance - } else { - let tempPartInstance = this.temporaryPartInstances.get(origPartId) - if (tempPartInstance !== undefined) { - return tempPartInstance - } else { - tempPartInstance = wrapPartToTemporaryInstance(protectString(''), part) - this.temporaryPartInstances.set(origPartId, tempPartInstance) - return tempPartInstance - } - } - } - updateDurations(now: number, isLowResolution: boolean) { - let totalRundownDuration = 0 - let remainingRundownDuration = 0 - let asPlayedRundownDuration = 0 - let asDisplayedRundownDuration = 0 - let waitAccumulator = 0 - let currentRemaining = 0 - let startsAtAccumulator = 0 - let displayStartsAtAccumulator = 0 - - Object.keys(this.displayDurationGroups).forEach((key) => delete this.displayDurationGroups[key]) - this.linearParts.length = 0 - const { playlist, parts, partInstancesMap } = this.props - - let nextAIndex = -1 - let currentAIndex = -1 - - if (playlist && parts) { - parts.forEach((origPart, itIndex) => { - const partInstance = this.getPartInstanceOrGetCachedTemp(partInstancesMap, origPart) - - // add piece to accumulator - const aIndex = this.linearParts.push([partInstance.part._id, waitAccumulator]) - 1 - - // if this is next segementLine, clear previous countdowns and clear accumulator - if (playlist.nextPartInstanceId === partInstance._id) { - nextAIndex = aIndex - } else if (playlist.currentPartInstanceId === partInstance._id) { - currentAIndex = aIndex - } - - const partCounts = - playlist.outOfOrderTiming || - !playlist.activationId || - (itIndex >= currentAIndex && currentAIndex >= 0) || - (itIndex >= nextAIndex && nextAIndex >= 0 && currentAIndex === -1) - - const partIsUntimed = partInstance.part.untimed || false - - // expected is just a sum of expectedDurations - totalRundownDuration += partInstance.part.expectedDuration || 0 - - const lastStartedPlayback = partInstance.timings?.startedPlayback - const playOffset = partInstance.timings?.playOffset || 0 - - let partDuration = 0 - let partExpectedDuration = 0 - let partDisplayDuration = 0 - let partDisplayDurationNoPlayback = 0 - - let displayDurationFromGroup = 0 - - partExpectedDuration = partInstance.part.expectedDuration || partInstance.timings?.duration || 0 - - // Display Duration groups are groups of two or more Parts, where some of them have an - // expectedDuration and some have 0. - // Then, some of them will have a displayDuration. The expectedDurations are pooled together, the parts with - // display durations will take up that much time in the Rundown. The left-over time from the display duration group - // will be used by Parts without expectedDurations. - let memberOfDisplayDurationGroup = false - // using a separate displayDurationGroup processing flag simplifies implementation - if ( - partInstance.part.displayDurationGroup && - // either this is not the first element of the displayDurationGroup - (this.displayDurationGroups[partInstance.part.displayDurationGroup] !== undefined || - // or there is a following member of this displayDurationGroup - (parts[itIndex + 1] && - parts[itIndex + 1].displayDurationGroup === partInstance.part.displayDurationGroup)) && - !partInstance.part.floated && - !partIsUntimed - ) { - this.displayDurationGroups[partInstance.part.displayDurationGroup] = - (this.displayDurationGroups[partInstance.part.displayDurationGroup] || 0) + - (partInstance.part.expectedDuration || 0) - displayDurationFromGroup = - partInstance.part.displayDuration || - Math.max( - 0, - this.displayDurationGroups[partInstance.part.displayDurationGroup], - partInstance.part.gap - ? MINIMAL_NONZERO_DURATION - : this.props.defaultDuration || Settings.defaultDisplayDuration - ) - partExpectedDuration = - partExpectedDuration || this.displayDurationGroups[partInstance.part.displayDurationGroup] || 0 - memberOfDisplayDurationGroup = true - } - - // This is where we actually calculate all the various variants of duration of a part - if (lastStartedPlayback && !partInstance.timings?.duration) { - // if duration isn't available, check if `takeOut` has already been set and use the difference - // between startedPlayback and takeOut as a temporary duration - const duration = - partInstance.timings?.duration || - (partInstance.timings?.takeOut ? lastStartedPlayback - partInstance.timings?.takeOut : undefined) - currentRemaining = Math.max( - 0, - (duration || - (memberOfDisplayDurationGroup ? displayDurationFromGroup : partInstance.part.expectedDuration) || - 0) - - (now - lastStartedPlayback) - ) - partDuration = - Math.max(duration || partInstance.part.expectedDuration || 0, now - lastStartedPlayback) - playOffset - // because displayDurationGroups have no actual timing on them, we need to have a copy of the - // partDisplayDuration, but calculated as if it's not playing, so that the countdown can be - // calculated - partDisplayDurationNoPlayback = - duration || - (memberOfDisplayDurationGroup ? displayDurationFromGroup : partInstance.part.expectedDuration) || - this.props.defaultDuration || - Settings.defaultDisplayDuration - partDisplayDuration = Math.max(partDisplayDurationNoPlayback, now - lastStartedPlayback) - this.partPlayed[unprotectString(partInstance.part._id)] = now - lastStartedPlayback - } else { - partDuration = (partInstance.timings?.duration || partInstance.part.expectedDuration || 0) - playOffset - partDisplayDurationNoPlayback = Math.max( - 0, - (partInstance.timings?.duration && partInstance.timings?.duration + playOffset) || - displayDurationFromGroup || - partInstance.part.expectedDuration || - this.props.defaultDuration || - Settings.defaultDisplayDuration - ) - partDisplayDuration = partDisplayDurationNoPlayback - this.partPlayed[unprotectString(partInstance.part._id)] = (partInstance.timings?.duration || 0) - playOffset - } - - // asPlayed is the actual duration so far and expected durations in unplayed lines. - // If item is onAir right now, it's duration is counted as expected duration or current - // playback duration whichever is larger. - // Parts that are Untimed are ignored always. - // Parts that don't count are ignored, unless they are being played or have been played. - if (!partIsUntimed) { - if (lastStartedPlayback && !partInstance.timings?.duration) { - asPlayedRundownDuration += Math.max(partExpectedDuration, now - lastStartedPlayback) - } else if (partInstance.timings?.duration) { - asPlayedRundownDuration += partInstance.timings.duration - } else if (partCounts) { - asPlayedRundownDuration += partInstance.part.expectedDuration || 0 - } - } - - // asDisplayed is the actual duration so far and expected durations in unplayed lines - // If item is onAir right now, it's duration is counted as expected duration or current - // playback duration whichever is larger. - // All parts are counted. - if (lastStartedPlayback && !partInstance.timings?.duration) { - asDisplayedRundownDuration += Math.max( - memberOfDisplayDurationGroup - ? Math.max(partExpectedDuration, partInstance.part.expectedDuration || 0) - : partInstance.part.expectedDuration || 0, - now - lastStartedPlayback - ) - } else { - asDisplayedRundownDuration += partInstance.timings?.duration || partInstance.part.expectedDuration || 0 - } - - // the part is the current part but has not yet started playback - if (playlist.currentPartInstanceId === partInstance._id && !lastStartedPlayback) { - currentRemaining = partDisplayDuration - } - - // Handle invalid parts by overriding the values to preset values for Invalid parts - if (partInstance.part.invalid && !partInstance.part.gap) { - partDisplayDuration = this.props.defaultDuration || Settings.defaultDisplayDuration - this.partPlayed[unprotectString(partInstance.part._id)] = 0 - } - - if ( - memberOfDisplayDurationGroup && - partInstance.part.displayDurationGroup && - !partInstance.part.floated && - !partInstance.part.invalid && - !partIsUntimed && - (partInstance.timings?.duration || partInstance.timings?.takeOut || partCounts) - ) { - this.displayDurationGroups[partInstance.part.displayDurationGroup] = - this.displayDurationGroups[partInstance.part.displayDurationGroup] - partDisplayDuration - } - const partInstancePartId = unprotectString(partInstance.part._id) - this.partExpectedDurations[partInstancePartId] = partExpectedDuration - this.partStartsAt[partInstancePartId] = startsAtAccumulator - this.partDisplayStartsAt[partInstancePartId] = displayStartsAtAccumulator - this.partDurations[partInstancePartId] = partDuration - this.partDisplayDurations[partInstancePartId] = partDisplayDuration - this.partDisplayDurationsNoPlayback[partInstancePartId] = partDisplayDurationNoPlayback - startsAtAccumulator += this.partDurations[partInstancePartId] - displayStartsAtAccumulator += this.partDisplayDurations[partInstancePartId] // || this.props.defaultDuration || 3000 - // waitAccumulator is used to calculate the countdowns for Parts relative to the current Part - // always add the full duration, in case by some manual intervention this segment should play twice - if (memberOfDisplayDurationGroup) { - waitAccumulator += - partInstance.timings?.duration || partDisplayDuration || partInstance.part.expectedDuration || 0 - } else { - waitAccumulator += partInstance.timings?.duration || partInstance.part.expectedDuration || 0 - } - - // remaining is the sum of unplayed lines + whatever is left of the current segment - // if outOfOrderTiming is true, count parts before current part towards remaining rundown duration - // if false (default), past unplayed parts will not count towards remaining time - if (!lastStartedPlayback && !partInstance.part.floated && partCounts && !partIsUntimed) { - remainingRundownDuration += partExpectedDuration || 0 - // item is onAir right now, and it's is currently shorter than expectedDuration - } else if ( - lastStartedPlayback && - !partInstance.timings?.duration && - playlist.currentPartInstanceId === partInstance._id && - lastStartedPlayback + partExpectedDuration > now && - !partIsUntimed - ) { - remainingRundownDuration += partExpectedDuration - (now - lastStartedPlayback) - } - }) - - // This is where the waitAccumulator-generated data in the linearSegLines is used to calculate the countdowns. - let localAccum = 0 - for (let i = 0; i < this.linearParts.length; i++) { - if (i < nextAIndex) { - // this is a line before next line - localAccum = this.linearParts[i][1] || 0 - // only null the values if not looping, if looping, these will be offset by the countdown for the last part - if (!playlist.loop) { - this.linearParts[i][1] = null // we use null to express 'will not probably be played out, if played in order' - } - } else if (i === nextAIndex) { - // this is a calculation for the next line, which is basically how much there is left of the current line - localAccum = this.linearParts[i][1] || 0 // if there is no current line, rebase following lines to the next line - this.linearParts[i][1] = currentRemaining - } else { - // these are lines after next line - // we take whatever value this line has, subtract the value as set on the Next Part - // (note that the Next Part value will be using currentRemaining as the countdown) - // and add the currentRemaining countdown, since we are currentRemaining + diff between next and - // this away from this line. - this.linearParts[i][1] = (this.linearParts[i][1] || 0) - localAccum + currentRemaining - } - } - // contiunation of linearParts calculations for looping playlists - if (playlist.loop) { - for (let i = 0; i < nextAIndex; i++) { - // offset the parts before the on air line by the countdown for the end of the rundown - this.linearParts[i][1] = (this.linearParts[i][1] || 0) + waitAccumulator - localAccum + currentRemaining - } - } - - // if (this.refreshDecimator % LOW_RESOLUTION_TIMING_DECIMATOR === 0) { - // const c = document.getElementById('debug-console') - // if (c) c.innerHTML = debugConsole.replace(/\n/g, '
') - // } - } - - let remainingTimeOnCurrentPart: number | undefined = undefined - let currentPartWillAutoNext = false - if (currentAIndex >= 0) { - const currentLivePart = parts[currentAIndex] - const currentLivePartInstance = findPartInstanceInMapOrWrapToTemporary(partInstancesMap, currentLivePart) - - const lastStartedPlayback = currentLivePartInstance.timings?.startedPlayback - - let onAirPartDuration = currentLivePartInstance.timings?.duration || currentLivePart.expectedDuration || 0 - if (currentLivePart.displayDurationGroup) { - onAirPartDuration = this.partExpectedDurations[unprotectString(currentLivePart._id)] || onAirPartDuration - } - - remainingTimeOnCurrentPart = lastStartedPlayback - ? now - (lastStartedPlayback + onAirPartDuration) - : onAirPartDuration * -1 - - currentPartWillAutoNext = !!( - currentLivePart.autoNext && - (currentLivePart.expectedDuration !== undefined ? currentLivePart.expectedDuration !== 0 : false) - ) - } - this.durations = Object.assign( this.durations, - literal({ - totalRundownDuration, - remainingRundownDuration, - asDisplayedRundownDuration, - asPlayedRundownDuration, - partCountdown: _.object(this.linearParts), - partDurations: this.partDurations, - partPlayed: this.partPlayed, - partStartsAt: this.partStartsAt, - partDisplayStartsAt: this.partDisplayStartsAt, - partExpectedDurations: this.partExpectedDurations, - partDisplayDurations: this.partDisplayDurations, - currentTime: now, - remainingTimeOnCurrentPart, - currentPartWillAutoNext, + this.timingCalculator.updateDurations( + now, isLowResolution, - }) + playlist, + parts, + partInstancesMap, + this.props.defaultDuration + ) ) } diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index 023aa3baad..de4ace347a 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -7,12 +7,7 @@ import { Translated, translateWithTracker } from '../../lib/ReactMeteorData/reac import { Segments, SegmentId } from '../../../lib/collections/Segments' import { Studio } from '../../../lib/collections/Studios' import { SegmentTimeline, SegmentTimelineClass } from './SegmentTimeline' -import { - RundownTiming, - computeSegmentDuration, - TimingEvent, - computeSegmentDisplayDuration, -} from '../RundownView/RundownTiming/RundownTiming' +import { RundownTiming, TimingEvent } from '../RundownView/RundownTiming/RundownTiming' import { UIStateStorage } from '../../lib/UIStateStorage' import { MeteorReactComponent } from '../../lib/MeteorReactComponent' import { @@ -48,9 +43,7 @@ import RundownViewEventBus, { import { memoizedIsolatedAutorun, slowDownReactivity } from '../../lib/reactiveData/reactiveDataHelper' import { checkPieceContentStatus, getNoteTypeForPieceStatus, ScanInfoForPackages } from '../../../lib/mediaObjects' import { getBasicNotesForSegment } from '../../../lib/rundownNotifications' -import { SegmentTimelinePartClass } from './SegmentTimelinePart' -import { RundownAPI } from '../../../lib/api/rundown' -import { Piece, Pieces } from '../../../lib/collections/Pieces' +import { computeSegmentDuration } from '../../../lib/rundown/rundownTiming' export const SIMULATED_PLAYBACK_SOFT_MARGIN = 0 export const SIMULATED_PLAYBACK_HARD_MARGIN = 2500 diff --git a/meteor/lib/rundown/__tests__/rundownTiming.test.ts b/meteor/lib/rundown/__tests__/rundownTiming.test.ts new file mode 100644 index 0000000000..0869c4efbb --- /dev/null +++ b/meteor/lib/rundown/__tests__/rundownTiming.test.ts @@ -0,0 +1,377 @@ +import { PartInstance } from '../../collections/PartInstances' +import { DBPart, Part, PartId } from '../../collections/Parts' +import { DBRundownPlaylist, RundownPlaylist } from '../../collections/RundownPlaylists' +import { literal, protectString } from '../../lib' +import { RundownTiming, RundownTimingCalculator } from '../rundownTiming' + +const DEFAULT_DURATION = 4000 + +function makeMockPlaylist(): RundownPlaylist { + return new RundownPlaylist( + literal({ + _id: protectString('mock-playlist'), + externalId: 'mock-playlist', + organizationId: protectString('test'), + studioId: protectString('studio0'), + name: 'Mock Playlist', + created: 0, + modified: 0, + currentPartInstanceId: null, + nextPartInstanceId: null, + previousPartInstanceId: null, + }) + ) +} + +function makeMockPart( + id: string, + rank: number, + rundownId: string, + segmentId: string, + durations: { expectedDuration?: number; displayDuration?: number; displayDurationGroup?: string } +): Part { + return new Part( + literal({ + _id: protectString(id), + externalId: id, + title: '', + segmentId: protectString(segmentId), + _rank: rank, + rundownId: protectString(rundownId), + ...durations, + }) + ) +} + +describe('rundown Timing Calculator', () => { + it('Provides output for empty playlist', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + const parts: Part[] = [] + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 0, + asPlayedRundownDuration: 0, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: {}, + partDisplayDurations: {}, + partDisplayStartsAt: {}, + partDurations: {}, + partExpectedDurations: {}, + partPlayed: {}, + partStartsAt: {}, + remainingRundownDuration: 0, + totalRundownDuration: 0, + }) + ) + }) + + it('Calculates time for unplayed playlist with start time and duration', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + playlist.expectedStart = 0 + playlist.expectedDuration = 4000 + const rundownId = 'rundown1' + const segmentId1 = 'segment1' + const segmentId2 = 'segment2' + const parts: Part[] = [] + parts.push(makeMockPart('part1', 0, rundownId, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part2', 0, rundownId, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part3', 0, rundownId, segmentId2, { expectedDuration: 1000 })) + parts.push(makeMockPart('part4', 0, rundownId, segmentId2, { expectedDuration: 1000 })) + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 4000, + asPlayedRundownDuration: 4000, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDisplayDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partDisplayStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partExpectedDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partPlayed: { + part1: 0, + part2: 0, + part3: 0, + part4: 0, + }, + partStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + remainingRundownDuration: 4000, + totalRundownDuration: 4000, + }) + ) + }) + + it('Calculates time for unplayed playlist with end time and duration', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + playlist.expectedDuration = 4000 + playlist.expectedEnd = 4000 + const rundownId = 'rundown1' + const segmentId1 = 'segment1' + const segmentId2 = 'segment2' + const parts: Part[] = [] + parts.push(makeMockPart('part1', 0, rundownId, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part2', 0, rundownId, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part3', 0, rundownId, segmentId2, { expectedDuration: 1000 })) + parts.push(makeMockPart('part4', 0, rundownId, segmentId2, { expectedDuration: 1000 })) + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 4000, + asPlayedRundownDuration: 4000, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDisplayDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partDisplayStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partExpectedDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partPlayed: { + part1: 0, + part2: 0, + part3: 0, + part4: 0, + }, + partStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + remainingRundownDuration: 4000, + totalRundownDuration: 4000, + }) + ) + }) + + it('Produces timing per rundown with start time and duration', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + playlist.expectedStart = 0 + playlist.expectedDuration = 4000 + const rundownId1 = 'rundown1' + const rundownId2 = 'rundown2' + const segmentId1 = 'segment1' + const segmentId2 = 'segment2' + const parts: Part[] = [] + parts.push(makeMockPart('part1', 0, rundownId1, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part2', 0, rundownId1, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part3', 0, rundownId2, segmentId2, { expectedDuration: 1000 })) + parts.push(makeMockPart('part4', 0, rundownId2, segmentId2, { expectedDuration: 1000 })) + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 4000, + asPlayedRundownDuration: 4000, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDisplayDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partDisplayStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partExpectedDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partPlayed: { + part1: 0, + part2: 0, + part3: 0, + part4: 0, + }, + partStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + remainingRundownDuration: 4000, + totalRundownDuration: 4000, + }) + ) + }) + + it('Handles display duration groups', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + playlist.expectedStart = 0 + playlist.expectedDuration = 4000 + const rundownId1 = 'rundown1' + const segmentId1 = 'segment1' + const segmentId2 = 'segment2' + const parts: Part[] = [] + parts.push( + makeMockPart('part1', 0, rundownId1, segmentId1, { + expectedDuration: 1000, + displayDuration: 2000, + displayDurationGroup: 'test', + }) + ) + parts.push( + makeMockPart('part2', 0, rundownId1, segmentId1, { + expectedDuration: 1000, + displayDuration: 3000, + displayDurationGroup: 'test', + }) + ) + parts.push( + makeMockPart('part3', 0, rundownId1, segmentId2, { + expectedDuration: 1000, + displayDuration: 4000, + displayDurationGroup: 'test', + }) + ) + parts.push( + makeMockPart('part4', 0, rundownId1, segmentId2, { + expectedDuration: 1000, + displayDuration: 5000, + displayDurationGroup: 'test', + }) + ) + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 4000, + asPlayedRundownDuration: 4000, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: { + part1: 0, + part2: 2000, + part3: 5000, + part4: 9000, + }, + partDisplayDurations: { + part1: 2000, + part2: 3000, + part3: 4000, + part4: 5000, + }, + partDisplayStartsAt: { + part1: 0, + part2: 2000, + part3: 5000, + part4: 9000, + }, + partDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partExpectedDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partPlayed: { + part1: 0, + part2: 0, + part3: 0, + part4: 0, + }, + partStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + remainingRundownDuration: 4000, + totalRundownDuration: 4000, + }) + ) + }) +}) diff --git a/meteor/lib/rundown/rundownTiming.ts b/meteor/lib/rundown/rundownTiming.ts new file mode 100644 index 0000000000..1bec59d69e --- /dev/null +++ b/meteor/lib/rundown/rundownTiming.ts @@ -0,0 +1,458 @@ +import _ from 'underscore' +import { + findPartInstanceInMapOrWrapToTemporary, + PartInstance, + wrapPartToTemporaryInstance, +} from '../collections/PartInstances' +import { Part, PartId } from '../collections/Parts' +import { RundownPlaylist } from '../collections/RundownPlaylists' +import { unprotectString, literal, protectString } from '../lib' +import { Settings } from '../Settings' + +// Minimum duration that a part can be assigned. Used by gap parts to allow them to "compress" to indicate time running out. +const MINIMAL_NONZERO_DURATION = 1 + +export class RundownTimingCalculator { + private temporaryPartInstances: Map = new Map() + + private linearParts: Array<[PartId, number | null]> = [] + // look at the comments on RundownTimingContext to understand what these do + private partDurations: Record = {} + private partExpectedDurations: Record = {} + private partPlayed: Record = {} + private partStartsAt: Record = {} + private partDisplayStartsAt: Record = {} + private partDisplayDurations: Record = {} + private partDisplayDurationsNoPlayback: Record = {} + private displayDurationGroups: Record = {} + + updateDurations( + now: number, + isLowResolution: boolean, + playlist: RundownPlaylist | undefined, + parts: Part[], + partInstancesMap: Map, + /** Fallback duration for Parts that have no as-played duration of their own. */ + defaultDuration?: number + ) { + let totalRundownDuration = 0 + let remainingRundownDuration = 0 + let asPlayedRundownDuration = 0 + let asDisplayedRundownDuration = 0 + let waitAccumulator = 0 + let currentRemaining = 0 + let startsAtAccumulator = 0 + let displayStartsAtAccumulator = 0 + + Object.keys(this.displayDurationGroups).forEach((key) => delete this.displayDurationGroups[key]) + this.linearParts.length = 0 + + let nextAIndex = -1 + let currentAIndex = -1 + + if (playlist && parts) { + parts.forEach((origPart, itIndex) => { + const partInstance = this.getPartInstanceOrGetCachedTemp(partInstancesMap, origPart) + + // add piece to accumulator + const aIndex = this.linearParts.push([partInstance.part._id, waitAccumulator]) - 1 + + // if this is next segementLine, clear previous countdowns and clear accumulator + if (playlist.nextPartInstanceId === partInstance._id) { + nextAIndex = aIndex + } else if (playlist.currentPartInstanceId === partInstance._id) { + currentAIndex = aIndex + } + + const partCounts = + playlist.outOfOrderTiming || + !playlist.activationId || + (itIndex >= currentAIndex && currentAIndex >= 0) || + (itIndex >= nextAIndex && nextAIndex >= 0 && currentAIndex === -1) + + const partIsUntimed = partInstance.part.untimed || false + + // expected is just a sum of expectedDurations + totalRundownDuration += partInstance.part.expectedDuration || 0 + + const lastStartedPlayback = partInstance.timings?.startedPlayback + const playOffset = partInstance.timings?.playOffset || 0 + + let partDuration = 0 + let partExpectedDuration = 0 + let partDisplayDuration = 0 + let partDisplayDurationNoPlayback = 0 + + let displayDurationFromGroup = 0 + + partExpectedDuration = partInstance.part.expectedDuration || partInstance.timings?.duration || 0 + + // Display Duration groups are groups of two or more Parts, where some of them have an + // expectedDuration and some have 0. + // Then, some of them will have a displayDuration. The expectedDurations are pooled together, the parts with + // display durations will take up that much time in the Rundown. The left-over time from the display duration group + // will be used by Parts without expectedDurations. + let memberOfDisplayDurationGroup = false + // using a separate displayDurationGroup processing flag simplifies implementation + if ( + partInstance.part.displayDurationGroup && + // either this is not the first element of the displayDurationGroup + (this.displayDurationGroups[partInstance.part.displayDurationGroup] !== undefined || + // or there is a following member of this displayDurationGroup + (parts[itIndex + 1] && + parts[itIndex + 1].displayDurationGroup === partInstance.part.displayDurationGroup)) && + !partInstance.part.floated && + !partIsUntimed + ) { + this.displayDurationGroups[partInstance.part.displayDurationGroup] = + (this.displayDurationGroups[partInstance.part.displayDurationGroup] || 0) + + (partInstance.part.expectedDuration || 0) + displayDurationFromGroup = + partInstance.part.displayDuration || + Math.max( + 0, + this.displayDurationGroups[partInstance.part.displayDurationGroup], + partInstance.part.gap + ? MINIMAL_NONZERO_DURATION + : defaultDuration || Settings.defaultDisplayDuration + ) + partExpectedDuration = + partExpectedDuration || this.displayDurationGroups[partInstance.part.displayDurationGroup] || 0 + memberOfDisplayDurationGroup = true + } + + // This is where we actually calculate all the various variants of duration of a part + if (lastStartedPlayback && !partInstance.timings?.duration) { + // if duration isn't available, check if `takeOut` has already been set and use the difference + // between startedPlayback and takeOut as a temporary duration + const duration = + partInstance.timings?.duration || + (partInstance.timings?.takeOut + ? lastStartedPlayback - partInstance.timings?.takeOut + : undefined) + currentRemaining = Math.max( + 0, + (duration || + (memberOfDisplayDurationGroup + ? displayDurationFromGroup + : partInstance.part.expectedDuration) || + 0) - + (now - lastStartedPlayback) + ) + partDuration = + Math.max(duration || partInstance.part.expectedDuration || 0, now - lastStartedPlayback) - + playOffset + // because displayDurationGroups have no actual timing on them, we need to have a copy of the + // partDisplayDuration, but calculated as if it's not playing, so that the countdown can be + // calculated + partDisplayDurationNoPlayback = + duration || + (memberOfDisplayDurationGroup + ? displayDurationFromGroup + : partInstance.part.expectedDuration) || + defaultDuration || + Settings.defaultDisplayDuration + partDisplayDuration = Math.max(partDisplayDurationNoPlayback, now - lastStartedPlayback) + this.partPlayed[unprotectString(partInstance.part._id)] = now - lastStartedPlayback + } else { + partDuration = + (partInstance.timings?.duration || partInstance.part.expectedDuration || 0) - playOffset + partDisplayDurationNoPlayback = Math.max( + 0, + (partInstance.timings?.duration && partInstance.timings?.duration + playOffset) || + displayDurationFromGroup || + partInstance.part.expectedDuration || + defaultDuration || + Settings.defaultDisplayDuration + ) + partDisplayDuration = partDisplayDurationNoPlayback + this.partPlayed[unprotectString(partInstance.part._id)] = + (partInstance.timings?.duration || 0) - playOffset + } + + // asPlayed is the actual duration so far and expected durations in unplayed lines. + // If item is onAir right now, it's duration is counted as expected duration or current + // playback duration whichever is larger. + // Parts that are Untimed are ignored always. + // Parts that don't count are ignored, unless they are being played or have been played. + if (!partIsUntimed) { + if (lastStartedPlayback && !partInstance.timings?.duration) { + asPlayedRundownDuration += Math.max(partExpectedDuration, now - lastStartedPlayback) + } else if (partInstance.timings?.duration) { + asPlayedRundownDuration += partInstance.timings.duration + } else if (partCounts) { + asPlayedRundownDuration += partInstance.part.expectedDuration || 0 + } + } + + // asDisplayed is the actual duration so far and expected durations in unplayed lines + // If item is onAir right now, it's duration is counted as expected duration or current + // playback duration whichever is larger. + // All parts are counted. + if (lastStartedPlayback && !partInstance.timings?.duration) { + asDisplayedRundownDuration += Math.max( + memberOfDisplayDurationGroup + ? Math.max(partExpectedDuration, partInstance.part.expectedDuration || 0) + : partInstance.part.expectedDuration || 0, + now - lastStartedPlayback + ) + } else { + asDisplayedRundownDuration += + partInstance.timings?.duration || partInstance.part.expectedDuration || 0 + } + + // the part is the current part but has not yet started playback + if (playlist.currentPartInstanceId === partInstance._id && !lastStartedPlayback) { + currentRemaining = partDisplayDuration + } + + // Handle invalid parts by overriding the values to preset values for Invalid parts + if (partInstance.part.invalid && !partInstance.part.gap) { + partDisplayDuration = defaultDuration || Settings.defaultDisplayDuration + this.partPlayed[unprotectString(partInstance.part._id)] = 0 + } + + if ( + memberOfDisplayDurationGroup && + partInstance.part.displayDurationGroup && + !partInstance.part.floated && + !partInstance.part.invalid && + !partIsUntimed && + (partInstance.timings?.duration || partInstance.timings?.takeOut || partCounts) + ) { + this.displayDurationGroups[partInstance.part.displayDurationGroup] = + this.displayDurationGroups[partInstance.part.displayDurationGroup] - partDisplayDuration + } + const partInstancePartId = unprotectString(partInstance.part._id) + this.partExpectedDurations[partInstancePartId] = partExpectedDuration + this.partStartsAt[partInstancePartId] = startsAtAccumulator + this.partDisplayStartsAt[partInstancePartId] = displayStartsAtAccumulator + this.partDurations[partInstancePartId] = partDuration + this.partDisplayDurations[partInstancePartId] = partDisplayDuration + this.partDisplayDurationsNoPlayback[partInstancePartId] = partDisplayDurationNoPlayback + startsAtAccumulator += this.partDurations[partInstancePartId] + displayStartsAtAccumulator += this.partDisplayDurations[partInstancePartId] // || this.props.defaultDuration || 3000 + // waitAccumulator is used to calculate the countdowns for Parts relative to the current Part + // always add the full duration, in case by some manual intervention this segment should play twice + if (memberOfDisplayDurationGroup) { + waitAccumulator += + partInstance.timings?.duration || partDisplayDuration || partInstance.part.expectedDuration || 0 + } else { + waitAccumulator += partInstance.timings?.duration || partInstance.part.expectedDuration || 0 + } + + // remaining is the sum of unplayed lines + whatever is left of the current segment + // if outOfOrderTiming is true, count parts before current part towards remaining rundown duration + // if false (default), past unplayed parts will not count towards remaining time + if (!lastStartedPlayback && !partInstance.part.floated && partCounts && !partIsUntimed) { + remainingRundownDuration += partExpectedDuration || 0 + // item is onAir right now, and it's is currently shorter than expectedDuration + } else if ( + lastStartedPlayback && + !partInstance.timings?.duration && + playlist.currentPartInstanceId === partInstance._id && + lastStartedPlayback + partExpectedDuration > now && + !partIsUntimed + ) { + remainingRundownDuration += partExpectedDuration - (now - lastStartedPlayback) + } + }) + + // This is where the waitAccumulator-generated data in the linearSegLines is used to calculate the countdowns. + let localAccum = 0 + for (let i = 0; i < this.linearParts.length; i++) { + if (i < nextAIndex) { + // this is a line before next line + localAccum = this.linearParts[i][1] || 0 + // only null the values if not looping, if looping, these will be offset by the countdown for the last part + if (!playlist.loop) { + this.linearParts[i][1] = null // we use null to express 'will not probably be played out, if played in order' + } + } else if (i === nextAIndex) { + // this is a calculation for the next line, which is basically how much there is left of the current line + localAccum = this.linearParts[i][1] || 0 // if there is no current line, rebase following lines to the next line + this.linearParts[i][1] = currentRemaining + } else { + // these are lines after next line + // we take whatever value this line has, subtract the value as set on the Next Part + // (note that the Next Part value will be using currentRemaining as the countdown) + // and add the currentRemaining countdown, since we are currentRemaining + diff between next and + // this away from this line. + this.linearParts[i][1] = (this.linearParts[i][1] || 0) - localAccum + currentRemaining + } + } + // contiunation of linearParts calculations for looping playlists + if (playlist.loop) { + for (let i = 0; i < nextAIndex; i++) { + // offset the parts before the on air line by the countdown for the end of the rundown + this.linearParts[i][1] = + (this.linearParts[i][1] || 0) + waitAccumulator - localAccum + currentRemaining + } + } + + // if (this.refreshDecimator % LOW_RESOLUTION_TIMING_DECIMATOR === 0) { + // const c = document.getElementById('debug-console') + // if (c) c.innerHTML = debugConsole.replace(/\n/g, '
') + // } + } + + let remainingTimeOnCurrentPart: number | undefined = undefined + let currentPartWillAutoNext = false + if (currentAIndex >= 0) { + const currentLivePart = parts[currentAIndex] + const currentLivePartInstance = findPartInstanceInMapOrWrapToTemporary(partInstancesMap, currentLivePart) + + const lastStartedPlayback = currentLivePartInstance.timings?.startedPlayback + + let onAirPartDuration = currentLivePartInstance.timings?.duration || currentLivePart.expectedDuration || 0 + if (currentLivePart.displayDurationGroup) { + onAirPartDuration = + this.partExpectedDurations[unprotectString(currentLivePart._id)] || onAirPartDuration + } + + remainingTimeOnCurrentPart = lastStartedPlayback + ? now - (lastStartedPlayback + onAirPartDuration) + : onAirPartDuration * -1 + + currentPartWillAutoNext = !!( + currentLivePart.autoNext && + (currentLivePart.expectedDuration !== undefined ? currentLivePart.expectedDuration !== 0 : false) + ) + } + + return literal({ + totalRundownDuration, + remainingRundownDuration, + asDisplayedRundownDuration, + asPlayedRundownDuration, + partCountdown: _.object(this.linearParts), + partDurations: this.partDurations, + partPlayed: this.partPlayed, + partStartsAt: this.partStartsAt, + partDisplayStartsAt: this.partDisplayStartsAt, + partExpectedDurations: this.partExpectedDurations, + partDisplayDurations: this.partDisplayDurations, + currentTime: now, + remainingTimeOnCurrentPart, + currentPartWillAutoNext, + isLowResolution, + }) + } + + clearTempPartInstances() { + this.temporaryPartInstances.clear() + } + + private getPartInstanceOrGetCachedTemp(partInstancesMap: Map, part: Part): PartInstance { + const origPartId = part._id + const partInstance = partInstancesMap.get(origPartId) + if (partInstance !== undefined) { + return partInstance + } else { + let tempPartInstance = this.temporaryPartInstances.get(origPartId) + if (tempPartInstance !== undefined) { + return tempPartInstance + } else { + tempPartInstance = wrapPartToTemporaryInstance(protectString(''), part) + this.temporaryPartInstances.set(origPartId, tempPartInstance) + return tempPartInstance + } + } + } +} + +export namespace RundownTiming { + /** + * Events used by the RundownTimingProvider + * @export + * @enum {number} + */ + export enum Events { + /** Event is emitted every now-and-then, generally to be used for simple displays */ + 'timeupdate' = 'sofie:rundownTimeUpdate', + /** event is emitted with a very high frequency (60 Hz), to be used sparingly as + * hooking up Components to it will cause a lot of renders + */ + 'timeupdateHR' = 'sofie:rundownTimeUpdateHR', + } + + /** + * Context object that will be passed to listening components. The dictionaries use the Part ID as a key. + * @export + * @interface RundownTimingContext + */ + export interface RundownTimingContext { + /** This is the total duration of the rundown as planned (using expectedDurations). */ + totalRundownDuration?: number + /** This is the content remaining to be played in the rundown (based on the expectedDurations). */ + remainingRundownDuration?: number + /** This is the total duration of the rundown: as planned for the unplayed (skipped & future) content, and as-run for the played-out. */ + asDisplayedRundownDuration?: number + /** This is the complete duration of the rundown: as planned for the unplayed content, and as-run for the played-out, but ignoring unplayed/unplayable parts in order */ + asPlayedRundownDuration?: number + /** this is the countdown to each of the parts relative to the current on air part. */ + partCountdown?: Record + /** The calculated durations of each of the Parts: as-planned/as-run depending on state. */ + partDurations?: Record + /** The offset of each of the Parts from the beginning of the Rundown. */ + partStartsAt?: Record + /** Same as partStartsAt, but will include display duration overrides + * (such as minimal display width for an Part, etc.). + */ + partDisplayStartsAt?: Record + /** Same as partDurations, but will include display duration overrides + * (such as minimal display width for an Part, etc.). + */ + partDisplayDurations?: Record + /** As-played durations of each part. Will be 0, if not yet played. + * Will be counted from start to now if currently playing. + */ + partPlayed?: Record + /** Expected durations of each of the parts or the as-played duration, + * if the Part does not have an expected duration. + */ + partExpectedDurations?: Record + /** Remaining time on current part */ + remainingTimeOnCurrentPart?: number | undefined + /** Current part will autoNext */ + currentPartWillAutoNext?: boolean + /** Current time of this calculation */ + currentTime?: number + /** Was this time context calculated during a high-resolution tick */ + isLowResolution: boolean + } + + /** + * This are the properties that will be injected by the withTiming HOC. + * @export + * @interface InjectedROTimingProps + */ + export interface InjectedROTimingProps { + timingDurations: RundownTimingContext + } +} + +/** + * Computes the actual (as-played fallbacking to expected) duration of a segment, consisting of given parts + * @export + * @param {RundownTiming.RundownTimingContext} timingDurations The timing durations calculated for the Rundown + * @param {Array} partIds The IDs of parts that are members of the segment + * @return number + */ +export function computeSegmentDuration( + timingDurations: RundownTiming.RundownTimingContext, + partIds: PartId[], + display?: boolean +): number { + let partDurations = timingDurations.partDurations + + if (partDurations === undefined) return 0 + + return partIds.reduce((memo, partId) => { + const pId = unprotectString(partId) + const partDuration = + (partDurations ? (partDurations[pId] !== undefined ? partDurations[pId] : 0) : 0) || + (display ? Settings.defaultDisplayDuration : 0) + return memo + partDuration + }, 0) +} From eedf5211fec8d47c70e4627ee2a2cce1e4a9be9b Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Mon, 14 Jun 2021 10:53:00 +0100 Subject: [PATCH 005/112] feat: UI components for timing to next break --- meteor/client/styles/rundownView.scss | 3 +- .../client/ui/ClockView/PresenterScreen.tsx | 6 +- meteor/client/ui/Prompter/OverUnderTimer.tsx | 9 +- meteor/client/ui/RundownView.tsx | 233 ++++++++++++------ .../ui/RundownView/RundownDividerHeader.tsx | 34 ++- .../client/ui/RundownView/RundownOverview.tsx | 2 +- .../RundownTiming/RundownTiming.ts | 52 +--- .../RundownTiming/RundownTimingProvider.tsx | 6 +- .../RundownView/RundownTiming/withTiming.tsx | 6 +- .../ui/SegmentTimeline/SegmentTimeline.tsx | 5 +- meteor/lib/collections/Rundowns.ts | 3 + .../rundown/__tests__/rundownTiming.test.ts | 54 ++-- meteor/lib/rundown/rundownTiming.ts | 148 ++++++----- .../blueprints-integration/src/rundown.ts | 4 + 14 files changed, 319 insertions(+), 246 deletions(-) diff --git a/meteor/client/styles/rundownView.scss b/meteor/client/styles/rundownView.scss index 7d9c3c5963..76b8feffce 100644 --- a/meteor/client/styles/rundownView.scss +++ b/meteor/client/styles/rundownView.scss @@ -2495,7 +2495,8 @@ svg.icon { } > .rundown-divider-timeline__expected-start, - > .rundown-divider-timeline__expected-duration { + > .rundown-divider-timeline__expected-duration, + > .rundown-divider-timeline__expected-end { vertical-align: bottom; margin: 1em 0.5em 0.5em; diff --git a/meteor/client/ui/ClockView/PresenterScreen.tsx b/meteor/client/ui/ClockView/PresenterScreen.tsx index cea32acc2f..2a70fcc35d 100644 --- a/meteor/client/ui/ClockView/PresenterScreen.tsx +++ b/meteor/client/ui/ClockView/PresenterScreen.tsx @@ -318,9 +318,9 @@ export class PresenterScreenBase extends MeteorReactComponent< const nextSegment = this.props.nextSegment const overUnderClock = playlist.expectedDuration - ? (this.props.timingDurations.asPlayedRundownDuration || 0) - playlist.expectedDuration - : (this.props.timingDurations.asPlayedRundownDuration || 0) - - (this.props.timingDurations.totalRundownDuration || 0) + ? (this.props.timingDurations.asDisplayedPlaylistDuration || 0) - playlist.expectedDuration + : (this.props.timingDurations.asDisplayedPlaylistDuration || 0) - + (this.props.timingDurations.totalPlaylistDuration || 0) return (
diff --git a/meteor/client/ui/Prompter/OverUnderTimer.tsx b/meteor/client/ui/Prompter/OverUnderTimer.tsx index a3bef12b3e..e38fbe67d1 100644 --- a/meteor/client/ui/Prompter/OverUnderTimer.tsx +++ b/meteor/client/ui/Prompter/OverUnderTimer.tsx @@ -15,17 +15,18 @@ interface IProps { export const OverUnderTimer = withTiming()( class OverUnderTimer extends React.Component> { render() { - const target = this.props.rundownPlaylist.expectedDuration || this.props.timingDurations.totalRundownDuration || 0 + const target = + this.props.rundownPlaylist.expectedDuration || this.props.timingDurations.totalPlaylistDuration || 0 return target ? ( target, + heavy: (this.props.timingDurations.totalPlaylistDuration || 0) <= target, + light: (this.props.timingDurations.totalPlaylistDuration || 0) > target, })} > {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedRundownDuration || 0) - target, + (this.props.timingDurations.totalPlaylistDuration || 0) - target, true, false, true, diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 897b17abad..df878cf476 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -205,6 +205,7 @@ interface ITimingDisplayProps { rundownPlaylist: RundownPlaylist currentRundown: Rundown | undefined rundownCount: number + isLastRundownInPlaylist: boolean } export enum RundownViewKbdShortcuts { @@ -270,9 +271,8 @@ const TimingDisplay = withTranslation()( ) } - render() { - const { t, rundownPlaylist } = this.props + const { t, rundownPlaylist, currentRundown } = this.props if (!rundownPlaylist) return null @@ -288,6 +288,15 @@ const TimingDisplay = withTranslation()( {t('Planned Start')} + ) : rundownPlaylist.expectedEnd && rundownPlaylist.expectedDuration ? ( + + {t('Expected Start')} + + ) : null} {rundownPlaylist.startedPlayback && rundownPlaylist.activationId && !rundownPlaylist.rehearsal ? ( rundownPlaylist.expectedStart ? ( @@ -341,68 +350,22 @@ const TimingDisplay = withTranslation()( ) : null} )} - {rundownPlaylist.expectedEnd || rundownPlaylist.expectedDuration ? ( + {rundownPlaylist.expectedDuration ? ( - {!rundownPlaylist.loop && rundownPlaylist.expectedEnd ? ( - - {t('Planned End')} - - - ) : !rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( - - {t('Planned End')} - - + {!rundownPlaylist.startedPlayback || + this.props.isLastRundownInPlaylist || + !currentRundown?.endIsBreak ? ( // TODO: Setting + ) : null} - {!rundownPlaylist.loop && - (rundownPlaylist.expectedEnd ? ( - - {RundownUtils.formatDiffToTimecode( - getCurrentTime() - - rundownPlaylist.expectedEnd + - (this.props.timingDurations.asPlayedRundownDuration ?? 0), - true, - true, - true - )} - - ) : rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( - - {RundownUtils.formatDiffToTimecode( - getCurrentTime() - (rundownPlaylist.expectedStart + rundownPlaylist.expectedDuration), - true, - true, - true - )} - - ) : null)} - {rundownPlaylist.expectedDuration ? ( - - (rundownPlaylist.expectedDuration || 0), - })} - > - {t('Diff')} - {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedRundownDuration || 0) - rundownPlaylist.expectedDuration, - true, - false, - true, - true, - true, - undefined, - true - )} - + {rundownPlaylist.startedPlayback && + currentRundown?.endIsBreak && + !this.props.isLastRundownInPlaylist ? ( // TODO: Setting // TODO: Find next break in higher-order component, so next breaks reflects next rundown in playlist marked as break. + ) : null} ) : ( @@ -413,31 +376,25 @@ const TimingDisplay = withTranslation()( ) : null} - {!rundownPlaylist.loop && this.props.rundownPlaylist.expectedEnd ? ( - - {t('Planned End')} - - - ) : null} {this.props.timingDurations && this.props.rundownCount < 2 ? ( // TEMPORARY: disable the diff counter for playlists longer than one rundown -- Jan Starzak, 2021-05-06 - (this.props.timingDurations.totalRundownDuration || 0), + (this.props.timingDurations.asPlayedPlaylistDuration || 0) > + (this.props.timingDurations.totalPlaylistDuration || 0), })} > {t('Diff')} {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedRundownDuration || 0) - - (this.props.timingDurations.totalRundownDuration || 0), + (this.props.timingDurations.asPlayedPlaylistDuration || 0) - + (this.props.timingDurations.totalPlaylistDuration || 0), true, false, true, @@ -457,6 +414,128 @@ const TimingDisplay = withTranslation()( ) ) +interface IEndTimingProps { + loop?: boolean + expectedStart?: number + expectedDuration: number + expectedEnd?: number +} + +const PlaylistEndTiming = withTranslation()( + withTiming()( + class PlaylistEndTiming extends React.Component>> { + render() { + let { t } = this.props + + return ( + + {!this.props.loop && this.props.expectedStart ? ( + + {t('Planned End')} + + + ) : !this.props.loop && this.props.expectedEnd ? ( + + {t('Planned End')} + + + ) : null} + {!this.props.loop && this.props.expectedStart && this.props.expectedDuration ? ( + + {RundownUtils.formatDiffToTimecode( + getCurrentTime() - (this.props.expectedStart + this.props.expectedDuration), + true, + true, + true + )} + + ) : !this.props.loop && this.props.expectedEnd ? ( + + {RundownUtils.formatDiffToTimecode(getCurrentTime() - this.props.expectedEnd, true, true, true)} + + ) : null} + {this.props.expectedDuration ? ( + (this.props.expectedDuration || 0), + })} + > + {t('Diff')} + {RundownUtils.formatDiffToTimecode( + (this.props.timingDurations.asPlayedPlaylistDuration || 0) - this.props.expectedDuration, + true, + false, + true, + true, + true, + undefined, + true + )} + + ) : null} + + ) + } + } + ) +) + +interface INextBreakTimingProps { + loop?: boolean + breakRundown: Rundown +} + +const NextBreakTiming = withTranslation()( + withTiming()( + class PlaylistEndTiming extends React.Component>> { + render() { + let { t, breakRundown } = this.props + + const rundownAsPlayedDuration = this.props.timingDurations.rundownAsPlayedDurations + ? this.props.timingDurations.rundownAsPlayedDurations[unprotectString(breakRundown._id)] + : undefined + + return ( + + + {t('Next Break')} + + + {!this.props.loop && breakRundown.expectedEnd ? ( + + {RundownUtils.formatDiffToTimecode(getCurrentTime() - breakRundown.expectedEnd, true, true, true)} + + ) : null} + {breakRundown.expectedDuration ? ( + (breakRundown.expectedDuration || 0), + })} + > + {t('Diff')} + {RundownUtils.formatDiffToTimecode( + (rundownAsPlayedDuration || 0) - breakRundown.expectedDuration, + true, + false, + true, + true, + true, + undefined, + true + )} + + ) : null} + + ) + } + } + ) +) + interface HotkeyDefinition { key: string label: string @@ -1360,6 +1439,10 @@ const RundownHeader = withTranslation()( rundownPlaylist={this.props.playlist} currentRundown={this.props.currentRundown} rundownCount={this.props.rundownIds.length} + isLastRundownInPlaylist={ + !!this.props.currentRundown?._id && + this.props.rundownIds.indexOf(this.props.currentRundown._id) === this.props.rundownIds.length - 1 + } /> , {} @@ -30,15 +30,15 @@ const RundownCountdown = withTranslation()( })(function RundownCountdown( props: Translated< WithTiming<{ - expectedStart: number | undefined + expectedStartOrEnd: number | undefined className?: string | undefined }> > ) { const { t } = props - if (props.expectedStart === undefined) return null + if (props.expectedStartOrEnd === undefined) return null - const time = props.expectedStart - (props.timingDurations.currentTime || 0) + const time = props.expectedStartOrEnd - (props.timingDurations.currentTime || 0) if (time < QUATER_DAY) { return ( @@ -70,7 +70,7 @@ export const RundownDividerHeader = withTranslation()(function RundownDividerHea return (

{rundown.name}

-

{playlist.name}

+ {rundown.name !== playlist.name &&

{playlist.name}

} {rundown.expectedStart ? (
{t('Planned Start')}  @@ -85,7 +85,7 @@ export const RundownDividerHeader = withTranslation()(function RundownDividerHea  
) : null} @@ -95,6 +95,24 @@ export const RundownDividerHeader = withTranslation()(function RundownDividerHea {RundownUtils.formatDiffToTimecode(rundown.expectedDuration, false, true, true, false, true)}
) : null} + {rundown.expectedEnd ? ( +
+ {t('Planned End')}  + + {rundown.expectedEnd} + +   + +
+ ) : null}
) }) diff --git a/meteor/client/ui/RundownView/RundownOverview.tsx b/meteor/client/ui/RundownView/RundownOverview.tsx index 3f67fc1d1e..ee560cd3a7 100644 --- a/meteor/client/ui/RundownView/RundownOverview.tsx +++ b/meteor/client/ui/RundownView/RundownOverview.tsx @@ -221,7 +221,7 @@ export const RundownOverview = withTracker - /** The calculated durations of each of the Parts: as-planned/as-run depending on state. */ - partDurations?: Record - /** The offset of each of the Parts from the beginning of the Rundown. */ - partStartsAt?: Record - /** Same as partStartsAt, but will include display duration overrides - * (such as minimal display width for an Part, etc.). - */ - partDisplayStartsAt?: Record - /** Same as partDurations, but will include display duration overrides - * (such as minimal display width for an Part, etc.). - */ - partDisplayDurations?: Record - /** As-played durations of each part. Will be 0, if not yet played. - * Will be counted from start to now if currently playing. - */ - partPlayed?: Record - /** Expected durations of each of the parts or the as-played duration, - * if the Part does not have an expected duration. - */ - partExpectedDurations?: Record - /** Remaining time on current part */ - remainingTimeOnCurrentPart?: number | undefined - /** Current part will autoNext */ - currentPartWillAutoNext?: boolean - /** Current time of this calculation */ - currentTime?: number - /** Was this time context calculated during a high-resolution tick */ - isLowResolution: boolean - } - /** * This are the properties that will be injected by the withTiming HOC. * @export diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index fcfad1a16b..0edb581023 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -9,7 +9,7 @@ import { MeteorReactComponent } from '../../../lib/MeteorReactComponent' import { RundownPlaylist } from '../../../../lib/collections/RundownPlaylists' import { PartInstance } from '../../../../lib/collections/PartInstances' import { RundownTiming, TimeEventArgs } from './RundownTiming' -import { RundownTimingCalculator } from '../../../../lib/rundown/rundownTiming' +import { RundownTimingCalculator, RundownTimingContext } from '../../../../lib/rundown/rundownTiming' const TIMING_DEFAULT_REFRESH_INTERVAL = 1000 / 60 // the interval for high-resolution events (timeupdateHR) const LOW_RESOLUTION_TIMING_DECIMATOR = 15 // the low-resolution events will be called every @@ -31,7 +31,7 @@ interface IRundownTimingProviderProps { defaultDuration?: number } interface IRundownTimingProviderChildContext { - durations: RundownTiming.RundownTimingContext + durations: RundownTimingContext } interface IRundownTimingProviderState {} interface IRundownTimingProviderTrackedProps { @@ -111,7 +111,7 @@ export const RundownTimingProvider = withTracker< durations: PropTypes.object.isRequired, } - durations: RundownTiming.RundownTimingContext = { + durations: RundownTimingContext = { isLowResolution: false, } refreshTimer: number diff --git a/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx b/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx index 9b9934e803..e2e2865bdc 100644 --- a/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx @@ -2,8 +2,10 @@ import * as React from 'react' import * as PropTypes from 'prop-types' import * as _ from 'underscore' import { RundownTiming } from './RundownTiming' +import { JsxEmit } from 'typescript' +import { RundownTimingContext } from '../../../../lib/rundown/rundownTiming' -export type TimingFilterFunction = (durations: RundownTiming.RundownTimingContext) => any +export type TimingFilterFunction = (durations: RundownTimingContext) => any export interface WithTimingOptions { isHighResolution?: boolean @@ -92,7 +94,7 @@ export function withTiming( } render() { - const durations: RundownTiming.RundownTimingContext = this.context.durations + const durations: RundownTimingContext = this.context.durations // If the timing HOC is supposed to be low resolution and we are rendering // during a high resolution tick, the WrappedComponent will render using diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx index 65d9323704..c92283f6fc 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -35,8 +35,7 @@ import { WarningIconSmall, CriticalIconSmall } from '../../lib/ui/icons/notifica import RundownViewEventBus, { RundownViewEvents, HighlightEvent } from '../RundownView/RundownViewEventBus' import { ZoomInIcon, ZoomOutIcon, ZoomShowAll } from '../../lib/segmentZoomIcon' -import { PartInstanceId } from '../../../lib/collections/PartInstances' -import { SegmentTimelineSmallPartFlag } from './SmallParts/SegmentTimelineSmallPartFlag' +import { RundownTimingContext } from '../../../lib/rundown/rundownTiming' interface IProps { id: string @@ -136,7 +135,7 @@ const SegmentTimelineZoom = class SegmentTimelineZoom extends React.Component< calculateSegmentDuration(): number { let total = 0 if (this.context && this.context.durations) { - const durations = this.context.durations as RundownTiming.RundownTimingContext + const durations = this.context.durations as RundownTimingContext this.props.parts.forEach((item) => { // total += durations.partDurations ? durations.partDurations[item._id] : (item.duration || item.renderedDuration || 1) const duration = Math.max( diff --git a/meteor/lib/collections/Rundowns.ts b/meteor/lib/collections/Rundowns.ts index df1335204d..2103d32e43 100644 --- a/meteor/lib/collections/Rundowns.ts +++ b/meteor/lib/collections/Rundowns.ts @@ -67,6 +67,8 @@ export interface DBRundown /** External id of the Rundown Playlist to put this rundown in */ playlistExternalId?: string + /** Whether the end of the rundown marks a commercial break */ + endIsBreak?: boolean /** Name (user-facing) of the external NCS this rundown came from */ externalNRCSName: string /** The id of the Rundown Playlist this rundown is in */ @@ -105,6 +107,7 @@ export class Rundown implements DBRundown { public notifiedCurrentPlayingPartExternalId?: string public notes?: Array public playlistExternalId?: string + public endIsBreak?: boolean public externalNRCSName: string public playlistId: RundownPlaylistId public playlistIdIsSetInSofie?: boolean diff --git a/meteor/lib/rundown/__tests__/rundownTiming.test.ts b/meteor/lib/rundown/__tests__/rundownTiming.test.ts index 0869c4efbb..2e923b6c0a 100644 --- a/meteor/lib/rundown/__tests__/rundownTiming.test.ts +++ b/meteor/lib/rundown/__tests__/rundownTiming.test.ts @@ -53,10 +53,11 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 0, - asPlayedRundownDuration: 0, + asDisplayedPlaylistDuration: 0, + asPlayedPlaylistDuration: 0, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: {}, partCountdown: {}, partDisplayDurations: {}, partDisplayStartsAt: {}, @@ -64,8 +65,8 @@ describe('rundown Timing Calculator', () => { partExpectedDurations: {}, partPlayed: {}, partStartsAt: {}, - remainingRundownDuration: 0, - totalRundownDuration: 0, + remainingPlaylistDuration: 0, + totalPlaylistDuration: 0, }) ) }) @@ -88,10 +89,13 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 4000, - asPlayedRundownDuration: 4000, + asDisplayedPlaylistDuration: 4000, + asPlayedPlaylistDuration: 4000, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: { + [rundownId]: 4000, + }, partCountdown: { part1: 0, part2: 1000, @@ -134,8 +138,8 @@ describe('rundown Timing Calculator', () => { part3: 2000, part4: 3000, }, - remainingRundownDuration: 4000, - totalRundownDuration: 4000, + remainingPlaylistDuration: 4000, + totalPlaylistDuration: 4000, }) ) }) @@ -158,10 +162,13 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 4000, - asPlayedRundownDuration: 4000, + asDisplayedPlaylistDuration: 4000, + asPlayedPlaylistDuration: 4000, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: { + [rundownId]: 4000, + }, partCountdown: { part1: 0, part2: 1000, @@ -204,8 +211,8 @@ describe('rundown Timing Calculator', () => { part3: 2000, part4: 3000, }, - remainingRundownDuration: 4000, - totalRundownDuration: 4000, + remainingPlaylistDuration: 4000, + totalPlaylistDuration: 4000, }) ) }) @@ -229,10 +236,14 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 4000, - asPlayedRundownDuration: 4000, + asDisplayedPlaylistDuration: 4000, + asPlayedPlaylistDuration: 4000, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: { + [rundownId1]: 2000, + [rundownId2]: 2000, + }, partCountdown: { part1: 0, part2: 1000, @@ -275,8 +286,8 @@ describe('rundown Timing Calculator', () => { part3: 2000, part4: 3000, }, - remainingRundownDuration: 4000, - totalRundownDuration: 4000, + remainingPlaylistDuration: 4000, + totalPlaylistDuration: 4000, }) ) }) @@ -323,10 +334,13 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 4000, - asPlayedRundownDuration: 4000, + asDisplayedPlaylistDuration: 4000, + asPlayedPlaylistDuration: 4000, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: { + [rundownId1]: 4000, + }, partCountdown: { part1: 0, part2: 2000, @@ -369,8 +383,8 @@ describe('rundown Timing Calculator', () => { part3: 2000, part4: 3000, }, - remainingRundownDuration: 4000, - totalRundownDuration: 4000, + remainingPlaylistDuration: 4000, + totalPlaylistDuration: 4000, }) ) }) diff --git a/meteor/lib/rundown/rundownTiming.ts b/meteor/lib/rundown/rundownTiming.ts index 1bec59d69e..d60bdcf2de 100644 --- a/meteor/lib/rundown/rundownTiming.ts +++ b/meteor/lib/rundown/rundownTiming.ts @@ -44,6 +44,9 @@ export class RundownTimingCalculator { let startsAtAccumulator = 0 let displayStartsAtAccumulator = 0 + let rundownExpectedDurations: Record = {} + let rundownAsPlayedDurations: Record = {} + Object.keys(this.displayDurationGroups).forEach((key) => delete this.displayDurationGroups[key]) this.linearParts.length = 0 @@ -176,12 +179,25 @@ export class RundownTimingCalculator { // Parts that are Untimed are ignored always. // Parts that don't count are ignored, unless they are being played or have been played. if (!partIsUntimed) { + let valToAddToAsPlayedDuration = 0 + if (lastStartedPlayback && !partInstance.timings?.duration) { - asPlayedRundownDuration += Math.max(partExpectedDuration, now - lastStartedPlayback) + valToAddToAsPlayedDuration = Math.max(partExpectedDuration, now - lastStartedPlayback) } else if (partInstance.timings?.duration) { - asPlayedRundownDuration += partInstance.timings.duration + valToAddToAsPlayedDuration = partInstance.timings.duration } else if (partCounts) { - asPlayedRundownDuration += partInstance.part.expectedDuration || 0 + valToAddToAsPlayedDuration = partInstance.part.expectedDuration || 0 + } + + asPlayedRundownDuration += valToAddToAsPlayedDuration + if (!rundownAsPlayedDurations[unprotectString(partInstance.part.rundownId)]) { + rundownAsPlayedDurations[ + unprotectString(partInstance.part.rundownId) + ] = valToAddToAsPlayedDuration + } else { + rundownAsPlayedDurations[ + unprotectString(partInstance.part.rundownId) + ] += valToAddToAsPlayedDuration } } @@ -256,6 +272,12 @@ export class RundownTimingCalculator { ) { remainingRundownDuration += partExpectedDuration - (now - lastStartedPlayback) } + + if (!rundownExpectedDurations[unprotectString(partInstance.part.rundownId)]) { + rundownExpectedDurations[unprotectString(partInstance.part.rundownId)] = partExpectedDuration + } else { + rundownExpectedDurations[unprotectString(partInstance.part.rundownId)] += partExpectedDuration + } }) // This is where the waitAccumulator-generated data in the linearSegLines is used to calculate the countdowns. @@ -320,11 +342,13 @@ export class RundownTimingCalculator { ) } - return literal({ - totalRundownDuration, - remainingRundownDuration, - asDisplayedRundownDuration, - asPlayedRundownDuration, + return literal({ + totalPlaylistDuration: totalRundownDuration, + remainingPlaylistDuration: remainingRundownDuration, + asDisplayedPlaylistDuration: asDisplayedRundownDuration, + asPlayedPlaylistDuration: asPlayedRundownDuration, + rundownExpectedDurations, + rundownAsPlayedDurations, partCountdown: _.object(this.linearParts), partDurations: this.partDurations, partPlayed: this.partPlayed, @@ -361,75 +385,49 @@ export class RundownTimingCalculator { } } -export namespace RundownTiming { - /** - * Events used by the RundownTimingProvider - * @export - * @enum {number} +export interface RundownTimingContext { + /** This is the total duration of the palylist as planned (using expectedDurations). */ + totalPlaylistDuration?: number + /** This is the content remaining to be played in the playlist (based on the expectedDurations). */ + remainingPlaylistDuration?: number + /** This is the total duration of the playlist: as planned for the unplayed (skipped & future) content, and as-run for the played-out. */ + asDisplayedPlaylistDuration?: number + /** This is the complete duration of the playlist: as planned for the unplayed content, and as-run for the played-out, but ignoring unplayed/unplayable parts in order */ + asPlayedPlaylistDuration?: number + /** Expected duration of each rundown in playlist (based on part expected durations) */ + rundownExpectedDurations?: Record + /** This is the complete duration of each rundown: as planned for the unplayed content, and as-run for the played-out, but ignoring unplayed/unplayable parts in order */ + rundownAsPlayedDurations?: Record + /** this is the countdown to each of the parts relative to the current on air part. */ + partCountdown?: Record + /** The calculated durations of each of the Parts: as-planned/as-run depending on state. */ + partDurations?: Record + /** The offset of each of the Parts from the beginning of the Playlist. */ + partStartsAt?: Record + /** Same as partStartsAt, but will include display duration overrides + * (such as minimal display width for an Part, etc.). */ - export enum Events { - /** Event is emitted every now-and-then, generally to be used for simple displays */ - 'timeupdate' = 'sofie:rundownTimeUpdate', - /** event is emitted with a very high frequency (60 Hz), to be used sparingly as - * hooking up Components to it will cause a lot of renders - */ - 'timeupdateHR' = 'sofie:rundownTimeUpdateHR', - } - - /** - * Context object that will be passed to listening components. The dictionaries use the Part ID as a key. - * @export - * @interface RundownTimingContext + partDisplayStartsAt?: Record + /** Same as partDurations, but will include display duration overrides + * (such as minimal display width for an Part, etc.). */ - export interface RundownTimingContext { - /** This is the total duration of the rundown as planned (using expectedDurations). */ - totalRundownDuration?: number - /** This is the content remaining to be played in the rundown (based on the expectedDurations). */ - remainingRundownDuration?: number - /** This is the total duration of the rundown: as planned for the unplayed (skipped & future) content, and as-run for the played-out. */ - asDisplayedRundownDuration?: number - /** This is the complete duration of the rundown: as planned for the unplayed content, and as-run for the played-out, but ignoring unplayed/unplayable parts in order */ - asPlayedRundownDuration?: number - /** this is the countdown to each of the parts relative to the current on air part. */ - partCountdown?: Record - /** The calculated durations of each of the Parts: as-planned/as-run depending on state. */ - partDurations?: Record - /** The offset of each of the Parts from the beginning of the Rundown. */ - partStartsAt?: Record - /** Same as partStartsAt, but will include display duration overrides - * (such as minimal display width for an Part, etc.). - */ - partDisplayStartsAt?: Record - /** Same as partDurations, but will include display duration overrides - * (such as minimal display width for an Part, etc.). - */ - partDisplayDurations?: Record - /** As-played durations of each part. Will be 0, if not yet played. - * Will be counted from start to now if currently playing. - */ - partPlayed?: Record - /** Expected durations of each of the parts or the as-played duration, - * if the Part does not have an expected duration. - */ - partExpectedDurations?: Record - /** Remaining time on current part */ - remainingTimeOnCurrentPart?: number | undefined - /** Current part will autoNext */ - currentPartWillAutoNext?: boolean - /** Current time of this calculation */ - currentTime?: number - /** Was this time context calculated during a high-resolution tick */ - isLowResolution: boolean - } - - /** - * This are the properties that will be injected by the withTiming HOC. - * @export - * @interface InjectedROTimingProps + partDisplayDurations?: Record + /** As-played durations of each part. Will be 0, if not yet played. + * Will be counted from start to now if currently playing. */ - export interface InjectedROTimingProps { - timingDurations: RundownTimingContext - } + partPlayed?: Record + /** Expected durations of each of the parts or the as-played duration, + * if the Part does not have an expected duration. + */ + partExpectedDurations?: Record + /** Remaining time on current part */ + remainingTimeOnCurrentPart?: number | undefined + /** Current part will autoNext */ + currentPartWillAutoNext?: boolean + /** Current time of this calculation */ + currentTime?: number + /** Was this time context calculated during a high-resolution tick */ + isLowResolution: boolean } /** @@ -440,7 +438,7 @@ export namespace RundownTiming { * @return number */ export function computeSegmentDuration( - timingDurations: RundownTiming.RundownTimingContext, + timingDurations: RundownTimingContext, partIds: PartId[], display?: boolean ): number { diff --git a/packages/blueprints-integration/src/rundown.ts b/packages/blueprints-integration/src/rundown.ts index 845e6ea758..9136908005 100644 --- a/packages/blueprints-integration/src/rundown.ts +++ b/packages/blueprints-integration/src/rundown.ts @@ -42,7 +42,11 @@ export interface IBlueprintRundown { /** A hint to the Core that the Rundown should be a part of a playlist */ playlistExternalId?: string + + /** Whether the end of the rundown marks a commercial break */ + endIsBreak?: boolean } + /** The Rundown sent from Core */ export interface IBlueprintRundownDB extends IBlueprintRundown, From d6262b103d17f9f7dc6ddd6874d5140ed25b1987 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Tue, 15 Jun 2021 11:20:19 +0100 Subject: [PATCH 006/112] fix: Rebase fixes --- .../ui/RundownView/RundownTiming/RundownTiming.ts | 9 +++++++++ .../ui/RundownView/RundownTiming/withTiming.tsx | 1 - .../client/ui/SegmentTimeline/SegmentTimeline.tsx | 2 ++ .../ui/SegmentTimeline/SegmentTimelineContainer.tsx | 13 ++++++++----- .../ui/SegmentTimeline/SegmentTimelinePart.tsx | 5 +++-- meteor/lib/rundown/__tests__/rundownTiming.test.ts | 12 ++++++------ meteor/lib/rundown/rundownTiming.ts | 12 +++++------- 7 files changed, 33 insertions(+), 21 deletions(-) diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts b/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts index 04482ae51b..94f03d0b57 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts @@ -1,4 +1,6 @@ import { RundownTimingContext } from '../../../../lib/rundown/rundownTiming' +import { PartUi } from '../../SegmentTimeline/SegmentTimelineContainer' +import { SegmentTimelinePartClass } from '../../SegmentTimeline/SegmentTimelinePart' export interface TimeEventArgs { currentTime: number @@ -37,3 +39,10 @@ export namespace RundownTiming { timingDurations: RundownTimingContext } } + +export function computeSegmentDisplayDuration(timingDurations: RundownTimingContext, parts: PartUi[]): number { + return parts.reduce( + (memo, part) => memo + SegmentTimelinePartClass.getPartDisplayDuration(part, timingDurations), + 0 + ) +} diff --git a/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx b/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx index e2e2865bdc..3d4aee7eff 100644 --- a/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import * as PropTypes from 'prop-types' import * as _ from 'underscore' import { RundownTiming } from './RundownTiming' -import { JsxEmit } from 'typescript' import { RundownTimingContext } from '../../../../lib/rundown/rundownTiming' export type TimingFilterFunction = (durations: RundownTimingContext) => any diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx index c92283f6fc..574b35923c 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -36,6 +36,8 @@ import RundownViewEventBus, { RundownViewEvents, HighlightEvent } from '../Rundo import { ZoomInIcon, ZoomOutIcon, ZoomShowAll } from '../../lib/segmentZoomIcon' import { RundownTimingContext } from '../../../lib/rundown/rundownTiming' +import { PartInstanceId } from '../../../lib/collections/PartInstances' +import { SegmentTimelineSmallPartFlag } from './SmallParts/SegmentTimelineSmallPartFlag' interface IProps { id: string diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index de4ace347a..8f2b6ff9f6 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -7,7 +7,7 @@ import { Translated, translateWithTracker } from '../../lib/ReactMeteorData/reac import { Segments, SegmentId } from '../../../lib/collections/Segments' import { Studio } from '../../../lib/collections/Studios' import { SegmentTimeline, SegmentTimelineClass } from './SegmentTimeline' -import { RundownTiming, TimingEvent } from '../RundownView/RundownTiming/RundownTiming' +import { computeSegmentDisplayDuration, RundownTiming, TimingEvent } from '../RundownView/RundownTiming/RundownTiming' import { UIStateStorage } from '../../lib/UIStateStorage' import { MeteorReactComponent } from '../../lib/MeteorReactComponent' import { @@ -43,7 +43,10 @@ import RundownViewEventBus, { import { memoizedIsolatedAutorun, slowDownReactivity } from '../../lib/reactiveData/reactiveDataHelper' import { checkPieceContentStatus, getNoteTypeForPieceStatus, ScanInfoForPackages } from '../../../lib/mediaObjects' import { getBasicNotesForSegment } from '../../../lib/rundownNotifications' -import { computeSegmentDuration } from '../../../lib/rundown/rundownTiming' +import { computeSegmentDuration, RundownTimingContext } from '../../../lib/rundown/rundownTiming' +import { SegmentTimelinePartClass } from './SegmentTimelinePart' +import { Piece, Pieces } from '../../../lib/collections/Pieces' +import { RundownAPI } from '../../../lib/api/rundown' export const SIMULATED_PLAYBACK_SOFT_MARGIN = 0 export const SIMULATED_PLAYBACK_HARD_MARGIN = 2500 @@ -723,7 +726,7 @@ export const SegmentTimelineContainer = translateWithTracker { + onGoToPartInner = (part: PartUi, timingDurations: RundownTimingContext, zoomInToFit?: boolean) => { let newScale: number | undefined let scrollLeft = @@ -751,7 +754,7 @@ export const SegmentTimelineContainer = translateWithTracker { if (this.props.segmentId === e.segmentId) { - const timingDurations = this.context?.durations as RundownTiming.RundownTimingContext + const timingDurations = this.context?.durations as RundownTimingContext const part = this.props.parts.find((part) => part.partId === e.partId) if (part) { @@ -762,7 +765,7 @@ export const SegmentTimelineContainer = translateWithTracker { if (this.props.segmentId === e.segmentId) { - const timingDurations = this.context?.durations as RundownTiming.RundownTimingContext + const timingDurations = this.context?.durations as RundownTimingContext const part = this.props.parts.find((part) => part.instance._id === e.partInstanceId) diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelinePart.tsx index 6da865e9b9..a0f02e850c 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelinePart.tsx @@ -36,6 +36,7 @@ import { SegmentEnd } from '../../lib/ui/icons/segment' import { getShowHiddenSourceLayers } from '../../lib/localStorage' import { Part } from '../../../lib/collections/Parts' import { TFunction } from 'i18next' +import { RundownTimingContext } from '../../../lib/rundown/rundownTiming' export const SegmentTimelineLineElementId = 'rundown__segment__line__' export const SegmentTimelinePartElementId = 'rundown__segment__part__' @@ -688,7 +689,7 @@ export class SegmentTimelinePartClass extends React.Component((props: IProps) => { return { isHighResolution: false, - filter: (durations: RundownTiming.RundownTimingContext) => { + filter: (durations: RundownTimingContext) => { durations = durations || {} const partId = unprotectString(props.part.instance.part._id) diff --git a/meteor/lib/rundown/__tests__/rundownTiming.test.ts b/meteor/lib/rundown/__tests__/rundownTiming.test.ts index 2e923b6c0a..434b529382 100644 --- a/meteor/lib/rundown/__tests__/rundownTiming.test.ts +++ b/meteor/lib/rundown/__tests__/rundownTiming.test.ts @@ -2,7 +2,7 @@ import { PartInstance } from '../../collections/PartInstances' import { DBPart, Part, PartId } from '../../collections/Parts' import { DBRundownPlaylist, RundownPlaylist } from '../../collections/RundownPlaylists' import { literal, protectString } from '../../lib' -import { RundownTiming, RundownTimingCalculator } from '../rundownTiming' +import { RundownTimingCalculator, RundownTimingContext } from '../rundownTiming' const DEFAULT_DURATION = 4000 @@ -51,7 +51,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 0, asPlayedPlaylistDuration: 0, @@ -87,7 +87,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 4000, asPlayedPlaylistDuration: 4000, @@ -160,7 +160,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 4000, asPlayedPlaylistDuration: 4000, @@ -234,7 +234,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 4000, asPlayedPlaylistDuration: 4000, @@ -332,7 +332,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 4000, asPlayedPlaylistDuration: 4000, diff --git a/meteor/lib/rundown/rundownTiming.ts b/meteor/lib/rundown/rundownTiming.ts index d60bdcf2de..ec563e5070 100644 --- a/meteor/lib/rundown/rundownTiming.ts +++ b/meteor/lib/rundown/rundownTiming.ts @@ -191,13 +191,11 @@ export class RundownTimingCalculator { asPlayedRundownDuration += valToAddToAsPlayedDuration if (!rundownAsPlayedDurations[unprotectString(partInstance.part.rundownId)]) { - rundownAsPlayedDurations[ - unprotectString(partInstance.part.rundownId) - ] = valToAddToAsPlayedDuration + rundownAsPlayedDurations[unprotectString(partInstance.part.rundownId)] = + valToAddToAsPlayedDuration } else { - rundownAsPlayedDurations[ - unprotectString(partInstance.part.rundownId) - ] += valToAddToAsPlayedDuration + rundownAsPlayedDurations[unprotectString(partInstance.part.rundownId)] += + valToAddToAsPlayedDuration } } @@ -433,7 +431,7 @@ export interface RundownTimingContext { /** * Computes the actual (as-played fallbacking to expected) duration of a segment, consisting of given parts * @export - * @param {RundownTiming.RundownTimingContext} timingDurations The timing durations calculated for the Rundown + * @param {RundownTimingContext} timingDurations The timing durations calculated for the Rundown * @param {Array} partIds The IDs of parts that are members of the segment * @return number */ From 79bd1895b3dfa56c55ace3e124915ad3b2d5bee2 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Tue, 15 Jun 2021 14:37:21 +0100 Subject: [PATCH 007/112] feat: Settings for header customisations --- meteor/client/ui/RundownView.tsx | 16 +++-- .../RundownHeaderLayoutSettings.tsx | 65 ++++++++++++++++++- meteor/lib/collections/RundownLayouts.ts | 6 ++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 4484796032..6baad08b8d 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -360,18 +360,20 @@ const TimingDisplay = withTranslation()( {!rundownPlaylist.startedPlayback || this.props.isLastRundownInPlaylist || - !currentRundown?.endIsBreak ? ( // TODO: Setting + !(currentRundown?.endIsBreak && this.props.layout?.hideExpectedEndBeforeBreak) ? ( ) : null} {rundownPlaylist.startedPlayback && currentRundown?.endIsBreak && - !this.props.isLastRundownInPlaylist ? ( // TODO: Setting // TODO: Find next break in higher-order component, so next breaks reflects next rundown in playlist marked as break. - + this.props.layout?.showNextBreakTiming && + !(this.props.isLastRundownInPlaylist && this.props.layout.lastRundownIsNotBreak) ? ( // TODO: Find next break in higher-order component, so next breaks reflects next rundown in playlist marked as break. + ) : null} ) : ( @@ -427,6 +429,7 @@ interface IEndTimingProps { expectedStart?: number expectedDuration: number expectedEnd?: number + endLabel?: string } const PlaylistEndTiming = withTranslation()( @@ -439,12 +442,12 @@ const PlaylistEndTiming = withTranslation()( {!this.props.loop && this.props.expectedStart ? ( - {t('Planned End')} + {t(this.props.endLabel ?? 'Planned End')} ) : !this.props.loop && this.props.expectedEnd ? ( - {t('Planned End')} + {t(this.props.endLabel ?? 'Planned End')} ) : null} @@ -494,6 +497,7 @@ const PlaylistEndTiming = withTranslation()( interface INextBreakTimingProps { loop?: boolean breakRundown: Rundown + breakText?: string } const NextBreakTiming = withTranslation()( @@ -509,7 +513,7 @@ const NextBreakTiming = withTranslation()( return ( - {t('Next Break')} + {t(this.props.breakText ?? 'Next Break')} {!this.props.loop && breakRundown.expectedEnd ? ( diff --git a/meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx b/meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx index 5fe708b4f6..c0fffd8cee 100644 --- a/meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx +++ b/meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx @@ -12,7 +12,7 @@ interface IProps { interface IState {} export default withTranslation()( - class ShelfLayoutSettings extends MeteorReactComponent, IState> { + class RundownHeaderLayoutSettings extends MeteorReactComponent, IState> { render() { const { t } = this.props @@ -20,7 +20,7 @@ export default withTranslation()(
+
+
+ +
+
+ +
+
+ +
+
+
diff --git a/meteor/lib/collections/RundownLayouts.ts b/meteor/lib/collections/RundownLayouts.ts index 6fb3ac4095..6f415b7cc3 100644 --- a/meteor/lib/collections/RundownLayouts.ts +++ b/meteor/lib/collections/RundownLayouts.ts @@ -193,6 +193,12 @@ export interface RundownLayoutRundownHeader extends RundownLayoutBase { type: RundownLayoutType.RUNDOWN_HEADER_LAYOUT expectedEndText: string nextBreakText: string + /** When true, hide the Planned End timer when there is a rundown marked as a break in the future */ + hideExpectedEndBeforeBreak: boolean + /** When a rundown is marked as a break, show the Next Break timing */ + showNextBreakTiming: boolean + /** If true, don't treat the last rundown as a break even if it's marked as one */ + lastRundownIsNotBreak: boolean } export enum ActionButtonType { From 44328e4685b06159ea808f7be4d6e2b9f665e0c6 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Tue, 15 Jun 2021 16:15:29 +0100 Subject: [PATCH 008/112] feat: Look ahead to find rundown marked as next break --- meteor/client/ui/RundownView.tsx | 74 +++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 6baad08b8d..8a582f6b09 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -209,6 +209,8 @@ const WarningDisplay = withTranslation()( interface ITimingDisplayProps { rundownPlaylist: RundownPlaylist currentRundown: Rundown | undefined + /** Rundowns between current rundown and rundown with next break (inclusive of both). Undefined if there's no break in the future. */ + rundownsBeforeNextBreak: Rundown[] | undefined rundownCount: number isLastRundownInPlaylist: boolean layout: RundownLayoutRundownHeader | undefined @@ -360,7 +362,7 @@ const TimingDisplay = withTranslation()( {!rundownPlaylist.startedPlayback || this.props.isLastRundownInPlaylist || - !(currentRundown?.endIsBreak && this.props.layout?.hideExpectedEndBeforeBreak) ? ( + !(this.props.rundownsBeforeNextBreak && this.props.layout?.hideExpectedEndBeforeBreak) ? ( ) : null} {rundownPlaylist.startedPlayback && - currentRundown?.endIsBreak && + this.props.rundownsBeforeNextBreak && this.props.layout?.showNextBreakTiming && - !(this.props.isLastRundownInPlaylist && this.props.layout.lastRundownIsNotBreak) ? ( // TODO: Find next break in higher-order component, so next breaks reflects next rundown in playlist marked as break. - + !(this.props.isLastRundownInPlaylist && this.props.layout.lastRundownIsNotBreak) ? ( + ) : null} ) : ( @@ -496,7 +501,7 @@ const PlaylistEndTiming = withTranslation()( interface INextBreakTimingProps { loop?: boolean - breakRundown: Rundown + rundownsBeforeBreak: Rundown[] breakText?: string } @@ -504,12 +509,27 @@ const NextBreakTiming = withTranslation()( withTiming()( class PlaylistEndTiming extends React.Component>> { render() { - let { t, breakRundown } = this.props + let { t, rundownsBeforeBreak } = this.props + let breakRundown = rundownsBeforeBreak.length ? rundownsBeforeBreak[rundownsBeforeBreak.length - 1] : undefined const rundownAsPlayedDuration = this.props.timingDurations.rundownAsPlayedDurations - ? this.props.timingDurations.rundownAsPlayedDurations[unprotectString(breakRundown._id)] + ? rundownsBeforeBreak.reduce( + (prev, curr) => (prev += this.props.timingDurations.rundownAsPlayedDurations![unprotectString(curr._id)]), + 0 + ) + : undefined + + const accumulatedExpectedDurations = this.props.timingDurations.rundownExpectedDurations + ? rundownsBeforeBreak.reduce( + (prev, curr) => (prev += this.props.timingDurations.rundownExpectedDurations![unprotectString(curr._id)]), + 0 + ) : undefined + if (!breakRundown) { + return null + } + return ( @@ -521,16 +541,16 @@ const NextBreakTiming = withTranslation()( {RundownUtils.formatDiffToTimecode(getCurrentTime() - breakRundown.expectedEnd, true, true, true)} ) : null} - {breakRundown.expectedDuration ? ( + {accumulatedExpectedDurations ? ( (breakRundown.expectedDuration || 0), + heavy: (rundownAsPlayedDuration || 0) < (accumulatedExpectedDurations || 0), + light: (rundownAsPlayedDuration || 0) > (accumulatedExpectedDurations || 0), })} > {t('Diff')} {RundownUtils.formatDiffToTimecode( - (rundownAsPlayedDuration || 0) - breakRundown.expectedDuration, + (rundownAsPlayedDuration || 0) - accumulatedExpectedDurations, true, false, true, @@ -558,6 +578,8 @@ interface IRundownHeaderProps { currentRundown: Rundown | undefined studio: Studio rundownIds: RundownId[] + /** Rundowns between current rundown and rundown with next break (inclusive of both). Undefined if there's no break in the future. */ + rundownsBeforeNextBreak: Rundown[] | undefined firstRundown: Rundown | undefined onActivate?: (isRehearsal: boolean) => void onRegisterHotkeys?: (hotkeys: Array) => void @@ -1451,6 +1473,7 @@ const RundownHeader = withTranslation()( (( } } + private getRundownsBeforeNextBreak( + currentRundown: Rundown | undefined, + breakRundowns: Rundown[] + ): Rundown[] | undefined { + if (!currentRundown) { + return undefined + } + + let currentRundownIndex = this.props.rundowns.findIndex((r) => r._id === currentRundown._id) + + if (currentRundownIndex === -1) { + return undefined + } + + let nextBreakIndex = this.props.rundowns.findIndex((rundown, index) => { + if (index < currentRundownIndex) { + return false + } + + return breakRundowns.some((r) => r._id == rundown._id) + }) + + return this.props.rundowns.slice(currentRundownIndex, nextBreakIndex + 1) + } + render() { const { t } = this.props @@ -2868,6 +2916,10 @@ export const RundownView = translateWithTracker(( playlist={this.props.playlist} studio={this.props.studio} rundownIds={this.props.rundowns.map((r) => r._id)} + rundownsBeforeNextBreak={this.getRundownsBeforeNextBreak( + this.state.currentRundown || this.props.rundowns[0], + this.props.rundowns.filter((r) => r.endIsBreak) + )} firstRundown={this.props.rundowns[0]} onActivate={this.onActivate} studioMode={this.state.studioMode} From 8b537b38c6a2eb69cef91fe776f6fe8906657d04 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Wed, 16 Jun 2021 10:22:04 +0100 Subject: [PATCH 009/112] fix: Blank timer labels --- meteor/client/ui/RundownView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 8a582f6b09..1543e1d365 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -447,12 +447,12 @@ const PlaylistEndTiming = withTranslation()( {!this.props.loop && this.props.expectedStart ? ( - {t(this.props.endLabel ?? 'Planned End')} + {t(this.props.endLabel || 'Planned End')} ) : !this.props.loop && this.props.expectedEnd ? ( - {t(this.props.endLabel ?? 'Planned End')} + {t(this.props.endLabel || 'Planned End')} ) : null} @@ -533,7 +533,7 @@ const NextBreakTiming = withTranslation()( return ( - {t(this.props.breakText ?? 'Next Break')} + {t(this.props.breakText || 'Next Break')} {!this.props.loop && breakRundown.expectedEnd ? ( From ffd1257346b3d783c58944895e2c4c8bbf0db51d Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Wed, 16 Jun 2021 10:32:51 +0100 Subject: [PATCH 010/112] fix: Check for last break --- meteor/client/ui/RundownView.tsx | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 1543e1d365..5dc195c1af 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -212,7 +212,8 @@ interface ITimingDisplayProps { /** Rundowns between current rundown and rundown with next break (inclusive of both). Undefined if there's no break in the future. */ rundownsBeforeNextBreak: Rundown[] | undefined rundownCount: number - isLastRundownInPlaylist: boolean + /** Whether the next break is also the last. */ + lastBreak: boolean layout: RundownLayoutRundownHeader | undefined } @@ -361,7 +362,7 @@ const TimingDisplay = withTranslation()( {rundownPlaylist.expectedDuration ? ( {!rundownPlaylist.startedPlayback || - this.props.isLastRundownInPlaylist || + this.props.lastBreak || !(this.props.rundownsBeforeNextBreak && this.props.layout?.hideExpectedEndBeforeBreak) ? ( void onRegisterHotkeys?: (hotkeys: Array) => void @@ -1475,7 +1478,7 @@ const RundownHeader = withTranslation()( currentRundown={this.props.currentRundown} rundownsBeforeNextBreak={this.props.rundownsBeforeNextBreak} rundownCount={this.props.rundownIds.length} - isLastRundownInPlaylist={ + lastBreak={ !!this.props.currentRundown?._id && this.props.rundownIds.indexOf(this.props.currentRundown._id) === this.props.rundownIds.length - 1 } @@ -2764,7 +2767,7 @@ export const RundownView = translateWithTracker(( private getRundownsBeforeNextBreak( currentRundown: Rundown | undefined, breakRundowns: Rundown[] - ): Rundown[] | undefined { + ): { rundownsBeforeNextBreak: Rundown[]; breakIsLastRundown } | undefined { if (!currentRundown) { return undefined } @@ -2783,7 +2786,10 @@ export const RundownView = translateWithTracker(( return breakRundowns.some((r) => r._id == rundown._id) }) - return this.props.rundowns.slice(currentRundownIndex, nextBreakIndex + 1) + return { + rundownsBeforeNextBreak: this.props.rundowns.slice(currentRundownIndex, nextBreakIndex + 1), + breakIsLastRundown: nextBreakIndex === this.props.rundowns.length, + } } render() { @@ -2798,6 +2804,11 @@ export const RundownView = translateWithTracker(( this.props.rundowns.find((r) => r._id === selectedPiece?.instance.rundownId)) || undefined + let breakProps = this.getRundownsBeforeNextBreak( + this.state.currentRundown || this.props.rundowns[0], + this.props.rundowns.filter((r) => r.endIsBreak) + ) + return ( @@ -2916,10 +2927,8 @@ export const RundownView = translateWithTracker(( playlist={this.props.playlist} studio={this.props.studio} rundownIds={this.props.rundowns.map((r) => r._id)} - rundownsBeforeNextBreak={this.getRundownsBeforeNextBreak( - this.state.currentRundown || this.props.rundowns[0], - this.props.rundowns.filter((r) => r.endIsBreak) - )} + rundownsBeforeNextBreak={breakProps?.rundownsBeforeNextBreak} + lastBreak={breakProps?.breakIsLastRundown} firstRundown={this.props.rundowns[0]} onActivate={this.onActivate} studioMode={this.state.studioMode} From 1e93b78cb2a8a15f82f9f285ffef92f9e21ac587 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Mon, 21 Jun 2021 15:31:13 +0100 Subject: [PATCH 011/112] wip: Top bar dashboard --- meteor/client/ui/RundownView.tsx | 71 ++++++++++++++----- .../ui/Settings/RundownLayoutEditor.tsx | 1 + .../ui/Settings/components/FilterEditor.tsx | 5 +- .../RundownHeaderLayoutSettings.tsx | 6 +- meteor/lib/api/rundownLayouts.ts | 17 ++--- 5 files changed, 70 insertions(+), 30 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 7879e5b87f..926d2601b8 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -102,6 +102,9 @@ import RundownViewEventBus, { RundownViewEvents } from './RundownView/RundownVie import { LoopingIcon } from '../lib/ui/icons/looping' import StudioPackageContainersContext from './RundownView/StudioPackageContainersContext' import { RundownLayoutsAPI } from '../../lib/api/rundownLayouts' +import { ShelfDashboardLayout } from './Shelf/ShelfDashboardLayout' +import { IAdLibListItem } from './Shelf/AdLibListItem' +import { BucketAdLibItem } from './Shelf/RundownViewBuckets' export const MAGIC_TIME_SCALE_FACTOR = 0.03 @@ -574,6 +577,7 @@ interface HotkeyDefinition { interface IRundownHeaderProps { playlist: RundownPlaylist + showStyleBase: ShowStyleBase currentRundown: Rundown | undefined studio: Studio rundownIds: RundownId[] @@ -592,6 +596,8 @@ interface IRundownHeaderProps { interface IRundownHeaderState { isError: boolean errorMessage?: string + shouldQueue: boolean + selectedPiece: BucketAdLibItem | IAdLibListItem | PieceUi | undefined } const RundownHeader = withTranslation()( @@ -720,6 +726,8 @@ const RundownHeader = withTranslation()( } this.state = { isError: false, + shouldQueue: false, + selectedPiece: undefined, } } componentDidMount() { @@ -1376,6 +1384,18 @@ const RundownHeader = withTranslation()( }) } + changeQueueAdLib = (shouldQueue: boolean) => { + this.setState({ + shouldQueue, + }) + } + + selectPiece = (piece: BucketAdLibItem | IAdLibListItem | PieceUi | undefined) => { + this.setState({ + selectedPiece: piece, + }) + } + render() { const { t } = this.props return ( @@ -1471,23 +1491,40 @@ const RundownHeader = withTranslation()( - - + {this.props.layout && RundownLayoutsAPI.isDashboardLayout(this.props.layout) ? ( + + ) : ( + + + + + )} diff --git a/meteor/client/ui/Settings/RundownLayoutEditor.tsx b/meteor/client/ui/Settings/RundownLayoutEditor.tsx index b6f503ee66..e7af9a3025 100644 --- a/meteor/client/ui/Settings/RundownLayoutEditor.tsx +++ b/meteor/client/ui/Settings/RundownLayoutEditor.tsx @@ -352,6 +352,7 @@ export default translateWithTracker((props: IProp filter={tab} index={index} showStyleBase={this.props.showStyleBase} + supportedElements={layout?.supportedElements ?? []} /> ))} diff --git a/meteor/client/ui/Settings/components/FilterEditor.tsx b/meteor/client/ui/Settings/components/FilterEditor.tsx index 3810825680..ca07ffa4ee 100644 --- a/meteor/client/ui/Settings/components/FilterEditor.tsx +++ b/meteor/client/ui/Settings/components/FilterEditor.tsx @@ -28,6 +28,7 @@ interface IProps { filter: RundownLayoutElementBase index: number showStyleBase: ShowStyleBase + supportedElements: RundownLayoutElementType[] } interface ITrackedProps {} @@ -1029,9 +1030,9 @@ export default translateWithTracker((props: IProp modifiedClassName="bghl" attribute={`filters.${this.props.index}.type`} obj={this.props.item} - options={RundownLayoutElementType} + options={this.props.supportedElements} type="dropdown" - mutateDisplayValue={(v) => (v === undefined ? RundownLayoutElementType.FILTER : v)} + mutateDisplayValue={(v) => (v === undefined ? this.props.supportedElements[0] : v)} collection={RundownLayouts} className="input text-input input-l" > diff --git a/meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx b/meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx index c0fffd8cee..5b9688b9fa 100644 --- a/meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx +++ b/meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx @@ -1,6 +1,6 @@ import React from 'react' import { withTranslation } from 'react-i18next' -import { RundownLayoutBase, RundownLayouts } from '../../../../../lib/collections/RundownLayouts' +import { RundownLayoutBase, RundownLayouts, RundownLayoutType } from '../../../../../lib/collections/RundownLayouts' import { EditAttribute } from '../../../../lib/EditAttribute' import { MeteorReactComponent } from '../../../../lib/MeteorReactComponent' import { Translated } from '../../../../lib/ReactMeteorData/ReactMeteorData' @@ -16,7 +16,7 @@ export default withTranslation()( render() { const { t } = this.props - return ( + return this.props.item.type === RundownLayoutType.RUNDOWN_HEADER_LAYOUT ? (
- ) + ) : null } } ) diff --git a/meteor/lib/api/rundownLayouts.ts b/meteor/lib/api/rundownLayouts.ts index 587b2e742f..efe7e55799 100644 --- a/meteor/lib/api/rundownLayouts.ts +++ b/meteor/lib/api/rundownLayouts.ts @@ -102,8 +102,7 @@ class RundownLayoutsRegistry { return literal({ _id: layoutType, type: layoutType, - filtersTitle: descriptor.filtersTitle, - supportedElements: descriptor.supportedElements, + ...descriptor, }) }), }, @@ -114,8 +113,7 @@ class RundownLayoutsRegistry { return literal({ _id: layoutType, type: layoutType, - filtersTitle: descriptor.filtersTitle, - supportedElements: descriptor.supportedElements, + ...descriptor, }) }), }, @@ -126,8 +124,7 @@ class RundownLayoutsRegistry { return literal({ _id: layoutType, type: layoutType, - filtersTitle: descriptor.filtersTitle, - supportedElements: descriptor.supportedElements, + ...descriptor, }) }), }, @@ -138,8 +135,7 @@ class RundownLayoutsRegistry { return literal({ _id: layoutType, type: layoutType, - filtersTitle: descriptor.filtersTitle, - supportedElements: descriptor.supportedElements, + ...descriptor, }) }), }, @@ -181,6 +177,11 @@ export namespace RundownLayoutsAPI { registry.RegisterRundownHeaderLayouts(RundownLayoutType.RUNDOWN_HEADER_LAYOUT, { supportedElements: [], }) + registry.RegisterRundownHeaderLayouts(RundownLayoutType.DASHBOARD_LAYOUT, { + filtersTitle: 'Layout Elements', + supportsFilters: true, + supportedElements: [RundownLayoutElementType.PIECE_COUNTDOWN], + }) export function GetSettingsManifest(): CustomizableRegionSettingsManifest[] { return registry.GetSettingsManifest() From cecaead45babe0d9900918c8a4040fa6551834f7 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Mon, 21 Jun 2021 16:26:59 +0100 Subject: [PATCH 012/112] chore: Produce next break info inside rundownTimingProvider --- meteor/client/ui/RundownView.tsx | 60 +++---------------- .../RundownTiming/RundownTimingProvider.tsx | 15 ++++- meteor/lib/rundown/rundownTiming.ts | 56 ++++++++++++++++- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 5dc195c1af..1ac518d9be 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -209,11 +209,7 @@ const WarningDisplay = withTranslation()( interface ITimingDisplayProps { rundownPlaylist: RundownPlaylist currentRundown: Rundown | undefined - /** Rundowns between current rundown and rundown with next break (inclusive of both). Undefined if there's no break in the future. */ - rundownsBeforeNextBreak: Rundown[] | undefined rundownCount: number - /** Whether the next break is also the last. */ - lastBreak: boolean layout: RundownLayoutRundownHeader | undefined } @@ -362,8 +358,10 @@ const TimingDisplay = withTranslation()( {rundownPlaylist.expectedDuration ? ( {!rundownPlaylist.startedPlayback || - this.props.lastBreak || - !(this.props.rundownsBeforeNextBreak && this.props.layout?.hideExpectedEndBeforeBreak) ? ( + this.props.timingDurations.breakIsLastRundown || + !( + this.props.timingDurations.rundownsBeforeNextBreak && this.props.layout?.hideExpectedEndBeforeBreak + ) ? ( ) : null} {rundownPlaylist.startedPlayback && - this.props.rundownsBeforeNextBreak && + this.props.timingDurations.rundownsBeforeNextBreak && this.props.layout?.showNextBreakTiming && - !(this.props.lastBreak && this.props.layout.lastRundownIsNotBreak) ? ( + !(this.props.timingDurations.breakIsLastRundown && this.props.layout.lastRundownIsNotBreak) ? ( ) : null} @@ -579,10 +577,6 @@ interface IRundownHeaderProps { currentRundown: Rundown | undefined studio: Studio rundownIds: RundownId[] - /** Rundowns between current rundown and rundown with next break (inclusive of both). Undefined if there's no break in the future. */ - rundownsBeforeNextBreak: Rundown[] | undefined - /** Whether the next break is also the last */ - lastBreak: boolean firstRundown: Rundown | undefined onActivate?: (isRehearsal: boolean) => void onRegisterHotkeys?: (hotkeys: Array) => void @@ -1476,12 +1470,7 @@ const RundownHeader = withTranslation()( (( } } - private getRundownsBeforeNextBreak( - currentRundown: Rundown | undefined, - breakRundowns: Rundown[] - ): { rundownsBeforeNextBreak: Rundown[]; breakIsLastRundown } | undefined { - if (!currentRundown) { - return undefined - } - - let currentRundownIndex = this.props.rundowns.findIndex((r) => r._id === currentRundown._id) - - if (currentRundownIndex === -1) { - return undefined - } - - let nextBreakIndex = this.props.rundowns.findIndex((rundown, index) => { - if (index < currentRundownIndex) { - return false - } - - return breakRundowns.some((r) => r._id == rundown._id) - }) - - return { - rundownsBeforeNextBreak: this.props.rundowns.slice(currentRundownIndex, nextBreakIndex + 1), - breakIsLastRundown: nextBreakIndex === this.props.rundowns.length, - } - } - render() { const { t } = this.props @@ -2804,11 +2765,6 @@ export const RundownView = translateWithTracker(( this.props.rundowns.find((r) => r._id === selectedPiece?.instance.rundownId)) || undefined - let breakProps = this.getRundownsBeforeNextBreak( - this.state.currentRundown || this.props.rundowns[0], - this.props.rundowns.filter((r) => r.endIsBreak) - ) - return ( @@ -2927,8 +2883,6 @@ export const RundownView = translateWithTracker(( playlist={this.props.playlist} studio={this.props.studio} rundownIds={this.props.rundowns.map((r) => r._id)} - rundownsBeforeNextBreak={breakProps?.rundownsBeforeNextBreak} - lastBreak={breakProps?.breakIsLastRundown} firstRundown={this.props.rundowns[0]} onActivate={this.onActivate} studioMode={this.state.studioMode} diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index 0edb581023..c3cd181778 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -10,6 +10,7 @@ import { RundownPlaylist } from '../../../../lib/collections/RundownPlaylists' import { PartInstance } from '../../../../lib/collections/PartInstances' import { RundownTiming, TimeEventArgs } from './RundownTiming' import { RundownTimingCalculator, RundownTimingContext } from '../../../../lib/rundown/rundownTiming' +import { Rundown } from '../../../../lib/collections/Rundowns' const TIMING_DEFAULT_REFRESH_INTERVAL = 1000 / 60 // the interval for high-resolution events (timeupdateHR) const LOW_RESOLUTION_TIMING_DECIMATOR = 15 // the low-resolution events will be called every @@ -35,6 +36,8 @@ interface IRundownTimingProviderChildContext { } interface IRundownTimingProviderState {} interface IRundownTimingProviderTrackedProps { + rundowns: Array + currentRundown: Rundown | undefined parts: Array partInstancesMap: Map } @@ -50,13 +53,19 @@ export const RundownTimingProvider = withTracker< IRundownTimingProviderState, IRundownTimingProviderTrackedProps >((props) => { + let rundowns: Array = [] let parts: Array = [] const partInstancesMap = new Map() + let currentRundown: Rundown | undefined if (props.playlist) { + rundowns = props.playlist.getRundowns() const { parts: incomingParts } = props.playlist.getSegmentsAndPartsSync() parts = incomingParts const partInstances = props.playlist.getActivePartInstances() + const currentPartInstance = partInstances.find((p) => p._id === props.playlist!.currentPartInstanceId) + currentRundown = currentPartInstance ? rundowns.find((r) => r._id === currentPartInstance.rundownId) : rundowns[0] + partInstances.forEach((partInstance) => { partInstancesMap.set(partInstance.part._id, partInstance) @@ -96,6 +105,8 @@ export const RundownTimingProvider = withTracker< }) } return { + rundowns, + currentRundown, parts, partInstancesMap, } @@ -194,13 +205,15 @@ export const RundownTimingProvider = withTracker< } updateDurations(now: number, isLowResolution: boolean) { - const { playlist, parts, partInstancesMap } = this.props + const { playlist, rundowns, currentRundown, parts, partInstancesMap } = this.props this.durations = Object.assign( this.durations, this.timingCalculator.updateDurations( now, isLowResolution, playlist, + rundowns, + currentRundown, parts, partInstancesMap, this.props.defaultDuration diff --git a/meteor/lib/rundown/rundownTiming.ts b/meteor/lib/rundown/rundownTiming.ts index ec563e5070..cfdf09b7f8 100644 --- a/meteor/lib/rundown/rundownTiming.ts +++ b/meteor/lib/rundown/rundownTiming.ts @@ -6,6 +6,7 @@ import { } from '../collections/PartInstances' import { Part, PartId } from '../collections/Parts' import { RundownPlaylist } from '../collections/RundownPlaylists' +import { Rundown } from '../collections/Rundowns' import { unprotectString, literal, protectString } from '../lib' import { Settings } from '../Settings' @@ -30,6 +31,8 @@ export class RundownTimingCalculator { now: number, isLowResolution: boolean, playlist: RundownPlaylist | undefined, + rundowns: Rundown[], + currentRundown: Rundown | undefined, parts: Part[], partInstancesMap: Map, /** Fallback duration for Parts that have no as-played duration of their own. */ @@ -47,13 +50,29 @@ export class RundownTimingCalculator { let rundownExpectedDurations: Record = {} let rundownAsPlayedDurations: Record = {} + let rundownsBeforeNextBreak: Rundown[] | undefined + let breakIsLastRundown: boolean | undefined + Object.keys(this.displayDurationGroups).forEach((key) => delete this.displayDurationGroups[key]) this.linearParts.length = 0 let nextAIndex = -1 let currentAIndex = -1 - if (playlist && parts) { + if (playlist) { + const breakProps = currentRundown + ? this.getRundownsBeforeNextBreak( + rundowns, + currentRundown, + rundowns.filter((r) => r.endIsBreak) + ) + : undefined + + if (breakProps) { + rundownsBeforeNextBreak = breakProps.rundownsBeforeNextBreak + breakIsLastRundown = breakProps.breakIsLastRundown + } + parts.forEach((origPart, itIndex) => { const partInstance = this.getPartInstanceOrGetCachedTemp(partInstancesMap, origPart) @@ -357,6 +376,8 @@ export class RundownTimingCalculator { currentTime: now, remainingTimeOnCurrentPart, currentPartWillAutoNext, + rundownsBeforeNextBreak, + breakIsLastRundown, isLowResolution, }) } @@ -381,6 +402,35 @@ export class RundownTimingCalculator { } } } + + private getRundownsBeforeNextBreak( + orderedRundowns: Rundown[], + currentRundown: Rundown | undefined, + breakRundowns: Rundown[] + ): { rundownsBeforeNextBreak: Rundown[]; breakIsLastRundown } | undefined { + if (!currentRundown) { + return undefined + } + + let currentRundownIndex = orderedRundowns.findIndex((r) => r._id === currentRundown._id) + + if (currentRundownIndex === -1) { + return undefined + } + + let nextBreakIndex = orderedRundowns.findIndex((rundown, index) => { + if (index < currentRundownIndex) { + return false + } + + return breakRundowns.some((r) => r._id == rundown._id) + }) + + return { + rundownsBeforeNextBreak: orderedRundowns.slice(currentRundownIndex, nextBreakIndex + 1), + breakIsLastRundown: nextBreakIndex === orderedRundowns.length, + } + } } export interface RundownTimingContext { @@ -424,6 +474,10 @@ export interface RundownTimingContext { currentPartWillAutoNext?: boolean /** Current time of this calculation */ currentTime?: number + /** Rundowns between current rundown and rundown with next break (inclusive of both). Undefined if there's no break in the future. */ + rundownsBeforeNextBreak?: Rundown[] + /** Whether the next break is also the last */ + breakIsLastRundown?: boolean /** Was this time context calculated during a high-resolution tick */ isLowResolution: boolean } From f4fd866524d65c822b2fc0518346873100a7e6e1 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Fri, 25 Jun 2021 11:54:11 +0100 Subject: [PATCH 013/112] feat: Endwords, Playlist start / end timer panels --- meteor/client/styles/_header.scss | 5 + meteor/client/styles/shelf/endTimerPanel.scss | 7 + meteor/client/styles/shelf/endWordsPanel.scss | 22 + .../client/styles/shelf/startTimerPanel.scss | 7 + .../MicFloatingInspector.tsx | 24 +- meteor/client/ui/RundownView.tsx | 347 ++--------- .../RundownTiming/NextBreakTiming.tsx | 78 +++ .../RundownTiming/PlaylistEndTiming.tsx | 132 +++++ .../RundownTiming/PlaylistStartTiming.tsx | 74 +++ .../RundownView/RundownTiming/RundownName.tsx | 98 ++++ .../RundownView/RundownTiming/TimeOfDay.tsx | 22 + .../ui/Settings/components/FilterEditor.tsx | 545 ++++++++++++------ meteor/client/ui/Shelf/DashboardPanel.tsx | 35 +- meteor/client/ui/Shelf/EndWordsPanel.tsx | 98 ++++ .../client/ui/Shelf/PlaylistEndTimerPanel.tsx | 72 +++ .../ui/Shelf/PlaylistStartTimerPanel.tsx | 60 ++ .../client/ui/Shelf/ShelfDashboardLayout.tsx | 9 + meteor/client/ui/scriptPreview.ts | 32 + meteor/lib/api/rundownLayouts.ts | 24 +- meteor/lib/collections/RundownLayouts.ts | 48 ++ 20 files changed, 1213 insertions(+), 526 deletions(-) create mode 100644 meteor/client/styles/shelf/endTimerPanel.scss create mode 100644 meteor/client/styles/shelf/endWordsPanel.scss create mode 100644 meteor/client/styles/shelf/startTimerPanel.scss create mode 100644 meteor/client/ui/RundownView/RundownTiming/NextBreakTiming.tsx create mode 100644 meteor/client/ui/RundownView/RundownTiming/PlaylistEndTiming.tsx create mode 100644 meteor/client/ui/RundownView/RundownTiming/PlaylistStartTiming.tsx create mode 100644 meteor/client/ui/RundownView/RundownTiming/RundownName.tsx create mode 100644 meteor/client/ui/RundownView/RundownTiming/TimeOfDay.tsx create mode 100644 meteor/client/ui/Shelf/EndWordsPanel.tsx create mode 100644 meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx create mode 100644 meteor/client/ui/Shelf/PlaylistStartTimerPanel.tsx create mode 100644 meteor/client/ui/scriptPreview.ts diff --git a/meteor/client/styles/_header.scss b/meteor/client/styles/_header.scss index 1e015f0243..38f060715f 100644 --- a/meteor/client/styles/_header.scss +++ b/meteor/client/styles/_header.scss @@ -25,6 +25,11 @@ margin-top: -0.5em; } } + + .dashboard { + flex: 1 1; + position: unset; + } } .super-dark { diff --git a/meteor/client/styles/shelf/endTimerPanel.scss b/meteor/client/styles/shelf/endTimerPanel.scss new file mode 100644 index 0000000000..7ceda059b8 --- /dev/null +++ b/meteor/client/styles/shelf/endTimerPanel.scss @@ -0,0 +1,7 @@ +.playlist-end-time-panel { + position: absolute; + + &.timing { + width: unset !important; + } +} diff --git a/meteor/client/styles/shelf/endWordsPanel.scss b/meteor/client/styles/shelf/endWordsPanel.scss new file mode 100644 index 0000000000..a1c49da902 --- /dev/null +++ b/meteor/client/styles/shelf/endWordsPanel.scss @@ -0,0 +1,22 @@ +.end-words-panel { + padding: 1vw 1vh; + overflow: hidden; + position: absolute; + + .row { + display: inline-block; + width: 100%; + overflow: hidden; + white-space: nowrap; + } + + .title { + font-weight: bold; + } + + .text { + text-overflow: ellipsis; + text-align: left; + direction: rtl; + } +} diff --git a/meteor/client/styles/shelf/startTimerPanel.scss b/meteor/client/styles/shelf/startTimerPanel.scss new file mode 100644 index 0000000000..c10006a5dc --- /dev/null +++ b/meteor/client/styles/shelf/startTimerPanel.scss @@ -0,0 +1,7 @@ +.playlist-start-time-panel { + position: absolute; + + &.timing { + width: unset !important; + } +} diff --git a/meteor/client/ui/FloatingInspectors/MicFloatingInspector.tsx b/meteor/client/ui/FloatingInspectors/MicFloatingInspector.tsx index 94e7e210aa..f96bac0764 100644 --- a/meteor/client/ui/FloatingInspectors/MicFloatingInspector.tsx +++ b/meteor/client/ui/FloatingInspectors/MicFloatingInspector.tsx @@ -4,6 +4,7 @@ import Moment from 'react-moment' import { FloatingInspector } from '../FloatingInspector' import { ScriptContent } from '@sofie-automation/blueprints-integration' +import { GetScriptPreview } from '../scriptPreview' interface IProps { typeClass?: string @@ -14,31 +15,10 @@ interface IProps { displayOn?: 'document' | 'viewport' } -const BREAK_SCRIPT_BREAKPOINT = 620 -const SCRIPT_PART_LENGTH = 250 - export function MicFloatingInspector(props: IProps) { const { t } = useTranslation() - let startOfScript = (props.content && props.content.fullScript) || '' - let cutLength = startOfScript.length - if (startOfScript.length > SCRIPT_PART_LENGTH) { - startOfScript = startOfScript.substring(0, startOfScript.substr(0, SCRIPT_PART_LENGTH).lastIndexOf(' ')) - cutLength = startOfScript.length - } - let endOfScript = (props.content && props.content.fullScript) || '' - if (endOfScript.length > SCRIPT_PART_LENGTH) { - endOfScript = endOfScript.substring( - endOfScript.indexOf(' ', Math.max(cutLength, endOfScript.length - SCRIPT_PART_LENGTH)), - endOfScript.length - ) - } - - const breakScript = !!( - props.content && - props.content.fullScript && - props.content.fullScript.length > BREAK_SCRIPT_BREAKPOINT - ) + let { startOfScript, endOfScript, breakScript } = GetScriptPreview(props.content.fullScript || '') return ( diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 4df8f335a3..9296b1ae6b 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -105,6 +105,11 @@ import { RundownLayoutsAPI } from '../../lib/api/rundownLayouts' import { ShelfDashboardLayout } from './Shelf/ShelfDashboardLayout' import { IAdLibListItem } from './Shelf/AdLibListItem' import { BucketAdLibItem } from './Shelf/RundownViewBuckets' +import { PlaylistEndTiming } from './RundownView/RundownTiming/PlaylistEndTiming' +import { NextBreakTiming } from './RundownView/RundownTiming/NextBreakTiming' +import { RundownName } from './RundownView/RundownTiming/RundownName' +import { TimeOfDay } from './RundownView/RundownTiming/TimeOfDay' +import { PlaylistStartTiming } from './RundownView/RundownTiming/PlaylistStartTiming' export const MAGIC_TIME_SCALE_FACTOR = 0.03 @@ -243,40 +248,6 @@ export enum RundownViewKbdShortcuts { const TimingDisplay = withTranslation()( withTiming()( class TimingDisplay extends React.Component>> { - private renderRundownName() { - const { rundownPlaylist, currentRundown, rundownCount, t } = this.props - return currentRundown && (rundownPlaylist.name !== currentRundown.name || rundownCount > 1) ? ( - - {rundownPlaylist.loop && } {currentRundown.name} {rundownPlaylist.name} - - ) : ( - - {rundownPlaylist.loop && } {rundownPlaylist.name} - - ) - } render() { const { t, rundownPlaylist, currentRundown } = this.props @@ -284,66 +255,13 @@ const TimingDisplay = withTranslation()( return (
- {rundownPlaylist.startedPlayback && rundownPlaylist.activationId && !rundownPlaylist.rehearsal ? ( - - {t('Started')} - - - ) : rundownPlaylist.expectedStart ? ( - - {t('Planned Start')} - - - ) : rundownPlaylist.expectedEnd && rundownPlaylist.expectedDuration ? ( - - {t('Expected Start')} - - - ) : null} - {rundownPlaylist.startedPlayback && rundownPlaylist.activationId && !rundownPlaylist.rehearsal ? ( - rundownPlaylist.expectedStart ? ( - - {this.renderRundownName()} - {RundownUtils.formatDiffToTimecode( - rundownPlaylist.startedPlayback - rundownPlaylist.expectedStart, - true, - false, - true, - true, - true - )} - - ) : ( - {this.renderRundownName()} - ) - ) : ( - (rundownPlaylist.expectedStart ? ( - rundownPlaylist.expectedStart, - })} - > - {this.renderRundownName()} - {RundownUtils.formatDiffToTimecode( - getCurrentTime() - rundownPlaylist.expectedStart, - true, - false, - true, - true, - true - )} - - ) : ( - {this.renderRundownName()} - )) || undefined - )} - - - + + + {rundownPlaylist.currentPartInstanceId && ( )} - {rundownPlaylist.expectedDuration ? ( - - {!rundownPlaylist.startedPlayback || - this.props.timingDurations.breakIsLastRundown || - !( - this.props.timingDurations.rundownsBeforeNextBreak && this.props.layout?.hideExpectedEndBeforeBreak - ) ? ( - - ) : null} - {rundownPlaylist.startedPlayback && - this.props.timingDurations.rundownsBeforeNextBreak && - this.props.layout?.showNextBreakTiming && - !(this.props.timingDurations.breakIsLastRundown && this.props.layout.lastRundownIsNotBreak) ? ( - - ) : null} - - ) : ( - - {!rundownPlaylist.loop && this.props.timingDurations ? ( - - - {this.props.layout?.expectedEndText ? t(this.props.layout.expectedEndText) : t('Expected End')} - - - - ) : null} - {this.props.timingDurations && this.props.rundownCount < 2 ? ( // TEMPORARY: disable the diff counter for playlists longer than one rundown -- Jan Starzak, 2021-05-06 - - (this.props.timingDurations.totalPlaylistDuration || 0), - })} - > - {t('Diff')} - {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedPlaylistDuration || 0) - - (this.props.timingDurations.totalPlaylistDuration || 0), - true, - false, - true, - true, - true, - undefined, - true - )} - - ) : null} - - )} -
- ) - } - } - ) -) - -interface IEndTimingProps { - loop?: boolean - expectedStart?: number - expectedDuration: number - expectedEnd?: number - endLabel?: string -} - -const PlaylistEndTiming = withTranslation()( - withTiming()( - class PlaylistEndTiming extends React.Component>> { - render() { - let { t } = this.props - - return ( - - {!this.props.loop && this.props.expectedStart ? ( - - {t(this.props.endLabel || 'Planned End')} - - - ) : !this.props.loop && this.props.expectedEnd ? ( - - {t(this.props.endLabel || 'Planned End')} - - - ) : null} - {!this.props.loop && this.props.expectedStart && this.props.expectedDuration ? ( - - {RundownUtils.formatDiffToTimecode( - getCurrentTime() - (this.props.expectedStart + this.props.expectedDuration), - true, - true, - true - )} - - ) : !this.props.loop && this.props.expectedEnd ? ( - - {RundownUtils.formatDiffToTimecode(getCurrentTime() - this.props.expectedEnd, true, true, true)} - - ) : null} - {this.props.expectedDuration ? ( - (this.props.expectedDuration || 0), - })} - > - {t('Diff')} - {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedPlaylistDuration || 0) - this.props.expectedDuration, - true, - false, - true, - true, - true, - undefined, - true - )} - - ) : null} - - ) - } - } - ) -) - -interface INextBreakTimingProps { - loop?: boolean - rundownsBeforeBreak: Rundown[] - breakText?: string -} - -const NextBreakTiming = withTranslation()( - withTiming()( - class PlaylistEndTiming extends React.Component>> { - render() { - let { t, rundownsBeforeBreak } = this.props - let breakRundown = rundownsBeforeBreak.length ? rundownsBeforeBreak[rundownsBeforeBreak.length - 1] : undefined - - const rundownAsPlayedDuration = this.props.timingDurations.rundownAsPlayedDurations - ? rundownsBeforeBreak.reduce( - (prev, curr) => (prev += this.props.timingDurations.rundownAsPlayedDurations![unprotectString(curr._id)]), - 0 - ) - : undefined - - const accumulatedExpectedDurations = this.props.timingDurations.rundownExpectedDurations - ? rundownsBeforeBreak.reduce( - (prev, curr) => (prev += this.props.timingDurations.rundownExpectedDurations![unprotectString(curr._id)]), - 0 - ) - : undefined - - if (!breakRundown) { - return null - } - - return ( - - - {t(this.props.breakText || 'Next Break')} - - - {!this.props.loop && breakRundown.expectedEnd ? ( - - {RundownUtils.formatDiffToTimecode(getCurrentTime() - breakRundown.expectedEnd, true, true, true)} - + {!rundownPlaylist.startedPlayback || + this.props.timingDurations.breakIsLastRundown || + !(this.props.timingDurations.rundownsBeforeNextBreak && this.props.layout?.hideExpectedEndBeforeBreak) ? ( + ) : null} - {accumulatedExpectedDurations ? ( - (accumulatedExpectedDurations || 0), - })} - > - {t('Diff')} - {RundownUtils.formatDiffToTimecode( - (rundownAsPlayedDuration || 0) - accumulatedExpectedDurations, - true, - false, - true, - true, - true, - undefined, - true - )} - + {rundownPlaylist.startedPlayback && + this.props.timingDurations.rundownsBeforeNextBreak && + this.props.layout?.showNextBreakTiming && + !(this.props.timingDurations.breakIsLastRundown && this.props.layout.lastRundownIsNotBreak) ? ( + ) : null} - + ) } } @@ -1463,7 +1197,7 @@ const RundownHeader = withTranslation()( playlist={this.props.playlist} oneMinuteBeforeAction={this.resetAndActivateRundown} /> -
+
-
-
- - - -
-
{this.props.layout && RundownLayoutsAPI.isDashboardLayout(this.props.layout) ? ( )} +
+
+ + + +
+
@@ -2924,6 +2658,7 @@ export const RundownView = translateWithTracker(( inActiveRundownView={this.props.inActiveRundownView} currentRundown={this.state.currentRundown || this.props.rundowns[0]} layout={this.state.rundownHeaderLayout} + showStyleBase={this.props.showStyleBase} /> diff --git a/meteor/client/ui/RundownView/RundownTiming/NextBreakTiming.tsx b/meteor/client/ui/RundownView/RundownTiming/NextBreakTiming.tsx new file mode 100644 index 0000000000..298198d324 --- /dev/null +++ b/meteor/client/ui/RundownView/RundownTiming/NextBreakTiming.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { WithTranslation, withTranslation } from 'react-i18next' +import Moment from 'react-moment' +import { Rundown } from '../../../../lib/collections/Rundowns' +import { getCurrentTime, unprotectString } from '../../../../lib/lib' +import { Translated } from '../../../lib/ReactMeteorData/ReactMeteorData' +import { RundownUtils } from '../../../lib/rundown' +import { WithTiming, withTiming } from './withTiming' +import ClassNames from 'classnames' + +interface INextBreakTimingProps { + loop?: boolean + rundownsBeforeBreak: Rundown[] + breakText?: string +} + +export const NextBreakTiming = withTranslation()( + withTiming()( + class PlaylistEndTiming extends React.Component>> { + render() { + let { t, rundownsBeforeBreak } = this.props + let breakRundown = rundownsBeforeBreak.length ? rundownsBeforeBreak[rundownsBeforeBreak.length - 1] : undefined + + const rundownAsPlayedDuration = this.props.timingDurations.rundownAsPlayedDurations + ? rundownsBeforeBreak.reduce( + (prev, curr) => (prev += this.props.timingDurations.rundownAsPlayedDurations![unprotectString(curr._id)]), + 0 + ) + : undefined + + const accumulatedExpectedDurations = this.props.timingDurations.rundownExpectedDurations + ? rundownsBeforeBreak.reduce( + (prev, curr) => (prev += this.props.timingDurations.rundownExpectedDurations![unprotectString(curr._id)]), + 0 + ) + : undefined + + if (!breakRundown) { + return null + } + + return ( + + + {t(this.props.breakText || 'Next Break')} + + + {!this.props.loop && breakRundown.expectedEnd ? ( + + {RundownUtils.formatDiffToTimecode(getCurrentTime() - breakRundown.expectedEnd, true, true, true)} + + ) : null} + {accumulatedExpectedDurations ? ( + (accumulatedExpectedDurations || 0), + })} + > + {t('Diff')} + {RundownUtils.formatDiffToTimecode( + (rundownAsPlayedDuration || 0) - accumulatedExpectedDurations, + true, + false, + true, + true, + true, + undefined, + true + )} + + ) : null} + + ) + } + } + ) +) diff --git a/meteor/client/ui/RundownView/RundownTiming/PlaylistEndTiming.tsx b/meteor/client/ui/RundownView/RundownTiming/PlaylistEndTiming.tsx new file mode 100644 index 0000000000..ba7b98ba14 --- /dev/null +++ b/meteor/client/ui/RundownView/RundownTiming/PlaylistEndTiming.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { WithTranslation, withTranslation } from 'react-i18next' +import Moment from 'react-moment' +import { getCurrentTime } from '../../../../lib/lib' +import { Translated } from '../../../lib/ReactMeteorData/ReactMeteorData' +import { RundownUtils } from '../../../lib/rundown' +import { withTiming, WithTiming } from './withTiming' +import ClassNames from 'classnames' + +interface IEndTimingProps { + loop?: boolean + expectedStart?: number + expectedDuration?: number + expectedEnd?: number + endLabel?: string + hidePlannedEnd?: boolean + hideCountdown?: boolean + hideDiff?: boolean + rundownCount: number +} + +export const PlaylistEndTiming = withTranslation()( + withTiming()( + class PlaylistEndTiming extends React.Component>> { + render() { + let { t } = this.props + + return ( + + {this.props.expectedDuration ? ( + + {!this.props.hidePlannedEnd && + (!this.props.loop && this.props.expectedStart ? ( + + {t(this.props.endLabel || 'Planned End')} + + + ) : !this.props.loop && this.props.expectedEnd ? ( + + {t(this.props.endLabel || 'Planned End')} + + + ) : null)} + {!this.props.hideCountdown && + (!this.props.loop && this.props.expectedStart && this.props.expectedDuration ? ( + + {RundownUtils.formatDiffToTimecode( + getCurrentTime() - (this.props.expectedStart + this.props.expectedDuration), + true, + true, + true + )} + + ) : !this.props.loop && this.props.expectedEnd ? ( + + {RundownUtils.formatDiffToTimecode(getCurrentTime() - this.props.expectedEnd, true, true, true)} + + ) : null)} + {this.props.expectedDuration && !this.props.hideDiff ? ( + (this.props.expectedDuration || 0), + })} + > + {t('Diff')} + {RundownUtils.formatDiffToTimecode( + (this.props.timingDurations.asPlayedPlaylistDuration || 0) - this.props.expectedDuration, + true, + false, + true, + true, + true, + undefined, + true + )} + + ) : null} + + ) : ( + + {!this.props.loop && this.props.timingDurations ? ( + + + {this.props.endLabel ? t(this.props.endLabel) : t('Expected End')} + + + + ) : null} + {this.props.timingDurations && this.props.rundownCount < 2 ? ( // TEMPORARY: disable the diff counter for playlists longer than one rundown -- Jan Starzak, 2021-05-06 + + (this.props.timingDurations.totalPlaylistDuration || 0), + })} + > + {t('Diff')} + {RundownUtils.formatDiffToTimecode( + (this.props.timingDurations.asPlayedPlaylistDuration || 0) - + (this.props.timingDurations.totalPlaylistDuration || 0), + true, + false, + true, + true, + true, + undefined, + true + )} + + ) : null} + + )} + + ) + } + } + ) +) diff --git a/meteor/client/ui/RundownView/RundownTiming/PlaylistStartTiming.tsx b/meteor/client/ui/RundownView/RundownTiming/PlaylistStartTiming.tsx new file mode 100644 index 0000000000..dd61559890 --- /dev/null +++ b/meteor/client/ui/RundownView/RundownTiming/PlaylistStartTiming.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { WithTranslation, withTranslation } from 'react-i18next' +import Moment from 'react-moment' +import { Translated } from '../../../lib/ReactMeteorData/ReactMeteorData' +import { withTiming, WithTiming } from './withTiming' +import { RundownPlaylist } from '../../../../lib/collections/RundownPlaylists' +import { RundownUtils } from '../../../lib/rundown' +import { getCurrentTime } from '../../../../lib/lib' +import ClassNames from 'classnames' + +interface IEndTimingProps { + rundownPlaylist: RundownPlaylist + hideDiff?: boolean +} + +export const PlaylistStartTiming = withTranslation()( + withTiming()( + class PlaylistStartTiming extends React.Component>> { + render() { + let { t, rundownPlaylist } = this.props + let expectedStart = rundownPlaylist.expectedStart + ? rundownPlaylist.expectedStart + : rundownPlaylist.expectedDuration && rundownPlaylist.expectedEnd + ? rundownPlaylist.expectedEnd - rundownPlaylist.expectedDuration + : undefined + + return ( + + {rundownPlaylist.startedPlayback && rundownPlaylist.activationId && !rundownPlaylist.rehearsal ? ( + + {t('Started')} + + + ) : rundownPlaylist.expectedStart ? ( + + {t('Planned Start')} + + + ) : rundownPlaylist.expectedEnd && rundownPlaylist.expectedDuration ? ( + + {t('Expected Start')} + + + ) : null} + {!this.props.hideDiff && expectedStart && ( + expectedStart, + light: getCurrentTime() <= expectedStart, + })} + > + {t('Diff')} + {rundownPlaylist.startedPlayback + ? RundownUtils.formatDiffToTimecode( + rundownPlaylist.startedPlayback - expectedStart, + true, + false, + true, + true, + true + ) + : RundownUtils.formatDiffToTimecode(getCurrentTime() - expectedStart, true, false, true, true, true)} + + )} + + ) + } + } + ) +) diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownName.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownName.tsx new file mode 100644 index 0000000000..06e3d0a4e0 --- /dev/null +++ b/meteor/client/ui/RundownView/RundownTiming/RundownName.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { WithTranslation, withTranslation } from 'react-i18next' +import { Translated } from '../../../lib/ReactMeteorData/ReactMeteorData' +import { withTiming, WithTiming } from './withTiming' +import ClassNames from 'classnames' +import { RundownPlaylist } from '../../../../lib/collections/RundownPlaylists' +import { LoopingIcon } from '../../../lib/ui/icons/looping' +import { Rundown } from '../../../../lib/collections/Rundowns' +import { RundownUtils } from '../../../lib/rundown' +import { getCurrentTime } from '../../../../lib/lib' + +interface IRundownNameProps { + rundownPlaylist: RundownPlaylist + currentRundown?: Rundown + rundownCount: number + hideDiff?: boolean +} + +export const RundownName = withTranslation()( + withTiming()( + class RundownName extends React.Component>> { + render() { + const { rundownPlaylist, currentRundown, rundownCount, t } = this.props + return ( + rundownPlaylist.expectedStart, + })} + > + {currentRundown && (rundownPlaylist.name !== currentRundown.name || rundownCount > 1) ? ( + + {rundownPlaylist.loop && } {currentRundown.name} {rundownPlaylist.name} + + ) : ( + + {rundownPlaylist.loop && } {rundownPlaylist.name} + + )} + {!this.props.hideDiff && + rundownPlaylist.startedPlayback && + rundownPlaylist.activationId && + !rundownPlaylist.rehearsal + ? rundownPlaylist.expectedStart && + RundownUtils.formatDiffToTimecode( + rundownPlaylist.startedPlayback - rundownPlaylist.expectedStart, + true, + false, + true, + true, + true + ) + : rundownPlaylist.expectedStart && + RundownUtils.formatDiffToTimecode( + getCurrentTime() - rundownPlaylist.expectedStart, + true, + false, + true, + true, + true + )} + + ) + } + } + ) +) diff --git a/meteor/client/ui/RundownView/RundownTiming/TimeOfDay.tsx b/meteor/client/ui/RundownView/RundownTiming/TimeOfDay.tsx new file mode 100644 index 0000000000..34e90b6786 --- /dev/null +++ b/meteor/client/ui/RundownView/RundownTiming/TimeOfDay.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { WithTranslation, withTranslation } from 'react-i18next' +import { Translated } from '../../../lib/ReactMeteorData/ReactMeteorData' +import { withTiming, WithTiming } from './withTiming' +import { getCurrentTime } from '../../../../lib/lib' +import Moment from 'react-moment' + +interface ITimeOfDayProps {} + +export const TimeOfDay = withTranslation()( + withTiming()( + class RundownName extends React.Component>> { + render() { + return ( + + + + ) + } + } + ) +) diff --git a/meteor/client/ui/Settings/components/FilterEditor.tsx b/meteor/client/ui/Settings/components/FilterEditor.tsx index ca07ffa4ee..2b732f47b2 100644 --- a/meteor/client/ui/Settings/components/FilterEditor.tsx +++ b/meteor/client/ui/Settings/components/FilterEditor.tsx @@ -12,9 +12,12 @@ import { RundownLayoutBase, RundownLayoutElementBase, RundownLayoutElementType, + RundownLayoutEndWords, RundownLayoutExternalFrame, RundownLayoutFilterBase, RundownLayoutPieceCountdown, + RundownLayoutPlaylistEndTimer, + RundownLayoutPlaylistStartTimer, RundownLayouts, } from '../../../../lib/collections/RundownLayouts' import { EditAttribute } from '../../../lib/EditAttribute' @@ -616,73 +619,9 @@ export default translateWithTracker((props: IProp /> + {isDashboardLayout && this.renderDashboardLayoutSettings(item, index)} {isDashboardLayout && ( -
- -
-
- -
-
- -
-
- -
-
- -
+ {isDashboardLayout && this.renderDashboardLayoutSettings(item, index)} {isDashboardLayout && (
-
-
- -
-
- -
-
-
- {isDashboardLayout && ( - -
- -
-
- )}
)}
@@ -889,6 +773,62 @@ export default translateWithTracker((props: IProp index: number, isRundownLayout: boolean, isDashboardLayout: boolean + ) { + const { t } = this.props + return ( + +
+ + (v === undefined || v.length === 0 ? false : true)} + mutateUpdateValue={(v) => undefined} + /> + { + return { name: l.name, value: l._id } + })} + type="multiselect" + label={t('Disabled')} + collection={RundownLayouts} + className="input text-input input-l dropdown" + mutateUpdateValue={(v) => (v && v.length > 0 ? v : undefined)} + /> +
+ {isDashboardLayout && this.renderDashboardLayoutSettings(item, index)} + {isDashboardLayout && ( +
+ +
+ )} +
+ ) + } + + renderPlaylistStartTimer( + item: RundownLayoutBase, + tab: RundownLayoutPlaylistStartTimer, + index: number, + isRundownLayout: boolean, + isDashboardLayout: boolean ) { const { t } = this.props return ( @@ -907,10 +847,160 @@ export default translateWithTracker((props: IProp
- + +
+ {isDashboardLayout && this.renderDashboardLayoutSettings(item, index)} + {isDashboardLayout && ( +
+ +
+ )} +
+ ) + } + + renderPlaylistEndTimer( + item: RundownLayoutBase, + tab: RundownLayoutPlaylistEndTimer, + index: number, + isRundownLayout: boolean, + isDashboardLayout: boolean + ) { + const { t } = this.props + return ( + +
+ +
+ +
+ +
+
+ +
+
+ +
+ {isDashboardLayout && this.renderDashboardLayoutSettings(item, index)} + {isDashboardLayout && ( +
+ +
+ )} +
+ ) + } + + renderEndWords( + item: RundownLayoutBase, + tab: RundownLayoutEndWords, + index: number, + isRundownLayout: boolean, + isDashboardLayout: boolean + ) { + const { t } = this.props + return ( + +
+ +
+
+ + ((props: IProp /> { + return { name: l.name, value: l._id } + })} + type="multiselect" + label={t('Disabled')} + collection={RundownLayouts} + className="input text-input input-l dropdown" + mutateUpdateValue={(v) => (v && v.length > 0 ? v : undefined)} + /> + {t('Source layers containing script')} +
+
+ + (v === undefined || v.length === 0 ? false : true)} + mutateUpdateValue={(v) => undefined} + /> + { return { name: l.name, value: l._id } @@ -931,67 +1048,107 @@ export default translateWithTracker((props: IProp className="input text-input input-l dropdown" mutateUpdateValue={(v) => (v && v.length > 0 ? v : undefined)} /> + + {t('Specify layers where at least one layer must have an active piece for end words to be shown')} + +
+
+
+ {isDashboardLayout && this.renderDashboardLayoutSettings(item, index)} {isDashboardLayout && ( - -
- -
-
- -
-
- -
-
- -
-
+
+ +
)}
) } + renderDashboardLayoutSettings(item: RundownLayoutBase, index: number) { + let { t } = this.props + + return ( + +
+ +
+
+ +
+
+ +
+
+ +
+
+ ) + } + render() { const { t } = this.props @@ -1065,6 +1222,30 @@ export default translateWithTracker((props: IProp isRundownLayout, isDashboardLayout ) + : RundownLayoutsAPI.isPlaylistStartTimer(this.props.filter) + ? this.renderPlaylistStartTimer( + this.props.item, + this.props.filter, + this.props.index, + isRundownLayout, + isDashboardLayout + ) + : RundownLayoutsAPI.isPlaylistEndTimer(this.props.filter) + ? this.renderPlaylistEndTimer( + this.props.item, + this.props.filter, + this.props.index, + isRundownLayout, + isDashboardLayout + ) + : RundownLayoutsAPI.isEndWords(this.props.filter) + ? this.renderEndWords( + this.props.item, + this.props.filter, + this.props.index, + isRundownLayout, + isDashboardLayout + ) : undefined} ) diff --git a/meteor/client/ui/Shelf/DashboardPanel.tsx b/meteor/client/ui/Shelf/DashboardPanel.tsx index 075e14ab42..9bbff1c841 100644 --- a/meteor/client/ui/Shelf/DashboardPanel.tsx +++ b/meteor/client/ui/Shelf/DashboardPanel.tsx @@ -676,7 +676,10 @@ export class DashboardPanelInner extends MeteorReactComponent< } } -export function getUnfinishedPieceInstancesReactive(currentPartInstanceId: PartInstanceId | null) { +export function getUnfinishedPieceInstancesReactive( + currentPartInstanceId: PartInstanceId | null, + includeNonAdLibPieces?: boolean +) { let prospectivePieces: PieceInstance[] = [] const now = getCurrentTime() if (currentPartInstanceId) { @@ -699,20 +702,22 @@ export function getUnfinishedPieceInstancesReactive(currentPartInstanceId: PartI }, ], }, - { - $or: [ - { - adLibSourceId: { - $exists: true, - }, - }, - { - 'piece.tags': { - $exists: true, - }, - }, - ], - }, + !includeNonAdLibPieces + ? { + $or: [ + { + adLibSourceId: { + $exists: true, + }, + }, + { + 'piece.tags': { + $exists: true, + }, + }, + ], + } + : {}, { $or: [ { diff --git a/meteor/client/ui/Shelf/EndWordsPanel.tsx b/meteor/client/ui/Shelf/EndWordsPanel.tsx new file mode 100644 index 0000000000..9443785789 --- /dev/null +++ b/meteor/client/ui/Shelf/EndWordsPanel.tsx @@ -0,0 +1,98 @@ +import * as React from 'react' +import * as _ from 'underscore' +import { + DashboardLayoutEndsWords, + RundownLayoutBase, + RundownLayoutEndWords, +} from '../../../lib/collections/RundownLayouts' +import { RundownLayoutsAPI } from '../../../lib/api/rundownLayouts' +import { dashboardElementPosition, getUnfinishedPieceInstancesReactive } from './DashboardPanel' +import { Translated, translateWithTracker, withTracker } from '../../lib/ReactMeteorData/ReactMeteorData' +import { MeteorReactComponent } from '../../lib/MeteorReactComponent' +import { RundownPlaylist } from '../../../lib/collections/RundownPlaylists' +import { PieceInstance } from '../../../lib/collections/PieceInstances' +import { ScriptContent } from '@sofie-automation/blueprints-integration' +import { GetScriptPreview } from '../scriptPreview' + +interface IEndsWordsPanelProps { + visible?: boolean + layout: RundownLayoutBase + panel: RundownLayoutEndWords + playlist: RundownPlaylist +} + +interface IEndsWordsPanelTrackedProps { + livePieceInstance?: PieceInstance +} + +interface IState {} + +export class EndWordsPanelInner extends MeteorReactComponent< + Translated, + IState +> { + constructor(props) { + super(props) + } + + render() { + const isDashboardLayout = RundownLayoutsAPI.isDashboardLayout(this.props.layout) + + let { t, livePieceInstance, panel } = this.props + let content = livePieceInstance?.piece.content as Partial | undefined + + let { endOfScript } = GetScriptPreview(content?.fullScript || '') + + return ( +
+ {t('End Words')} + ‎{endOfScript}‎ +
+ ) + } +} + +export const EndWordsPanel = translateWithTracker( + (props: IEndsWordsPanelProps & IEndsWordsPanelTrackedProps) => { + const unfinishedPieces = getUnfinishedPieceInstancesReactive(props.playlist.currentPartInstanceId, true) + let livePieceInstance: PieceInstance | undefined + let activeLayers = unfinishedPieces.map((p) => p.piece.sourceLayerId) + let containsEveryRequiredLayer = props.panel.requireAllSourcelayers + ? props.panel.requiredLayers?.every((s) => activeLayers.includes(s)) + : false + let containsRequiredLayer = containsEveryRequiredLayer + ? true + : props.panel.requiredLayers && props.panel.requiredLayers.length + ? props.panel.requiredLayers.some((s) => activeLayers.includes(s)) + : false + + if ( + (!props.panel.requireAllSourcelayers || containsEveryRequiredLayer) && + (!props.panel.requiredLayers?.length || containsRequiredLayer) + ) { + livePieceInstance = + props.panel.scriptSourceLayerIds && props.panel.scriptSourceLayerIds.length + ? _.flatten(Object.values(unfinishedPieces)).find((piece: PieceInstance) => { + return ( + (props.panel.scriptSourceLayerIds || []).indexOf(piece.piece.sourceLayerId) !== -1 && + piece.partInstanceId === props.playlist.currentPartInstanceId + ) + }) + : undefined + } + return { livePieceInstance } + }, + (_data, props: IEndsWordsPanelProps, nextProps: IEndsWordsPanelProps) => { + return !_.isEqual(props, nextProps) + } +)(EndWordsPanelInner) diff --git a/meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx b/meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx new file mode 100644 index 0000000000..549a55456e --- /dev/null +++ b/meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx @@ -0,0 +1,72 @@ +import * as React from 'react' +import * as _ from 'underscore' +import { + DashboardLayoutPlaylistEndTimer, + DashboardLayoutPlaylistStartTimer, + RundownLayoutBase, + RundownLayoutPlaylistEndTimer, +} from '../../../lib/collections/RundownLayouts' +import { RundownLayoutsAPI } from '../../../lib/api/rundownLayouts' +import { dashboardElementPosition } from './DashboardPanel' +import { Translated } from '../../lib/ReactMeteorData/ReactMeteorData' +import { MeteorReactComponent } from '../../lib/MeteorReactComponent' +import { RundownPlaylist } from '../../../lib/collections/RundownPlaylists' +import { withTranslation } from 'react-i18next' +import { PlaylistStartTiming } from '../RundownView/RundownTiming/PlaylistStartTiming' +import { PlaylistEndTiming } from '../RundownView/RundownTiming/PlaylistEndTiming' + +interface IPlaylistStartTimerPanelProps { + visible?: boolean + layout: RundownLayoutBase + panel: RundownLayoutPlaylistEndTimer + playlist: RundownPlaylist +} + +interface IState {} + +export class PlaylistEndTimerPanelInner extends MeteorReactComponent< + Translated, + IState +> { + constructor(props) { + super(props) + } + + render() { + const isDashboardLayout = RundownLayoutsAPI.isDashboardLayout(this.props.layout) + + let { playlist, panel } = this.props + + if (!playlist.expectedDuration) { + return null + } + + return ( +
+ +
+ ) + } +} + +export const PlaylistEndTimerPanel = withTranslation()(PlaylistEndTimerPanelInner) diff --git a/meteor/client/ui/Shelf/PlaylistStartTimerPanel.tsx b/meteor/client/ui/Shelf/PlaylistStartTimerPanel.tsx new file mode 100644 index 0000000000..3874389c66 --- /dev/null +++ b/meteor/client/ui/Shelf/PlaylistStartTimerPanel.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import * as _ from 'underscore' +import { + DashboardLayoutPlaylistStartTimer, + RundownLayoutBase, + RundownLayoutPlaylistStartTimer, +} from '../../../lib/collections/RundownLayouts' +import { RundownLayoutsAPI } from '../../../lib/api/rundownLayouts' +import { dashboardElementPosition } from './DashboardPanel' +import { Translated } from '../../lib/ReactMeteorData/ReactMeteorData' +import { MeteorReactComponent } from '../../lib/MeteorReactComponent' +import { RundownPlaylist } from '../../../lib/collections/RundownPlaylists' +import { withTranslation } from 'react-i18next' +import { PlaylistStartTiming } from '../RundownView/RundownTiming/PlaylistStartTiming' + +interface IPlaylistStartTimerPanelProps { + visible?: boolean + layout: RundownLayoutBase + panel: RundownLayoutPlaylistStartTimer + playlist: RundownPlaylist +} + +interface IState {} + +export class PlaylistStartTimerPanelInner extends MeteorReactComponent< + Translated, + IState +> { + constructor(props) { + super(props) + } + + render() { + const isDashboardLayout = RundownLayoutsAPI.isDashboardLayout(this.props.layout) + + let { playlist } = this.props + + if (!playlist.expectedDuration) { + return null + } + + return ( +
+ +
+ ) + } +} + +export const PlaylistStartTimerPanel = withTranslation()(PlaylistStartTimerPanelInner) diff --git a/meteor/client/ui/Shelf/ShelfDashboardLayout.tsx b/meteor/client/ui/Shelf/ShelfDashboardLayout.tsx index c0afb5df16..bac44b4e35 100644 --- a/meteor/client/ui/Shelf/ShelfDashboardLayout.tsx +++ b/meteor/client/ui/Shelf/ShelfDashboardLayout.tsx @@ -14,6 +14,9 @@ import { BucketAdLibItem } from './RundownViewBuckets' import { IAdLibListItem } from './AdLibListItem' import { PieceUi } from '../SegmentTimeline/SegmentTimelineContainer' import { AdLibPieceUi } from './AdLibPanel' +import { PlaylistStartTimerPanel } from './PlaylistStartTimerPanel' +import { EndWordsPanel } from './EndWordsPanel' +import { PlaylistEndTimerPanel } from './PlaylistEndTimerPanel' export interface IShelfDashboardLayoutProps { rundownLayout: DashboardLayout @@ -103,6 +106,12 @@ export function ShelfDashboardLayout(props: IShelfDashboardLayoutProps) { playlist={props.playlist} visible={true} /> + ) : RundownLayoutsAPI.isPlaylistStartTimer(panel) ? ( + + ) : RundownLayoutsAPI.isPlaylistEndTimer(panel) ? ( + + ) : RundownLayoutsAPI.isEndWords(panel) ? ( + ) : undefined )} {rundownLayout.actionButtons && ( diff --git a/meteor/client/ui/scriptPreview.ts b/meteor/client/ui/scriptPreview.ts new file mode 100644 index 0000000000..e028a90406 --- /dev/null +++ b/meteor/client/ui/scriptPreview.ts @@ -0,0 +1,32 @@ +const BREAK_SCRIPT_BREAKPOINT = 620 +const SCRIPT_PART_LENGTH = 250 + +interface ScriptPreview { + startOfScript: string + endOfScript: string + breakScript: boolean +} + +export function GetScriptPreview(fullScript: string): ScriptPreview { + let startOfScript = fullScript + let cutLength = startOfScript.length + if (startOfScript.length > SCRIPT_PART_LENGTH) { + startOfScript = startOfScript.substring(0, startOfScript.substr(0, SCRIPT_PART_LENGTH).lastIndexOf(' ')) + cutLength = startOfScript.length + } + let endOfScript = fullScript + if (endOfScript.length > SCRIPT_PART_LENGTH) { + endOfScript = endOfScript.substring( + endOfScript.indexOf(' ', Math.max(cutLength, endOfScript.length - SCRIPT_PART_LENGTH)), + endOfScript.length + ) + } + + const breakScript = fullScript.length > BREAK_SCRIPT_BREAKPOINT + + return { + startOfScript, + endOfScript, + breakScript, + } +} diff --git a/meteor/lib/api/rundownLayouts.ts b/meteor/lib/api/rundownLayouts.ts index d8edb8ed95..b5242355d3 100644 --- a/meteor/lib/api/rundownLayouts.ts +++ b/meteor/lib/api/rundownLayouts.ts @@ -15,6 +15,9 @@ import { RundownLayoutRundownHeader, RundownLayoutShelfBase, CustomizableRegions, + RundownLayoutPlaylistStartTimer, + RundownLayoutPlaylistEndTimer, + RundownLayoutEndWords, } from '../collections/RundownLayouts' import { ShowStyleBaseId } from '../collections/ShowStyleBases' import * as _ from 'underscore' @@ -168,7 +171,12 @@ export namespace RundownLayoutsAPI { registry.RegisterRundownHeaderLayouts(RundownLayoutType.DASHBOARD_LAYOUT, { filtersTitle: 'Layout Elements', supportsFilters: true, - supportedElements: [RundownLayoutElementType.PIECE_COUNTDOWN], + supportedElements: [ + RundownLayoutElementType.PIECE_COUNTDOWN, + RundownLayoutElementType.PLAYLIST_START_TIMER, + RundownLayoutElementType.PLAYLIST_END_TIMER, + RundownLayoutElementType.END_WORDS, + ], }) export function GetSettingsManifest(): CustomizableRegionSettingsManifest[] { @@ -223,6 +231,20 @@ export namespace RundownLayoutsAPI { return element.type === RundownLayoutElementType.PIECE_COUNTDOWN } + export function isPlaylistStartTimer( + element: RundownLayoutElementBase + ): element is RundownLayoutPlaylistStartTimer { + return element.type === RundownLayoutElementType.PLAYLIST_START_TIMER + } + + export function isPlaylistEndTimer(element: RundownLayoutElementBase): element is RundownLayoutPlaylistEndTimer { + return element.type === RundownLayoutElementType.PLAYLIST_END_TIMER + } + + export function isEndWords(element: RundownLayoutElementBase): element is RundownLayoutEndWords { + return element.type === RundownLayoutElementType.END_WORDS + } + export function adLibRegionToFilter(element: RundownLayoutAdLibRegion): RundownLayoutFilterBase { return { ..._.pick(element, '_id', 'name', 'rank', 'tags'), diff --git a/meteor/lib/collections/RundownLayouts.ts b/meteor/lib/collections/RundownLayouts.ts index 6f415b7cc3..d5fdcaefa9 100644 --- a/meteor/lib/collections/RundownLayouts.ts +++ b/meteor/lib/collections/RundownLayouts.ts @@ -45,6 +45,9 @@ export enum RundownLayoutElementType { EXTERNAL_FRAME = 'external_frame', ADLIB_REGION = 'adlib_region', PIECE_COUNTDOWN = 'piece_countdown', + PLAYLIST_START_TIMER = 'playlist_start_timer', + PLAYLIST_END_TIMER = 'playlist_end_timer', + END_WORDS = 'end_words', } export interface RundownLayoutElementBase { @@ -79,6 +82,30 @@ export interface RundownLayoutPieceCountdown extends RundownLayoutElementBase { sourceLayerIds: string[] | undefined } +export interface RundownLayoutPlaylistStartTimer extends RundownLayoutElementBase { + type: RundownLayoutElementType.PLAYLIST_START_TIMER + hideDiff: boolean +} + +export interface RundownLayoutPlaylistEndTimer extends RundownLayoutElementBase { + type: RundownLayoutElementType.PLAYLIST_END_TIMER + expectedEndText: string + hideCountdown: boolean + hideDiff: boolean + hidePlannedEnd: boolean +} + +export interface RundownLayoutEndWords extends RundownLayoutElementBase { + type: RundownLayoutElementType.PLAYLIST_END_TIMER + scriptSourceLayerIds?: string[] + requiredLayers?: string[] + /** + * Require that all required sourcelayers be active in order to show end words. + * This allows end words to be tied to a combination of e.g. script + VT. + */ + requireAllSourcelayers: boolean +} + /** * A filter to be applied against the AdLib Pieces. If a member is undefined, the pool is not tested * against that filter. A member must match all of the sub-filters to be included in a filter view @@ -132,6 +159,27 @@ export interface DashboardLayoutPieceCountdown extends RundownLayoutPieceCountdo scale: number } +export interface DashboardLayoutPlaylistStartTimer extends RundownLayoutPlaylistStartTimer { + x: number + y: number + width: number + scale: number +} + +export interface DashboardLayoutPlaylistEndTimer extends RundownLayoutPlaylistEndTimer { + x: number + y: number + width: number + scale: number +} + +export interface DashboardLayoutEndsWords extends RundownLayoutEndWords { + x: number + y: number + width: number + scale: number +} + export interface DashboardLayoutFilter extends RundownLayoutFilterBase { x: number y: number From 1e9324a1ba4845733b89e5683c4869f255f5a5e6 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Thu, 3 Jun 2021 17:26:51 +0100 Subject: [PATCH 014/112] feat: Playlist/Rundown expectedEnd display in lobby / header --- meteor/client/styles/rundownList.scss | 10 ++++--- .../client/ui/ClockView/PresenterScreen.tsx | 1 + meteor/client/ui/RundownList.tsx | 3 +++ .../ui/RundownList/RundownListItemView.tsx | 15 +++++++++-- .../ui/RundownList/RundownPlaylistUi.tsx | 15 +++++++++-- meteor/client/ui/RundownView.tsx | 26 ++++++++++++------- .../StudioScreenSaver/StudioScreenSaver.tsx | 1 + meteor/lib/collections/RundownPlaylists.ts | 4 +++ meteor/lib/collections/Rundowns.ts | 1 + meteor/server/api/rundownPlaylist.ts | 3 +++ .../migration/deprecatedDataTypes/1_0_1.ts | 2 ++ .../blueprints-integration/src/rundown.ts | 4 +++ 12 files changed, 68 insertions(+), 17 deletions(-) diff --git a/meteor/client/styles/rundownList.scss b/meteor/client/styles/rundownList.scss index 9f4fbe7e97..89fa18937d 100644 --- a/meteor/client/styles/rundownList.scss +++ b/meteor/client/styles/rundownList.scss @@ -10,7 +10,7 @@ * Note that the standard size is used for several columns, which is why these * percentages do not add up to a perfect 100% (ie. 1.0) */ - --nameColumnSize: 0.4; + --nameColumnSize: 0.3; --showStyleColumnSize: 0.17; --standardColumnSize: 0.11; --layoutSelectionColumnSize: 0.075; @@ -23,6 +23,7 @@ --showStyleColumnWidth: calc(var(--componentWidth) * var(--showStyleColumnSize)); --airTimeColumnWidth: calc(var(--componentWidth) * var(--standardColumnSize)); --durationColumnWidth: calc(var(--componentWidth) * var(--standardColumnSize)); + --expectedEndColumnWidth: calc(var(--componentWidth) * var(--standardColumnSize)); --lastModifiedColumnWidth: calc(var(--componentWidth) * var(--standardColumnSize)); --layoutSelectionColumnWidth: calc(var(--componentWidth) * var(--layoutSelectionColumnSize)); --actionsColumnWidth: calc(var(--componentWidth) * var(--actionsColumnSize)); @@ -36,7 +37,7 @@ display: grid; grid-template-columns: var(--nameColumnWidth) var(--showStyleColumnWidth) var(--airTimeColumnWidth) - var(--durationColumnWidth) var(--lastModifiedColumnWidth) var(--layoutSelectionColumnWidth); + var(--durationColumnWidth) var(--expectedEndColumnWidth) var(--lastModifiedColumnWidth) var(--layoutSelectionColumnWidth); background-color: #898989; color: #fff; @@ -173,7 +174,7 @@ // for the header, the first column spans the first four item columns grid-template-columns: calc(var(--nameColumnWidth) + var(--showStyleColumnWidth)) var(--airTimeColumnWidth) - var(--durationColumnWidth) var(--lastModifiedColumnWidth); + var(--durationColumnWidth) var(--expectedEndColumnWidth) var(--lastModifiedColumnWidth); > span { align-self: center; @@ -246,6 +247,7 @@ --showStyleColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--showStyleColumnSize)); --airTimeColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--standardColumnSize)); --durationColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--standardColumnSize)); + --expectedEndColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--standardColumnSize)); --lastModifiedColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--standardColumnSize)); --actionsColumnWidth: calc((var(--componentWidth) + var(--leftGutterWidth)) * var(--actionsColumnSize)); @@ -304,7 +306,7 @@ display: grid; grid-template-columns: var(--nameColumnWidth) var(--showStyleColumnWidth) var(--airTimeColumnWidth) - var(--durationColumnWidth) var(--lastModifiedColumnWidth) var(--layoutSelectionColumnWidth); + var(--durationColumnWidth) var(--expectedEndColumnWidth) var(--lastModifiedColumnWidth) var(--layoutSelectionColumnWidth); .rundown-list-item__actions { padding: 0 0 3px; // cater for icons being larger than the font diff --git a/meteor/client/ui/ClockView/PresenterScreen.tsx b/meteor/client/ui/ClockView/PresenterScreen.tsx index d401c4b376..cea32acc2f 100644 --- a/meteor/client/ui/ClockView/PresenterScreen.tsx +++ b/meteor/client/ui/ClockView/PresenterScreen.tsx @@ -74,6 +74,7 @@ function getShowStyleBaseIdSegmentPartUi( name: 1, expectedStart: 1, expectedDuration: 1, + expectedEnd: 1, }, }) showStyleBaseId = currentRundown?.showStyleBaseId diff --git a/meteor/client/ui/RundownList.tsx b/meteor/client/ui/RundownList.tsx index b6c2117edc..0038423be2 100644 --- a/meteor/client/ui/RundownList.tsx +++ b/meteor/client/ui/RundownList.tsx @@ -303,6 +303,9 @@ export const RundownList = translateWithTracker((): IRundownsListProps => { {t('Show Style')} {t('On Air Start Time')} {t('Duration')} + {this.props.rundownPlaylists.some( + (p) => !!p.expectedEnd || p.rundowns.some((r) => r.expectedEnd) + ) && {t('Expected End Time')}} {t('Last Updated')} {this.props.rundownLayouts.some((l) => l.exposeAsShelf || l.exposeAsStandalone) && ( {t('Shelf Layout')} diff --git a/meteor/client/ui/RundownList/RundownListItemView.tsx b/meteor/client/ui/RundownList/RundownListItemView.tsx index 6b544cb2fa..a9362d6e05 100644 --- a/meteor/client/ui/RundownList/RundownListItemView.tsx +++ b/meteor/client/ui/RundownList/RundownListItemView.tsx @@ -107,7 +107,9 @@ export default withTranslation()(function RundownListItemView(props: Translated<
{rundown.expectedStart ? ( - + + ) : rundown.expectedEnd && rundown.expectedDuration ? ( + ) : ( {t('Not set')} )} @@ -138,7 +140,16 @@ export default withTranslation()(function RundownListItemView(props: Translated< )} - + {rundown.expectedEnd ? ( + + ) : rundown.expectedStart && rundown.expectedDuration ? ( + + ) : ( + {t('Not set')} + )} + + + {rundownLayouts.some((l) => l.exposeAsShelf || l.exposeAsStandalone) && ( diff --git a/meteor/client/ui/RundownList/RundownPlaylistUi.tsx b/meteor/client/ui/RundownList/RundownPlaylistUi.tsx index fefd48073d..13466246d0 100644 --- a/meteor/client/ui/RundownList/RundownPlaylistUi.tsx +++ b/meteor/client/ui/RundownList/RundownPlaylistUi.tsx @@ -340,7 +340,9 @@ export const RundownPlaylistUi = DropTarget( {playlist.expectedStart ? ( - + + ) : playlist.expectedEnd && playlist.expectedDuration ? ( + ) : ( {t('Not set')} )} @@ -357,7 +359,16 @@ export const RundownPlaylistUi = DropTarget( )} - + {playlist.expectedEnd ? ( + + ) : playlist.expectedStart && playlist.expectedDuration ? ( + + ) : ( + {t('Not set')} + )} + + + {rundownLayouts.some((l) => l.exposeAsShelf || l.exposeAsStandalone) && ( diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index c1a0e25970..7953eba643 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -338,7 +338,7 @@ const TimingDisplay = withTranslation()( ) : null} )} - {rundownPlaylist.expectedDuration ? ( + {rundownPlaylist.expectedEnd || rundownPlaylist.expectedDuration ? ( {!rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( @@ -349,6 +349,11 @@ const TimingDisplay = withTranslation()( date={rundownPlaylist.expectedStart + rundownPlaylist.expectedDuration} /> + ) : !rundownPlaylist.loop && rundownPlaylist.expectedEnd ? ( + + {t('Planned End')} + + ) : null} {!rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( @@ -407,14 +412,17 @@ const TimingDisplay = withTranslation()( ) : null ) : ( - - {t('Expected End')} - - + + {t('Expected End')} + + ) ) : null} {this.props.timingDurations && this.props.rundownCount < 2 ? ( // TEMPORARY: disable the diff counter for playlists longer than one rundown -- Jan Starzak, 2021-05-06 diff --git a/meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx b/meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx index 027cfe1aa4..2090bdeeed 100644 --- a/meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx +++ b/meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx @@ -58,6 +58,7 @@ export const findNextPlaylist = (props: IProps) => { name: 1, expectedStart: 1, expectedDuration: 1, + expectedEnd: 1, studioId: 1, }, } diff --git a/meteor/lib/collections/RundownPlaylists.ts b/meteor/lib/collections/RundownPlaylists.ts index 26b577560f..87c888f5ad 100644 --- a/meteor/lib/collections/RundownPlaylists.ts +++ b/meteor/lib/collections/RundownPlaylists.ts @@ -65,6 +65,8 @@ export interface DBRundownPlaylist { expectedStart?: Time /** How long the playlist is expected to take ON AIR */ expectedDuration?: number + /** When the playlist is expected to end */ + expectedEnd?: Time /** Is the playlist in rehearsal mode (can be used, when active: true) */ rehearsal?: boolean /** Playout hold state */ @@ -122,6 +124,7 @@ export class RundownPlaylist implements DBRundownPlaylist { public rundownsStartedPlayback?: Record public expectedStart?: Time public expectedDuration?: number + public expectedEnd?: Time public rehearsal?: boolean public holdState?: RundownHoldState public activationId?: RundownPlaylistActivationId @@ -213,6 +216,7 @@ export class RundownPlaylist implements DBRundownPlaylist { playlistId: 1, expectedStart: 1, expectedDuration: 1, + expectedEnd: 1, showStyleBaseId: 1, }, }) diff --git a/meteor/lib/collections/Rundowns.ts b/meteor/lib/collections/Rundowns.ts index c8013fe2f5..df1335204d 100644 --- a/meteor/lib/collections/Rundowns.ts +++ b/meteor/lib/collections/Rundowns.ts @@ -86,6 +86,7 @@ export class Rundown implements DBRundown { public description?: string public expectedStart?: Time public expectedDuration?: number + public expectedEnd?: Time public metaData?: unknown // From IBlueprintRundownDB: public _id: RundownId diff --git a/meteor/server/api/rundownPlaylist.ts b/meteor/server/api/rundownPlaylist.ts index 7a0c2107fb..ff88d46c31 100644 --- a/meteor/server/api/rundownPlaylist.ts +++ b/meteor/server/api/rundownPlaylist.ts @@ -171,6 +171,7 @@ export function produceRundownPlaylistInfoFromRundown( name: playlistInfo.playlist.name, expectedStart: playlistInfo.playlist.expectedStart, expectedDuration: playlistInfo.playlist.expectedDuration, + expectedEnd: playlistInfo.playlist.expectedEnd, loop: playlistInfo.playlist.loop, @@ -213,6 +214,7 @@ function defaultPlaylistForRundown( name: rundown.name, expectedStart: rundown.expectedStart, expectedDuration: rundown.expectedDuration, + expectedEnd: rundown.expectedEnd, modified: getCurrentTime(), } @@ -487,6 +489,7 @@ function sortDefaultRundownInPlaylistOrder(rundowns: ReadonlyDeep, ReadonlyDeep>(rundowns, { sort: { expectedStart: 1, + expectedEnd: 1, name: 1, _id: 1, }, diff --git a/meteor/server/migration/deprecatedDataTypes/1_0_1.ts b/meteor/server/migration/deprecatedDataTypes/1_0_1.ts index 7c7174f357..8c386680ad 100644 --- a/meteor/server/migration/deprecatedDataTypes/1_0_1.ts +++ b/meteor/server/migration/deprecatedDataTypes/1_0_1.ts @@ -14,6 +14,7 @@ export interface Rundown { name: string expectedStart?: Time expectedDuration?: number + expectedEnd?: Time metaData?: { [key: string]: any } @@ -61,6 +62,7 @@ export function makePlaylistFromRundown_1_0_0( nextPartInstanceId: null, expectedDuration: rundown.expectedDuration, expectedStart: rundown.expectedStart, + expectedEnd: rundown.expectedEnd, holdState: rundown.holdState, name: rundown.name, nextPartManual: rundown.nextPartManual, diff --git a/packages/blueprints-integration/src/rundown.ts b/packages/blueprints-integration/src/rundown.ts index 56bc6ccea7..85da97b1ac 100644 --- a/packages/blueprints-integration/src/rundown.ts +++ b/packages/blueprints-integration/src/rundown.ts @@ -13,6 +13,8 @@ export interface IBlueprintRundownPlaylistInfo { expectedStart?: Time /** Expected duration of the rundown playlist */ expectedDuration?: number + /** Expected end time of the rundown playlist */ + expectedEnd?: Time /** Should the rundown playlist use out-of-order timing mode (unplayed content will be played eventually) as opposed to normal timing mode (unplayed content behind the OnAir line has been skipped) */ outOfOrderTiming?: boolean /** Should the rundown playlist loop at the end */ @@ -34,6 +36,8 @@ export interface IBlueprintRundown { expectedStart?: Time /** Expected duration of the rundown */ expectedDuration?: number + /** Expected end time of the rundown */ + expectedEnd?: Time /** Arbitrary data storage for plugins */ metaData?: TMetadata From f8cc7b7908b02340d3971a799359e761d5b666b6 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Fri, 4 Jun 2021 13:33:04 +0100 Subject: [PATCH 015/112] feat: End time diff --- meteor/client/ui/RundownView.tsx | 72 ++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 7953eba643..809f5750be 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -268,6 +268,20 @@ const TimingDisplay = withTranslation()(
) } + + private getEndTimeDiff(expectedDuration: number) { + let diff = 0 + if (this.props.rundownPlaylist.expectedEnd) { + let nowDiff = getCurrentTime() - this.props.rundownPlaylist.expectedEnd + let durationDiff = expectedDuration - (this.props.timingDurations.asPlayedRundownDuration ?? 0) + diff = nowDiff + durationDiff + } else { + diff = (this.props.timingDurations.asPlayedRundownDuration || 0) - expectedDuration + } + + return diff + } + render() { const { t, rundownPlaylist } = this.props @@ -340,7 +354,12 @@ const TimingDisplay = withTranslation()( )} {rundownPlaylist.expectedEnd || rundownPlaylist.expectedDuration ? ( - {!rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( + {!rundownPlaylist.loop && rundownPlaylist.expectedEnd ? ( + + {t('Planned End')} + + + ) : !rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( {t('Planned End')} - ) : !rundownPlaylist.loop && rundownPlaylist.expectedEnd ? ( - - {t('Planned End')} - - ) : null} - {!rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( - - {RundownUtils.formatDiffToTimecode( - getCurrentTime() - (rundownPlaylist.expectedStart + rundownPlaylist.expectedDuration), - true, - true, - true - )} - - ) : null} - {rundownPlaylist.expectedDuration && this.props.rundownCount < 2 ? ( // TEMPORARY: disable the diff counter for playlists longer than one rundown -- Jan Starzak, 2021-05-06 + {!rundownPlaylist.loop && + (rundownPlaylist.expectedEnd ? ( + + {RundownUtils.formatDiffToTimecode( + getCurrentTime() - rundownPlaylist.expectedEnd, + true, + true, + true + )} + + ) : rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( + + {RundownUtils.formatDiffToTimecode( + getCurrentTime() - (rundownPlaylist.expectedStart + rundownPlaylist.expectedDuration), + true, + true, + true + )} + + ) : null)} + {rundownPlaylist.expectedDuration ? ( {t('Diff')} {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedRundownDuration || 0) - rundownPlaylist.expectedDuration, + this.getEndTimeDiff(rundownPlaylist.expectedDuration), true, false, true, @@ -417,13 +441,15 @@ const TimingDisplay = withTranslation()( - ) + ) : null} + {!rundownPlaylist.loop && this.props.rundownPlaylist.expectedEnd ? ( + + {t('Planned End')} + + ) : null} {this.props.timingDurations && this.props.rundownCount < 2 ? ( // TEMPORARY: disable the diff counter for playlists longer than one rundown -- Jan Starzak, 2021-05-06 Date: Fri, 4 Jun 2021 14:33:21 +0100 Subject: [PATCH 016/112] fix: End time diff --- meteor/client/ui/RundownView.tsx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 809f5750be..7c7e998969 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -269,19 +269,6 @@ const TimingDisplay = withTranslation()( ) } - private getEndTimeDiff(expectedDuration: number) { - let diff = 0 - if (this.props.rundownPlaylist.expectedEnd) { - let nowDiff = getCurrentTime() - this.props.rundownPlaylist.expectedEnd - let durationDiff = expectedDuration - (this.props.timingDurations.asPlayedRundownDuration ?? 0) - diff = nowDiff + durationDiff - } else { - diff = (this.props.timingDurations.asPlayedRundownDuration || 0) - expectedDuration - } - - return diff - } - render() { const { t, rundownPlaylist } = this.props @@ -373,7 +360,9 @@ const TimingDisplay = withTranslation()( (rundownPlaylist.expectedEnd ? ( {RundownUtils.formatDiffToTimecode( - getCurrentTime() - rundownPlaylist.expectedEnd, + getCurrentTime() - + rundownPlaylist.expectedEnd + + (this.props.timingDurations.asPlayedRundownDuration ?? 0), true, true, true @@ -402,7 +391,7 @@ const TimingDisplay = withTranslation()( > {t('Diff')} {RundownUtils.formatDiffToTimecode( - this.getEndTimeDiff(rundownPlaylist.expectedDuration), + (this.props.timingDurations.asPlayedRundownDuration || 0) - rundownPlaylist.expectedDuration, true, false, true, From 757082214d90bf1cf518350227f685626960a893 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Wed, 9 Jun 2021 15:54:35 +0100 Subject: [PATCH 017/112] wip: Move rundown timing to lib and add tests --- .../RundownTiming/RundownTiming.ts | 35 -- .../RundownTiming/RundownTimingProvider.tsx | 342 +------------ .../SegmentTimelineContainer.tsx | 11 +- .../rundown/__tests__/rundownTiming.test.ts | 377 ++++++++++++++ meteor/lib/rundown/rundownTiming.ts | 458 ++++++++++++++++++ 5 files changed, 849 insertions(+), 374 deletions(-) create mode 100644 meteor/lib/rundown/__tests__/rundownTiming.test.ts create mode 100644 meteor/lib/rundown/rundownTiming.ts diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts b/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts index 98fe3c934d..16524cf1e0 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts @@ -87,38 +87,3 @@ export namespace RundownTiming { timingDurations: RundownTimingContext } } - -/** - * Computes the actual (as-played fallbacking to expected) duration of a segment, consisting of given parts - * @export - * @param {RundownTiming.RundownTimingContext} timingDurations The timing durations calculated for the Rundown - * @param {Array} partIds The IDs of parts that are members of the segment - * @return number - */ -export function computeSegmentDuration( - timingDurations: RundownTiming.RundownTimingContext, - partIds: PartId[], - display?: boolean -): number { - const partDurations = timingDurations.partDurations - - if (partDurations === undefined) return 0 - - return partIds.reduce((memo, partId) => { - const pId = unprotectString(partId) - const partDuration = - (partDurations ? (partDurations[pId] !== undefined ? partDurations[pId] : 0) : 0) || - (display ? Settings.defaultDisplayDuration : 0) - return memo + partDuration - }, 0) -} - -export function computeSegmentDisplayDuration( - timingDurations: RundownTiming.RundownTimingContext, - parts: PartUi[] -): number { - return parts.reduce( - (memo, part) => memo + SegmentTimelinePartClass.getPartDisplayDuration(part, timingDurations), - 0 - ) -} diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index 248c5dd3fc..fcfad1a16b 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -4,19 +4,12 @@ import * as PropTypes from 'prop-types' import * as _ from 'underscore' import { withTracker } from '../../../lib/ReactMeteorData/react-meteor-data' import { Part, PartId } from '../../../../lib/collections/Parts' -import { getCurrentTime, literal, protectString, unprotectString } from '../../../../lib/lib' +import { getCurrentTime } from '../../../../lib/lib' import { MeteorReactComponent } from '../../../lib/MeteorReactComponent' import { RundownPlaylist } from '../../../../lib/collections/RundownPlaylists' -import { - PartInstance, - wrapPartToTemporaryInstance, - findPartInstanceInMapOrWrapToTemporary, -} from '../../../../lib/collections/PartInstances' -import { Settings } from '../../../../lib/Settings' +import { PartInstance } from '../../../../lib/collections/PartInstances' import { RundownTiming, TimeEventArgs } from './RundownTiming' - -// Minimum duration that a part can be assigned. Used by gap parts to allow them to "compress" to indicate time running out. -const MINIMAL_NONZERO_DURATION = 1 +import { RundownTimingCalculator } from '../../../../lib/rundown/rundownTiming' const TIMING_DEFAULT_REFRESH_INTERVAL = 1000 / 60 // the interval for high-resolution events (timeupdateHR) const LOW_RESOLUTION_TIMING_DECIMATOR = 15 // the low-resolution events will be called every @@ -125,18 +118,7 @@ export const RundownTimingProvider = withTracker< refreshTimerInterval: number refreshDecimator: number - private temporaryPartInstances: Map = new Map() - - private linearParts: Array<[PartId, number | null]> = [] - // look at the comments on RundownTimingContext to understand what these do - private partDurations: Record = {} - private partExpectedDurations: Record = {} - private partPlayed: Record = {} - private partStartsAt: Record = {} - private partDisplayStartsAt: Record = {} - private partDisplayDurations: Record = {} - private partDisplayDurationsNoPlayback: Record = {} - private displayDurationGroups: Record = {} + private timingCalculator: RundownTimingCalculator = new RundownTimingCalculator() constructor(props: IRundownTimingProviderProps & IRundownTimingProviderTrackedProps) { super(props) @@ -180,7 +162,7 @@ export const RundownTimingProvider = withTracker< } if (prevProps.parts !== this.props.parts) { // empty the temporary Part Instances cache - this.temporaryPartInstances.clear() + this.timingCalculator.clearTempPartInstances() this.onRefreshTimer() } } @@ -211,318 +193,18 @@ export const RundownTimingProvider = withTracker< window.dispatchEvent(event) } - private getPartInstanceOrGetCachedTemp(partInstancesMap: Map, part: Part): PartInstance { - const origPartId = part._id - const partInstance = partInstancesMap.get(origPartId) - if (partInstance !== undefined) { - return partInstance - } else { - let tempPartInstance = this.temporaryPartInstances.get(origPartId) - if (tempPartInstance !== undefined) { - return tempPartInstance - } else { - tempPartInstance = wrapPartToTemporaryInstance(protectString(''), part) - this.temporaryPartInstances.set(origPartId, tempPartInstance) - return tempPartInstance - } - } - } - updateDurations(now: number, isLowResolution: boolean) { - let totalRundownDuration = 0 - let remainingRundownDuration = 0 - let asPlayedRundownDuration = 0 - let asDisplayedRundownDuration = 0 - let waitAccumulator = 0 - let currentRemaining = 0 - let startsAtAccumulator = 0 - let displayStartsAtAccumulator = 0 - - Object.keys(this.displayDurationGroups).forEach((key) => delete this.displayDurationGroups[key]) - this.linearParts.length = 0 - const { playlist, parts, partInstancesMap } = this.props - - let nextAIndex = -1 - let currentAIndex = -1 - - if (playlist && parts) { - parts.forEach((origPart, itIndex) => { - const partInstance = this.getPartInstanceOrGetCachedTemp(partInstancesMap, origPart) - - // add piece to accumulator - const aIndex = this.linearParts.push([partInstance.part._id, waitAccumulator]) - 1 - - // if this is next segementLine, clear previous countdowns and clear accumulator - if (playlist.nextPartInstanceId === partInstance._id) { - nextAIndex = aIndex - } else if (playlist.currentPartInstanceId === partInstance._id) { - currentAIndex = aIndex - } - - const partCounts = - playlist.outOfOrderTiming || - !playlist.activationId || - (itIndex >= currentAIndex && currentAIndex >= 0) || - (itIndex >= nextAIndex && nextAIndex >= 0 && currentAIndex === -1) - - const partIsUntimed = partInstance.part.untimed || false - - // expected is just a sum of expectedDurations - totalRundownDuration += partInstance.part.expectedDuration || 0 - - const lastStartedPlayback = partInstance.timings?.startedPlayback - const playOffset = partInstance.timings?.playOffset || 0 - - let partDuration = 0 - let partExpectedDuration = 0 - let partDisplayDuration = 0 - let partDisplayDurationNoPlayback = 0 - - let displayDurationFromGroup = 0 - - partExpectedDuration = partInstance.part.expectedDuration || partInstance.timings?.duration || 0 - - // Display Duration groups are groups of two or more Parts, where some of them have an - // expectedDuration and some have 0. - // Then, some of them will have a displayDuration. The expectedDurations are pooled together, the parts with - // display durations will take up that much time in the Rundown. The left-over time from the display duration group - // will be used by Parts without expectedDurations. - let memberOfDisplayDurationGroup = false - // using a separate displayDurationGroup processing flag simplifies implementation - if ( - partInstance.part.displayDurationGroup && - // either this is not the first element of the displayDurationGroup - (this.displayDurationGroups[partInstance.part.displayDurationGroup] !== undefined || - // or there is a following member of this displayDurationGroup - (parts[itIndex + 1] && - parts[itIndex + 1].displayDurationGroup === partInstance.part.displayDurationGroup)) && - !partInstance.part.floated && - !partIsUntimed - ) { - this.displayDurationGroups[partInstance.part.displayDurationGroup] = - (this.displayDurationGroups[partInstance.part.displayDurationGroup] || 0) + - (partInstance.part.expectedDuration || 0) - displayDurationFromGroup = - partInstance.part.displayDuration || - Math.max( - 0, - this.displayDurationGroups[partInstance.part.displayDurationGroup], - partInstance.part.gap - ? MINIMAL_NONZERO_DURATION - : this.props.defaultDuration || Settings.defaultDisplayDuration - ) - partExpectedDuration = - partExpectedDuration || this.displayDurationGroups[partInstance.part.displayDurationGroup] || 0 - memberOfDisplayDurationGroup = true - } - - // This is where we actually calculate all the various variants of duration of a part - if (lastStartedPlayback && !partInstance.timings?.duration) { - // if duration isn't available, check if `takeOut` has already been set and use the difference - // between startedPlayback and takeOut as a temporary duration - const duration = - partInstance.timings?.duration || - (partInstance.timings?.takeOut ? lastStartedPlayback - partInstance.timings?.takeOut : undefined) - currentRemaining = Math.max( - 0, - (duration || - (memberOfDisplayDurationGroup ? displayDurationFromGroup : partInstance.part.expectedDuration) || - 0) - - (now - lastStartedPlayback) - ) - partDuration = - Math.max(duration || partInstance.part.expectedDuration || 0, now - lastStartedPlayback) - playOffset - // because displayDurationGroups have no actual timing on them, we need to have a copy of the - // partDisplayDuration, but calculated as if it's not playing, so that the countdown can be - // calculated - partDisplayDurationNoPlayback = - duration || - (memberOfDisplayDurationGroup ? displayDurationFromGroup : partInstance.part.expectedDuration) || - this.props.defaultDuration || - Settings.defaultDisplayDuration - partDisplayDuration = Math.max(partDisplayDurationNoPlayback, now - lastStartedPlayback) - this.partPlayed[unprotectString(partInstance.part._id)] = now - lastStartedPlayback - } else { - partDuration = (partInstance.timings?.duration || partInstance.part.expectedDuration || 0) - playOffset - partDisplayDurationNoPlayback = Math.max( - 0, - (partInstance.timings?.duration && partInstance.timings?.duration + playOffset) || - displayDurationFromGroup || - partInstance.part.expectedDuration || - this.props.defaultDuration || - Settings.defaultDisplayDuration - ) - partDisplayDuration = partDisplayDurationNoPlayback - this.partPlayed[unprotectString(partInstance.part._id)] = (partInstance.timings?.duration || 0) - playOffset - } - - // asPlayed is the actual duration so far and expected durations in unplayed lines. - // If item is onAir right now, it's duration is counted as expected duration or current - // playback duration whichever is larger. - // Parts that are Untimed are ignored always. - // Parts that don't count are ignored, unless they are being played or have been played. - if (!partIsUntimed) { - if (lastStartedPlayback && !partInstance.timings?.duration) { - asPlayedRundownDuration += Math.max(partExpectedDuration, now - lastStartedPlayback) - } else if (partInstance.timings?.duration) { - asPlayedRundownDuration += partInstance.timings.duration - } else if (partCounts) { - asPlayedRundownDuration += partInstance.part.expectedDuration || 0 - } - } - - // asDisplayed is the actual duration so far and expected durations in unplayed lines - // If item is onAir right now, it's duration is counted as expected duration or current - // playback duration whichever is larger. - // All parts are counted. - if (lastStartedPlayback && !partInstance.timings?.duration) { - asDisplayedRundownDuration += Math.max( - memberOfDisplayDurationGroup - ? Math.max(partExpectedDuration, partInstance.part.expectedDuration || 0) - : partInstance.part.expectedDuration || 0, - now - lastStartedPlayback - ) - } else { - asDisplayedRundownDuration += partInstance.timings?.duration || partInstance.part.expectedDuration || 0 - } - - // the part is the current part but has not yet started playback - if (playlist.currentPartInstanceId === partInstance._id && !lastStartedPlayback) { - currentRemaining = partDisplayDuration - } - - // Handle invalid parts by overriding the values to preset values for Invalid parts - if (partInstance.part.invalid && !partInstance.part.gap) { - partDisplayDuration = this.props.defaultDuration || Settings.defaultDisplayDuration - this.partPlayed[unprotectString(partInstance.part._id)] = 0 - } - - if ( - memberOfDisplayDurationGroup && - partInstance.part.displayDurationGroup && - !partInstance.part.floated && - !partInstance.part.invalid && - !partIsUntimed && - (partInstance.timings?.duration || partInstance.timings?.takeOut || partCounts) - ) { - this.displayDurationGroups[partInstance.part.displayDurationGroup] = - this.displayDurationGroups[partInstance.part.displayDurationGroup] - partDisplayDuration - } - const partInstancePartId = unprotectString(partInstance.part._id) - this.partExpectedDurations[partInstancePartId] = partExpectedDuration - this.partStartsAt[partInstancePartId] = startsAtAccumulator - this.partDisplayStartsAt[partInstancePartId] = displayStartsAtAccumulator - this.partDurations[partInstancePartId] = partDuration - this.partDisplayDurations[partInstancePartId] = partDisplayDuration - this.partDisplayDurationsNoPlayback[partInstancePartId] = partDisplayDurationNoPlayback - startsAtAccumulator += this.partDurations[partInstancePartId] - displayStartsAtAccumulator += this.partDisplayDurations[partInstancePartId] // || this.props.defaultDuration || 3000 - // waitAccumulator is used to calculate the countdowns for Parts relative to the current Part - // always add the full duration, in case by some manual intervention this segment should play twice - if (memberOfDisplayDurationGroup) { - waitAccumulator += - partInstance.timings?.duration || partDisplayDuration || partInstance.part.expectedDuration || 0 - } else { - waitAccumulator += partInstance.timings?.duration || partInstance.part.expectedDuration || 0 - } - - // remaining is the sum of unplayed lines + whatever is left of the current segment - // if outOfOrderTiming is true, count parts before current part towards remaining rundown duration - // if false (default), past unplayed parts will not count towards remaining time - if (!lastStartedPlayback && !partInstance.part.floated && partCounts && !partIsUntimed) { - remainingRundownDuration += partExpectedDuration || 0 - // item is onAir right now, and it's is currently shorter than expectedDuration - } else if ( - lastStartedPlayback && - !partInstance.timings?.duration && - playlist.currentPartInstanceId === partInstance._id && - lastStartedPlayback + partExpectedDuration > now && - !partIsUntimed - ) { - remainingRundownDuration += partExpectedDuration - (now - lastStartedPlayback) - } - }) - - // This is where the waitAccumulator-generated data in the linearSegLines is used to calculate the countdowns. - let localAccum = 0 - for (let i = 0; i < this.linearParts.length; i++) { - if (i < nextAIndex) { - // this is a line before next line - localAccum = this.linearParts[i][1] || 0 - // only null the values if not looping, if looping, these will be offset by the countdown for the last part - if (!playlist.loop) { - this.linearParts[i][1] = null // we use null to express 'will not probably be played out, if played in order' - } - } else if (i === nextAIndex) { - // this is a calculation for the next line, which is basically how much there is left of the current line - localAccum = this.linearParts[i][1] || 0 // if there is no current line, rebase following lines to the next line - this.linearParts[i][1] = currentRemaining - } else { - // these are lines after next line - // we take whatever value this line has, subtract the value as set on the Next Part - // (note that the Next Part value will be using currentRemaining as the countdown) - // and add the currentRemaining countdown, since we are currentRemaining + diff between next and - // this away from this line. - this.linearParts[i][1] = (this.linearParts[i][1] || 0) - localAccum + currentRemaining - } - } - // contiunation of linearParts calculations for looping playlists - if (playlist.loop) { - for (let i = 0; i < nextAIndex; i++) { - // offset the parts before the on air line by the countdown for the end of the rundown - this.linearParts[i][1] = (this.linearParts[i][1] || 0) + waitAccumulator - localAccum + currentRemaining - } - } - - // if (this.refreshDecimator % LOW_RESOLUTION_TIMING_DECIMATOR === 0) { - // const c = document.getElementById('debug-console') - // if (c) c.innerHTML = debugConsole.replace(/\n/g, '
') - // } - } - - let remainingTimeOnCurrentPart: number | undefined = undefined - let currentPartWillAutoNext = false - if (currentAIndex >= 0) { - const currentLivePart = parts[currentAIndex] - const currentLivePartInstance = findPartInstanceInMapOrWrapToTemporary(partInstancesMap, currentLivePart) - - const lastStartedPlayback = currentLivePartInstance.timings?.startedPlayback - - let onAirPartDuration = currentLivePartInstance.timings?.duration || currentLivePart.expectedDuration || 0 - if (currentLivePart.displayDurationGroup) { - onAirPartDuration = this.partExpectedDurations[unprotectString(currentLivePart._id)] || onAirPartDuration - } - - remainingTimeOnCurrentPart = lastStartedPlayback - ? now - (lastStartedPlayback + onAirPartDuration) - : onAirPartDuration * -1 - - currentPartWillAutoNext = !!( - currentLivePart.autoNext && - (currentLivePart.expectedDuration !== undefined ? currentLivePart.expectedDuration !== 0 : false) - ) - } - this.durations = Object.assign( this.durations, - literal({ - totalRundownDuration, - remainingRundownDuration, - asDisplayedRundownDuration, - asPlayedRundownDuration, - partCountdown: _.object(this.linearParts), - partDurations: this.partDurations, - partPlayed: this.partPlayed, - partStartsAt: this.partStartsAt, - partDisplayStartsAt: this.partDisplayStartsAt, - partExpectedDurations: this.partExpectedDurations, - partDisplayDurations: this.partDisplayDurations, - currentTime: now, - remainingTimeOnCurrentPart, - currentPartWillAutoNext, + this.timingCalculator.updateDurations( + now, isLowResolution, - }) + playlist, + parts, + partInstancesMap, + this.props.defaultDuration + ) ) } diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index 023aa3baad..de4ace347a 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -7,12 +7,7 @@ import { Translated, translateWithTracker } from '../../lib/ReactMeteorData/reac import { Segments, SegmentId } from '../../../lib/collections/Segments' import { Studio } from '../../../lib/collections/Studios' import { SegmentTimeline, SegmentTimelineClass } from './SegmentTimeline' -import { - RundownTiming, - computeSegmentDuration, - TimingEvent, - computeSegmentDisplayDuration, -} from '../RundownView/RundownTiming/RundownTiming' +import { RundownTiming, TimingEvent } from '../RundownView/RundownTiming/RundownTiming' import { UIStateStorage } from '../../lib/UIStateStorage' import { MeteorReactComponent } from '../../lib/MeteorReactComponent' import { @@ -48,9 +43,7 @@ import RundownViewEventBus, { import { memoizedIsolatedAutorun, slowDownReactivity } from '../../lib/reactiveData/reactiveDataHelper' import { checkPieceContentStatus, getNoteTypeForPieceStatus, ScanInfoForPackages } from '../../../lib/mediaObjects' import { getBasicNotesForSegment } from '../../../lib/rundownNotifications' -import { SegmentTimelinePartClass } from './SegmentTimelinePart' -import { RundownAPI } from '../../../lib/api/rundown' -import { Piece, Pieces } from '../../../lib/collections/Pieces' +import { computeSegmentDuration } from '../../../lib/rundown/rundownTiming' export const SIMULATED_PLAYBACK_SOFT_MARGIN = 0 export const SIMULATED_PLAYBACK_HARD_MARGIN = 2500 diff --git a/meteor/lib/rundown/__tests__/rundownTiming.test.ts b/meteor/lib/rundown/__tests__/rundownTiming.test.ts new file mode 100644 index 0000000000..0869c4efbb --- /dev/null +++ b/meteor/lib/rundown/__tests__/rundownTiming.test.ts @@ -0,0 +1,377 @@ +import { PartInstance } from '../../collections/PartInstances' +import { DBPart, Part, PartId } from '../../collections/Parts' +import { DBRundownPlaylist, RundownPlaylist } from '../../collections/RundownPlaylists' +import { literal, protectString } from '../../lib' +import { RundownTiming, RundownTimingCalculator } from '../rundownTiming' + +const DEFAULT_DURATION = 4000 + +function makeMockPlaylist(): RundownPlaylist { + return new RundownPlaylist( + literal({ + _id: protectString('mock-playlist'), + externalId: 'mock-playlist', + organizationId: protectString('test'), + studioId: protectString('studio0'), + name: 'Mock Playlist', + created: 0, + modified: 0, + currentPartInstanceId: null, + nextPartInstanceId: null, + previousPartInstanceId: null, + }) + ) +} + +function makeMockPart( + id: string, + rank: number, + rundownId: string, + segmentId: string, + durations: { expectedDuration?: number; displayDuration?: number; displayDurationGroup?: string } +): Part { + return new Part( + literal({ + _id: protectString(id), + externalId: id, + title: '', + segmentId: protectString(segmentId), + _rank: rank, + rundownId: protectString(rundownId), + ...durations, + }) + ) +} + +describe('rundown Timing Calculator', () => { + it('Provides output for empty playlist', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + const parts: Part[] = [] + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 0, + asPlayedRundownDuration: 0, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: {}, + partDisplayDurations: {}, + partDisplayStartsAt: {}, + partDurations: {}, + partExpectedDurations: {}, + partPlayed: {}, + partStartsAt: {}, + remainingRundownDuration: 0, + totalRundownDuration: 0, + }) + ) + }) + + it('Calculates time for unplayed playlist with start time and duration', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + playlist.expectedStart = 0 + playlist.expectedDuration = 4000 + const rundownId = 'rundown1' + const segmentId1 = 'segment1' + const segmentId2 = 'segment2' + const parts: Part[] = [] + parts.push(makeMockPart('part1', 0, rundownId, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part2', 0, rundownId, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part3', 0, rundownId, segmentId2, { expectedDuration: 1000 })) + parts.push(makeMockPart('part4', 0, rundownId, segmentId2, { expectedDuration: 1000 })) + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 4000, + asPlayedRundownDuration: 4000, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDisplayDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partDisplayStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partExpectedDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partPlayed: { + part1: 0, + part2: 0, + part3: 0, + part4: 0, + }, + partStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + remainingRundownDuration: 4000, + totalRundownDuration: 4000, + }) + ) + }) + + it('Calculates time for unplayed playlist with end time and duration', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + playlist.expectedDuration = 4000 + playlist.expectedEnd = 4000 + const rundownId = 'rundown1' + const segmentId1 = 'segment1' + const segmentId2 = 'segment2' + const parts: Part[] = [] + parts.push(makeMockPart('part1', 0, rundownId, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part2', 0, rundownId, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part3', 0, rundownId, segmentId2, { expectedDuration: 1000 })) + parts.push(makeMockPart('part4', 0, rundownId, segmentId2, { expectedDuration: 1000 })) + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 4000, + asPlayedRundownDuration: 4000, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDisplayDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partDisplayStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partExpectedDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partPlayed: { + part1: 0, + part2: 0, + part3: 0, + part4: 0, + }, + partStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + remainingRundownDuration: 4000, + totalRundownDuration: 4000, + }) + ) + }) + + it('Produces timing per rundown with start time and duration', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + playlist.expectedStart = 0 + playlist.expectedDuration = 4000 + const rundownId1 = 'rundown1' + const rundownId2 = 'rundown2' + const segmentId1 = 'segment1' + const segmentId2 = 'segment2' + const parts: Part[] = [] + parts.push(makeMockPart('part1', 0, rundownId1, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part2', 0, rundownId1, segmentId1, { expectedDuration: 1000 })) + parts.push(makeMockPart('part3', 0, rundownId2, segmentId2, { expectedDuration: 1000 })) + parts.push(makeMockPart('part4', 0, rundownId2, segmentId2, { expectedDuration: 1000 })) + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 4000, + asPlayedRundownDuration: 4000, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDisplayDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partDisplayStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + partDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partExpectedDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partPlayed: { + part1: 0, + part2: 0, + part3: 0, + part4: 0, + }, + partStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + remainingRundownDuration: 4000, + totalRundownDuration: 4000, + }) + ) + }) + + it('Handles display duration groups', () => { + const timing = new RundownTimingCalculator() + const playlist: RundownPlaylist = makeMockPlaylist() + playlist.expectedStart = 0 + playlist.expectedDuration = 4000 + const rundownId1 = 'rundown1' + const segmentId1 = 'segment1' + const segmentId2 = 'segment2' + const parts: Part[] = [] + parts.push( + makeMockPart('part1', 0, rundownId1, segmentId1, { + expectedDuration: 1000, + displayDuration: 2000, + displayDurationGroup: 'test', + }) + ) + parts.push( + makeMockPart('part2', 0, rundownId1, segmentId1, { + expectedDuration: 1000, + displayDuration: 3000, + displayDurationGroup: 'test', + }) + ) + parts.push( + makeMockPart('part3', 0, rundownId1, segmentId2, { + expectedDuration: 1000, + displayDuration: 4000, + displayDurationGroup: 'test', + }) + ) + parts.push( + makeMockPart('part4', 0, rundownId1, segmentId2, { + expectedDuration: 1000, + displayDuration: 5000, + displayDurationGroup: 'test', + }) + ) + const partInstancesMap: Map = new Map() + const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) + expect(result).toEqual( + literal({ + isLowResolution: false, + asDisplayedRundownDuration: 4000, + asPlayedRundownDuration: 4000, + currentPartWillAutoNext: false, + currentTime: 0, + partCountdown: { + part1: 0, + part2: 2000, + part3: 5000, + part4: 9000, + }, + partDisplayDurations: { + part1: 2000, + part2: 3000, + part3: 4000, + part4: 5000, + }, + partDisplayStartsAt: { + part1: 0, + part2: 2000, + part3: 5000, + part4: 9000, + }, + partDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partExpectedDurations: { + part1: 1000, + part2: 1000, + part3: 1000, + part4: 1000, + }, + partPlayed: { + part1: 0, + part2: 0, + part3: 0, + part4: 0, + }, + partStartsAt: { + part1: 0, + part2: 1000, + part3: 2000, + part4: 3000, + }, + remainingRundownDuration: 4000, + totalRundownDuration: 4000, + }) + ) + }) +}) diff --git a/meteor/lib/rundown/rundownTiming.ts b/meteor/lib/rundown/rundownTiming.ts new file mode 100644 index 0000000000..1bec59d69e --- /dev/null +++ b/meteor/lib/rundown/rundownTiming.ts @@ -0,0 +1,458 @@ +import _ from 'underscore' +import { + findPartInstanceInMapOrWrapToTemporary, + PartInstance, + wrapPartToTemporaryInstance, +} from '../collections/PartInstances' +import { Part, PartId } from '../collections/Parts' +import { RundownPlaylist } from '../collections/RundownPlaylists' +import { unprotectString, literal, protectString } from '../lib' +import { Settings } from '../Settings' + +// Minimum duration that a part can be assigned. Used by gap parts to allow them to "compress" to indicate time running out. +const MINIMAL_NONZERO_DURATION = 1 + +export class RundownTimingCalculator { + private temporaryPartInstances: Map = new Map() + + private linearParts: Array<[PartId, number | null]> = [] + // look at the comments on RundownTimingContext to understand what these do + private partDurations: Record = {} + private partExpectedDurations: Record = {} + private partPlayed: Record = {} + private partStartsAt: Record = {} + private partDisplayStartsAt: Record = {} + private partDisplayDurations: Record = {} + private partDisplayDurationsNoPlayback: Record = {} + private displayDurationGroups: Record = {} + + updateDurations( + now: number, + isLowResolution: boolean, + playlist: RundownPlaylist | undefined, + parts: Part[], + partInstancesMap: Map, + /** Fallback duration for Parts that have no as-played duration of their own. */ + defaultDuration?: number + ) { + let totalRundownDuration = 0 + let remainingRundownDuration = 0 + let asPlayedRundownDuration = 0 + let asDisplayedRundownDuration = 0 + let waitAccumulator = 0 + let currentRemaining = 0 + let startsAtAccumulator = 0 + let displayStartsAtAccumulator = 0 + + Object.keys(this.displayDurationGroups).forEach((key) => delete this.displayDurationGroups[key]) + this.linearParts.length = 0 + + let nextAIndex = -1 + let currentAIndex = -1 + + if (playlist && parts) { + parts.forEach((origPart, itIndex) => { + const partInstance = this.getPartInstanceOrGetCachedTemp(partInstancesMap, origPart) + + // add piece to accumulator + const aIndex = this.linearParts.push([partInstance.part._id, waitAccumulator]) - 1 + + // if this is next segementLine, clear previous countdowns and clear accumulator + if (playlist.nextPartInstanceId === partInstance._id) { + nextAIndex = aIndex + } else if (playlist.currentPartInstanceId === partInstance._id) { + currentAIndex = aIndex + } + + const partCounts = + playlist.outOfOrderTiming || + !playlist.activationId || + (itIndex >= currentAIndex && currentAIndex >= 0) || + (itIndex >= nextAIndex && nextAIndex >= 0 && currentAIndex === -1) + + const partIsUntimed = partInstance.part.untimed || false + + // expected is just a sum of expectedDurations + totalRundownDuration += partInstance.part.expectedDuration || 0 + + const lastStartedPlayback = partInstance.timings?.startedPlayback + const playOffset = partInstance.timings?.playOffset || 0 + + let partDuration = 0 + let partExpectedDuration = 0 + let partDisplayDuration = 0 + let partDisplayDurationNoPlayback = 0 + + let displayDurationFromGroup = 0 + + partExpectedDuration = partInstance.part.expectedDuration || partInstance.timings?.duration || 0 + + // Display Duration groups are groups of two or more Parts, where some of them have an + // expectedDuration and some have 0. + // Then, some of them will have a displayDuration. The expectedDurations are pooled together, the parts with + // display durations will take up that much time in the Rundown. The left-over time from the display duration group + // will be used by Parts without expectedDurations. + let memberOfDisplayDurationGroup = false + // using a separate displayDurationGroup processing flag simplifies implementation + if ( + partInstance.part.displayDurationGroup && + // either this is not the first element of the displayDurationGroup + (this.displayDurationGroups[partInstance.part.displayDurationGroup] !== undefined || + // or there is a following member of this displayDurationGroup + (parts[itIndex + 1] && + parts[itIndex + 1].displayDurationGroup === partInstance.part.displayDurationGroup)) && + !partInstance.part.floated && + !partIsUntimed + ) { + this.displayDurationGroups[partInstance.part.displayDurationGroup] = + (this.displayDurationGroups[partInstance.part.displayDurationGroup] || 0) + + (partInstance.part.expectedDuration || 0) + displayDurationFromGroup = + partInstance.part.displayDuration || + Math.max( + 0, + this.displayDurationGroups[partInstance.part.displayDurationGroup], + partInstance.part.gap + ? MINIMAL_NONZERO_DURATION + : defaultDuration || Settings.defaultDisplayDuration + ) + partExpectedDuration = + partExpectedDuration || this.displayDurationGroups[partInstance.part.displayDurationGroup] || 0 + memberOfDisplayDurationGroup = true + } + + // This is where we actually calculate all the various variants of duration of a part + if (lastStartedPlayback && !partInstance.timings?.duration) { + // if duration isn't available, check if `takeOut` has already been set and use the difference + // between startedPlayback and takeOut as a temporary duration + const duration = + partInstance.timings?.duration || + (partInstance.timings?.takeOut + ? lastStartedPlayback - partInstance.timings?.takeOut + : undefined) + currentRemaining = Math.max( + 0, + (duration || + (memberOfDisplayDurationGroup + ? displayDurationFromGroup + : partInstance.part.expectedDuration) || + 0) - + (now - lastStartedPlayback) + ) + partDuration = + Math.max(duration || partInstance.part.expectedDuration || 0, now - lastStartedPlayback) - + playOffset + // because displayDurationGroups have no actual timing on them, we need to have a copy of the + // partDisplayDuration, but calculated as if it's not playing, so that the countdown can be + // calculated + partDisplayDurationNoPlayback = + duration || + (memberOfDisplayDurationGroup + ? displayDurationFromGroup + : partInstance.part.expectedDuration) || + defaultDuration || + Settings.defaultDisplayDuration + partDisplayDuration = Math.max(partDisplayDurationNoPlayback, now - lastStartedPlayback) + this.partPlayed[unprotectString(partInstance.part._id)] = now - lastStartedPlayback + } else { + partDuration = + (partInstance.timings?.duration || partInstance.part.expectedDuration || 0) - playOffset + partDisplayDurationNoPlayback = Math.max( + 0, + (partInstance.timings?.duration && partInstance.timings?.duration + playOffset) || + displayDurationFromGroup || + partInstance.part.expectedDuration || + defaultDuration || + Settings.defaultDisplayDuration + ) + partDisplayDuration = partDisplayDurationNoPlayback + this.partPlayed[unprotectString(partInstance.part._id)] = + (partInstance.timings?.duration || 0) - playOffset + } + + // asPlayed is the actual duration so far and expected durations in unplayed lines. + // If item is onAir right now, it's duration is counted as expected duration or current + // playback duration whichever is larger. + // Parts that are Untimed are ignored always. + // Parts that don't count are ignored, unless they are being played or have been played. + if (!partIsUntimed) { + if (lastStartedPlayback && !partInstance.timings?.duration) { + asPlayedRundownDuration += Math.max(partExpectedDuration, now - lastStartedPlayback) + } else if (partInstance.timings?.duration) { + asPlayedRundownDuration += partInstance.timings.duration + } else if (partCounts) { + asPlayedRundownDuration += partInstance.part.expectedDuration || 0 + } + } + + // asDisplayed is the actual duration so far and expected durations in unplayed lines + // If item is onAir right now, it's duration is counted as expected duration or current + // playback duration whichever is larger. + // All parts are counted. + if (lastStartedPlayback && !partInstance.timings?.duration) { + asDisplayedRundownDuration += Math.max( + memberOfDisplayDurationGroup + ? Math.max(partExpectedDuration, partInstance.part.expectedDuration || 0) + : partInstance.part.expectedDuration || 0, + now - lastStartedPlayback + ) + } else { + asDisplayedRundownDuration += + partInstance.timings?.duration || partInstance.part.expectedDuration || 0 + } + + // the part is the current part but has not yet started playback + if (playlist.currentPartInstanceId === partInstance._id && !lastStartedPlayback) { + currentRemaining = partDisplayDuration + } + + // Handle invalid parts by overriding the values to preset values for Invalid parts + if (partInstance.part.invalid && !partInstance.part.gap) { + partDisplayDuration = defaultDuration || Settings.defaultDisplayDuration + this.partPlayed[unprotectString(partInstance.part._id)] = 0 + } + + if ( + memberOfDisplayDurationGroup && + partInstance.part.displayDurationGroup && + !partInstance.part.floated && + !partInstance.part.invalid && + !partIsUntimed && + (partInstance.timings?.duration || partInstance.timings?.takeOut || partCounts) + ) { + this.displayDurationGroups[partInstance.part.displayDurationGroup] = + this.displayDurationGroups[partInstance.part.displayDurationGroup] - partDisplayDuration + } + const partInstancePartId = unprotectString(partInstance.part._id) + this.partExpectedDurations[partInstancePartId] = partExpectedDuration + this.partStartsAt[partInstancePartId] = startsAtAccumulator + this.partDisplayStartsAt[partInstancePartId] = displayStartsAtAccumulator + this.partDurations[partInstancePartId] = partDuration + this.partDisplayDurations[partInstancePartId] = partDisplayDuration + this.partDisplayDurationsNoPlayback[partInstancePartId] = partDisplayDurationNoPlayback + startsAtAccumulator += this.partDurations[partInstancePartId] + displayStartsAtAccumulator += this.partDisplayDurations[partInstancePartId] // || this.props.defaultDuration || 3000 + // waitAccumulator is used to calculate the countdowns for Parts relative to the current Part + // always add the full duration, in case by some manual intervention this segment should play twice + if (memberOfDisplayDurationGroup) { + waitAccumulator += + partInstance.timings?.duration || partDisplayDuration || partInstance.part.expectedDuration || 0 + } else { + waitAccumulator += partInstance.timings?.duration || partInstance.part.expectedDuration || 0 + } + + // remaining is the sum of unplayed lines + whatever is left of the current segment + // if outOfOrderTiming is true, count parts before current part towards remaining rundown duration + // if false (default), past unplayed parts will not count towards remaining time + if (!lastStartedPlayback && !partInstance.part.floated && partCounts && !partIsUntimed) { + remainingRundownDuration += partExpectedDuration || 0 + // item is onAir right now, and it's is currently shorter than expectedDuration + } else if ( + lastStartedPlayback && + !partInstance.timings?.duration && + playlist.currentPartInstanceId === partInstance._id && + lastStartedPlayback + partExpectedDuration > now && + !partIsUntimed + ) { + remainingRundownDuration += partExpectedDuration - (now - lastStartedPlayback) + } + }) + + // This is where the waitAccumulator-generated data in the linearSegLines is used to calculate the countdowns. + let localAccum = 0 + for (let i = 0; i < this.linearParts.length; i++) { + if (i < nextAIndex) { + // this is a line before next line + localAccum = this.linearParts[i][1] || 0 + // only null the values if not looping, if looping, these will be offset by the countdown for the last part + if (!playlist.loop) { + this.linearParts[i][1] = null // we use null to express 'will not probably be played out, if played in order' + } + } else if (i === nextAIndex) { + // this is a calculation for the next line, which is basically how much there is left of the current line + localAccum = this.linearParts[i][1] || 0 // if there is no current line, rebase following lines to the next line + this.linearParts[i][1] = currentRemaining + } else { + // these are lines after next line + // we take whatever value this line has, subtract the value as set on the Next Part + // (note that the Next Part value will be using currentRemaining as the countdown) + // and add the currentRemaining countdown, since we are currentRemaining + diff between next and + // this away from this line. + this.linearParts[i][1] = (this.linearParts[i][1] || 0) - localAccum + currentRemaining + } + } + // contiunation of linearParts calculations for looping playlists + if (playlist.loop) { + for (let i = 0; i < nextAIndex; i++) { + // offset the parts before the on air line by the countdown for the end of the rundown + this.linearParts[i][1] = + (this.linearParts[i][1] || 0) + waitAccumulator - localAccum + currentRemaining + } + } + + // if (this.refreshDecimator % LOW_RESOLUTION_TIMING_DECIMATOR === 0) { + // const c = document.getElementById('debug-console') + // if (c) c.innerHTML = debugConsole.replace(/\n/g, '
') + // } + } + + let remainingTimeOnCurrentPart: number | undefined = undefined + let currentPartWillAutoNext = false + if (currentAIndex >= 0) { + const currentLivePart = parts[currentAIndex] + const currentLivePartInstance = findPartInstanceInMapOrWrapToTemporary(partInstancesMap, currentLivePart) + + const lastStartedPlayback = currentLivePartInstance.timings?.startedPlayback + + let onAirPartDuration = currentLivePartInstance.timings?.duration || currentLivePart.expectedDuration || 0 + if (currentLivePart.displayDurationGroup) { + onAirPartDuration = + this.partExpectedDurations[unprotectString(currentLivePart._id)] || onAirPartDuration + } + + remainingTimeOnCurrentPart = lastStartedPlayback + ? now - (lastStartedPlayback + onAirPartDuration) + : onAirPartDuration * -1 + + currentPartWillAutoNext = !!( + currentLivePart.autoNext && + (currentLivePart.expectedDuration !== undefined ? currentLivePart.expectedDuration !== 0 : false) + ) + } + + return literal({ + totalRundownDuration, + remainingRundownDuration, + asDisplayedRundownDuration, + asPlayedRundownDuration, + partCountdown: _.object(this.linearParts), + partDurations: this.partDurations, + partPlayed: this.partPlayed, + partStartsAt: this.partStartsAt, + partDisplayStartsAt: this.partDisplayStartsAt, + partExpectedDurations: this.partExpectedDurations, + partDisplayDurations: this.partDisplayDurations, + currentTime: now, + remainingTimeOnCurrentPart, + currentPartWillAutoNext, + isLowResolution, + }) + } + + clearTempPartInstances() { + this.temporaryPartInstances.clear() + } + + private getPartInstanceOrGetCachedTemp(partInstancesMap: Map, part: Part): PartInstance { + const origPartId = part._id + const partInstance = partInstancesMap.get(origPartId) + if (partInstance !== undefined) { + return partInstance + } else { + let tempPartInstance = this.temporaryPartInstances.get(origPartId) + if (tempPartInstance !== undefined) { + return tempPartInstance + } else { + tempPartInstance = wrapPartToTemporaryInstance(protectString(''), part) + this.temporaryPartInstances.set(origPartId, tempPartInstance) + return tempPartInstance + } + } + } +} + +export namespace RundownTiming { + /** + * Events used by the RundownTimingProvider + * @export + * @enum {number} + */ + export enum Events { + /** Event is emitted every now-and-then, generally to be used for simple displays */ + 'timeupdate' = 'sofie:rundownTimeUpdate', + /** event is emitted with a very high frequency (60 Hz), to be used sparingly as + * hooking up Components to it will cause a lot of renders + */ + 'timeupdateHR' = 'sofie:rundownTimeUpdateHR', + } + + /** + * Context object that will be passed to listening components. The dictionaries use the Part ID as a key. + * @export + * @interface RundownTimingContext + */ + export interface RundownTimingContext { + /** This is the total duration of the rundown as planned (using expectedDurations). */ + totalRundownDuration?: number + /** This is the content remaining to be played in the rundown (based on the expectedDurations). */ + remainingRundownDuration?: number + /** This is the total duration of the rundown: as planned for the unplayed (skipped & future) content, and as-run for the played-out. */ + asDisplayedRundownDuration?: number + /** This is the complete duration of the rundown: as planned for the unplayed content, and as-run for the played-out, but ignoring unplayed/unplayable parts in order */ + asPlayedRundownDuration?: number + /** this is the countdown to each of the parts relative to the current on air part. */ + partCountdown?: Record + /** The calculated durations of each of the Parts: as-planned/as-run depending on state. */ + partDurations?: Record + /** The offset of each of the Parts from the beginning of the Rundown. */ + partStartsAt?: Record + /** Same as partStartsAt, but will include display duration overrides + * (such as minimal display width for an Part, etc.). + */ + partDisplayStartsAt?: Record + /** Same as partDurations, but will include display duration overrides + * (such as minimal display width for an Part, etc.). + */ + partDisplayDurations?: Record + /** As-played durations of each part. Will be 0, if not yet played. + * Will be counted from start to now if currently playing. + */ + partPlayed?: Record + /** Expected durations of each of the parts or the as-played duration, + * if the Part does not have an expected duration. + */ + partExpectedDurations?: Record + /** Remaining time on current part */ + remainingTimeOnCurrentPart?: number | undefined + /** Current part will autoNext */ + currentPartWillAutoNext?: boolean + /** Current time of this calculation */ + currentTime?: number + /** Was this time context calculated during a high-resolution tick */ + isLowResolution: boolean + } + + /** + * This are the properties that will be injected by the withTiming HOC. + * @export + * @interface InjectedROTimingProps + */ + export interface InjectedROTimingProps { + timingDurations: RundownTimingContext + } +} + +/** + * Computes the actual (as-played fallbacking to expected) duration of a segment, consisting of given parts + * @export + * @param {RundownTiming.RundownTimingContext} timingDurations The timing durations calculated for the Rundown + * @param {Array} partIds The IDs of parts that are members of the segment + * @return number + */ +export function computeSegmentDuration( + timingDurations: RundownTiming.RundownTimingContext, + partIds: PartId[], + display?: boolean +): number { + let partDurations = timingDurations.partDurations + + if (partDurations === undefined) return 0 + + return partIds.reduce((memo, partId) => { + const pId = unprotectString(partId) + const partDuration = + (partDurations ? (partDurations[pId] !== undefined ? partDurations[pId] : 0) : 0) || + (display ? Settings.defaultDisplayDuration : 0) + return memo + partDuration + }, 0) +} From f1ddbe5a6514b6a18b3dc028f08ad11d3f5dbb55 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Mon, 14 Jun 2021 10:53:00 +0100 Subject: [PATCH 018/112] feat: UI components for timing to next break --- meteor/client/styles/rundownView.scss | 3 +- .../client/ui/ClockView/PresenterScreen.tsx | 6 +- meteor/client/ui/Prompter/OverUnderTimer.tsx | 9 +- meteor/client/ui/RundownView.tsx | 233 ++++++++++++------ .../ui/RundownView/RundownDividerHeader.tsx | 34 ++- .../client/ui/RundownView/RundownOverview.tsx | 2 +- .../RundownTiming/RundownTiming.ts | 52 +--- .../RundownTiming/RundownTimingProvider.tsx | 6 +- .../RundownView/RundownTiming/withTiming.tsx | 6 +- .../ui/SegmentTimeline/SegmentTimeline.tsx | 3 +- meteor/lib/collections/Rundowns.ts | 3 + .../rundown/__tests__/rundownTiming.test.ts | 54 ++-- meteor/lib/rundown/rundownTiming.ts | 148 ++++++----- .../blueprints-integration/src/rundown.ts | 4 + 14 files changed, 319 insertions(+), 244 deletions(-) diff --git a/meteor/client/styles/rundownView.scss b/meteor/client/styles/rundownView.scss index 9985695a66..1c4ad01c30 100644 --- a/meteor/client/styles/rundownView.scss +++ b/meteor/client/styles/rundownView.scss @@ -2530,7 +2530,8 @@ svg.icon { } > .rundown-divider-timeline__expected-start, - > .rundown-divider-timeline__expected-duration { + > .rundown-divider-timeline__expected-duration, + > .rundown-divider-timeline__expected-end { vertical-align: bottom; margin: 1em 0.5em 0.5em; diff --git a/meteor/client/ui/ClockView/PresenterScreen.tsx b/meteor/client/ui/ClockView/PresenterScreen.tsx index cea32acc2f..2a70fcc35d 100644 --- a/meteor/client/ui/ClockView/PresenterScreen.tsx +++ b/meteor/client/ui/ClockView/PresenterScreen.tsx @@ -318,9 +318,9 @@ export class PresenterScreenBase extends MeteorReactComponent< const nextSegment = this.props.nextSegment const overUnderClock = playlist.expectedDuration - ? (this.props.timingDurations.asPlayedRundownDuration || 0) - playlist.expectedDuration - : (this.props.timingDurations.asPlayedRundownDuration || 0) - - (this.props.timingDurations.totalRundownDuration || 0) + ? (this.props.timingDurations.asDisplayedPlaylistDuration || 0) - playlist.expectedDuration + : (this.props.timingDurations.asDisplayedPlaylistDuration || 0) - + (this.props.timingDurations.totalPlaylistDuration || 0) return (
diff --git a/meteor/client/ui/Prompter/OverUnderTimer.tsx b/meteor/client/ui/Prompter/OverUnderTimer.tsx index a3bef12b3e..e38fbe67d1 100644 --- a/meteor/client/ui/Prompter/OverUnderTimer.tsx +++ b/meteor/client/ui/Prompter/OverUnderTimer.tsx @@ -15,17 +15,18 @@ interface IProps { export const OverUnderTimer = withTiming()( class OverUnderTimer extends React.Component> { render() { - const target = this.props.rundownPlaylist.expectedDuration || this.props.timingDurations.totalRundownDuration || 0 + const target = + this.props.rundownPlaylist.expectedDuration || this.props.timingDurations.totalPlaylistDuration || 0 return target ? ( target, + heavy: (this.props.timingDurations.totalPlaylistDuration || 0) <= target, + light: (this.props.timingDurations.totalPlaylistDuration || 0) > target, })} > {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedRundownDuration || 0) - target, + (this.props.timingDurations.totalPlaylistDuration || 0) - target, true, false, true, diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 7c7e998969..fb46103ee3 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -203,6 +203,7 @@ interface ITimingDisplayProps { rundownPlaylist: RundownPlaylist currentRundown: Rundown | undefined rundownCount: number + isLastRundownInPlaylist: boolean } export enum RundownViewKbdShortcuts { @@ -268,9 +269,8 @@ const TimingDisplay = withTranslation()( ) } - render() { - const { t, rundownPlaylist } = this.props + const { t, rundownPlaylist, currentRundown } = this.props if (!rundownPlaylist) return null @@ -286,6 +286,15 @@ const TimingDisplay = withTranslation()( {t('Planned Start')} + ) : rundownPlaylist.expectedEnd && rundownPlaylist.expectedDuration ? ( + + {t('Expected Start')} + + ) : null} {rundownPlaylist.startedPlayback && rundownPlaylist.activationId && !rundownPlaylist.rehearsal ? ( rundownPlaylist.expectedStart ? ( @@ -339,68 +348,22 @@ const TimingDisplay = withTranslation()( ) : null} )} - {rundownPlaylist.expectedEnd || rundownPlaylist.expectedDuration ? ( + {rundownPlaylist.expectedDuration ? ( - {!rundownPlaylist.loop && rundownPlaylist.expectedEnd ? ( - - {t('Planned End')} - - - ) : !rundownPlaylist.loop && rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( - - {t('Planned End')} - - + {!rundownPlaylist.startedPlayback || + this.props.isLastRundownInPlaylist || + !currentRundown?.endIsBreak ? ( // TODO: Setting + ) : null} - {!rundownPlaylist.loop && - (rundownPlaylist.expectedEnd ? ( - - {RundownUtils.formatDiffToTimecode( - getCurrentTime() - - rundownPlaylist.expectedEnd + - (this.props.timingDurations.asPlayedRundownDuration ?? 0), - true, - true, - true - )} - - ) : rundownPlaylist.expectedStart && rundownPlaylist.expectedDuration ? ( - - {RundownUtils.formatDiffToTimecode( - getCurrentTime() - (rundownPlaylist.expectedStart + rundownPlaylist.expectedDuration), - true, - true, - true - )} - - ) : null)} - {rundownPlaylist.expectedDuration ? ( - - (rundownPlaylist.expectedDuration || 0), - })} - > - {t('Diff')} - {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedRundownDuration || 0) - rundownPlaylist.expectedDuration, - true, - false, - true, - true, - true, - undefined, - true - )} - + {rundownPlaylist.startedPlayback && + currentRundown?.endIsBreak && + !this.props.isLastRundownInPlaylist ? ( // TODO: Setting // TODO: Find next break in higher-order component, so next breaks reflects next rundown in playlist marked as break. + ) : null} ) : ( @@ -430,31 +393,25 @@ const TimingDisplay = withTranslation()( ) : null} - {!rundownPlaylist.loop && this.props.rundownPlaylist.expectedEnd ? ( - - {t('Planned End')} - - - ) : null} {this.props.timingDurations && this.props.rundownCount < 2 ? ( // TEMPORARY: disable the diff counter for playlists longer than one rundown -- Jan Starzak, 2021-05-06 - (this.props.timingDurations.totalRundownDuration || 0), + (this.props.timingDurations.asPlayedPlaylistDuration || 0) > + (this.props.timingDurations.totalPlaylistDuration || 0), })} > {t('Diff')} {RundownUtils.formatDiffToTimecode( - (this.props.timingDurations.asPlayedRundownDuration || 0) - - (this.props.timingDurations.totalRundownDuration || 0), + (this.props.timingDurations.asPlayedPlaylistDuration || 0) - + (this.props.timingDurations.totalPlaylistDuration || 0), true, false, true, @@ -474,6 +431,128 @@ const TimingDisplay = withTranslation()( ) ) +interface IEndTimingProps { + loop?: boolean + expectedStart?: number + expectedDuration: number + expectedEnd?: number +} + +const PlaylistEndTiming = withTranslation()( + withTiming()( + class PlaylistEndTiming extends React.Component>> { + render() { + let { t } = this.props + + return ( + + {!this.props.loop && this.props.expectedStart ? ( + + {t('Planned End')} + + + ) : !this.props.loop && this.props.expectedEnd ? ( + + {t('Planned End')} + + + ) : null} + {!this.props.loop && this.props.expectedStart && this.props.expectedDuration ? ( + + {RundownUtils.formatDiffToTimecode( + getCurrentTime() - (this.props.expectedStart + this.props.expectedDuration), + true, + true, + true + )} + + ) : !this.props.loop && this.props.expectedEnd ? ( + + {RundownUtils.formatDiffToTimecode(getCurrentTime() - this.props.expectedEnd, true, true, true)} + + ) : null} + {this.props.expectedDuration ? ( + (this.props.expectedDuration || 0), + })} + > + {t('Diff')} + {RundownUtils.formatDiffToTimecode( + (this.props.timingDurations.asPlayedPlaylistDuration || 0) - this.props.expectedDuration, + true, + false, + true, + true, + true, + undefined, + true + )} + + ) : null} + + ) + } + } + ) +) + +interface INextBreakTimingProps { + loop?: boolean + breakRundown: Rundown +} + +const NextBreakTiming = withTranslation()( + withTiming()( + class PlaylistEndTiming extends React.Component>> { + render() { + let { t, breakRundown } = this.props + + const rundownAsPlayedDuration = this.props.timingDurations.rundownAsPlayedDurations + ? this.props.timingDurations.rundownAsPlayedDurations[unprotectString(breakRundown._id)] + : undefined + + return ( + + + {t('Next Break')} + + + {!this.props.loop && breakRundown.expectedEnd ? ( + + {RundownUtils.formatDiffToTimecode(getCurrentTime() - breakRundown.expectedEnd, true, true, true)} + + ) : null} + {breakRundown.expectedDuration ? ( + (breakRundown.expectedDuration || 0), + })} + > + {t('Diff')} + {RundownUtils.formatDiffToTimecode( + (rundownAsPlayedDuration || 0) - breakRundown.expectedDuration, + true, + false, + true, + true, + true, + undefined, + true + )} + + ) : null} + + ) + } + } + ) +) + interface HotkeyDefinition { key: string label: string @@ -1377,6 +1456,10 @@ const RundownHeader = withTranslation()( rundownPlaylist={this.props.playlist} currentRundown={this.props.currentRundown} rundownCount={this.props.rundownIds.length} + isLastRundownInPlaylist={ + !!this.props.currentRundown?._id && + this.props.rundownIds.indexOf(this.props.currentRundown._id) === this.props.rundownIds.length - 1 + } /> , {} @@ -30,15 +30,15 @@ const RundownCountdown = withTranslation()( })(function RundownCountdown( props: Translated< WithTiming<{ - expectedStart: number | undefined + expectedStartOrEnd: number | undefined className?: string | undefined }> > ) { const { t } = props - if (props.expectedStart === undefined) return null + if (props.expectedStartOrEnd === undefined) return null - const time = props.expectedStart - (props.timingDurations.currentTime || 0) + const time = props.expectedStartOrEnd - (props.timingDurations.currentTime || 0) if (time < QUATER_DAY) { return ( @@ -70,7 +70,7 @@ export const RundownDividerHeader = withTranslation()(function RundownDividerHea return (

{rundown.name}

-

{playlist.name}

+ {rundown.name !== playlist.name &&

{playlist.name}

} {rundown.expectedStart ? (
{t('Planned Start')}  @@ -85,7 +85,7 @@ export const RundownDividerHeader = withTranslation()(function RundownDividerHea  
) : null} @@ -95,6 +95,24 @@ export const RundownDividerHeader = withTranslation()(function RundownDividerHea {RundownUtils.formatDiffToTimecode(rundown.expectedDuration, false, true, true, false, true)}
) : null} + {rundown.expectedEnd ? ( +
+ {t('Planned End')}  + + {rundown.expectedEnd} + +   + +
+ ) : null}
) }) diff --git a/meteor/client/ui/RundownView/RundownOverview.tsx b/meteor/client/ui/RundownView/RundownOverview.tsx index 3f67fc1d1e..ee560cd3a7 100644 --- a/meteor/client/ui/RundownView/RundownOverview.tsx +++ b/meteor/client/ui/RundownView/RundownOverview.tsx @@ -221,7 +221,7 @@ export const RundownOverview = withTracker - /** The calculated durations of each of the Parts: as-planned/as-run depending on state. */ - partDurations?: Record - /** The offset of each of the Parts from the beginning of the Rundown. */ - partStartsAt?: Record - /** Same as partStartsAt, but will include display duration overrides - * (such as minimal display width for an Part, etc.). - */ - partDisplayStartsAt?: Record - /** Same as partDurations, but will include display duration overrides - * (such as minimal display width for an Part, etc.). - */ - partDisplayDurations?: Record - /** As-played durations of each part. Will be 0, if not yet played. - * Will be counted from start to now if currently playing. - */ - partPlayed?: Record - /** Expected durations of each of the parts or the as-played duration, - * if the Part does not have an expected duration. - */ - partExpectedDurations?: Record - /** Remaining time on current part */ - remainingTimeOnCurrentPart?: number | undefined - /** Current part will autoNext */ - currentPartWillAutoNext?: boolean - /** Current time of this calculation */ - currentTime?: number - /** Was this time context calculated during a high-resolution tick */ - isLowResolution: boolean - } - /** * This are the properties that will be injected by the withTiming HOC. * @export diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index fcfad1a16b..0edb581023 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -9,7 +9,7 @@ import { MeteorReactComponent } from '../../../lib/MeteorReactComponent' import { RundownPlaylist } from '../../../../lib/collections/RundownPlaylists' import { PartInstance } from '../../../../lib/collections/PartInstances' import { RundownTiming, TimeEventArgs } from './RundownTiming' -import { RundownTimingCalculator } from '../../../../lib/rundown/rundownTiming' +import { RundownTimingCalculator, RundownTimingContext } from '../../../../lib/rundown/rundownTiming' const TIMING_DEFAULT_REFRESH_INTERVAL = 1000 / 60 // the interval for high-resolution events (timeupdateHR) const LOW_RESOLUTION_TIMING_DECIMATOR = 15 // the low-resolution events will be called every @@ -31,7 +31,7 @@ interface IRundownTimingProviderProps { defaultDuration?: number } interface IRundownTimingProviderChildContext { - durations: RundownTiming.RundownTimingContext + durations: RundownTimingContext } interface IRundownTimingProviderState {} interface IRundownTimingProviderTrackedProps { @@ -111,7 +111,7 @@ export const RundownTimingProvider = withTracker< durations: PropTypes.object.isRequired, } - durations: RundownTiming.RundownTimingContext = { + durations: RundownTimingContext = { isLowResolution: false, } refreshTimer: number diff --git a/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx b/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx index 9b9934e803..e2e2865bdc 100644 --- a/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx @@ -2,8 +2,10 @@ import * as React from 'react' import * as PropTypes from 'prop-types' import * as _ from 'underscore' import { RundownTiming } from './RundownTiming' +import { JsxEmit } from 'typescript' +import { RundownTimingContext } from '../../../../lib/rundown/rundownTiming' -export type TimingFilterFunction = (durations: RundownTiming.RundownTimingContext) => any +export type TimingFilterFunction = (durations: RundownTimingContext) => any export interface WithTimingOptions { isHighResolution?: boolean @@ -92,7 +94,7 @@ export function withTiming( } render() { - const durations: RundownTiming.RundownTimingContext = this.context.durations + const durations: RundownTimingContext = this.context.durations // If the timing HOC is supposed to be low resolution and we are rendering // during a high resolution tick, the WrappedComponent will render using diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx index 1fb6bf7099..695c5b783e 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -38,6 +38,7 @@ import { ZoomInIcon, ZoomOutIcon, ZoomShowAll } from '../../lib/segmentZoomIcon' import { PartInstanceId } from '../../../lib/collections/PartInstances' import { SegmentTimelineSmallPartFlag } from './SmallParts/SegmentTimelineSmallPartFlag' import { UIStateStorage } from '../../lib/UIStateStorage' +import { RundownTimingContext } from '../../../lib/rundown/rundownTiming' interface IProps { id: string @@ -138,7 +139,7 @@ const SegmentTimelineZoom = class SegmentTimelineZoom extends React.Component< calculateSegmentDuration(): number { let total = 0 if (this.context && this.context.durations) { - const durations = this.context.durations as RundownTiming.RundownTimingContext + const durations = this.context.durations as RundownTimingContext this.props.parts.forEach((item) => { // total += durations.partDurations ? durations.partDurations[item._id] : (item.duration || item.renderedDuration || 1) const duration = Math.max( diff --git a/meteor/lib/collections/Rundowns.ts b/meteor/lib/collections/Rundowns.ts index df1335204d..2103d32e43 100644 --- a/meteor/lib/collections/Rundowns.ts +++ b/meteor/lib/collections/Rundowns.ts @@ -67,6 +67,8 @@ export interface DBRundown /** External id of the Rundown Playlist to put this rundown in */ playlistExternalId?: string + /** Whether the end of the rundown marks a commercial break */ + endIsBreak?: boolean /** Name (user-facing) of the external NCS this rundown came from */ externalNRCSName: string /** The id of the Rundown Playlist this rundown is in */ @@ -105,6 +107,7 @@ export class Rundown implements DBRundown { public notifiedCurrentPlayingPartExternalId?: string public notes?: Array public playlistExternalId?: string + public endIsBreak?: boolean public externalNRCSName: string public playlistId: RundownPlaylistId public playlistIdIsSetInSofie?: boolean diff --git a/meteor/lib/rundown/__tests__/rundownTiming.test.ts b/meteor/lib/rundown/__tests__/rundownTiming.test.ts index 0869c4efbb..2e923b6c0a 100644 --- a/meteor/lib/rundown/__tests__/rundownTiming.test.ts +++ b/meteor/lib/rundown/__tests__/rundownTiming.test.ts @@ -53,10 +53,11 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 0, - asPlayedRundownDuration: 0, + asDisplayedPlaylistDuration: 0, + asPlayedPlaylistDuration: 0, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: {}, partCountdown: {}, partDisplayDurations: {}, partDisplayStartsAt: {}, @@ -64,8 +65,8 @@ describe('rundown Timing Calculator', () => { partExpectedDurations: {}, partPlayed: {}, partStartsAt: {}, - remainingRundownDuration: 0, - totalRundownDuration: 0, + remainingPlaylistDuration: 0, + totalPlaylistDuration: 0, }) ) }) @@ -88,10 +89,13 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 4000, - asPlayedRundownDuration: 4000, + asDisplayedPlaylistDuration: 4000, + asPlayedPlaylistDuration: 4000, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: { + [rundownId]: 4000, + }, partCountdown: { part1: 0, part2: 1000, @@ -134,8 +138,8 @@ describe('rundown Timing Calculator', () => { part3: 2000, part4: 3000, }, - remainingRundownDuration: 4000, - totalRundownDuration: 4000, + remainingPlaylistDuration: 4000, + totalPlaylistDuration: 4000, }) ) }) @@ -158,10 +162,13 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 4000, - asPlayedRundownDuration: 4000, + asDisplayedPlaylistDuration: 4000, + asPlayedPlaylistDuration: 4000, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: { + [rundownId]: 4000, + }, partCountdown: { part1: 0, part2: 1000, @@ -204,8 +211,8 @@ describe('rundown Timing Calculator', () => { part3: 2000, part4: 3000, }, - remainingRundownDuration: 4000, - totalRundownDuration: 4000, + remainingPlaylistDuration: 4000, + totalPlaylistDuration: 4000, }) ) }) @@ -229,10 +236,14 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 4000, - asPlayedRundownDuration: 4000, + asDisplayedPlaylistDuration: 4000, + asPlayedPlaylistDuration: 4000, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: { + [rundownId1]: 2000, + [rundownId2]: 2000, + }, partCountdown: { part1: 0, part2: 1000, @@ -275,8 +286,8 @@ describe('rundown Timing Calculator', () => { part3: 2000, part4: 3000, }, - remainingRundownDuration: 4000, - totalRundownDuration: 4000, + remainingPlaylistDuration: 4000, + totalPlaylistDuration: 4000, }) ) }) @@ -323,10 +334,13 @@ describe('rundown Timing Calculator', () => { expect(result).toEqual( literal({ isLowResolution: false, - asDisplayedRundownDuration: 4000, - asPlayedRundownDuration: 4000, + asDisplayedPlaylistDuration: 4000, + asPlayedPlaylistDuration: 4000, currentPartWillAutoNext: false, currentTime: 0, + rundownExpectedDurations: { + [rundownId1]: 4000, + }, partCountdown: { part1: 0, part2: 2000, @@ -369,8 +383,8 @@ describe('rundown Timing Calculator', () => { part3: 2000, part4: 3000, }, - remainingRundownDuration: 4000, - totalRundownDuration: 4000, + remainingPlaylistDuration: 4000, + totalPlaylistDuration: 4000, }) ) }) diff --git a/meteor/lib/rundown/rundownTiming.ts b/meteor/lib/rundown/rundownTiming.ts index 1bec59d69e..d60bdcf2de 100644 --- a/meteor/lib/rundown/rundownTiming.ts +++ b/meteor/lib/rundown/rundownTiming.ts @@ -44,6 +44,9 @@ export class RundownTimingCalculator { let startsAtAccumulator = 0 let displayStartsAtAccumulator = 0 + let rundownExpectedDurations: Record = {} + let rundownAsPlayedDurations: Record = {} + Object.keys(this.displayDurationGroups).forEach((key) => delete this.displayDurationGroups[key]) this.linearParts.length = 0 @@ -176,12 +179,25 @@ export class RundownTimingCalculator { // Parts that are Untimed are ignored always. // Parts that don't count are ignored, unless they are being played or have been played. if (!partIsUntimed) { + let valToAddToAsPlayedDuration = 0 + if (lastStartedPlayback && !partInstance.timings?.duration) { - asPlayedRundownDuration += Math.max(partExpectedDuration, now - lastStartedPlayback) + valToAddToAsPlayedDuration = Math.max(partExpectedDuration, now - lastStartedPlayback) } else if (partInstance.timings?.duration) { - asPlayedRundownDuration += partInstance.timings.duration + valToAddToAsPlayedDuration = partInstance.timings.duration } else if (partCounts) { - asPlayedRundownDuration += partInstance.part.expectedDuration || 0 + valToAddToAsPlayedDuration = partInstance.part.expectedDuration || 0 + } + + asPlayedRundownDuration += valToAddToAsPlayedDuration + if (!rundownAsPlayedDurations[unprotectString(partInstance.part.rundownId)]) { + rundownAsPlayedDurations[ + unprotectString(partInstance.part.rundownId) + ] = valToAddToAsPlayedDuration + } else { + rundownAsPlayedDurations[ + unprotectString(partInstance.part.rundownId) + ] += valToAddToAsPlayedDuration } } @@ -256,6 +272,12 @@ export class RundownTimingCalculator { ) { remainingRundownDuration += partExpectedDuration - (now - lastStartedPlayback) } + + if (!rundownExpectedDurations[unprotectString(partInstance.part.rundownId)]) { + rundownExpectedDurations[unprotectString(partInstance.part.rundownId)] = partExpectedDuration + } else { + rundownExpectedDurations[unprotectString(partInstance.part.rundownId)] += partExpectedDuration + } }) // This is where the waitAccumulator-generated data in the linearSegLines is used to calculate the countdowns. @@ -320,11 +342,13 @@ export class RundownTimingCalculator { ) } - return literal({ - totalRundownDuration, - remainingRundownDuration, - asDisplayedRundownDuration, - asPlayedRundownDuration, + return literal({ + totalPlaylistDuration: totalRundownDuration, + remainingPlaylistDuration: remainingRundownDuration, + asDisplayedPlaylistDuration: asDisplayedRundownDuration, + asPlayedPlaylistDuration: asPlayedRundownDuration, + rundownExpectedDurations, + rundownAsPlayedDurations, partCountdown: _.object(this.linearParts), partDurations: this.partDurations, partPlayed: this.partPlayed, @@ -361,75 +385,49 @@ export class RundownTimingCalculator { } } -export namespace RundownTiming { - /** - * Events used by the RundownTimingProvider - * @export - * @enum {number} +export interface RundownTimingContext { + /** This is the total duration of the palylist as planned (using expectedDurations). */ + totalPlaylistDuration?: number + /** This is the content remaining to be played in the playlist (based on the expectedDurations). */ + remainingPlaylistDuration?: number + /** This is the total duration of the playlist: as planned for the unplayed (skipped & future) content, and as-run for the played-out. */ + asDisplayedPlaylistDuration?: number + /** This is the complete duration of the playlist: as planned for the unplayed content, and as-run for the played-out, but ignoring unplayed/unplayable parts in order */ + asPlayedPlaylistDuration?: number + /** Expected duration of each rundown in playlist (based on part expected durations) */ + rundownExpectedDurations?: Record + /** This is the complete duration of each rundown: as planned for the unplayed content, and as-run for the played-out, but ignoring unplayed/unplayable parts in order */ + rundownAsPlayedDurations?: Record + /** this is the countdown to each of the parts relative to the current on air part. */ + partCountdown?: Record + /** The calculated durations of each of the Parts: as-planned/as-run depending on state. */ + partDurations?: Record + /** The offset of each of the Parts from the beginning of the Playlist. */ + partStartsAt?: Record + /** Same as partStartsAt, but will include display duration overrides + * (such as minimal display width for an Part, etc.). */ - export enum Events { - /** Event is emitted every now-and-then, generally to be used for simple displays */ - 'timeupdate' = 'sofie:rundownTimeUpdate', - /** event is emitted with a very high frequency (60 Hz), to be used sparingly as - * hooking up Components to it will cause a lot of renders - */ - 'timeupdateHR' = 'sofie:rundownTimeUpdateHR', - } - - /** - * Context object that will be passed to listening components. The dictionaries use the Part ID as a key. - * @export - * @interface RundownTimingContext + partDisplayStartsAt?: Record + /** Same as partDurations, but will include display duration overrides + * (such as minimal display width for an Part, etc.). */ - export interface RundownTimingContext { - /** This is the total duration of the rundown as planned (using expectedDurations). */ - totalRundownDuration?: number - /** This is the content remaining to be played in the rundown (based on the expectedDurations). */ - remainingRundownDuration?: number - /** This is the total duration of the rundown: as planned for the unplayed (skipped & future) content, and as-run for the played-out. */ - asDisplayedRundownDuration?: number - /** This is the complete duration of the rundown: as planned for the unplayed content, and as-run for the played-out, but ignoring unplayed/unplayable parts in order */ - asPlayedRundownDuration?: number - /** this is the countdown to each of the parts relative to the current on air part. */ - partCountdown?: Record - /** The calculated durations of each of the Parts: as-planned/as-run depending on state. */ - partDurations?: Record - /** The offset of each of the Parts from the beginning of the Rundown. */ - partStartsAt?: Record - /** Same as partStartsAt, but will include display duration overrides - * (such as minimal display width for an Part, etc.). - */ - partDisplayStartsAt?: Record - /** Same as partDurations, but will include display duration overrides - * (such as minimal display width for an Part, etc.). - */ - partDisplayDurations?: Record - /** As-played durations of each part. Will be 0, if not yet played. - * Will be counted from start to now if currently playing. - */ - partPlayed?: Record - /** Expected durations of each of the parts or the as-played duration, - * if the Part does not have an expected duration. - */ - partExpectedDurations?: Record - /** Remaining time on current part */ - remainingTimeOnCurrentPart?: number | undefined - /** Current part will autoNext */ - currentPartWillAutoNext?: boolean - /** Current time of this calculation */ - currentTime?: number - /** Was this time context calculated during a high-resolution tick */ - isLowResolution: boolean - } - - /** - * This are the properties that will be injected by the withTiming HOC. - * @export - * @interface InjectedROTimingProps + partDisplayDurations?: Record + /** As-played durations of each part. Will be 0, if not yet played. + * Will be counted from start to now if currently playing. */ - export interface InjectedROTimingProps { - timingDurations: RundownTimingContext - } + partPlayed?: Record + /** Expected durations of each of the parts or the as-played duration, + * if the Part does not have an expected duration. + */ + partExpectedDurations?: Record + /** Remaining time on current part */ + remainingTimeOnCurrentPart?: number | undefined + /** Current part will autoNext */ + currentPartWillAutoNext?: boolean + /** Current time of this calculation */ + currentTime?: number + /** Was this time context calculated during a high-resolution tick */ + isLowResolution: boolean } /** @@ -440,7 +438,7 @@ export namespace RundownTiming { * @return number */ export function computeSegmentDuration( - timingDurations: RundownTiming.RundownTimingContext, + timingDurations: RundownTimingContext, partIds: PartId[], display?: boolean ): number { diff --git a/packages/blueprints-integration/src/rundown.ts b/packages/blueprints-integration/src/rundown.ts index 85da97b1ac..c22d0b5322 100644 --- a/packages/blueprints-integration/src/rundown.ts +++ b/packages/blueprints-integration/src/rundown.ts @@ -44,7 +44,11 @@ export interface IBlueprintRundown { /** A hint to the Core that the Rundown should be a part of a playlist */ playlistExternalId?: string + + /** Whether the end of the rundown marks a commercial break */ + endIsBreak?: boolean } + /** The Rundown sent from Core */ export interface IBlueprintRundownDB extends IBlueprintRundown, From e094c56d60c6eb9b043919e7c97547d2af44da56 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Tue, 15 Jun 2021 11:20:19 +0100 Subject: [PATCH 019/112] fix: Rebase fixes --- .../ui/RundownView/RundownTiming/RundownTiming.ts | 9 +++++++++ .../ui/RundownView/RundownTiming/withTiming.tsx | 1 - .../client/ui/SegmentTimeline/SegmentTimeline.tsx | 2 ++ .../ui/SegmentTimeline/SegmentTimelineContainer.tsx | 13 ++++++++----- .../ui/SegmentTimeline/SegmentTimelinePart.tsx | 5 +++-- meteor/lib/rundown/__tests__/rundownTiming.test.ts | 12 ++++++------ meteor/lib/rundown/rundownTiming.ts | 12 +++++------- 7 files changed, 33 insertions(+), 21 deletions(-) diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts b/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts index 04482ae51b..94f03d0b57 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTiming.ts @@ -1,4 +1,6 @@ import { RundownTimingContext } from '../../../../lib/rundown/rundownTiming' +import { PartUi } from '../../SegmentTimeline/SegmentTimelineContainer' +import { SegmentTimelinePartClass } from '../../SegmentTimeline/SegmentTimelinePart' export interface TimeEventArgs { currentTime: number @@ -37,3 +39,10 @@ export namespace RundownTiming { timingDurations: RundownTimingContext } } + +export function computeSegmentDisplayDuration(timingDurations: RundownTimingContext, parts: PartUi[]): number { + return parts.reduce( + (memo, part) => memo + SegmentTimelinePartClass.getPartDisplayDuration(part, timingDurations), + 0 + ) +} diff --git a/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx b/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx index e2e2865bdc..3d4aee7eff 100644 --- a/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/withTiming.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import * as PropTypes from 'prop-types' import * as _ from 'underscore' import { RundownTiming } from './RundownTiming' -import { JsxEmit } from 'typescript' import { RundownTimingContext } from '../../../../lib/rundown/rundownTiming' export type TimingFilterFunction = (durations: RundownTimingContext) => any diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx index 695c5b783e..b4053b9817 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -39,6 +39,8 @@ import { PartInstanceId } from '../../../lib/collections/PartInstances' import { SegmentTimelineSmallPartFlag } from './SmallParts/SegmentTimelineSmallPartFlag' import { UIStateStorage } from '../../lib/UIStateStorage' import { RundownTimingContext } from '../../../lib/rundown/rundownTiming' +import { PartInstanceId } from '../../../lib/collections/PartInstances' +import { SegmentTimelineSmallPartFlag } from './SmallParts/SegmentTimelineSmallPartFlag' interface IProps { id: string diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index de4ace347a..8f2b6ff9f6 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -7,7 +7,7 @@ import { Translated, translateWithTracker } from '../../lib/ReactMeteorData/reac import { Segments, SegmentId } from '../../../lib/collections/Segments' import { Studio } from '../../../lib/collections/Studios' import { SegmentTimeline, SegmentTimelineClass } from './SegmentTimeline' -import { RundownTiming, TimingEvent } from '../RundownView/RundownTiming/RundownTiming' +import { computeSegmentDisplayDuration, RundownTiming, TimingEvent } from '../RundownView/RundownTiming/RundownTiming' import { UIStateStorage } from '../../lib/UIStateStorage' import { MeteorReactComponent } from '../../lib/MeteorReactComponent' import { @@ -43,7 +43,10 @@ import RundownViewEventBus, { import { memoizedIsolatedAutorun, slowDownReactivity } from '../../lib/reactiveData/reactiveDataHelper' import { checkPieceContentStatus, getNoteTypeForPieceStatus, ScanInfoForPackages } from '../../../lib/mediaObjects' import { getBasicNotesForSegment } from '../../../lib/rundownNotifications' -import { computeSegmentDuration } from '../../../lib/rundown/rundownTiming' +import { computeSegmentDuration, RundownTimingContext } from '../../../lib/rundown/rundownTiming' +import { SegmentTimelinePartClass } from './SegmentTimelinePart' +import { Piece, Pieces } from '../../../lib/collections/Pieces' +import { RundownAPI } from '../../../lib/api/rundown' export const SIMULATED_PLAYBACK_SOFT_MARGIN = 0 export const SIMULATED_PLAYBACK_HARD_MARGIN = 2500 @@ -723,7 +726,7 @@ export const SegmentTimelineContainer = translateWithTracker { + onGoToPartInner = (part: PartUi, timingDurations: RundownTimingContext, zoomInToFit?: boolean) => { let newScale: number | undefined let scrollLeft = @@ -751,7 +754,7 @@ export const SegmentTimelineContainer = translateWithTracker { if (this.props.segmentId === e.segmentId) { - const timingDurations = this.context?.durations as RundownTiming.RundownTimingContext + const timingDurations = this.context?.durations as RundownTimingContext const part = this.props.parts.find((part) => part.partId === e.partId) if (part) { @@ -762,7 +765,7 @@ export const SegmentTimelineContainer = translateWithTracker { if (this.props.segmentId === e.segmentId) { - const timingDurations = this.context?.durations as RundownTiming.RundownTimingContext + const timingDurations = this.context?.durations as RundownTimingContext const part = this.props.parts.find((part) => part.instance._id === e.partInstanceId) diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelinePart.tsx index 6da865e9b9..a0f02e850c 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelinePart.tsx @@ -36,6 +36,7 @@ import { SegmentEnd } from '../../lib/ui/icons/segment' import { getShowHiddenSourceLayers } from '../../lib/localStorage' import { Part } from '../../../lib/collections/Parts' import { TFunction } from 'i18next' +import { RundownTimingContext } from '../../../lib/rundown/rundownTiming' export const SegmentTimelineLineElementId = 'rundown__segment__line__' export const SegmentTimelinePartElementId = 'rundown__segment__part__' @@ -688,7 +689,7 @@ export class SegmentTimelinePartClass extends React.Component((props: IProps) => { return { isHighResolution: false, - filter: (durations: RundownTiming.RundownTimingContext) => { + filter: (durations: RundownTimingContext) => { durations = durations || {} const partId = unprotectString(props.part.instance.part._id) diff --git a/meteor/lib/rundown/__tests__/rundownTiming.test.ts b/meteor/lib/rundown/__tests__/rundownTiming.test.ts index 2e923b6c0a..434b529382 100644 --- a/meteor/lib/rundown/__tests__/rundownTiming.test.ts +++ b/meteor/lib/rundown/__tests__/rundownTiming.test.ts @@ -2,7 +2,7 @@ import { PartInstance } from '../../collections/PartInstances' import { DBPart, Part, PartId } from '../../collections/Parts' import { DBRundownPlaylist, RundownPlaylist } from '../../collections/RundownPlaylists' import { literal, protectString } from '../../lib' -import { RundownTiming, RundownTimingCalculator } from '../rundownTiming' +import { RundownTimingCalculator, RundownTimingContext } from '../rundownTiming' const DEFAULT_DURATION = 4000 @@ -51,7 +51,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 0, asPlayedPlaylistDuration: 0, @@ -87,7 +87,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 4000, asPlayedPlaylistDuration: 4000, @@ -160,7 +160,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 4000, asPlayedPlaylistDuration: 4000, @@ -234,7 +234,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 4000, asPlayedPlaylistDuration: 4000, @@ -332,7 +332,7 @@ describe('rundown Timing Calculator', () => { const partInstancesMap: Map = new Map() const result = timing.updateDurations(0, false, playlist, parts, partInstancesMap, DEFAULT_DURATION) expect(result).toEqual( - literal({ + literal({ isLowResolution: false, asDisplayedPlaylistDuration: 4000, asPlayedPlaylistDuration: 4000, diff --git a/meteor/lib/rundown/rundownTiming.ts b/meteor/lib/rundown/rundownTiming.ts index d60bdcf2de..ec563e5070 100644 --- a/meteor/lib/rundown/rundownTiming.ts +++ b/meteor/lib/rundown/rundownTiming.ts @@ -191,13 +191,11 @@ export class RundownTimingCalculator { asPlayedRundownDuration += valToAddToAsPlayedDuration if (!rundownAsPlayedDurations[unprotectString(partInstance.part.rundownId)]) { - rundownAsPlayedDurations[ - unprotectString(partInstance.part.rundownId) - ] = valToAddToAsPlayedDuration + rundownAsPlayedDurations[unprotectString(partInstance.part.rundownId)] = + valToAddToAsPlayedDuration } else { - rundownAsPlayedDurations[ - unprotectString(partInstance.part.rundownId) - ] += valToAddToAsPlayedDuration + rundownAsPlayedDurations[unprotectString(partInstance.part.rundownId)] += + valToAddToAsPlayedDuration } } @@ -433,7 +431,7 @@ export interface RundownTimingContext { /** * Computes the actual (as-played fallbacking to expected) duration of a segment, consisting of given parts * @export - * @param {RundownTiming.RundownTimingContext} timingDurations The timing durations calculated for the Rundown + * @param {RundownTimingContext} timingDurations The timing durations calculated for the Rundown * @param {Array} partIds The IDs of parts that are members of the segment * @return number */ From 162384e4c44d0a8fa108a5becd299f82b544db0c Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Mon, 19 Apr 2021 17:01:52 +0100 Subject: [PATCH 020/112] feat: Skeleton of rundown layout registry --- meteor/lib/api/rundownLayouts.ts | 107 +++++++++++++++++++++++ meteor/lib/collections/RundownLayouts.ts | 17 +++- 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/meteor/lib/api/rundownLayouts.ts b/meteor/lib/api/rundownLayouts.ts index 1560f24ab6..0c01e4ed75 100644 --- a/meteor/lib/api/rundownLayouts.ts +++ b/meteor/lib/api/rundownLayouts.ts @@ -11,6 +11,9 @@ import { RundownLayoutAdLibRegion, PieceDisplayStyle, RundownLayoutPieceCountdown, + RundownViewLayout, + RundownLayoutTopBar, + LayoutFactory, } from '../collections/RundownLayouts' import { ShowStyleBaseId } from '../collections/ShowStyleBases' import * as _ from 'underscore' @@ -29,7 +32,107 @@ export enum RundownLayoutsAPIMethods { 'createRundownLayout' = 'rundownLayout.createRundownLayout', } +interface LayoutDescriptor { + factory: LayoutFactory + supportedElements: RundownLayoutElementType[] +} + +class RundownLayoutsRegistry { + private shelfLayouts: Map> = new Map() + private rundownViewLayouts: Map> = new Map() + private miniShelfLayouts: Map> = new Map() + private topBarLayouts: Map> = new Map() + + public RegisterShelfLayout(id: RundownLayoutType, description: LayoutDescriptor) { + this.shelfLayouts.set(id, description) + } + + public RegisterRundownViewLayout(id: RundownLayoutType, description: LayoutDescriptor) { + this.rundownViewLayouts.set(id, description) + } + + public RegisterMiniShelfLayout(id: RundownLayoutType, description: LayoutDescriptor) { + this.miniShelfLayouts.set(id, description) + } + + public RegisterTopBarLayouts(id: RundownLayoutType, description: LayoutDescriptor) { + this.topBarLayouts.set(id, description) + } + + public IsShelfLayout(id: RundownLayoutType) { + return this.shelfLayouts.has(id) + } + + public IsRudownViewLayout(id: RundownLayoutType) { + return this.rundownViewLayouts.has(id) + } + + public IsMiniShelfLayout(id: RundownLayoutType) { + return this.miniShelfLayouts.has(id) + } + + public IsTopBarLayout(id: RundownLayoutType) { + return this.topBarLayouts.has(id) + } +} + export namespace RundownLayoutsAPI { + const registry = new RundownLayoutsRegistry() + registry.RegisterShelfLayout(RundownLayoutType.DASHBOARD_LAYOUT, { + factory: { createLayout: () => undefined }, + supportedElements: [ + RundownLayoutElementType.ADLIB_REGION, + RundownLayoutElementType.EXTERNAL_FRAME, + RundownLayoutElementType.FILTER, + RundownLayoutElementType.PIECE_COUNTDOWN, + ], + }) + registry.RegisterShelfLayout(RundownLayoutType.RUNDOWN_LAYOUT, { + factory: { createLayout: () => undefined }, + supportedElements: [ + RundownLayoutElementType.ADLIB_REGION, + RundownLayoutElementType.EXTERNAL_FRAME, + RundownLayoutElementType.FILTER, + RundownLayoutElementType.PIECE_COUNTDOWN, + ], + }) + registry.RegisterMiniShelfLayout(RundownLayoutType.DASHBOARD_LAYOUT, { + factory: { createLayout: () => undefined }, + supportedElements: [], + }) + registry.RegisterMiniShelfLayout(RundownLayoutType.RUNDOWN_LAYOUT, { + factory: { createLayout: () => undefined }, + supportedElements: [], + }) + registry.RegisterRundownViewLayout(RundownLayoutType.RUNDOWN_VIEW_LAYOUT, { + factory: { createLayout: () => undefined }, + supportedElements: [], + }) + registry.RegisterTopBarLayouts(RundownLayoutType.TOP_BAR_LAYOUT, { + factory: { createLayout: () => undefined }, + supportedElements: [], + }) + + export function IsLayoutForShelf(layout: RundownLayoutBase): layout is RundownLayoutBase { + return registry.IsShelfLayout(layout.type) + } + + export function IsLayoutForRundownView(layout: RundownLayoutBase): layout is RundownLayoutBase { + return registry.IsRudownViewLayout(layout.type) + } + + export function IsLayoutForMiniShelf(layout: RundownLayoutBase): layout is RundownLayoutBase { + return registry.IsMiniShelfLayout(layout.type) + } + + export function IsLayoutForTopBar(layout: RundownLayoutBase): layout is RundownLayoutBase { + return registry.IsTopBarLayout(layout.type) + } + + export function isRundownViewLayout(layout: RundownLayoutBase): layout is RundownViewLayout { + return layout.type === RundownLayoutType.RUNDOWN_VIEW_LAYOUT + } + export function isRundownLayout(layout: RundownLayoutBase): layout is RundownLayout { return layout.type === RundownLayoutType.RUNDOWN_LAYOUT } @@ -38,6 +141,10 @@ export namespace RundownLayoutsAPI { return layout.type === RundownLayoutType.DASHBOARD_LAYOUT } + export function isTopBarLayout(layout: RundownLayoutBase): layout is RundownLayoutTopBar { + return layout.type === RundownLayoutType.TOP_BAR_LAYOUT + } + export function isFilter(element: RundownLayoutElementBase): element is RundownLayoutFilterBase { return element.type === undefined || element.type === RundownLayoutElementType.FILTER } diff --git a/meteor/lib/collections/RundownLayouts.ts b/meteor/lib/collections/RundownLayouts.ts index 3b1bd80536..31c92ee3d9 100644 --- a/meteor/lib/collections/RundownLayouts.ts +++ b/meteor/lib/collections/RundownLayouts.ts @@ -6,6 +6,10 @@ import { ShowStyleBaseId } from './ShowStyleBases' import { UserId } from './Users' import { registerIndex } from '../database' +export interface LayoutFactory { + createLayout(...args: any[]): T | undefined +} + /** * The view targeted by this layout: * RUNDOWN_LAYOUT: a Rundown view for highly scripted shows: a show split into Segments and Parts, @@ -16,8 +20,10 @@ import { registerIndex } from '../database' * @enum {string} */ export enum RundownLayoutType { + RUNDOWN_VIEW_LAYOUT = 'rundown_view_layout', RUNDOWN_LAYOUT = 'rundown_layout', DASHBOARD_LAYOUT = 'dashboard_layout', + TOP_BAR_LAYOUT = 'top_bar_layout', } /** @@ -152,7 +158,7 @@ export interface RundownLayoutBase { blueprintId?: BlueprintId userId?: UserId name: string - type: RundownLayoutType.RUNDOWN_LAYOUT | RundownLayoutType.DASHBOARD_LAYOUT + type: RundownLayoutType filters: RundownLayoutElementBase[] exposeAsStandalone: boolean exposeAsShelf: boolean @@ -162,11 +168,20 @@ export interface RundownLayoutBase { startingHeight?: number } +export interface RundownViewLayout extends RundownLayoutBase { + type: RundownLayoutType.RUNDOWN_VIEW_LAYOUT + expectedEndText: string +} + export interface RundownLayout extends RundownLayoutBase { type: RundownLayoutType.RUNDOWN_LAYOUT filters: RundownLayoutElementBase[] } +export interface RundownLayoutTopBar extends RundownLayoutBase { + type: RundownLayoutType.TOP_BAR_LAYOUT +} + export enum ActionButtonType { TAKE = 'take', HOLD = 'hold', From 9bdfe2f1abf1bb8b90b67a797fd2ed324ad25bb8 Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Mon, 10 May 2021 17:16:46 +0100 Subject: [PATCH 021/112] chore: Move setting for filters to separate component for reuse --- .../ui/Settings/RundownLayoutEditor.tsx | 76 +- .../ui/Settings/components/FilterEditor.tsx | 1030 +++++++++++++++++ 2 files changed, 1039 insertions(+), 67 deletions(-) create mode 100644 meteor/client/ui/Settings/components/FilterEditor.tsx diff --git a/meteor/client/ui/Settings/RundownLayoutEditor.tsx b/meteor/client/ui/Settings/RundownLayoutEditor.tsx index ead902ac97..1471ab3410 100644 --- a/meteor/client/ui/Settings/RundownLayoutEditor.tsx +++ b/meteor/client/ui/Settings/RundownLayoutEditor.tsx @@ -5,32 +5,24 @@ import { EditAttribute } from '../../lib/EditAttribute' import { Translated, translateWithTracker } from '../../lib/ReactMeteorData/react-meteor-data' import { ShowStyleBase } from '../../../lib/collections/ShowStyleBases' import { MeteorReactComponent } from '../../lib/MeteorReactComponent' -import { faStar, faUpload, faPlus, faCheck, faPencilAlt, faDownload, faTrash } from '@fortawesome/free-solid-svg-icons' +import { faUpload, faPlus, faCheck, faPencilAlt, faDownload, faTrash } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { RundownLayouts, - RundownLayout, RundownLayoutType, RundownLayoutBase, RundownLayoutFilter, PieceDisplayStyle, - RundownLayoutFilterBase, DashboardLayout, ActionButtonType, DashboardLayoutActionButton, RundownLayoutElementType, - RundownLayoutElementBase, - RundownLayoutExternalFrame, - RundownLayoutAdLibRegion, - RundownLayoutAdLibRegionRole, RundownLayoutId, - RundownLayoutPieceCountdown, } from '../../../lib/collections/RundownLayouts' import { RundownLayoutsAPI } from '../../../lib/api/rundownLayouts' import { PubSub } from '../../../lib/api/pubsub' import { literal, unprotectString } from '../../../lib/lib' import { Random } from 'meteor/random' -import { SourceLayerType } from '@sofie-automation/blueprints-integration' import { UploadButton } from '../../lib/uploadButton' import { doModalDialog } from '../../lib/ModalDialog' import { NotificationCenter, Notification, NoticeLevel } from '../../lib/notifications/notifications' @@ -39,6 +31,7 @@ import { Studio } from '../../../lib/collections/Studios' import { Link } from 'react-router-dom' import { MeteorCall } from '../../../lib/api/methods' import { defaultColorPickerPalette } from '../../lib/colorPicker' +import FilterEditor from './components/FilterEditor' export interface IProps { showStyleBase: ShowStyleBase @@ -136,13 +129,6 @@ export default translateWithTracker((props: IProp }) } - onToggleDefault = (item: RundownLayout, index: number, value: boolean) => { - const obj = _.object(item.filters.map((item, i) => [`filters.${i}.default`, i === index ? value : false])) - RundownLayouts.update(item._id, { - $set: obj, - }) - } - onRemoveButton = (item: RundownLayoutBase, button: DashboardLayoutActionButton) => { RundownLayouts.update(item._id, { $pull: { @@ -153,16 +139,6 @@ export default translateWithTracker((props: IProp }) } - onRemoveElement = (item: RundownLayoutBase, filter: RundownLayoutElementBase) => { - RundownLayouts.update(item._id, { - $pull: { - filters: { - _id: filter._id, - }, - }, - }) - } - isItemEdited = (layoutBase: RundownLayoutBase) => { return this.state.editedItems.indexOf(layoutBase._id) >= 0 } @@ -1336,47 +1312,13 @@ export default translateWithTracker((props: IProp

{t('There are no filters set up yet')}

) : null} {item.filters.map((tab, index) => ( -
- - {isRundownLayout && ( - - )} -
-
- -
-
- {RundownLayoutsAPI.isFilter(tab) - ? this.renderFilter(item, tab, index, isRundownLayout, isDashboardLayout) - : RundownLayoutsAPI.isExternalFrame(tab) - ? this.renderFrame(item, tab, index, isRundownLayout, isDashboardLayout) - : RundownLayoutsAPI.isAdLibRegion(tab) - ? this.renderAdLibRegion(item, tab, index, isRundownLayout, isDashboardLayout) - : RundownLayoutsAPI.isPieceCountdown(tab) - ? this.renderPieceCountdown(item, tab, index, isRundownLayout, isDashboardLayout) - : undefined} -
+ ))}
) diff --git a/meteor/client/ui/Settings/components/FilterEditor.tsx b/meteor/client/ui/Settings/components/FilterEditor.tsx new file mode 100644 index 0000000000..154f3d0370 --- /dev/null +++ b/meteor/client/ui/Settings/components/FilterEditor.tsx @@ -0,0 +1,1030 @@ +import { faTrash, faStar } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import React from 'react' +import ClassNames from 'classnames' +import _ from 'underscore' +import { RundownLayoutsAPI } from '../../../../lib/api/rundownLayouts' +import { + PieceDisplayStyle, + RundownLayout, + RundownLayoutAdLibRegion, + RundownLayoutAdLibRegionRole, + RundownLayoutBase, + RundownLayoutElementBase, + RundownLayoutElementType, + RundownLayoutExternalFrame, + RundownLayoutFilterBase, + RundownLayoutPieceCountdown, + RundownLayouts, +} from '../../../../lib/collections/RundownLayouts' +import { EditAttribute } from '../../../lib/EditAttribute' +import { MeteorReactComponent } from '../../../lib/MeteorReactComponent' +import { Translated, translateWithTracker } from '../../../lib/ReactMeteorData/react-meteor-data' +import { ShowStyleBase } from '../../../../lib/collections/ShowStyleBases' +import { SourceLayerType } from '@sofie-automation/blueprints-integration' + +interface IProps { + item: RundownLayoutBase + filter: RundownLayoutElementBase + index: number + showStyleBase: ShowStyleBase +} + +interface ITrackedProps {} + +interface IState {} + +export default translateWithTracker((props: IProps) => { + return {} +})( + class FilterEditor extends MeteorReactComponent, IState> { + onToggleDefault = (item: RundownLayout, index: number, value: boolean) => { + const obj = _.object(item.filters.map((item, i) => [`filters.${i}.default`, i === index ? value : false])) + RundownLayouts.update(item._id, { + $set: obj, + }) + } + + onRemoveElement = (item: RundownLayoutBase, filter: RundownLayoutElementBase) => { + RundownLayouts.update(item._id, { + $pull: { + filters: { + _id: filter._id, + }, + }, + }) + } + + renderFilter( + item: RundownLayoutBase, + tab: RundownLayoutFilterBase, + index: number, + isRundownLayout: boolean, + isDashboardLayout: boolean + ) { + const { t } = this.props + const isList = tab.displayStyle === PieceDisplayStyle.LIST + const rundownBaselineOptions = [ + { + name: t('Yes'), + value: true, + }, + { + name: t('No'), + value: false, + }, + { + name: t('Only Match Global AdLibs'), + value: 'only', + }, + ] + + return ( + +
+ +
+ {isDashboardLayout && ( + +
+ +
+ {isList && ( +
+ +
+ )} +
+ +
+
+ +
+
+ +
+
+ +
+ {!isList && ( + +
+ +
+
+ +
+
+ )} +
+ )} +
+ +
+
+ +
+
+ + +
+ {isDashboardLayout && ( + +
+ +
+
+ )} +
+ + (v === undefined || v.length === 0 ? false : true)} + mutateUpdateValue={(v) => undefined} + /> + { + return { name: l.name, value: l._id } + })} + type="multiselect" + label={t('Filter Disabled')} + collection={RundownLayouts} + className="input text-input input-l dropdown" + mutateUpdateValue={(v) => (v && v.length > 0 ? v : undefined)} + /> +
+
+ + (v === undefined || v.length === 0 ? false : true)} + mutateUpdateValue={(v) => undefined} + /> + + v && v.length > 0 ? v.map((a) => parseInt(a, 10)) : undefined + } + /> +
+
+ + (v === undefined || v.length === 0 ? false : true)} + mutateUpdateValue={(v) => undefined} + /> + { + return { name: l.name, value: l._id } + })} + type="multiselect" + label={t('Filter Disabled')} + collection={RundownLayouts} + className="input text-input input-l dropdown" + mutateUpdateValue={(v) => (v && v.length > 0 ? v : undefined)} + /> +
+
+ +
+
+ +
+ {isDashboardLayout && ( + +
+ +
+
+ +
+
+ )} + {isDashboardLayout && ( + +
+ +
+
+ )} +
+ +
+ {isDashboardLayout && ( + +
+ +
+
+ )} + {isDashboardLayout && ( + +
+ +
+
+ +
+
+ +
+
+ +
+
+ )} +
+ ) + } + + renderFrame( + item: RundownLayoutBase, + tab: RundownLayoutExternalFrame, + index: number, + isRundownLayout: boolean, + isDashboardLayout: boolean + ) { + const { t } = this.props + return ( + +
+ +
+
+ +
+ {isDashboardLayout && ( + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ )} +
+ +
+
+ ) + } + + renderAdLibRegion( + item: RundownLayoutBase, + tab: RundownLayoutAdLibRegion, + index: number, + isRundownLayout: boolean, + isDashboardLayout: boolean + ) { + const { t } = this.props + return ( + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ {isDashboardLayout && ( + +
+ +
+
+ +
+
+ +
+
+ +
+ {isDashboardLayout && ( + +
+ +
+
+ )} +
+ )} +
+ ) + } + + renderPieceCountdown( + item: RundownLayoutBase, + tab: RundownLayoutPieceCountdown, + index: number, + isRundownLayout: boolean, + isDashboardLayout: boolean + ) { + const { t } = this.props + return ( + +
+ +
+
+ + (v === undefined || v.length === 0 ? false : true)} + mutateUpdateValue={(v) => undefined} + /> + { + return { name: l.name, value: l._id } + })} + type="multiselect" + label={t('Disabled')} + collection={RundownLayouts} + className="input text-input input-l dropdown" + mutateUpdateValue={(v) => (v && v.length > 0 ? v : undefined)} + /> +
+ {isDashboardLayout && ( + +
+ +
+
+ +
+
+ +
+
+ +
+
+ )} +
+ ) + } + + render() { + const { t } = this.props + + const isRundownLayout = RundownLayoutsAPI.isRundownLayout(this.props.item) + const isDashboardLayout = RundownLayoutsAPI.isDashboardLayout(this.props.item) + + return ( +
+ + {isRundownLayout && ( + + )} +
+
+ +
+
+ {RundownLayoutsAPI.isFilter(this.props.filter) + ? this.renderFilter( + this.props.item, + this.props.filter, + this.props.index, + isRundownLayout, + isDashboardLayout + ) + : RundownLayoutsAPI.isExternalFrame(this.props.filter) + ? this.renderFrame(this.props.item, this.props.filter, this.props.index, isRundownLayout, isDashboardLayout) + : RundownLayoutsAPI.isAdLibRegion(this.props.filter) + ? this.renderAdLibRegion( + this.props.item, + this.props.filter, + this.props.index, + isRundownLayout, + isDashboardLayout + ) + : RundownLayoutsAPI.isPieceCountdown(this.props.filter) + ? this.renderPieceCountdown( + this.props.item, + this.props.filter, + this.props.index, + isRundownLayout, + isDashboardLayout + ) + : undefined} +
+ ) + } + } +) From 72295d16c1de7ee49ce560509a3db26809463d7c Mon Sep 17 00:00:00 2001 From: Tom Lee Date: Wed, 12 May 2021 13:04:53 +0100 Subject: [PATCH 022/112] feat: Describe rundown view and shelf layouts through manifests --- meteor/client/ui/RundownList.tsx | 9 +- .../ui/RundownList/RundownListItemView.tsx | 5 +- .../ui/RundownList/RundownPlaylistUi.tsx | 5 +- .../RundownShelfLayoutSelection.tsx | 5 +- meteor/client/ui/RundownView.tsx | 70 +++-- .../ui/Settings/RundownLayoutEditor.tsx | 295 ++++++++---------- .../ui/Settings/ShowStyleBaseSettings.tsx | 21 +- .../rundownLayouts/ShelfLayoutSettings.tsx | 77 +++++ meteor/client/ui/Shelf/Shelf.tsx | 4 +- meteor/lib/api/rundownLayouts.ts | 97 ++++-- meteor/lib/collections/RundownLayouts.ts | 28 +- meteor/server/api/rundownLayouts.ts | 8 +- 12 files changed, 371 insertions(+), 253 deletions(-) create mode 100644 meteor/client/ui/Settings/components/rundownLayouts/ShelfLayoutSettings.tsx diff --git a/meteor/client/ui/RundownList.tsx b/meteor/client/ui/RundownList.tsx index 0038423be2..01deee519d 100644 --- a/meteor/client/ui/RundownList.tsx +++ b/meteor/client/ui/RundownList.tsx @@ -32,6 +32,7 @@ import { RundownListFooter } from './RundownList/RundownListFooter' import RundownPlaylistDragLayer from './RundownList/RundownPlaylistDragLayer' import { RundownPlaylistUi } from './RundownList/RundownPlaylistUi' import { doUserAction, UserAction } from '../lib/userAction' +import { RundownLayoutsAPI } from '../../lib/api/rundownLayouts' export enum ToolTipStep { TOOLTIP_START_HERE = 'TOOLTIP_START_HERE', @@ -306,10 +307,10 @@ export const RundownList = translateWithTracker((): IRundownsListProps => { {this.props.rundownPlaylists.some( (p) => !!p.expectedEnd || p.rundowns.some((r) => r.expectedEnd) ) && {t('Expected End Time')}} - {t('Last Updated')} - {this.props.rundownLayouts.some((l) => l.exposeAsShelf || l.exposeAsStandalone) && ( - {t('Shelf Layout')} - )} + {t('Last updated')} + {this.props.rundownLayouts.some( + (l) => RundownLayoutsAPI.IsLayoutForShelf(l) && (l.exposeAsShelf || l.exposeAsStandalone) + ) && {t('Shelf Layout')}} {this.renderRundownPlaylists(rundownPlaylists)}