diff --git a/meteor/server/api/userActions.ts b/meteor/server/api/userActions.ts index 43dc0a46c6..348380cbf6 100644 --- a/meteor/server/api/userActions.ts +++ b/meteor/server/api/userActions.ts @@ -191,7 +191,8 @@ class ServerUserActionAPI eventTime: Time, rundownPlaylistId: RundownPlaylistId, partDelta: number, - segmentDelta: number + segmentDelta: number, + ignoreQuickLoop: boolean | null ) { return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this, @@ -208,6 +209,7 @@ class ServerUserActionAPI playlistId: rundownPlaylistId, partDelta: partDelta, segmentDelta: segmentDelta, + ignoreQuickLoop: ignoreQuickLoop ?? undefined, } ) } diff --git a/meteor/server/migration/upgrades/defaultSystemActionTriggers.ts b/meteor/server/migration/upgrades/defaultSystemActionTriggers.ts index 29242cbecd..2a6bd9b122 100644 --- a/meteor/server/migration/upgrades/defaultSystemActionTriggers.ts +++ b/meteor/server/migration/upgrades/defaultSystemActionTriggers.ts @@ -281,6 +281,7 @@ export const DEFAULT_CORE_TRIGGERS: IBlueprintDefaultCoreSystemTriggers = { ], parts: 1, segments: 0, + ignoreQuickLoop: false, }, }, triggers: { @@ -305,6 +306,7 @@ export const DEFAULT_CORE_TRIGGERS: IBlueprintDefaultCoreSystemTriggers = { ], parts: 0, segments: 1, + ignoreQuickLoop: false, }, }, triggers: { @@ -329,6 +331,7 @@ export const DEFAULT_CORE_TRIGGERS: IBlueprintDefaultCoreSystemTriggers = { ], parts: -1, segments: 0, + ignoreQuickLoop: false, }, }, triggers: { @@ -353,6 +356,7 @@ export const DEFAULT_CORE_TRIGGERS: IBlueprintDefaultCoreSystemTriggers = { ], parts: 0, segments: -1, + ignoreQuickLoop: false, }, }, triggers: { diff --git a/packages/blueprints-integration/src/context/adlibActionContext.ts b/packages/blueprints-integration/src/context/adlibActionContext.ts index ec1b19a4bb..50c28fcf44 100644 --- a/packages/blueprints-integration/src/context/adlibActionContext.ts +++ b/packages/blueprints-integration/src/context/adlibActionContext.ts @@ -31,7 +31,7 @@ export interface IActionExecutionContext // getNextShowStyleConfig(): Readonly<{ [key: string]: ConfigItemValue }> /** Move the next part through the rundown. Can move by either a number of parts, or segments in either direction. */ - moveNextPart(partDelta: number, segmentDelta: number): Promise + moveNextPart(partDelta: number, segmentDelta: number, ignoreQuickloop?: boolean): Promise /** Set flag to perform take after executing the current action. Returns state of the flag after each call. */ takeAfterExecuteAction(take: boolean): Promise /** Inform core that a take out of the current partinstance should be blocked until the specified time */ diff --git a/packages/blueprints-integration/src/context/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index da6afe52ae..dd0d7f8c37 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -76,5 +76,5 @@ export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContex * Multiple calls of this inside one call to `onSetAsNext` will replace earlier calls. * @returns Whether a new Part was found using the provided offset */ - moveNextPart(partDelta: number, segmentDelta: number): Promise + moveNextPart(partDelta: number, segmentDelta: number, ignoreQuickLoop?: boolean): Promise } diff --git a/packages/blueprints-integration/src/triggers.ts b/packages/blueprints-integration/src/triggers.ts index c360fa6567..faca7a2847 100644 --- a/packages/blueprints-integration/src/triggers.ts +++ b/packages/blueprints-integration/src/triggers.ts @@ -230,6 +230,13 @@ export interface IMoveNextAction extends ITriggeredActionBase { * @memberof IMoveNextAction */ parts: number + /** + * When moving the next part it should ignore any of the boundaries set by the QuickLoop feature + * + * @type {boolean} + * @memberof IMoveNextAction + */ + ignoreQuickLoop: boolean } export interface ICreateSnapshotForDebugAction extends ITriggeredActionBase { diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index 9132ae8ba5..6be6da9470 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -231,6 +231,7 @@ export interface StopPiecesOnSourceLayersProps extends RundownPlayoutPropsBase { export interface MoveNextPartProps extends RundownPlayoutPropsBase { partDelta: number segmentDelta: number + ignoreQuickLoop?: boolean } export type ActivateHoldProps = RundownPlayoutPropsBase export type DeactivateHoldProps = RundownPlayoutPropsBase diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 7de1cf88ac..eec0200251 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -121,7 +121,7 @@ export class OnSetAsNextContext return this.partAndPieceInstanceService.removePieceInstances('next', pieceInstanceIds) } - async moveNextPart(partDelta: number, segmentDelta: number): Promise { + async moveNextPart(partDelta: number, segmentDelta: number, ignoreQuickLoop?: boolean): Promise { if (typeof partDelta !== 'number') throw new Error('partDelta must be a number') if (typeof segmentDelta !== 'number') throw new Error('segmentDelta must be a number') @@ -132,7 +132,13 @@ export class OnSetAsNextContext } this.pendingMoveNextPart = { - selectedPart: selectNewPartWithOffsets(this.jobContext, this.playoutModel, partDelta, segmentDelta), + selectedPart: selectNewPartWithOffsets( + this.jobContext, + this.playoutModel, + partDelta, + segmentDelta, + ignoreQuickLoop + ), } return !!this.pendingMoveNextPart.selectedPart diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 8ce4882fd8..64000b5994 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -157,8 +157,14 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct return this.partAndPieceInstanceService.queuePart(rawPart, rawPieces) } - async moveNextPart(partDelta: number, segmentDelta: number): Promise { - const selectedPart = selectNewPartWithOffsets(this._context, this._playoutModel, partDelta, segmentDelta) + async moveNextPart(partDelta: number, segmentDelta: number, ignoreQuickloop?: boolean): Promise { + const selectedPart = selectNewPartWithOffsets( + this._context, + this._playoutModel, + partDelta, + segmentDelta, + ignoreQuickloop + ) if (selectedPart) await setNextPartFromPart(this._context, this._playoutModel, selectedPart, true) } diff --git a/packages/job-worker/src/playout/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index 2ea3bb46bf..7ded24012f 100644 --- a/packages/job-worker/src/playout/model/PlayoutModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutModel.ts @@ -167,6 +167,25 @@ export interface PlayoutModelReadonly extends StudioPlayoutModelBaseReadonly { */ getRundownIds(): RundownId[] + /** + * Returns any segmentId's that are found between 2 quickloop markers, none will be returned if + * the end is before the start. + * @param start A quickloop marker + * @param end A quickloop marker + */ + getSegmentsBetweenQuickLoopMarker(start: QuickLoopMarker, end: QuickLoopMarker): SegmentId[] + + /** + * Returns any segmentId's that are found between 2 quickloop markers, none will be returned if + * the end is before the start. + * @param start A quickloop marker + * @param end A quickloop marker + */ + getPartsBetweenQuickLoopMarker( + start: QuickLoopMarker, + end: QuickLoopMarker + ): { parts: PartId[]; segments: SegmentId[] } + /** * Search for a PieceInstance in the RundownPlaylist * @param id Id of the PieceInstance @@ -350,14 +369,6 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa */ setQuickLoopMarker(type: 'start' | 'end', marker: QuickLoopMarker | null): void - /** - * Returns any segmentId's that are found between 2 quickloop markers, none will be returned if - * the end is before the start. - * @param start A quickloop marker - * @param end A quickloop marker - */ - getSegmentsBetweenQuickLoopMarker(start: QuickLoopMarker, end: QuickLoopMarker): SegmentId[] - calculatePartTimings( fromPartInstance: PlayoutPartInstanceModel | null, toPartInstance: PlayoutPartInstanceModel, diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 4219e7bc95..383e27ad4b 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -240,6 +240,16 @@ export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { return undefined } + getSegmentsBetweenQuickLoopMarker(start: QuickLoopMarker, end: QuickLoopMarker): SegmentId[] { + return this.quickLoopService.getSegmentsBetweenMarkers(start, end) + } + getPartsBetweenQuickLoopMarker( + start: QuickLoopMarker, + end: QuickLoopMarker + ): { parts: PartId[]; segments: SegmentId[] } { + return this.quickLoopService.getPartsBetweenMarkers(start, end) + } + #isMultiGatewayMode: boolean | undefined = undefined public get isMultiGatewayMode(): boolean { if (this.#isMultiGatewayMode === undefined) { @@ -828,10 +838,6 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } - getSegmentsBetweenQuickLoopMarker(start: QuickLoopMarker, end: QuickLoopMarker): SegmentId[] { - return this.quickLoopService.getSegmentsBetweenMarkers(start, end) - } - /** Notifications */ async getAllNotifications( diff --git a/packages/job-worker/src/playout/model/services/QuickLoopService.ts b/packages/job-worker/src/playout/model/services/QuickLoopService.ts index b9d252ca7a..c0fcfc8a32 100644 --- a/packages/job-worker/src/playout/model/services/QuickLoopService.ts +++ b/packages/job-worker/src/playout/model/services/QuickLoopService.ts @@ -8,7 +8,7 @@ import { import { ForceQuickLoopAutoNext } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings' import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { PlayoutPartInstanceModel } from '../PlayoutPartInstanceModel' import { JobContext } from '../../../jobs' @@ -150,6 +150,7 @@ export class QuickLoopService { } getSegmentsBetweenMarkers(startMarker: QuickLoopMarker, endMarker: QuickLoopMarker): SegmentId[] { + // note - this function could be refactored to call getPartsBetweenMarkers instead but it will be less efficient const segments = this.playoutModel.getAllOrderedSegments() const segmentIds: SegmentId[] = [] @@ -201,6 +202,71 @@ export class QuickLoopService { return segmentIds } + getPartsBetweenMarkers( + startMarker: QuickLoopMarker, + endMarker: QuickLoopMarker + ): { parts: PartId[]; segments: SegmentId[] } { + const parts = this.playoutModel.getAllOrderedParts() + const segmentIds: SegmentId[] = [] + const partIds: PartId[] = [] + + let passedStart = false + let seenLastRundown = false + let seenLastSegment = false + + for (const p of parts) { + if ( + !passedStart && + ((startMarker.type === QuickLoopMarkerType.PART && p._id === startMarker.id) || + (startMarker.type === QuickLoopMarkerType.SEGMENT && p.segmentId === startMarker.id) || + (startMarker.type === QuickLoopMarkerType.RUNDOWN && p.rundownId === startMarker.id) || + startMarker.type === QuickLoopMarkerType.PLAYLIST) + ) { + // the start marker is this part, this is the first part in the loop, or this is the first segment that is in the loop + // segments from here on are included in the loop + passedStart = true + } + + if (endMarker.type === QuickLoopMarkerType.RUNDOWN) { + // last rundown needs to be inclusive so we need to break once the rundownId is not equal to segment's rundownId + if (p.rundownId === endMarker.id) { + if (!passedStart) { + // we hit the end before the start so quit now: + break + } + seenLastRundown = true + } else if (seenLastRundown) { + // we have passed the last rundown + break + } + } else if (endMarker.type === QuickLoopMarkerType.SEGMENT) { + // last segment needs to be inclusive so we need to break once the segmentId changes but not before + if (p.segmentId === endMarker.id) { + if (!passedStart) { + // we hit the end before the start so quit now: + break + } + seenLastSegment = true + } else if (seenLastSegment) { + // we have passed the last rundown + break + } + } + + if (passedStart) { + if (segmentIds.slice(-1)[0] !== p.segmentId) segmentIds.push(p.segmentId) + partIds.push(p._id) + } + + if (endMarker.type === QuickLoopMarkerType.PART && p._id === endMarker.id) { + // the endMarker is this part so we can quit now + break + } + } + + return { parts: partIds, segments: segmentIds } + } + private areMarkersFlipped(startPosition: MarkerPosition, endPosition: MarkerPosition) { return compareMarkerPositions(startPosition, endPosition) < 0 } diff --git a/packages/job-worker/src/playout/moveNextPart.ts b/packages/job-worker/src/playout/moveNextPart.ts index ca6a3e4e9c..995f9c495c 100644 --- a/packages/job-worker/src/playout/moveNextPart.ts +++ b/packages/job-worker/src/playout/moveNextPart.ts @@ -11,7 +11,8 @@ export function selectNewPartWithOffsets( _context: JobContext, playoutModel: PlayoutModelReadonly, partDelta: number, - segmentDelta: number + segmentDelta: number, + ignoreQuickLoop = false ): ReadonlyDeep | null { const playlist = playoutModel.playlist @@ -23,8 +24,21 @@ export function selectNewPartWithOffsets( if (!refPart || !refPartInstance) throw new Error(`RundownPlaylist "${playlist._id}" has no next and no current part!`) - const rawSegments = playoutModel.getAllOrderedSegments() - const rawParts = playoutModel.getAllOrderedParts() + let rawSegments = playoutModel.getAllOrderedSegments() + let rawParts = playoutModel.getAllOrderedParts() + let allowWrap = false // whether we should wrap to the first part if the curIndex + delta exceeds the total number of parts + + if (!ignoreQuickLoop && playlist.quickLoop?.start && playlist.quickLoop.end) { + const partsInQuickloop = playoutModel.getPartsBetweenQuickLoopMarker( + playlist.quickLoop.start, + playlist.quickLoop.end + ) + if (partsInQuickloop.parts.includes(refPart._id)) { + rawParts = rawParts.filter((p) => partsInQuickloop.parts.includes(p._id)) + rawSegments = rawSegments.filter((s) => partsInQuickloop.segments.includes(s.segment._id)) + allowWrap = true + } + } if (segmentDelta) { // Ignores horizontalDelta @@ -37,7 +51,14 @@ export function selectNewPartWithOffsets( const refSegmentIndex = considerSegments.findIndex((s) => s.segment._id === refPart.segmentId) if (refSegmentIndex === -1) throw new Error(`Segment "${refPart.segmentId}" not found!`) - const targetSegmentIndex = refSegmentIndex + segmentDelta + let targetSegmentIndex = refSegmentIndex + segmentDelta + if (allowWrap) { + targetSegmentIndex = targetSegmentIndex % considerSegments.length + if (targetSegmentIndex < 0) { + // -1 becomes last segment + targetSegmentIndex = considerSegments.length + targetSegmentIndex + } + } const targetSegment = considerSegments[targetSegmentIndex] if (!targetSegment) return null @@ -64,7 +85,6 @@ export function selectNewPartWithOffsets( } } - // TODO - looping playlists if (selectedPart) { // Switch to that part return selectedPart @@ -88,7 +108,11 @@ export function selectNewPartWithOffsets( } // Get the past we are after - const targetPartIndex = refPartIndex + partDelta + let targetPartIndex = allowWrap ? (refPartIndex + partDelta) % playabaleParts.length : refPartIndex + partDelta + if (allowWrap) { + targetPartIndex = targetPartIndex % playabaleParts.length + if (targetPartIndex < 0) targetPartIndex = playabaleParts.length + targetPartIndex // -1 becomes last part + } let targetPart = playabaleParts[targetPartIndex] if (targetPart && targetPart._id === currentPartInstance?.part._id) { // Cant go to the current part (yet) diff --git a/packages/job-worker/src/playout/setNextJobs.ts b/packages/job-worker/src/playout/setNextJobs.ts index fe297ae313..6a041f3c86 100644 --- a/packages/job-worker/src/playout/setNextJobs.ts +++ b/packages/job-worker/src/playout/setNextJobs.ts @@ -68,7 +68,13 @@ export async function handleMoveNextPart(context: JobContext, data: MoveNextPart } }, async (playoutModel) => { - const selectedPart = selectNewPartWithOffsets(context, playoutModel, data.partDelta, data.segmentDelta) + const selectedPart = selectNewPartWithOffsets( + context, + playoutModel, + data.partDelta, + data.segmentDelta, + data.ignoreQuickLoop + ) if (!selectedPart) return null await setNextPartFromPart(context, playoutModel, selectedPart, true) diff --git a/packages/meteor-lib/src/api/userActions.ts b/packages/meteor-lib/src/api/userActions.ts index 01db5ba8fe..5ff3351320 100644 --- a/packages/meteor-lib/src/api/userActions.ts +++ b/packages/meteor-lib/src/api/userActions.ts @@ -58,7 +58,8 @@ export interface NewUserActionAPI { eventTime: Time, rundownPlaylistId: RundownPlaylistId, partDelta: number, - segmentDelta: number + segmentDelta: number, + ignoreQuickLoop?: boolean ): Promise> prepareForBroadcast( userEvent: string, diff --git a/packages/meteor-lib/src/triggers/actionFactory.ts b/packages/meteor-lib/src/triggers/actionFactory.ts index 7796716ffc..99667b8a76 100644 --- a/packages/meteor-lib/src/triggers/actionFactory.ts +++ b/packages/meteor-lib/src/triggers/actionFactory.ts @@ -568,15 +568,16 @@ export function createAction( ) } case PlayoutActions.moveNext: - return createUserActionWithCtx(triggersContext, action, UserAction.MOVE_NEXT, async (e, ts, ctx) => - triggersContext.MeteorCall.userAction.moveNext( + return createUserActionWithCtx(triggersContext, action, UserAction.MOVE_NEXT, async (e, ts, ctx) => { + return triggersContext.MeteorCall.userAction.moveNext( e, ts, ctx.rundownPlaylistId.get(null), action.parts ?? 0, - action.segments ?? 0 + action.segments ?? 0, + action.ignoreQuickLoop ) - ) + }) case PlayoutActions.reloadRundownPlaylistData: if (isActionTriggeredFromUiContext(triggersContext, action)) { return createRundownPlaylistSoftResyncAction(action.filterChain as IGUIContextFilterLink[]) diff --git a/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx index d9f150dcf3..c5bcd12512 100644 --- a/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx +++ b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx @@ -66,6 +66,9 @@ function getArguments(t: TFunction, action: SomeAction): string[] { if (action.parts) { result.push(t('Parts: {{delta}}', { delta: (action.parts > 0 ? '+' : '') + action.parts })) } + if (action.ignoreQuickLoop) { + result.push(t('Ignore QuickLoop')) + } break case PlayoutActions.reloadRundownPlaylistData: break @@ -318,6 +321,18 @@ function getActionParametersEditor( }) } /> + + { + onChange({ + ...action, + ignoreQuickLoop: newVal, + }) + }} + /> ) case PlayoutActions.reloadRundownPlaylistData: diff --git a/packages/webui/src/client/ui/Shelf/DashboardActionButtonGroup.tsx b/packages/webui/src/client/ui/Shelf/DashboardActionButtonGroup.tsx index 8e652677da..cdb12c8f6c 100644 --- a/packages/webui/src/client/ui/Shelf/DashboardActionButtonGroup.tsx +++ b/packages/webui/src/client/ui/Shelf/DashboardActionButtonGroup.tsx @@ -41,11 +41,18 @@ export const DashboardActionButtonGroup = withTranslation()( } } - moveNext = (e: any, horizontalDelta: number, verticalDelta: number) => { + moveNext = (e: any, horizontalDelta: number, verticalDelta: number, ignoreQuickLoop?: boolean) => { const { t } = this.props if (this.props.studioMode) { doUserAction(t, e, UserAction.MOVE_NEXT, (e, ts) => - MeteorCall.userAction.moveNext(e, ts, this.props.playlist._id, horizontalDelta, verticalDelta) + MeteorCall.userAction.moveNext( + e, + ts, + this.props.playlist._id, + horizontalDelta, + verticalDelta, + ignoreQuickLoop + ) ) } }