From e79ee4277c5005da282cecc3daf296e4c7ac32d5 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 27 Mar 2023 18:43:48 +0200 Subject: [PATCH 01/14] chore: add unit tests --- .../preparedGroupPlayData.test.ts.snap | 280 ++++++++++++++++++ .../__tests__/preparedGroupPlayData.test.ts | 261 +++++++++++++++- 2 files changed, 538 insertions(+), 3 deletions(-) diff --git a/apps/app/src/lib/playout/__tests__/__snapshots__/preparedGroupPlayData.test.ts.snap b/apps/app/src/lib/playout/__tests__/__snapshots__/preparedGroupPlayData.test.ts.snap index c594697d..ed143051 100644 --- a/apps/app/src/lib/playout/__tests__/__snapshots__/preparedGroupPlayData.test.ts.snap +++ b/apps/app/src/lib/playout/__tests__/__snapshots__/preparedGroupPlayData.test.ts.snap @@ -393,6 +393,286 @@ exports[`prepareGroupPlayData Default Group Play Part A when stopped 8`] = ` } `; +exports[`prepareGroupPlayData Default Group Play infinite Part B when stopped 1`] = ` +{ + "sections": [ + { + "duration": null, + "endAction": "infinite", + "endTime": null, + "parts": [ + { + "duration": null, + "endAction": "infinite", + "part": { + "id": "partB", + "name": "Part B", + "resolved": { + "duration": null, + "label": "Part B", + }, + "timeline": [], + "triggers": [], + }, + "startTime": 1000, + }, + ], + "pauseTime": undefined, + "repeating": false, + "schedule": false, + "startTime": 1000, + "stopTime": undefined, + }, + ], + "type": "single", + "validUntil": undefined, +} +`; + +exports[`prepareGroupPlayData Default Group Play infinite Part B when stopped 2`] = ` +{ + "allPartsArePaused": false, + "anyPartIsPlaying": true, + "countdowns": {}, + "groupIsPlaying": true, + "groupScheduledToPlay": [], + "playheads": { + "partB": { + "endAction": "infinite", + "fromSchedule": false, + "isInRepeating": true, + "partDuration": null, + "partEndTime": null, + "partId": "partB", + "partPauseTime": undefined, + "partStartTime": 1000, + "playheadTime": 1, + }, + }, + "sectionEndAction": "infinite", + "sectionEndTime": null, + "sectionTimeToEnd": null, +} +`; + +exports[`prepareGroupPlayData Default Group Play infinite Part B when stopped 3`] = ` +{ + "sections": [ + { + "duration": null, + "endAction": "stop", + "endTime": 9000, + "parts": [ + { + "duration": null, + "endAction": "infinite", + "part": { + "id": "partB", + "name": "Part B", + "resolved": { + "duration": null, + "label": "Part B", + }, + "timeline": [], + "triggers": [], + }, + "startTime": 1000, + }, + ], + "pauseTime": undefined, + "repeating": false, + "schedule": false, + "startTime": 1000, + "stopTime": 9000, + }, + ], + "type": "single", + "validUntil": undefined, +} +`; + +exports[`prepareGroupPlayData Default Group Play infinite Part B when stopped 4`] = ` +{ + "allPartsArePaused": true, + "anyPartIsPlaying": false, + "countdowns": {}, + "groupIsPlaying": false, + "groupScheduledToPlay": [], + "playheads": {}, + "sectionEndAction": null, + "sectionEndTime": null, + "sectionTimeToEnd": null, +} +`; + +exports[`prepareGroupPlayData Multi-play Group Play Part A, then B 1`] = ` +{ + "sections": { + "partA": [ + { + "duration": 1000, + "endAction": "stop", + "endTime": 2000, + "parts": [ + { + "duration": 1000, + "endAction": "stop", + "part": { + "id": "partA", + "name": "Part A", + "resolved": { + "duration": 1000, + "label": "Part A", + }, + "timeline": [], + "triggers": [], + }, + "startTime": 1000, + }, + ], + "pauseTime": undefined, + "repeating": undefined, + "schedule": false, + "startTime": 1000, + "stopTime": undefined, + }, + ], + }, + "type": "multi", + "validUntil": undefined, +} +`; + +exports[`prepareGroupPlayData Multi-play Group Play Part A, then B 2`] = ` +{ + "allPartsArePaused": false, + "anyPartIsPlaying": true, + "countdowns": {}, + "groupIsPlaying": false, + "groupScheduledToPlay": [], + "playheads": { + "partA": { + "endAction": "stop", + "fromSchedule": false, + "isInRepeating": true, + "partDuration": 1000, + "partEndTime": 2000, + "partId": "partA", + "partPauseTime": undefined, + "partStartTime": 1000, + "playheadTime": 1, + }, + }, + "sectionEndAction": null, + "sectionEndTime": null, + "sectionTimeToEnd": null, +} +`; + +exports[`prepareGroupPlayData Multi-play Group Play Part A, then B 3`] = ` +{ + "sections": { + "partA": [ + { + "duration": 1000, + "endAction": "stop", + "endTime": 2000, + "parts": [ + { + "duration": 1000, + "endAction": "stop", + "part": { + "id": "partA", + "name": "Part A", + "resolved": { + "duration": 1000, + "label": "Part A", + }, + "timeline": [], + "triggers": [], + }, + "startTime": 1000, + }, + ], + "pauseTime": undefined, + "repeating": undefined, + "schedule": false, + "startTime": 1000, + "stopTime": undefined, + }, + ], + "partB": [ + { + "duration": 2000, + "endAction": "stop", + "endTime": 3500, + "parts": [ + { + "duration": 2000, + "endAction": "stop", + "part": { + "id": "partB", + "name": "Part B", + "resolved": { + "duration": 2000, + "label": "Part B", + }, + "timeline": [], + "triggers": [], + }, + "startTime": 1500, + }, + ], + "pauseTime": undefined, + "repeating": undefined, + "schedule": false, + "startTime": 1500, + "stopTime": undefined, + }, + ], + }, + "type": "multi", + "validUntil": undefined, +} +`; + +exports[`prepareGroupPlayData Multi-play Group Play Part A, then B 4`] = ` +{ + "allPartsArePaused": false, + "anyPartIsPlaying": true, + "countdowns": {}, + "groupIsPlaying": false, + "groupScheduledToPlay": [], + "playheads": { + "partA": { + "endAction": "stop", + "fromSchedule": false, + "isInRepeating": true, + "partDuration": 1000, + "partEndTime": 2000, + "partId": "partA", + "partPauseTime": undefined, + "partStartTime": 1000, + "playheadTime": 503, + }, + "partB": { + "endAction": "stop", + "fromSchedule": false, + "isInRepeating": true, + "partDuration": 2000, + "partEndTime": 3500, + "partId": "partB", + "partPauseTime": undefined, + "partStartTime": 1500, + "playheadTime": 3, + }, + }, + "sectionEndAction": null, + "sectionEndTime": null, + "sectionTimeToEnd": null, +} +`; + exports[`prepareGroupPlayData Playlist Group Play Group 1`] = ` { "sections": [ diff --git a/apps/app/src/lib/playout/__tests__/preparedGroupPlayData.test.ts b/apps/app/src/lib/playout/__tests__/preparedGroupPlayData.test.ts index a92bc40d..b29d8fd3 100644 --- a/apps/app/src/lib/playout/__tests__/preparedGroupPlayData.test.ts +++ b/apps/app/src/lib/playout/__tests__/preparedGroupPlayData.test.ts @@ -246,6 +246,83 @@ describe('prepareGroupPlayData', () => { expect(playData).toMatchSnapshot() } }) + test('Play infinite Part B when stopped', () => { + const group0 = getTestGroup() + expect(group0.oneAtATime).toBeTruthy() + + const part = getPart(group0, 'partB') + // Set the part to be infinite: + part.resolved.duration = null + + // Play the part: + RundownActions.playPart(group0, part, 1000) + postProcessGroup(group0, 1000) + + { + const prepared = prepareGroupPlayData(group0, 1000) + if (!prepared) throw new Error('Prepared is falsy') + expect(prepared).toMatchSnapshot() + + const playData = getGroupPlayData(prepared, 1001) + + expect(playData).toMatchObject({ + groupIsPlaying: true, + anyPartIsPlaying: true, + allPartsArePaused: false, + sectionTimeToEnd: null, + sectionEndTime: null, + sectionEndAction: SectionEndAction.INFINITE, + }) + expect(Object.keys(playData.playheads)).toHaveLength(1) + expect(playData.playheads['partB']).toMatchObject({ + playheadTime: 1, + partStartTime: 1000, + partPauseTime: undefined, + partEndTime: null, + partDuration: null, + partId: 'partB', + fromSchedule: false, + }) + expect(Object.keys(playData.countdowns)).toHaveLength(0) + + expect(playData).toMatchSnapshot() + + // Ensure it is still playing long after: + { + const playData = getGroupPlayData(prepared, 900000) + expect(playData.playheads['partB']).toMatchObject({ + playheadTime: 899000, + partStartTime: 1000, + partPauseTime: undefined, + partEndTime: null, + partDuration: null, + partId: 'partB', + fromSchedule: false, + }) + } + } + + // Now stop the part: + RundownActions.stopPart(group0, 'partB', 9000) + postProcessGroup(group0, 9000) + { + const prepared = prepareGroupPlayData(group0, 9000) + if (!prepared) throw new Error('Prepared is falsy') + expect(prepared).toMatchSnapshot() + + const playData = getGroupPlayData(prepared, 9001) + expect(playData).toMatchObject({ + groupIsPlaying: false, + anyPartIsPlaying: false, + // allPartsArePaused: true, + sectionTimeToEnd: null, + sectionEndTime: null, + sectionEndAction: null, + }) + expect(Object.keys(playData.playheads)).toHaveLength(0) + expect(playData).toMatchSnapshot() + } + }) }) describe('Playlist Group', () => { @@ -459,6 +536,187 @@ describe('prepareGroupPlayData', () => { expect(Object.keys(playData.playheads)).toHaveLength(0) } }) + test('Play, when Part B is infinite', () => { + const group0 = getTestGroup() + expect(group0.oneAtATime).toBeTruthy() + + const partA = getPart(group0, 'partA') + const partB = getPart(group0, 'partB') + + // Set part B to be infinite: + partB.resolved.duration = null + + // Play part A: + RundownActions.playPart(group0, partA, 1000) + postProcessGroup(group0, 1000) + + { + const prepared = prepareGroupPlayData(group0, 1001) + if (!prepared) throw new Error('Prepared is falsy') + + const playData = getGroupPlayData(prepared, 1001) + + expect(playData).toMatchObject({ + groupIsPlaying: true, + anyPartIsPlaying: true, + allPartsArePaused: false, + sectionTimeToEnd: null, + sectionEndTime: null, + sectionEndAction: SectionEndAction.INFINITE, + }) + expect(Object.keys(playData.playheads)).toHaveLength(1) + expect(playData.playheads['partA']).toMatchObject({ + playheadTime: 1, + partStartTime: 1000, + partPauseTime: undefined, + partEndTime: 2000, + partDuration: 1000, + partId: 'partA', + endAction: PlayPartEndAction.NEXT_PART, + fromSchedule: false, + }) + expect(Object.keys(playData.countdowns)).toStrictEqual(['partB']) + } + { + const prepared = prepareGroupPlayData(group0, 3500) + if (!prepared) throw new Error('Prepared is falsy') + + const playData = getGroupPlayData(prepared, 3501) + + expect(playData).toMatchObject({ + groupIsPlaying: true, + anyPartIsPlaying: true, + allPartsArePaused: false, + sectionTimeToEnd: null, + sectionEndTime: null, + sectionEndAction: SectionEndAction.INFINITE, + }) + expect(Object.keys(playData.playheads)).toHaveLength(1) + expect(playData.playheads['partB']).toMatchObject({ + playheadTime: 1501, + partStartTime: 2000, + partPauseTime: undefined, + partEndTime: null, + partDuration: null, + partId: 'partB', + endAction: PlayPartEndAction.INFINITE, + fromSchedule: false, + }) + expect(Object.keys(playData.countdowns)).toHaveLength(0) + } + }) + }) + + describe('Multi-play Group', () => { + // Tests a Group set as a playlist + function getTestGroup(): Group { + const group = getCommonGroup() + group.oneAtATime = false + group.autoPlay = false + group.loop = false + return group + } + test('Play Part A, then B', () => { + const group0 = getTestGroup() + expect(group0.oneAtATime).toBeFalsy() + + const partA = getPart(group0, 'partA') + const partB = getPart(group0, 'partB') + + // Play part A: + RundownActions.playPart(group0, partA, 1000) + postProcessGroup(group0, 1000) + { + const prepared = prepareGroupPlayData(group0, 1001) + if (!prepared) throw new Error('Prepared is falsy') + expect(prepared).toMatchSnapshot() + + expect(prepared).toMatchObject({ + type: 'multi', + }) + expect(Object.keys(prepared.sections)).toStrictEqual(['partA']) + + const playData = getGroupPlayData(prepared, 1001) + + expect(playData).toMatchObject({ + groupIsPlaying: false, + anyPartIsPlaying: true, + allPartsArePaused: false, + }) + expect(Object.keys(playData.playheads)).toHaveLength(1) + expect(playData.playheads['partA']).toMatchObject({ + playheadTime: 1, + partStartTime: 1000, + partPauseTime: undefined, + partEndTime: 2000, + partDuration: 1000, + partId: 'partA', + endAction: PlayPartEndAction.STOP, + fromSchedule: false, + }) + expect(Object.keys(playData.countdowns)).toHaveLength(0) + expect(playData).toMatchSnapshot() + + // Check that it would stop playing after a while: + const playDataLater = getGroupPlayData(prepared, 2001) + expect(Object.keys(playDataLater.playheads)).toHaveLength(0) + expect(Object.keys(playDataLater.countdowns)).toHaveLength(0) + } + // Play part B: + RundownActions.playPart(group0, partB, 1500) + postProcessGroup(group0, 1500) + { + const prepared = prepareGroupPlayData(group0, 1503) + if (!prepared) throw new Error('Prepared is falsy') + expect(prepared).toMatchSnapshot() + + expect(Object.keys(prepared.sections)).toStrictEqual(['partA', 'partB']) + + const playData = getGroupPlayData(prepared, 1503) + + expect(playData).toMatchObject({ + groupIsPlaying: false, + anyPartIsPlaying: true, + allPartsArePaused: false, + }) + expect(Object.keys(playData.playheads)).toHaveLength(2) + expect(playData.playheads['partA']).toMatchObject({ + playheadTime: 503, + partStartTime: 1000, + partPauseTime: undefined, + partEndTime: 2000, + partDuration: 1000, + partId: 'partA', + endAction: PlayPartEndAction.STOP, + fromSchedule: false, + }) + expect(playData.playheads['partB']).toMatchObject({ + playheadTime: 3, + partStartTime: 1500, + partPauseTime: undefined, + partEndTime: 3500, + partDuration: 2000, + partId: 'partB', + endAction: PlayPartEndAction.STOP, + fromSchedule: false, + }) + expect(Object.keys(playData.countdowns)).toHaveLength(0) + expect(playData).toMatchSnapshot() + + { + // Check that A stops playing after a while: + const playDataLater = getGroupPlayData(prepared, 2001) + expect(Object.keys(playDataLater.playheads)).toHaveLength(1) + expect(Object.keys(playDataLater.countdowns)).toHaveLength(0) + } + { + // Check that B stops playing after a while: + const playDataLater = getGroupPlayData(prepared, 3501) + expect(Object.keys(playDataLater.playheads)).toHaveLength(0) + expect(Object.keys(playDataLater.countdowns)).toHaveLength(0) + } + } + }) }) // Test cases to add: @@ -469,9 +727,6 @@ describe('prepareGroupPlayData', () => { // Pause Part A when playing Part A // Pause (cue) Part B when playing Part A - // Play infinite part - // Play looping part - // Schedule play }) function getPart(group: Group, partId: string): Part { From 77ee6493d4a2e24f5309d487632f6fbdef3bc9b1 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 11 Oct 2023 21:01:34 +0200 Subject: [PATCH 02/14] fix: Add option to set a custom label on timeline objects --- apps/app/src/electron/IPCServer.ts | 5 +- apps/app/src/ipc/IPCAPI.ts | 4 +- apps/app/src/lib/TimelineObj.ts | 9 +- apps/app/src/lib/util.ts | 2 +- apps/app/src/models/rundown/TimelineObj.ts | 2 + .../rundown/GroupView/TimelineObject.tsx | 2 +- .../components/sidebar/DataRow/DataRow.tsx | 20 ++-- .../sidebar/SideBarEditTimelineObject.tsx | 97 +++++++++++++------ .../app/src/react/styles/sidebar/sidebar.scss | 9 ++ 9 files changed, 103 insertions(+), 47 deletions(-) diff --git a/apps/app/src/electron/IPCServer.ts b/apps/app/src/electron/IPCServer.ts index 12c2d5d8..4ba68834 100644 --- a/apps/app/src/electron/IPCServer.ts +++ b/apps/app/src/electron/IPCServer.ts @@ -1597,9 +1597,7 @@ export class IPCServer groupId: string partId: string timelineObjId: string - timelineObj: { - obj: PartialDeep - } + timelineObj: PartialDeep> }): Promise | undefined> { const { rundown, group, part } = this.getPart(arg) @@ -1615,6 +1613,7 @@ export class IPCServer const timelineObjIndex = findTimelineObjIndex(part, arg.timelineObjId) if (arg.timelineObj.obj !== undefined) deepExtendRemovingUndefined(timelineObj.obj, arg.timelineObj.obj) + if (arg.timelineObj.customLabel !== undefined) timelineObj.customLabel = arg.timelineObj.customLabel postProcessPart(part) this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) diff --git a/apps/app/src/ipc/IPCAPI.ts b/apps/app/src/ipc/IPCAPI.ts index 4c526ca1..129547f9 100644 --- a/apps/app/src/ipc/IPCAPI.ts +++ b/apps/app/src/ipc/IPCAPI.ts @@ -162,9 +162,7 @@ export interface IPCServerMethods { groupId: string partId: string timelineObjId: string - timelineObj: { - obj: PartialDeep - } + timelineObj: PartialDeep> }) => void deleteTimelineObj: (arg: { rundownId: string; groupId: string; partId: string; timelineObjId: string }) => void insertTimelineObjs: (arg: { diff --git a/apps/app/src/lib/TimelineObj.ts b/apps/app/src/lib/TimelineObj.ts index 8113b762..707b96c2 100644 --- a/apps/app/src/lib/TimelineObj.ts +++ b/apps/app/src/lib/TimelineObj.ts @@ -41,12 +41,14 @@ export interface TimelineObjectDescription { } export function describeTimelineObject( - obj: TSRTimelineObj, + timelineObj: TimelineObj, deviceMetadata?: MetadataAny | null ): TimelineObjectDescription { + const obj: TSRTimelineObj = timelineObj.obj let label: string = obj.id let inTransition: TimelineObjectDescription['inTransition'] = undefined let outTransition: TimelineObjectDescription['outTransition'] = undefined + if (obj.content.deviceType === DeviceType.CASPARCG) { if (obj.content.type === TimelineContentTypeCasparCg.MEDIA) { label = obj.content.file @@ -278,6 +280,11 @@ export function describeTimelineObject( // assertNever(obj.content) } + if (timelineObj.customLabel !== undefined) { + const customLabel: string = `${timelineObj.customLabel || ''}`.trim() + if (customLabel) label = customLabel + } + // @ts-expect-error type const type: string = obj.content.type const contentTypeClassNames: string[] = [`device-${DeviceType[obj.content.deviceType]}`, type] diff --git a/apps/app/src/lib/util.ts b/apps/app/src/lib/util.ts index 743e684e..9025cc08 100644 --- a/apps/app/src/lib/util.ts +++ b/apps/app/src/lib/util.ts @@ -833,7 +833,7 @@ export function getPartLabel(part: Part): string { if (part.timeline) { const firstTimelineObj = part.timeline[0] if (firstTimelineObj) { - const description = describeTimelineObject(firstTimelineObj.obj) + const description = describeTimelineObject(firstTimelineObj) return description.label } } diff --git a/apps/app/src/models/rundown/TimelineObj.ts b/apps/app/src/models/rundown/TimelineObj.ts index a28bba27..3faf4d45 100644 --- a/apps/app/src/models/rundown/TimelineObj.ts +++ b/apps/app/src/models/rundown/TimelineObj.ts @@ -3,6 +3,8 @@ import { TSRTimelineContent, TSRTimelineObj } from 'timeline-state-resolver-type export interface TimelineObj { obj: TSRTimelineObj + customLabel?: string + resolved: { instances: TimelineObjResolvedInstance[] } diff --git a/apps/app/src/react/components/rundown/GroupView/TimelineObject.tsx b/apps/app/src/react/components/rundown/GroupView/TimelineObject.tsx index b120e3de..5df03f99 100644 --- a/apps/app/src/react/components/rundown/GroupView/TimelineObject.tsx +++ b/apps/app/src/react/components/rundown/GroupView/TimelineObject.tsx @@ -199,7 +199,7 @@ export const TimelineObject: React.FC<{ widthPercentage = null } - const description = describeTimelineObject(obj, deviceMetadata) + const description = describeTimelineObject(timelineObj, deviceMetadata) useEffect(() => { const onKey = () => { diff --git a/apps/app/src/react/components/sidebar/DataRow/DataRow.tsx b/apps/app/src/react/components/sidebar/DataRow/DataRow.tsx index 66adbfcf..f1a053bd 100644 --- a/apps/app/src/react/components/sidebar/DataRow/DataRow.tsx +++ b/apps/app/src/react/components/sidebar/DataRow/DataRow.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useContext } from 'react' import { useSnackbar } from 'notistack' +import { Tooltip } from '@mui/material' import { ErrorHandlerContext } from '../../../contexts/ErrorHandler' import './style.scss' @@ -22,15 +23,16 @@ export const DataRow = (props: { label: string; value: any }): JSX.Element => {
{props.label}
-
{ - void copyValueToClipboard() - }} - > - {props.value} -
+ +
{ + void copyValueToClipboard() + }} + > + {props.value} +
+
) } diff --git a/apps/app/src/react/components/sidebar/SideBarEditTimelineObject.tsx b/apps/app/src/react/components/sidebar/SideBarEditTimelineObject.tsx index 947d4b16..311bb845 100644 --- a/apps/app/src/react/components/sidebar/SideBarEditTimelineObject.tsx +++ b/apps/app/src/react/components/sidebar/SideBarEditTimelineObject.tsx @@ -1,5 +1,5 @@ import { IPCServerContext } from '../../contexts/IPCServer' -import React, { useContext, useState } from 'react' +import React, { useCallback, useContext, useState } from 'react' import { Mappings } from 'timeline-state-resolver-types' import { TrashBtn } from '../inputs/TrashBtn' import { DataRow } from './DataRow/DataRow' @@ -11,7 +11,12 @@ import { EditTimelineObjContent } from './timelineObj/editTimelineObj' import { describeTimelineObject } from '../../../lib/TimelineObj' import { ConfirmationDialog } from '../util/ConfirmationDialog' import { computed } from 'mobx' -import { firstValue } from '../../lib/multipleEdit' +import { anyAreTrue, firstValue, inputValue } from '../../lib/multipleEdit' +import { TextInput } from '../inputs/TextInput' +import { PartialDeep } from 'type-fest/source/partial-deep' +import { OnSave } from './timelineObj/timelineObjs/lib' +import { TimelineObj } from '../../../models/rundown/TimelineObj' +import { Tooltip } from '@mui/material' export const SideBarEditTimelineObject: React.FC<{ rundownId: string @@ -26,6 +31,7 @@ export const SideBarEditTimelineObject: React.FC<{ const ipcServer = useContext(IPCServerContext) const { handleError } = useContext(ErrorHandlerContext) const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false) + const [editCustomLabel, setEditCustomLabel] = useState(false) const gui = store.guiStore @@ -44,14 +50,40 @@ export const SideBarEditTimelineObject: React.FC<{ }) ).get() - if (fullObjs.length === 0) return null - const modifiableObjects = fullObjs.filter((o) => !o.groupOrPartLocked) + const onSave = useCallback( + (update: PartialDeep) => { + modifiableObjects.forEach((o) => { + ipcServer + .updateTimelineObj({ + rundownId: rundownId, + groupId: o.groupId, + partId: o.partId, + timelineObjId: o.timelineObjId, + timelineObj: update, + }) + .catch(handleError) + }) + }, + [modifiableObjects, handleError, ipcServer, rundownId] + ) + const onSaveObj = useCallback( + (updateObj) => { + onSave({ obj: updateObj }) + }, + [onSave] + ) + + if (fullObjs.length === 0) return null + let label = 'N/A' + let hasCustomLabel = false if (fullObjs.length === 1) { - const desciption = firstValue(modifiableObjects, (o) => describeTimelineObject(o.timelineObj.obj)) - if (desciption) label = desciption.label + const description = firstValue(modifiableObjects, (o) => describeTimelineObject(o.timelineObj)) + if (description) label = description.label + + hasCustomLabel = anyAreTrue(modifiableObjects, (o) => Boolean(o.timelineObj.customLabel)) } else { label = `${fullObjs.length} objects` } @@ -60,11 +92,21 @@ export const SideBarEditTimelineObject: React.FC<{ <>
- {fullObjs.length > 1 - ? `${fullObjs.length} Timeline objects` - : `Timeline object: ${ - firstValue(fullObjs, (o) => describeTimelineObject(o.timelineObj.obj)?.label) || '' - }`} + {fullObjs.length > 1 ? ( + `${fullObjs.length} Timeline objects` + ) : ( + + { + e.preventDefault() + setEditCustomLabel(true) + }} + > + Timeline object: $ + {firstValue(fullObjs, (o) => describeTimelineObject(o.timelineObj)?.label) || ''} + + + )}
1 ? 'Different IDs' : fullObjs[0].partId} /> - { - modifiableObjects.forEach((o) => { - ipcServer - .updateTimelineObj({ - rundownId: rundownId, - groupId: o.groupId, - partId: o.partId, - timelineObjId: o.timelineObjId, - timelineObj: { - obj: updateObj, - }, - }) - .catch(handleError) - }) - }} - /> + {(editCustomLabel || hasCustomLabel) && ( +
+ o.timelineObj.customLabel, undefined)} + onChange={(v) => { + onSave({ customLabel: (v ?? '').trim() }) + }} + allowUndefined={true} + /> +
+ )} + + .sidebar__content { From b5c8c1c109d058f738713106abbd69d7d610076f Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 26 Oct 2023 11:15:41 +0200 Subject: [PATCH 03/14] fix: make bridge and device initialization more robust --- apps/app/src/electron/bridgeHandler.ts | 48 +++++++++++++++----------- shared/packages/tsr-bridge/src/TSR.ts | 47 ++++++++++++++++++------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/apps/app/src/electron/bridgeHandler.ts b/apps/app/src/electron/bridgeHandler.ts index 60dc8f34..e98b5aa9 100644 --- a/apps/app/src/electron/bridgeHandler.ts +++ b/apps/app/src/electron/bridgeHandler.ts @@ -33,8 +33,6 @@ import { AnalogInput } from '../models/project/AnalogInput' export const { version: CURRENT_VERSION }: { version: string } = require('../../package.json') export const SERVER_PORT = 5400 -type AnyBridgeConnection = WebsocketBridgeConnection | LocalBridgeConnection - /** This handles connected bridges */ export class BridgeHandler { server: WebsocketServer @@ -48,7 +46,7 @@ export class BridgeHandler { } > = new Map() private internalBridge: LocalBridgeConnection | null = null - private connectedBridges: Array = [] + private connectedBridges: Map = new Map() private mappings: Mappings = {} private timelines: { [timelineId: string]: TSRTimeline } = {} private settings: Map = new Map() @@ -73,6 +71,18 @@ export class BridgeHandler { ...callbacks, getMappings: () => this.mappings, getTimelines: () => this.timelines, + onInitialized: (connection) => { + if (connection.bridgeId == null) return + this.connectedBridges.set(connection.bridgeId, connection) + }, + onDisconnected: (connection) => { + if (connection.bridgeId == null) return + const currentConnection = this.connectedBridges.get(connection.bridgeId) + if (currentConnection === connection) { + // delete only if that's the same connection, otherwise a new one might have been initiated before this one fully closed + this.connectedBridges.delete(connection.bridgeId) + } + }, } this.server = new WebsocketServer(this.log, SERVER_PORT, (connection: WebsocketConnection) => { // On connection: @@ -89,10 +99,9 @@ export class BridgeHandler { for (const [bridgeId, outgoing] of this.outgoingBridges.entries()) { if (outgoing.connection?.connectionId === connection.connectionId) { bridge.bridgeId = bridgeId + this.connectedBridges.set(bridgeId, bridge) } } - - this.connectedBridges.push(bridge) }) this.server.on('close', () => { @@ -108,8 +117,8 @@ export class BridgeHandler { this.reconnectToBridges() }, 1000) } - getBridgeConnection(bridgeId: BridgeId): AnyBridgeConnection | undefined { - return this.connectedBridges.find((b) => b.bridgeId === bridgeId) + getBridgeConnection(bridgeId: BridgeId): AbstractBridgeConnection | undefined { + return this.connectedBridges.get(bridgeId) } async onClose(): Promise { if (this.internalBridge) { @@ -129,18 +138,13 @@ export class BridgeHandler { this.storage, this.connectionCallbacks ) - this.connectedBridges.push(this.internalBridge) + this.connectedBridges.set(INTERNAL_BRIDGE_ID, this.internalBridge) } } else { if (this.internalBridge) { this.session.updateBridgeStatus(INTERNAL_BRIDGE_ID, null) - const bridgeIndex = this.connectedBridges.findIndex( - (connectedBridge) => connectedBridge === this.internalBridge - ) - if (bridgeIndex >= 0) { - this.connectedBridges.splice(bridgeIndex, 1) - } const internalBridge = this.internalBridge + this.connectedBridges.delete(INTERNAL_BRIDGE_ID) this.internalBridge = null internalBridge.destroy().catch(this.log.error) } @@ -218,7 +222,7 @@ export class BridgeHandler { if (!_.isEqual(this.mappings, mappings)) { this.mappings = mappings - for (const bridgeConnection of this.connectedBridges) { + for (const bridgeConnection of this.connectedBridges.values()) { bridgeConnection.setMappings(mappings) } } @@ -228,20 +232,20 @@ export class BridgeHandler { if (timeline) { this.timelines[timelineId] = timeline - for (const bridgeConnection of this.connectedBridges) { + for (const bridgeConnection of this.connectedBridges.values()) { bridgeConnection.addTimeline(timelineId, timeline) } } else { delete this.timelines[timelineId] - for (const bridgeConnection of this.connectedBridges) { + for (const bridgeConnection of this.connectedBridges.values()) { bridgeConnection.removeTimeline(timelineId) } } } } updateSettings(bridgeId: BridgeId, settings: Bridge['settings']): void { - const bridgeConnection = this.connectedBridges.find((bc) => bc.bridgeId === bridgeId) + const bridgeConnection = this.connectedBridges.get(bridgeId) if (bridgeConnection) { bridgeConnection.setSettings(settings) } @@ -303,12 +307,12 @@ export class BridgeHandler { modified: number }[] ): void { - for (const bridgeConnection of this.connectedBridges) { + for (const bridgeConnection of this.connectedBridges.values()) { bridgeConnection.updateDatastore(updates) } } refreshResources(): void { - for (const bridgeConnection of this.connectedBridges) { + for (const bridgeConnection of this.connectedBridges.values()) { bridgeConnection.refreshResources() } } @@ -323,6 +327,8 @@ interface BridgeHandlerCallbacks { interface BridgeConnectionCallbacks extends BridgeHandlerCallbacks { getMappings: () => Mappings getTimelines: () => { [timelineId: string]: TSRTimeline } + onInitialized: (connection: AbstractBridgeConnection) => void + onDisconnected: (connection: AbstractBridgeConnection) => void } abstract class AbstractBridgeConnection { @@ -412,6 +418,7 @@ abstract class AbstractBridgeConnection { } else if (this.bridgeId !== id) { throw new Error(`bridgeId ID mismatch: "${this.bridgeId}" vs "${id}"`) } + this.callbacks.onInitialized(this) if (version !== CURRENT_VERSION) { this.callbacks.onVersionMismatch(id, version, CURRENT_VERSION) @@ -625,6 +632,7 @@ export class WebsocketBridgeConnection extends AbstractBridgeConnection { this.session.updateBridgeStatus(this.bridgeId, status) } + this.callbacks.onDisconnected(this) } }) this.connection.on('message', this.handleMessage.bind(this)) diff --git a/shared/packages/tsr-bridge/src/TSR.ts b/shared/packages/tsr-bridge/src/TSR.ts index b78d8de4..44bf10d6 100644 --- a/shared/packages/tsr-bridge/src/TSR.ts +++ b/shared/packages/tsr-bridge/src/TSR.ts @@ -1,5 +1,12 @@ import _ from 'lodash' -import { Conductor, ConductorOptions, DeviceOptionsAny, DeviceType, OSCDeviceType } from 'timeline-state-resolver' +import { + AbortError, + Conductor, + ConductorOptions, + DeviceOptionsAny, + DeviceType, + OSCDeviceType, +} from 'timeline-state-resolver' import { MetadataAny, ResourceAny, TSRDeviceId, unprotectString } from '@shared/models' import { BridgeAPI, LoggerLike } from '@shared/api' import { CasparCGSideload } from './sideload/CasparCG' @@ -18,7 +25,7 @@ export class TSR { public newConnection = false public conductor: Conductor public send: (message: BridgeAPI.FromBridge.Any) => void - private devices = new Map() + private devices = new Map() private sideLoadedDevices = new Map() @@ -113,10 +120,14 @@ export class TSR { if (!existingDevice || !_.isEqual(existingDevice, newDevice)) { if (existingDevice) { + existingDevice.abortController.abort() await this.conductor.removeDevice(unprotectString(deviceId)) } + await this._removeSideloadDevice(deviceId) - this.devices.set(deviceId, newDevice) + const abortController = new AbortController() + + this.devices.set(deviceId, { ...newDevice, abortController }) this.onDeviceStatus(deviceId, { statusCode: StatusCode.UNKNOWN, messages: ['Initializing'], @@ -128,7 +139,9 @@ export class TSR { this.sideLoadDevice(deviceId, newDevice) // Create the device, but don't initialize it: - const devicePr = this.conductor.createDevice(unprotectString(deviceId), newDevice) + const devicePr = this.conductor.createDevice(unprotectString(deviceId), newDevice, { + signal: abortController.signal, + }) this.onDeviceStatus(deviceId, { active: true, @@ -171,16 +184,21 @@ export class TSR { }) // now initialize it - await this.conductor.initDevice(unprotectString(deviceId), newDevice) + await this.conductor.initDevice(unprotectString(deviceId), newDevice, undefined, { + signal: abortController.signal, + }) this.onDeviceStatus(deviceId, await device.device.getStatus()) - })().catch((error) => this.log.error('TSR device error: ' + stringifyError(error))) + })().catch((error) => { + if (!(error instanceof AbortError)) this.log.error('TSR device error: ' + stringifyError(error)) + }) } } // Removed: - for (const deviceId of this.devices.keys()) { + for (const [deviceId, oldDevice] of this.devices.entries()) { const newDevice = deviceOptions.get(deviceId) if (!newDevice || newDevice.disable) { + oldDevice.abortController.abort() await this._removeDevice(deviceId) this.reportRemovedDevice(deviceId) @@ -192,11 +210,7 @@ export class TSR { } private async _removeDevice(deviceId: TSRDeviceId): Promise { // Delete the sideloaded device, if any - const sideLoadedDevice = this.sideLoadedDevices.get(deviceId) - if (sideLoadedDevice) { - await sideLoadedDevice.close() - this.sideLoadedDevices.delete(deviceId) - } + await this._removeSideloadDevice(deviceId) // HACK: There are some scenarios in which this method will never return. // For example, when trying to remove a CasparCG device that has never connected. @@ -207,6 +221,15 @@ export class TSR { this.devices.delete(deviceId) this.deviceStatus.delete(deviceId) } + + private async _removeSideloadDevice(deviceId: TSRDeviceId) { + const sideLoadedDevice = this.sideLoadedDevices.get(deviceId) + if (sideLoadedDevice) { + await sideLoadedDevice.close() + this.sideLoadedDevices.delete(deviceId) + } + } + public refreshResourcesAndMetadata( cb: (deviceId: TSRDeviceId, resources: ResourceAny[], metadata: MetadataAny) => void ): void { From 65cab0503ffa2c0b3abadadc280323dcc0b343b3 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 26 Oct 2023 13:14:56 +0200 Subject: [PATCH 04/14] fix: comparing device options do not include the abort controlled --- shared/packages/tsr-bridge/src/TSR.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/shared/packages/tsr-bridge/src/TSR.ts b/shared/packages/tsr-bridge/src/TSR.ts index 44bf10d6..3a1746df 100644 --- a/shared/packages/tsr-bridge/src/TSR.ts +++ b/shared/packages/tsr-bridge/src/TSR.ts @@ -25,7 +25,7 @@ export class TSR { public newConnection = false public conductor: Conductor public send: (message: BridgeAPI.FromBridge.Any) => void - private devices = new Map() + private devices = new Map() private sideLoadedDevices = new Map() @@ -113,12 +113,12 @@ export class TSR { private async _updateDevices(): Promise { // Added/updated: const deviceOptions = this.deviceOptions - for (const [deviceId, newDevice] of deviceOptions.entries()) { - if (newDevice.disable) continue + for (const [deviceId, newDeviceOptions] of deviceOptions.entries()) { + if (newDeviceOptions.disable) continue const existingDevice = this.devices.get(deviceId) - if (!existingDevice || !_.isEqual(existingDevice, newDevice)) { + if (!existingDevice || !_.isEqual(existingDevice.options, newDeviceOptions)) { if (existingDevice) { existingDevice.abortController.abort() await this.conductor.removeDevice(unprotectString(deviceId)) @@ -127,7 +127,7 @@ export class TSR { const abortController = new AbortController() - this.devices.set(deviceId, { ...newDevice, abortController }) + this.devices.set(deviceId, { options: newDeviceOptions, abortController }) this.onDeviceStatus(deviceId, { statusCode: StatusCode.UNKNOWN, messages: ['Initializing'], @@ -136,10 +136,10 @@ export class TSR { // Run async so as not to block other devices from being processed. ;(async () => { - this.sideLoadDevice(deviceId, newDevice) + this.sideLoadDevice(deviceId, newDeviceOptions) // Create the device, but don't initialize it: - const devicePr = this.conductor.createDevice(unprotectString(deviceId), newDevice, { + const devicePr = this.conductor.createDevice(unprotectString(deviceId), newDeviceOptions, { signal: abortController.signal, }) @@ -184,7 +184,7 @@ export class TSR { }) // now initialize it - await this.conductor.initDevice(unprotectString(deviceId), newDevice, undefined, { + await this.conductor.initDevice(unprotectString(deviceId), newDeviceOptions, undefined, { signal: abortController.signal, }) @@ -313,7 +313,8 @@ export class TSR { if (status && device) { // Hack to get rid of warnings for UDP OSC devices, which always have an UNKNOWN status code. - const isOscUdp = device.type === DeviceType.OSC && device.options?.type === OSCDeviceType.UDP + const isOscUdp = + device.options.type === DeviceType.OSC && device.options.options?.type === OSCDeviceType.UDP const ok = isOscUdp ? true : status.statusCode === StatusCode.GOOD const message = status.messages?.join(', ') ?? '' this.send({ From 23855da0564b575007ca7d755b64a19b466938c9 Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 27 Oct 2023 19:39:28 +0200 Subject: [PATCH 05/14] fix: undo/redo and object order after removal --- apps/app/src/electron/EverythingService.ts | 170 +++++++++++++++----- apps/app/src/electron/SuperConductor.ts | 4 +- apps/app/src/electron/api/ApiServer.ts | 12 ++ apps/app/src/electron/api/ProjectService.ts | 12 ++ apps/app/src/ipc/IPCAPI.ts | 32 ++-- apps/app/src/lib/__tests__/util.test.ts | 37 ++++- apps/app/src/lib/util.ts | 44 ++++- apps/app/src/react/App.tsx | 22 ++- apps/app/src/react/api/ApiClient.ts | 18 ++- tsconfig.build.json | 2 +- 10 files changed, 281 insertions(+), 72 deletions(-) diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 73c509c2..e35d8cf7 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -26,7 +26,6 @@ import { listAvailableDeviceIDs, MoveTarget, shortID, - unReplaceUndefined, updateGroupPlayingParts, } from '../lib/util' import { PartialDeep } from 'type-fest' @@ -129,6 +128,32 @@ type ConvertToServerSide = { : T[K] } +// const unreplaceUndefined = async (context: HookContext, next: NextFunction) => { +// context.arguments = unReplaceUndefined(context.arguments) +// await next() +// } + +// const undoable = async (context: HookContext, next: NextFunction) => { +// await next() +// if (context.self && isUndoable(context.result)) { +// context.self.registerUndoable( +// context.arguments, +// (context.self as any)[context.method ?? '']?.bind(context.self), +// context.result +// ) +// } +// } + +function Undoable(target: EverythingService, _key: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value + descriptor.value = async function (...args: any) { + const result = await originalMethod.apply(this, args) + target.registerUndoable.call(this, args, originalMethod.bind(this), result) + return result + } + return descriptor +} + /** * This class is used server-side, to handle requests from the client * The methods in here will later be moved away to other Services @@ -142,7 +167,6 @@ export class EverythingService private undoPointer: UndoPointer = -1 constructor( - ipcMain: Electron.IpcMain, private _log: LoggerLike, private _renderLog: LoggerLike, private storage: StorageHandler, @@ -166,44 +190,63 @@ export class EverythingService for (const methodName of Object.getOwnPropertyNames(EverythingService.prototype)) { if (methodName[0] !== '_') { const fcn = (this as any)[methodName].bind(this) - if (fcn) { - ipcMain.handle(methodName, async (event, args0: string[]) => { - try { - const args = unReplaceUndefined(args0) - const result = await fcn(...args) - if (isUndoable(result)) { - // Clear any future things in the undo ledger: - this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) - // Add the new action to the undo ledger: - this.undoLedger.push({ - description: result.description, - arguments: args, - undo: result.undo, - redo: fcn, - }) - if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { - this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) - } - this.undoPointer = this.undoLedger.length - 1 - this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) - - // string represents "anything but undefined" here: - return (result as UndoableResult).result - } else { - return result - } - } catch (error) { - this.callbacks.handleError( - `Error when calling ${methodName}: ${error}`, - typeof error === 'object' && (error as any).stack - ) - throw error - } - }) + if (typeof fcn === 'function') { + // Monkey-patching methods in this class. There are definitely better ways to do it... + // ;(this as any)[methodName] = async (...args0: string[]) => { + // try { + // const args = unReplaceUndefined(args0) + // let result = fcn(...args) + // if (result instanceof Promise) result = await result + // if (isUndoable(result)) { + // // Clear any future things in the undo ledger: + // this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) + // // Add the new action to the undo ledger: + // this.undoLedger.push({ + // description: result.description, + // arguments: args, + // undo: result.undo, + // redo: fcn, + // }) + // if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { + // this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) + // } + // this.undoPointer = this.undoLedger.length - 1 + // this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + // // string represents "anything but undefined" here: + // return (result as UndoableResult).result + // } else { + // return result + // } + // } catch (error) { + // this.callbacks.handleError( + // `Error when calling ${methodName}: ${error}`, + // typeof error === 'object' && (error as any).stack + // ) + // throw error + // } + // } } } } } + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + public registerUndoable(args: any, fcn: () => any, result: UndoableResult): void { + this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) + // Add the new action to the undo ledger: + this.undoLedger.push({ + description: result.description, + arguments: args, + undo: result.undo, + redo: fcn, + }) + if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { + this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) + } + this.undoPointer = this.undoLedger.length - 1 + this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + } + public getProject(): Project { return this.storage.getProject() } @@ -607,6 +650,7 @@ export class EverythingService await this.playPart({ rundownId: arg.rundownId, groupId: arg.groupId, partId: prevPart.id }) } } + @Undoable async newPart(arg: { rundownId: string /** The group to create the part into. If null; will create a "transparent group" */ @@ -669,6 +713,7 @@ export class EverythingService result, } } + @Undoable async insertParts(arg: { rundownId: string groupId: string | null @@ -774,6 +819,7 @@ export class EverythingService }) } } + @Undoable private async _insertPartsAsTransparentGroup(arg: { rundownId: string parts: { part: Part; resources: ResourceAny[] }[] @@ -827,6 +873,7 @@ export class EverythingService result: inserted, } } + @Undoable async updatePart(arg: { rundownId: string groupId: string @@ -870,6 +917,7 @@ export class EverythingService description: ActionDescription.UpdatePart, } } + @Undoable async upsertPart(arg: { rundownId: string groupId: string @@ -931,6 +979,7 @@ export class EverythingService description: ActionDescription.UpsertPart, } } + @Undoable async upsertPartByExternalId(arg: { rundownId: string groupId: string @@ -952,6 +1001,7 @@ export class EverythingService part: arg.part, }) } + @Undoable async newGroup(arg: { rundownId: string; name: string }): Promise> { const newGroup: Group = { ...getDefaultGroup(), @@ -973,6 +1023,7 @@ export class EverythingService result: newGroup.id, } } + @Undoable async insertGroups(arg: { rundownId: string groups: { @@ -1064,6 +1115,7 @@ export class EverythingService result: inserted, } } + @Undoable async updateGroup(arg: { rundownId: string groupId: string @@ -1113,6 +1165,7 @@ export class EverythingService description: ActionDescription.UpdateGroup, } } + @Undoable async upsertGroup(arg: { rundownId: string groupId: string | undefined @@ -1202,6 +1255,7 @@ export class EverythingService description: ActionDescription.UpsertGroup, } } + @Undoable async upsertGroupByExternalId(arg: { rundownId: string externalId: string @@ -1222,6 +1276,7 @@ export class EverythingService useExternalIdForParts: true, }) } + @Undoable async deletePart(arg: { rundownId: string groupId: string @@ -1266,6 +1321,7 @@ export class EverythingService description: ActionDescription.DeletePart, } } + @Undoable async deleteGroup(arg: { rundownId: string; groupId: string }): Promise | undefined> { const { rundown, group } = this.getGroup(arg) @@ -1296,6 +1352,7 @@ export class EverythingService description: ActionDescription.DeleteGroup, } } + @Undoable async moveParts(arg: { parts: { rundownId: string; partId: string }[] to: { rundownId: string; groupId: string | null; target: MoveTarget } @@ -1481,6 +1538,7 @@ export class EverythingService result: resultingParts, } } + @Undoable async duplicatePart(arg: { rundownId: string; groupId: string; partId: string }): Promise> { const { rundown, group, part } = this.getPart(arg) @@ -1530,6 +1588,7 @@ export class EverythingService description: ActionDescription.DuplicatePart, } } + @Undoable async moveGroups(arg: { rundownId: string groupIds: string[] @@ -1573,6 +1632,7 @@ export class EverythingService description: ActionDescription.MoveGroup, } } + @Undoable async duplicateGroup(arg: { rundownId: string; groupId: string }): Promise> { const { rundown, group } = this.getGroup(arg) @@ -1599,6 +1659,7 @@ export class EverythingService } } + @Undoable async updateTimelineObj(arg: { rundownId: string groupId: string @@ -1638,6 +1699,8 @@ export class EverythingService description: ActionDescription.UpdateTimelineObj, } } + + @Undoable async deleteTimelineObj(arg: { rundownId: string groupId: string @@ -1648,7 +1711,7 @@ export class EverythingService const result = findTimelineObjInRundown(rundown, arg.timelineObjId) if (!result) throw new Error(`TimelineObj ${arg.timelineObjId} not found.`) - const { group, part, timelineObj } = result + const { group, part } = result const groupId = group.id const partId = part.id @@ -1656,10 +1719,13 @@ export class EverythingService return } - const timelineObjIndex = findTimelineObjIndex(part, arg.timelineObjId) - const modified = deleteTimelineObj(part, arg.timelineObjId) + const originalPartTimeline = part.timeline + const modifiedPartTimeline = deleteTimelineObj(originalPartTimeline, arg.timelineObjId) - if (modified) postProcessPart(part) + if (modifiedPartTimeline !== originalPartTimeline) { + part.timeline = modifiedPartTimeline + postProcessPart(part) + } if (part.timeline.length <= 0) this.stopPart({ rundownId: arg.rundownId, groupId, partId }).catch(this._log.error) this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) @@ -1668,14 +1734,15 @@ export class EverythingService undo: () => { const { rundown, group, part } = this.getPart({ rundownId: arg.rundownId, groupId, partId }) - // Re-insert the timelineObj in its original position. - part.timeline.splice(timelineObjIndex, 0, timelineObj) + // Replace with the original timeline. + part.timeline = originalPartTimeline postProcessPart(part) this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.DeleteTimelineObj, } } + @Undoable async insertTimelineObjs(arg: { rundownId: string groupId: string @@ -1801,6 +1868,7 @@ export class EverythingService result: inserted, } } + @Undoable async moveTimelineObjToNewLayer(arg: { rundownId: string groupId: string @@ -1855,6 +1923,7 @@ export class EverythingService } } + @Undoable async addResourcesToTimeline(arg: { rundownId: string groupId: string @@ -1976,6 +2045,7 @@ export class EverythingService description: ActionDescription.addResourcesToTimeline, } } + @Undoable async toggleGroupLoop(arg: { rundownId: string groupId: string @@ -2006,6 +2076,7 @@ export class EverythingService description: ActionDescription.ToggleGroupLoop, } } + @Undoable async toggleGroupAutoplay(arg: { rundownId: string groupId: string @@ -2036,6 +2107,7 @@ export class EverythingService description: ActionDescription.ToggleGroupAutoplay, } } + @Undoable async toggleGroupOneAtATime(arg: { rundownId: string groupId: string @@ -2077,6 +2149,7 @@ export class EverythingService description: ActionDescription.toggleGroupOneAtATime, } } + @Undoable async toggleGroupDisable(arg: { rundownId: string groupId: string @@ -2107,6 +2180,7 @@ export class EverythingService description: ActionDescription.ToggleGroupDisable, } } + @Undoable async toggleGroupLock(arg: { rundownId: string; groupId: string; value: boolean }): Promise> { const { rundown, group } = this.getGroup(arg) const originalValue = group.locked @@ -2126,6 +2200,7 @@ export class EverythingService description: ActionDescription.ToggleGroupLock, } } + @Undoable async toggleGroupCollapse(arg: { rundownId: string groupId: string @@ -2149,6 +2224,7 @@ export class EverythingService description: ActionDescription.ToggleGroupCollapse, } } + @Undoable async toggleAllGroupsCollapse(arg: { rundownId: string; value: boolean }): Promise> { const { rundown } = this.getRundown(arg) @@ -2216,6 +2292,7 @@ export class EverythingService return this.storage.getProject() } + @Undoable async newRundown(arg: { name: string }): Promise> { const rundown = this.storage.newRundown(arg.name) const fileName = rundown.name @@ -2247,6 +2324,7 @@ export class EverythingService // Note: This is not undoable } + @Undoable async openRundown(arg: { rundownId: string }): Promise> { this.storage.openRundown(arg.rundownId) this._saveUpdates({}) @@ -2259,6 +2337,7 @@ export class EverythingService description: ActionDescription.OpenRundown, } } + @Undoable async closeRundown(arg: { rundownId: string }): Promise> { const { rundown } = this.getRundown(arg) if (!rundown) { @@ -2282,6 +2361,7 @@ export class EverythingService } } + @Undoable async renameRundown(arg: { rundownId: string; newName: string }): Promise> { const rundown = this.storage.getRundown(arg.rundownId) if (!rundown) { @@ -2326,6 +2406,7 @@ export class EverythingService const playData = getGroupPlayData(group.preparedPlayData) return Boolean(playData.playheads[part.id]) } + @Undoable async createMissingMapping(arg: { rundownId: string; mappingId: string }): Promise> { const project = this.getProject() const rundown = this.storage.getRundown(arg.rundownId) @@ -2411,6 +2492,7 @@ export class EverythingService } } + @Undoable async addPeripheralArea(arg: { bridgeId: BridgeId; deviceId: PeripheralId }): Promise> { const bridgeIdStr = unprotectString(arg.bridgeId) const deviceIdStr = unprotectString(arg.deviceId) @@ -2456,6 +2538,7 @@ export class EverythingService description: ActionDescription.AddPeripheralArea, } } + @Undoable async removePeripheralArea(data: { bridgeId: BridgeId deviceId: PeripheralId @@ -2498,6 +2581,7 @@ export class EverythingService description: ActionDescription.RemovePeripheralArea, } } + @Undoable async updatePeripheralArea(arg: { bridgeId: BridgeId deviceId: PeripheralId @@ -2545,6 +2629,7 @@ export class EverythingService description: ActionDescription.UpdatePeripheralArea, } } + @Undoable async assignAreaToGroup(arg: { groupId: string | undefined areaId: string @@ -2608,6 +2693,7 @@ export class EverythingService async finishDefiningArea(): Promise { this._saveUpdates({ definingArea: null }) } + @Undoable async setApplicationTrigger(arg: { triggerAction: ApplicationTrigger['action'] trigger: ApplicationTrigger | null diff --git a/apps/app/src/electron/SuperConductor.ts b/apps/app/src/electron/SuperConductor.ts index 360f4b30..e13866eb 100644 --- a/apps/app/src/electron/SuperConductor.ts +++ b/apps/app/src/electron/SuperConductor.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, dialog, ipcMain } from 'electron' +import { BrowserWindow, dialog } from 'electron' import { autoUpdater } from 'electron-updater' import { AutoFillMode, Group } from '../models/rundown/Group' import { EverythingService } from './EverythingService' @@ -225,7 +225,7 @@ export class SuperConductor { }, }) - this.ipcServer = new EverythingService(ipcMain, this.log, this.renderLog, this.storage, this, this.session, { + this.ipcServer = new EverythingService(this.log, this.renderLog, this.storage, this, this.session, { refreshResources: () => { this.refreshResources() }, diff --git a/apps/app/src/electron/api/ApiServer.ts b/apps/app/src/electron/api/ApiServer.ts index 21c1a0da..d70893cc 100644 --- a/apps/app/src/electron/api/ApiServer.ts +++ b/apps/app/src/electron/api/ApiServer.ts @@ -14,6 +14,7 @@ import { ClientMethods, ProjectsEvents, RundownsEvents, ServiceName, ServiceType import { Project, ProjectBase } from '../../models/project/Project' import { PartService } from './PartService' import { GroupService } from './GroupService' +import { unReplaceUndefined } from '../../lib/util' export class ApiServer { private app = koa(feathers()) @@ -61,6 +62,17 @@ export class ApiServer { events: [], }) + // TODO: potentially may break some thing in ultra rare cases. Should we enable it only for selected methods? + this.app.hooks({ + before: { + all: [ + async (context: HookContext) => { + context.data = unReplaceUndefined(context.data) + }, + ], + }, + }) + this.app.service(ServiceName.RUNDOWNS).publish((data: Rundown, _context: HookContext) => { return this.app.channel(RUNDOWN_CHANNEL_PREFIX + data.id) }) diff --git a/apps/app/src/electron/api/ProjectService.ts b/apps/app/src/electron/api/ProjectService.ts index 6649a9d8..9f23ab78 100644 --- a/apps/app/src/electron/api/ProjectService.ts +++ b/apps/app/src/electron/api/ProjectService.ts @@ -65,4 +65,16 @@ export class ProjectService extends EventEmitter { // TODO: access control return await this.everythingService.importProject() } + + async undo(): Promise { + // TODO: likely not the best place for this method + // TODO: access control + return await this.everythingService.undo() + } + + async redo(): Promise { + // TODO: likely not the best place for this method + // TODO: access control + return await this.everythingService.redo() + } } diff --git a/apps/app/src/ipc/IPCAPI.ts b/apps/app/src/ipc/IPCAPI.ts index fb2f7658..22f8c8bd 100644 --- a/apps/app/src/ipc/IPCAPI.ts +++ b/apps/app/src/ipc/IPCAPI.ts @@ -51,6 +51,7 @@ type KeyArrays = { type ServiceKeyArrays = KeyArrays // TODO: this is temporary; Use decorators or something +// those are the arrays of service methods exposed to the clients export const ClientMethods: ServiceKeyArrays = { [ServiceName.GROUPS]: [ 'create', @@ -82,7 +83,18 @@ export const ClientMethods: ServiceKeyArrays = { 'deleteTimelineObj', 'insertTimelineObjs', ], - [ServiceName.PROJECTS]: ['get', 'create', 'update', 'getAll', 'open', 'import', 'export', 'unsubscribe'], + [ServiceName.PROJECTS]: [ + 'get', + 'create', + 'update', + 'getAll', + 'open', + 'import', + 'export', + 'unsubscribe', + 'undo', + 'redo', + ], [ServiceName.REPORTING]: [ 'log', 'handleClientError', @@ -99,26 +111,8 @@ export const ClientMethods: ServiceKeyArrays = { 'isPlaying', 'close', 'open', - // 'pauseGroup', - // 'playGroup', - // 'playNext', - // 'playPrev', - // 'stopGroup', 'updateTimelineObj', 'moveTimelineObjToNewLayer', - - // 'newPart', - // 'insertParts', - // 'updatePart', - // 'newGroup', - // 'insertGroups', - // 'updateGroup', - // 'deletePart', - // 'deleteGroup', - // 'moveParts', - // 'duplicatePart', - // 'moveGroups', - // 'duplicateGroup', ], } diff --git a/apps/app/src/lib/__tests__/util.test.ts b/apps/app/src/lib/__tests__/util.test.ts index fc83112b..c951a2d4 100644 --- a/apps/app/src/lib/__tests__/util.test.ts +++ b/apps/app/src/lib/__tests__/util.test.ts @@ -1,4 +1,5 @@ -import { deepExtendRemovingUndefined } from '../util' +import { TimelineObj } from '../../models/rundown/TimelineObj' +import { deepExtendRemovingUndefined, deleteTimelineObj } from '../util' test('deepExtendRemovingUndefined', () => { { @@ -85,3 +86,37 @@ test('deepExtendRemovingUndefined', () => { }) } }) + +function makeTestObject(id: string, start: string | number): TimelineObj { + return { + obj: { + content: {} as any, + enable: { + start, + }, + id, + layer: '', + }, + resolved: {} as any + } +} + +describe('deleteTimelineObj', () => { + it('returns the same array if no changes', () => { + const timeline = [makeTestObject('obj1', '#obj2.end + 500'), makeTestObject('obj2', 1000)] + const result = deleteTimelineObj(timeline, 'obj3') + expect(result).toBe(timeline) + }) + + it('patches dependent objects', () => { + const timeline = [makeTestObject('obj1', '#obj2.end + 500'), makeTestObject('obj2', 1000)] + const result = deleteTimelineObj([makeTestObject('obj1', '#obj2.end + 500'), makeTestObject('obj2', 1000)], 'obj2') + expect(result).toEqual([makeTestObject('obj1', '1000 + 500')]) // not great, but acceptable + }) + + it('patches dependent objects 2', () => { + const timeline = [makeTestObject('obj1', '#obj2.end + 500'), makeTestObject('obj2', '#obj3.end'), makeTestObject('obj3', 0)] + const result = deleteTimelineObj(timeline, 'obj2') + expect(result).toEqual([makeTestObject('obj1', '#obj3.end + 500'), makeTestObject('obj3', 0)]) + }) +}) diff --git a/apps/app/src/lib/util.ts b/apps/app/src/lib/util.ts index a94c5dce..9e68487e 100644 --- a/apps/app/src/lib/util.ts +++ b/apps/app/src/lib/util.ts @@ -119,12 +119,46 @@ export const deletePart = (group: Group, partId: string): Part | undefined => { } return deletedPart } -export const deleteTimelineObj = (part: Part, timelineObjId: string): boolean => { - if (part.timeline.find((t) => t.obj.id === timelineObjId)) { - part.timeline = part.timeline.filter((t) => t.obj.id !== timelineObjId) - return true +export const deleteTimelineObj = (timeline: TimelineObj[], timelineObjId: string): TimelineObj[] => { + const foundObj = timeline.find((t) => t.obj.id === timelineObjId) + if (!foundObj) return timeline + const idRegexEnd = new RegExp(`#${timelineObjId}.end`, 'g') + const firstEnable = Array.isArray(foundObj.obj.enable) ? foundObj.obj.enable[0] : foundObj.obj.enable + const start = firstEnable?.start + if (typeof start === 'string' || typeof start === 'number') { + timeline = timeline.map((tObj) => { + const enableArray = Array.isArray(tObj.obj.enable) ? tObj.obj.enable : [tObj.obj.enable] + for (const enable of enableArray) { + if (enable.start && typeof enable.start === 'string') { + // TODO: immer js? + tObj = { + ...tObj, + obj: { + ...tObj.obj, + enable: { + ...enable, + start: enable.start.replace(idRegexEnd, start.toString()), + }, + }, + } + } + if (enable.end && typeof enable.end === 'string') { + tObj = { + ...tObj, + obj: { + ...tObj.obj, + enable: { + ...enable, + end: enable.end.replace(idRegexEnd, start.toString()), + }, + }, + } + } + } + return tObj + }) } - return false + return timeline.filter((t) => t.obj.id !== timelineObjId) } export function getResolvedTimelineTotalDuration(resolvedTimeline: ResolvedTimeline, filterInfinites: true): number diff --git a/apps/app/src/react/App.tsx b/apps/app/src/react/App.tsx index 1d23b193..65580273 100644 --- a/apps/app/src/react/App.tsx +++ b/apps/app/src/react/App.tsx @@ -519,16 +519,36 @@ export const App = observer(function App() { handleError(error) } } + function onUndo(): void { + setUserAgreementScreenOpen(false) + // eslint-disable-next-line @typescript-eslint/unbound-method + serverAPI.undo().catch(handleError) + } + function onRedo(): void { + setUserAgreementScreenOpen(false) + // eslint-disable-next-line @typescript-eslint/unbound-method + serverAPI.redo().catch(handleError) + } sorensen.bind('Escape', onEscapeKey, { up: false, global: true, exclusive: true, preventDefaultPartials: false, }) + sorensen.bind('Control+KeyZ', onUndo, { + up: false, + global: true, + }) + sorensen.bind('Control+KeyY', onRedo, { + up: false, + global: true, + }) return () => { sorensen.unbind('Escape', onEscapeKey) + sorensen.unbind('Control+KeyZ', onUndo) + sorensen.unbind('Control+KeyY', onRedo) } - }, [sorensenInitialized, handleError, gui, currentRundownId]) + }, [sorensenInitialized, handleError, gui, currentRundownId, serverAPI]) useMemoComputedValue(() => { if (!project) return diff --git a/apps/app/src/react/api/ApiClient.ts b/apps/app/src/react/api/ApiClient.ts index 194e203b..f44b449f 100644 --- a/apps/app/src/react/api/ApiClient.ts +++ b/apps/app/src/react/api/ApiClient.ts @@ -1,7 +1,7 @@ import { ClientMethods, IPCServerMethods, ServiceName, ServiceTypes } from '../../ipc/IPCAPI' import { replaceUndefined } from '../../lib/util' -import { feathers } from '@feathersjs/feathers' +import { HookContext, feathers } from '@feathersjs/feathers' import socketio, { SocketService } from '@feathersjs/socketio-client' import io from 'socket.io-client' import { Rundown } from '../../models/rundown/Rundown' @@ -61,6 +61,16 @@ app.use( } ) +app.hooks({ + before: { + all: [ + async (context: HookContext) => { + context.data = replaceUndefined(context.data) + }, + ], + }, +}) + type ServerArgs = Parameters type ServerReturn = Promise> @@ -320,4 +330,10 @@ export class ApiClient { async setApplicationTrigger(...args: ServerArgs<'setApplicationTrigger'>): ServerReturn<'setApplicationTrigger'> { return this.invokeServerMethod('setApplicationTrigger', ...args) } + async undo(): Promise { + return this.projectService.undo() + } + async redo(): Promise { + return this.projectService.redo() + } } diff --git a/tsconfig.build.json b/tsconfig.build.json index b1ec65ce..b546f770 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,7 +17,7 @@ "target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": ["es6"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ From 61ab14287c30710f16a6551d6de109abeb80e301 Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 27 Oct 2023 19:43:35 +0200 Subject: [PATCH 06/14] chore: remove commented out code --- apps/app/src/electron/EverythingService.ts | 57 ---------------------- 1 file changed, 57 deletions(-) diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index e35d8cf7..7bc35d6a 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -128,22 +128,6 @@ type ConvertToServerSide = { : T[K] } -// const unreplaceUndefined = async (context: HookContext, next: NextFunction) => { -// context.arguments = unReplaceUndefined(context.arguments) -// await next() -// } - -// const undoable = async (context: HookContext, next: NextFunction) => { -// await next() -// if (context.self && isUndoable(context.result)) { -// context.self.registerUndoable( -// context.arguments, -// (context.self as any)[context.method ?? '']?.bind(context.self), -// context.result -// ) -// } -// } - function Undoable(target: EverythingService, _key: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value descriptor.value = async function (...args: any) { @@ -187,47 +171,6 @@ export class EverythingService } ) { super() - for (const methodName of Object.getOwnPropertyNames(EverythingService.prototype)) { - if (methodName[0] !== '_') { - const fcn = (this as any)[methodName].bind(this) - if (typeof fcn === 'function') { - // Monkey-patching methods in this class. There are definitely better ways to do it... - // ;(this as any)[methodName] = async (...args0: string[]) => { - // try { - // const args = unReplaceUndefined(args0) - // let result = fcn(...args) - // if (result instanceof Promise) result = await result - // if (isUndoable(result)) { - // // Clear any future things in the undo ledger: - // this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) - // // Add the new action to the undo ledger: - // this.undoLedger.push({ - // description: result.description, - // arguments: args, - // undo: result.undo, - // redo: fcn, - // }) - // if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { - // this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) - // } - // this.undoPointer = this.undoLedger.length - 1 - // this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) - // // string represents "anything but undefined" here: - // return (result as UndoableResult).result - // } else { - // return result - // } - // } catch (error) { - // this.callbacks.handleError( - // `Error when calling ${methodName}: ${error}`, - // typeof error === 'object' && (error as any).stack - // ) - // throw error - // } - // } - } - } - } } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types From 5c8e3bb84c7ee090fc299c1e4fe93e38bfa4df3c Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 31 Oct 2023 11:03:24 +0100 Subject: [PATCH 07/14] refactor: undo/redo with browser compatibility --- apps/app/package.json | 1 + apps/app/src/electron/ClientEventBus.ts | 15 +- apps/app/src/electron/EverythingService.ts | 134 ++++++++---------- apps/app/src/electron/SuperConductor.ts | 104 ++++++++------ apps/app/src/electron/UndoService.ts | 110 ++++++++++++++ apps/app/src/electron/api/ApiServer.ts | 2 +- apps/app/src/electron/api/ProjectService.ts | 12 +- apps/app/src/ipc/IPCAPI.ts | 81 ++++++----- apps/app/src/main.ts | 36 ++--- apps/app/src/models/project/Project.ts | 14 ++ apps/app/src/preload.ts | 8 ++ apps/app/src/react/App.tsx | 26 +++- apps/app/src/react/api/ApiClient.ts | 14 +- apps/app/src/react/api/ElectronApi.ts | 3 + .../app/src/react/api/RealtimeDataProvider.ts | 9 +- .../deviceStatuses/DeviceStatuses.tsx | 5 +- .../components/pages/homePage/HomePage.tsx | 2 +- apps/app/src/react/mobx/AnalogStore.ts | 1 - apps/app/src/react/mobx/AppStore.ts | 8 ++ .../src/react/mobx/GDDValidatorStoreStore.ts | 1 - apps/app/src/react/mobx/GroupPlayDataStore.ts | 1 - apps/app/src/react/mobx/GuiStore.ts | 3 +- .../react/mobx/ResourcesAndMetadataStore.ts | 1 - yarn.lock | 5 + 24 files changed, 395 insertions(+), 201 deletions(-) create mode 100644 apps/app/src/electron/UndoService.ts create mode 100644 apps/app/src/preload.ts create mode 100644 apps/app/src/react/api/ElectronApi.ts diff --git a/apps/app/package.json b/apps/app/package.json index 34869639..d44afb08 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -92,6 +92,7 @@ "deepmerge-ts": "^5.1.0", "electron-is-dev": "^2.0.0", "electron-updater": "^5.3.0", + "eventemitter3": "^5.0.1", "file-loader": "^6.2.0", "formik": "^2.2.9", "formik-mui": "^5.0.0-alpha.0", diff --git a/apps/app/src/electron/ClientEventBus.ts b/apps/app/src/electron/ClientEventBus.ts index 25a2ac24..d639f39b 100644 --- a/apps/app/src/electron/ClientEventBus.ts +++ b/apps/app/src/electron/ClientEventBus.ts @@ -11,10 +11,18 @@ import { ActiveAnalog } from '../models/rundown/Analog' import { AnalogInput } from '../models/project/AnalogInput' import { BridgeId } from '@shared/api' import { BridgePeripheralId } from '@shared/lib' -import { EventEmitter } from 'stream' +import EventEmitter from 'eventemitter3' +import { SerializableLedgers } from '../models/project/Project' + +type ClientEventBusEvents = { + callMethod: (...args: any[]) => void // legacy + updateUndoLedgers: (undoLedgers: SerializableLedgers) => void + updateRundown: (rundown: Rundown) => void + updateProject: (rundown: Project) => void +} // --- some of it might be needed, most of it hopefully not -export class ClientEventBus extends EventEmitter implements IPCClientMethods { +export class ClientEventBus extends EventEmitter implements IPCClientMethods { close(): void { // Nothing here } @@ -31,6 +39,9 @@ export class ClientEventBus extends EventEmitter implements IPCClientMethods { updateRundown(_fileName: string, rundown: Rundown): void { this.emit('updateRundown', rundown) // TODO: some type safety, please } + updateUndoLedgers(data: SerializableLedgers): void { + this.emit('updateUndoLedgers', data) // TODO: some type safety, please + } updateResourcesAndMetadata( resources: Array<{ id: ResourceId; resource: ResourceAny | null }>, metadata: SerializedProtectedMap diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 7bc35d6a..83fce8ad 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -40,13 +40,7 @@ import { TSRTimelineContent, DeviceOptionsAny, } from 'timeline-state-resolver-types' -import { - ActionDescription, - IPCServerMethods, - MAX_UNDO_LEDGER_LENGTH, - UndoableResult, - UpdateAppDataOptions, -} from '../ipc/IPCAPI' +import { ActionDescription, IPCServerMethods, UndoableResult, UpdateAppDataOptions } from '../ipc/IPCAPI' import { GroupPreparedPlayData } from '../models/GUI/PreparedPlayhead' import { convertToFilename, ExportProjectData, StorageHandler } from './storageHandler' import { Rundown } from '../models/rundown/Rundown' @@ -64,8 +58,6 @@ import { assertNever, deepClone, getResourceIdFromTimelineObj, omit } from '@sha import { TimelineObj } from '../models/rundown/TimelineObj' import { Project, ProjectBase } from '../models/project/Project' import { AppData } from '../models/App/AppData' -import EventEmitter from 'events' -import TypedEmitter from 'typed-emitter' import { filterMapping, getMappingFromTimelineObject, @@ -88,21 +80,15 @@ import { TriggersHandler } from './triggersHandler' import { GDDSchema, ValidatorCache } from 'graphics-data-definition' import * as RundownActions from './rundownActions' import { SuperConductor } from './SuperConductor' +import { UndoLedgerKey, UndoLedgerService } from './UndoService' +import { SpecialLedgers } from '../models/project/Project' -type UndoLedger = Action[] -type UndoPointer = number -type UndoFunction = () => Promise | void -type UndoableFunction = (...args: any[]) => Promise> -interface Action { - description: ActionDescription - arguments: any[] - redo: UndoableFunction - undo: UndoFunction -} - -type IPCServerEvents = { - updatedUndoLedger: (undoLedger: Readonly, undoPointer: Readonly) => void -} +// type IPCServerEvents = { +// updatedUndoLedger: ( +// undoLedger: Readonly<{ [key: string | symbol]: UndoLedger }>, +// undoPointer: Readonly +// ) => void +// } export function isUndoable(result: unknown): result is UndoableResult { if (typeof result !== 'object' || result === null) { @@ -132,7 +118,9 @@ function Undoable(target: EverythingService, _key: string, descriptor: PropertyD const originalMethod = descriptor.value descriptor.value = async function (...args: any) { const result = await originalMethod.apply(this, args) - target.registerUndoable.call(this, args, originalMethod.bind(this), result) + if (isUndoable(result)) { + target.pushUndoable.call(this, result.ledgerKey, args, originalMethod.bind(this), result) + } return result } return descriptor @@ -142,13 +130,8 @@ function Undoable(target: EverythingService, _key: string, descriptor: PropertyD * This class is used server-side, to handle requests from the client * The methods in here will later be moved away to other Services */ -export class EverythingService - extends (EventEmitter as new () => TypedEmitter) - implements ConvertToServerSide -{ +export class EverythingService implements ConvertToServerSide { public triggers?: TriggersHandler - private undoLedger: UndoLedger = [] - private undoPointer: UndoPointer = -1 constructor( private _log: LoggerLike, @@ -156,6 +139,7 @@ export class EverythingService private storage: StorageHandler, private superConductor: SuperConductor, private session: SessionHandler, + private undoService: UndoLedgerService, private callbacks: { onClientConnected: () => void installUpdate: () => void @@ -169,25 +153,10 @@ export class EverythingService onAgreeToUserAgreement: () => void handleError: (error: string, stack?: string) => void } - ) { - super() - } + ) {} - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - public registerUndoable(args: any, fcn: () => any, result: UndoableResult): void { - this.undoLedger.splice(this.undoPointer + 1, this.undoLedger.length) - // Add the new action to the undo ledger: - this.undoLedger.push({ - description: result.description, - arguments: args, - undo: result.undo, - redo: fcn, - }) - if (this.undoLedger.length > MAX_UNDO_LEDGER_LENGTH) { - this.undoLedger.splice(0, this.undoLedger.length - MAX_UNDO_LEDGER_LENGTH) - } - this.undoPointer = this.undoLedger.length - 1 - this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + public pushUndoable(key: UndoLedgerKey, args: unknown[], fcn: () => any, result: UndoableResult): void { + this.undoService.pushUndoable(key, args, fcn, result) } public getProject(): Project { @@ -254,34 +223,11 @@ export class EverythingService return { rundown, group, part } } - async undo(): Promise { - const action = this.undoLedger[this.undoPointer] - try { - await action.undo() - this.undoPointer-- - } catch (error) { - this._log.error('Error when undoing:', error) - - // Clear - this.undoLedger.splice(0, this.undoLedger.length) - this.undoPointer = -1 - } - this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + async undo(key: string): Promise { + await this.undoService.undo(key) } - async redo(): Promise { - const action = this.undoLedger[this.undoPointer + 1] - try { - const redoResult = await action.redo(...action.arguments) - action.undo = redoResult.undo - this.undoPointer++ - } catch (error) { - this._log.error('Error when redoing:', error) - - // Clear - this.undoLedger.splice(0, this.undoLedger.length) - this.undoPointer = -1 - } - this.emit('updatedUndoLedger', this.undoLedger, this.undoPointer) + async redo(key: string): Promise { + await this.undoService.redo(key) } async log(arg: { level: LogLevel; params: any[] }): Promise { @@ -498,6 +444,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, noEffectOnPlayout: true }) }, description: ActionDescription.SetPartTrigger, + ledgerKey: arg.rundownId, } } async stopGroup(arg: { rundownId: string; groupId: string }): Promise { @@ -654,6 +601,7 @@ export class EverythingService }, description: ActionDescription.NewPart, result, + ledgerKey: arg.rundownId, } } @Undoable @@ -753,6 +701,7 @@ export class EverythingService }, description: ActionDescription.InsertParts, result: inserted, + ledgerKey: arg.rundownId, } } else { return this._insertPartsAsTransparentGroup({ @@ -814,6 +763,7 @@ export class EverythingService return { ...r, result: inserted, + ledgerKey: arg.rundownId, } } @Undoable @@ -858,6 +808,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.UpdatePart, + ledgerKey: arg.rundownId, } } @Undoable @@ -920,6 +871,7 @@ export class EverythingService } }, description: ActionDescription.UpsertPart, + ledgerKey: arg.rundownId, } } @Undoable @@ -964,6 +916,7 @@ export class EverythingService }, description: ActionDescription.NewGroup, result: newGroup.id, + ledgerKey: arg.rundownId, } } @Undoable @@ -1056,6 +1009,7 @@ export class EverythingService }, description: ActionDescription.InsertGroups, result: inserted, + ledgerKey: arg.rundownId, } } @Undoable @@ -1106,6 +1060,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.UpdateGroup, + ledgerKey: arg.rundownId, } } @Undoable @@ -1196,6 +1151,7 @@ export class EverythingService } }, description: ActionDescription.UpsertGroup, + ledgerKey: arg.rundownId, } } @Undoable @@ -1262,6 +1218,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.DeletePart, + ledgerKey: arg.rundownId, } } @Undoable @@ -1293,6 +1250,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group: deletedGroup }) }, description: ActionDescription.DeleteGroup, + ledgerKey: arg.rundownId, } } @Undoable @@ -1479,6 +1437,7 @@ export class EverythingService }, description: ActionDescription.MovePart, result: resultingParts, + ledgerKey: arg.to.rundownId, } } @Undoable @@ -1529,6 +1488,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group: newGroup ? undefined : group }) }, description: ActionDescription.DuplicatePart, + ledgerKey: arg.rundownId, } } @Undoable @@ -1573,6 +1533,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.MoveGroup, + ledgerKey: arg.rundownId, } } @Undoable @@ -1599,6 +1560,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.DuplicateGroup, + ledgerKey: arg.rundownId, } } @@ -1640,6 +1602,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.UpdateTimelineObj, + ledgerKey: arg.rundownId, } } @@ -1683,6 +1646,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.DeleteTimelineObj, + ledgerKey: arg.rundownId, } } @Undoable @@ -1809,6 +1773,7 @@ export class EverythingService }, description: ActionDescription.AddTimelineObj, result: inserted, + ledgerKey: arg.rundownId, } } @Undoable @@ -1863,6 +1828,7 @@ export class EverythingService this._saveUpdates({ project: updatedProject, rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.MoveTimelineObjToNewLayer, + ledgerKey: arg.rundownId, } } @@ -1986,6 +1952,7 @@ export class EverythingService this._saveUpdates({ project: updatedProject, rundownId: arg.rundownId, rundown }) }, description: ActionDescription.addResourcesToTimeline, + ledgerKey: arg.rundownId, } } @Undoable @@ -2017,6 +1984,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.ToggleGroupLoop, + ledgerKey: arg.rundownId, } } @Undoable @@ -2048,6 +2016,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.ToggleGroupAutoplay, + ledgerKey: arg.rundownId, } } @Undoable @@ -2090,6 +2059,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.toggleGroupOneAtATime, + ledgerKey: arg.rundownId, } } @Undoable @@ -2121,6 +2091,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.ToggleGroupDisable, + ledgerKey: arg.rundownId, } } @Undoable @@ -2141,6 +2112,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group, noEffectOnPlayout: true }) }, description: ActionDescription.ToggleGroupLock, + ledgerKey: arg.rundownId, } } @Undoable @@ -2165,6 +2137,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group, noEffectOnPlayout: true }) }, description: ActionDescription.ToggleGroupCollapse, + ledgerKey: arg.rundownId, } } @Undoable @@ -2193,6 +2166,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, noEffectOnPlayout: true }) }, description: ActionDescription.ToggleAllGroupsCollapse, + ledgerKey: arg.rundownId, } } async refreshResources(): Promise { @@ -2248,6 +2222,7 @@ export class EverythingService }, description: ActionDescription.NewRundown, result: rundown, + ledgerKey: rundown.id, } } async deleteRundown(arg: { rundownId: string }): Promise { @@ -2278,6 +2253,7 @@ export class EverythingService this._saveUpdates({}) }, description: ActionDescription.OpenRundown, + ledgerKey: SpecialLedgers.APPLICATION, } } @Undoable @@ -2301,6 +2277,7 @@ export class EverythingService this._saveUpdates({}) }, description: ActionDescription.CloseRundown, + ledgerKey: SpecialLedgers.APPLICATION, } } @@ -2322,6 +2299,7 @@ export class EverythingService }, description: ActionDescription.RenameRundown, result: newRundownId, + ledgerKey: arg.rundownId, } } async isRundownPlaying(arg: { rundownId: string }): Promise { @@ -2432,6 +2410,7 @@ export class EverythingService } }, description: ActionDescription.CreateMissingMapping, + ledgerKey: arg.rundownId, } } @@ -2479,6 +2458,7 @@ export class EverythingService } }, description: ActionDescription.AddPeripheralArea, + ledgerKey: SpecialLedgers.PERIPHERALS, } } @Undoable @@ -2522,6 +2502,7 @@ export class EverythingService } }, description: ActionDescription.RemovePeripheralArea, + ledgerKey: SpecialLedgers.PERIPHERALS, } } @Undoable @@ -2570,6 +2551,7 @@ export class EverythingService } }, description: ActionDescription.UpdatePeripheralArea, + ledgerKey: SpecialLedgers.PERIPHERALS, } } @Undoable @@ -2609,6 +2591,7 @@ export class EverythingService this._saveUpdates({ project }) }, description: ActionDescription.AssignAreaToGroup, + ledgerKey: SpecialLedgers.PERIPHERALS, } } async startDefiningArea(arg: { bridgeId: BridgeId; deviceId: PeripheralId; areaId: string }): Promise { @@ -2676,6 +2659,7 @@ export class EverythingService this._saveUpdates({ appData, noEffectOnPlayout: true }) }, description: ActionDescription.SetApplicationTrigger, + ledgerKey: SpecialLedgers.APPLICATION, } } diff --git a/apps/app/src/electron/SuperConductor.ts b/apps/app/src/electron/SuperConductor.ts index e13866eb..2a843588 100644 --- a/apps/app/src/electron/SuperConductor.ts +++ b/apps/app/src/electron/SuperConductor.ts @@ -41,6 +41,7 @@ import { ActiveAnalog } from '../models/rundown/Analog' import { AnalogHandler } from './analogHandler' import { AnalogInput } from '../models/project/AnalogInput' import { SystemMessageOptions } from '../ipc/IPCAPI' +import { UndoLedgerService } from './UndoService' export class SuperConductor { ipcServer: EverythingService @@ -225,53 +226,66 @@ export class SuperConductor { }, }) - this.ipcServer = new EverythingService(this.log, this.renderLog, this.storage, this, this.session, { - refreshResources: () => { - this.refreshResources() - }, - refreshResourcesSetAuto: (interval: number) => { - const project = this.storage.getProject() - project.autoRefreshInterval = interval - this.storage.updateProject(project) - }, - onClientConnected: () => { - // Nothing here yet - }, - installUpdate: () => { - autoUpdater.autoRunAppAfterInstall = true - autoUpdater.quitAndInstall() - }, - updateTimeline: (group: Group): GroupPreparedPlayData | null => { - return this.updateTimeline(group) - }, - updatePeripherals: (): void => { - this.triggers?.triggerUpdatePeripherals() - this.analogHandler?.triggerUpdatePeripherals() - }, - setKeyboardKeys: (activeKeys: ActiveTrigger[]): void => { - this.triggers?.setKeyboardKeys(activeKeys) - }, - triggerHandleAutoFill: () => { - this.triggerHandleAutoFill() - }, - makeDevData: async () => { - await this.storage.makeDevData() - }, - onAgreeToUserAgreement: () => { - this.telemetryHandler.setUserHasAgreed() - this.telemetryHandler.onAcceptUserAgreement() - - if (!this.hasStoredStartupUserStatistics) { - this.hasStoredStartupUserStatistics = true - this.telemetryHandler.onStartup() - } - }, - handleError: (error: string, stack?: string) => { - this.log.error(error, stack) - this.telemetryHandler.onError(error, stack) - }, + const undoLedgerService = new UndoLedgerService(this.log) + undoLedgerService.on('updatedUndoLedger', (data) => { + this.clientEventBus.updateUndoLedgers(data) }) + this.ipcServer = new EverythingService( + this.log, + this.renderLog, + this.storage, + this, + this.session, + undoLedgerService, + { + refreshResources: () => { + this.refreshResources() + }, + refreshResourcesSetAuto: (interval: number) => { + const project = this.storage.getProject() + project.autoRefreshInterval = interval + this.storage.updateProject(project) + }, + onClientConnected: () => { + // Nothing here yet + }, + installUpdate: () => { + autoUpdater.autoRunAppAfterInstall = true + autoUpdater.quitAndInstall() + }, + updateTimeline: (group: Group): GroupPreparedPlayData | null => { + return this.updateTimeline(group) + }, + updatePeripherals: (): void => { + this.triggers?.triggerUpdatePeripherals() + this.analogHandler?.triggerUpdatePeripherals() + }, + setKeyboardKeys: (activeKeys: ActiveTrigger[]): void => { + this.triggers?.setKeyboardKeys(activeKeys) + }, + triggerHandleAutoFill: () => { + this.triggerHandleAutoFill() + }, + makeDevData: async () => { + await this.storage.makeDevData() + }, + onAgreeToUserAgreement: () => { + this.telemetryHandler.setUserHasAgreed() + this.telemetryHandler.onAcceptUserAgreement() + + if (!this.hasStoredStartupUserStatistics) { + this.hasStoredStartupUserStatistics = true + this.telemetryHandler.onStartup() + } + }, + handleError: (error: string, stack?: string) => { + this.log.error(error, stack) + this.telemetryHandler.onError(error, stack) + }, + } + ) + this.triggers = new TriggersHandler(this.log, this.storage, this.ipcServer, this.bridgeHandler, this.session) this.triggers.on('error', (e) => this.log.error(e)) this.triggers.on('failedGlobalTriggers', (failedGlobalTriggers) => { diff --git a/apps/app/src/electron/UndoService.ts b/apps/app/src/electron/UndoService.ts new file mode 100644 index 00000000..376a236c --- /dev/null +++ b/apps/app/src/electron/UndoService.ts @@ -0,0 +1,110 @@ +import { LoggerLike } from '@shared/api' +import { ActionDescription, UndoableResult } from '../ipc/IPCAPI' +import EventEmitter from 'eventemitter3' +import _ from 'lodash' +import { SerializableLedgers } from '../models/project/Project' +import { SpecialLedgers } from '../models/project/Project' + +export const MAX_UNDO_LEDGER_LENGTH = 100 + +export interface UndoLedger { + actions: Action[] + pointer: UndoPointer +} +export type UndoLedgerKey = string | SpecialLedgers +type UndoPointer = number +type UndoFunction = () => Promise | void +export type UndoableFunction = (...args: any[]) => Promise> +interface Action { + description: ActionDescription + arguments: unknown[] + redo: UndoableFunction + undo: UndoFunction +} + +type UndoServiceEvents = { + updatedUndoLedger: (undoLedgers: SerializableLedgers) => void +} + +export class UndoLedgerService extends EventEmitter { + constructor(private _log: LoggerLike) { + super() + } + private undoLedgers = new Map() + + public pushUndoable(key: UndoLedgerKey, args: unknown[], fcn: () => any, result: UndoableResult): void { + let ledger = this.undoLedgers.get(key) + if (!ledger) { + ledger = this.makeEmptyLedger() + this.undoLedgers.set(key, ledger) + } + ledger.actions.splice(ledger.pointer + 1, ledger.actions.length) + // Add the new action to the undo ledger: + ledger.actions.push({ + description: result.description, + arguments: args, + undo: result.undo, + redo: fcn, + }) + if (ledger.actions.length > MAX_UNDO_LEDGER_LENGTH) { + ledger.actions.splice(0, ledger.actions.length - MAX_UNDO_LEDGER_LENGTH) + } + ledger.pointer = ledger.actions.length - 1 + this.emitUpdate() + } + + private emitUpdate() { + this.emit('updatedUndoLedger', this.toSerializable()) + } + + public async undo(key: UndoLedgerKey): Promise { + const ledger = this.undoLedgers.get(key) + const action = ledger?.actions?.[ledger?.pointer] + if (!action) return + try { + await action.undo() + ledger.pointer-- + } catch (error) { + this._log.error('Error when undoing:', error) + + // Clear + this.undoLedgers.set(key, this.makeEmptyLedger()) + } + this.emitUpdate() + } + + public async redo(key: UndoLedgerKey): Promise { + const ledger = this.undoLedgers.get(key) + const action = ledger?.actions?.[ledger?.pointer + 1] + if (!action) return + try { + const redoResult = await action.redo(...action.arguments) + action.undo = redoResult.undo + ledger.pointer++ + } catch (error) { + this._log.error('Error when redoing:', error) + + // Clear + this.undoLedgers.set(key, this.makeEmptyLedger()) + } + this.emitUpdate() + } + + private makeEmptyLedger(): UndoLedger { + return { + actions: [], + pointer: -1, + } + } + + private toSerializable(): SerializableLedgers { + return _.mapValues(Object.fromEntries(this.undoLedgers.entries()), (ledger: UndoLedger) => ({ + undo: ledger.actions[ledger.pointer] + ? { description: ledger.actions[ledger.pointer]?.description } + : undefined, + redo: ledger.actions[ledger.pointer + 1] + ? { description: ledger.actions[ledger.pointer + 1]?.description } + : undefined, + })) + } +} diff --git a/apps/app/src/electron/api/ApiServer.ts b/apps/app/src/electron/api/ApiServer.ts index d70893cc..3ab73c58 100644 --- a/apps/app/src/electron/api/ApiServer.ts +++ b/apps/app/src/electron/api/ApiServer.ts @@ -41,7 +41,7 @@ export class ApiServer { this.app.use(ServiceName.PROJECTS, new ProjectService(this.app, ipcServer, clientEventBus), { methods: ClientMethods[ServiceName.PROJECTS], - serviceEvents: ['created', ProjectsEvents.UPDATED, 'deleted'], + serviceEvents: ['created', ProjectsEvents.UPDATED, 'deleted', ProjectsEvents.UNDO_LEDGERS_UPDATED], }) this.app.use(ServiceName.PARTS, new PartService(this.app, ipcServer, clientEventBus), { diff --git a/apps/app/src/electron/api/ProjectService.ts b/apps/app/src/electron/api/ProjectService.ts index 9f23ab78..8c164ab2 100644 --- a/apps/app/src/electron/api/ProjectService.ts +++ b/apps/app/src/electron/api/ProjectService.ts @@ -4,6 +4,7 @@ import EventEmitter from 'node:events' import { ProjectsEvents, ServiceTypes } from '../../ipc/IPCAPI' import { Project, ProjectBase } from '../../models/project/Project' import { ClientEventBus } from '../ClientEventBus' +import { SerializableLedgers } from '../../models/project/Project' export const PROJECTS_CHANNEL_PREFIX = 'projects' export class ProjectService extends EventEmitter { @@ -16,6 +17,9 @@ export class ProjectService extends EventEmitter { clientEventBus.on('updateProject', (project: Project) => { this.emit(ProjectsEvents.UPDATED, project) }) + clientEventBus.on('updateUndoLedgers', (ledgers: SerializableLedgers) => { + this.emit(ProjectsEvents.UNDO_LEDGERS_UPDATED, ledgers) + }) } async get(_id: string): Promise { @@ -66,15 +70,15 @@ export class ProjectService extends EventEmitter { return await this.everythingService.importProject() } - async undo(): Promise { + async undo(data: { key: string }): Promise { // TODO: likely not the best place for this method // TODO: access control - return await this.everythingService.undo() + return await this.everythingService.undo(data.key) } - async redo(): Promise { + async redo(data: { key: string }): Promise { // TODO: likely not the best place for this method // TODO: access control - return await this.everythingService.redo() + return await this.everythingService.redo(data.key) } } diff --git a/apps/app/src/ipc/IPCAPI.ts b/apps/app/src/ipc/IPCAPI.ts index 22f8c8bd..428bfcc2 100644 --- a/apps/app/src/ipc/IPCAPI.ts +++ b/apps/app/src/ipc/IPCAPI.ts @@ -1,6 +1,6 @@ import { PartialDeep } from 'type-fest' import { BridgeStatus } from '../models/project/Bridge' -import { Project } from '../models/project/Project' +import { Project, SerializableLedger } from '../models/project/Project' import { ResourceAny, ResourceId, MetadataAny, SerializedProtectedMap, TSRDeviceId } from '@shared/models' import { Rundown } from '../models/rundown/Rundown' import { TimelineObj } from '../models/rundown/TimelineObj' @@ -23,8 +23,7 @@ import { type ProjectService } from '../electron/api/ProjectService' import { type ReportingService } from '../electron/api/ReportingService' import { type RundownService } from '../electron/api/RundownService' import { type GroupService } from '../electron/api/GroupService' - -export const MAX_UNDO_LEDGER_LENGTH = 100 +import { type SpecialLedgers } from '../models/project/Project' export enum ServiceName { GROUPS = 'groups', @@ -117,50 +116,55 @@ export const ClientMethods: ServiceKeyArrays = { } export const enum ActionDescription { - NewPart = 'create new part', - InsertParts = 'insert part(s)', - UpdatePart = 'update part', + NewPart = 'Create new part', + InsertParts = 'Insert part(s)', + UpdatePart = 'Update part', SetPartTrigger = 'Assign trigger', - NewGroup = 'create new group', - InsertGroups = 'insert group(s)', - UpdateGroup = 'update group', - DeletePart = 'delete part', - DeleteGroup = 'delete group', - MovePart = 'move part', - MoveGroup = 'move group', - UpdateTimelineObj = 'update timeline object', - DeleteTimelineObj = 'delete timeline object', - AddTimelineObj = 'add timeline obj', - addResourcesToTimeline = 'add resource to timeline', - ToggleGroupLoop = 'toggle group loop', - ToggleGroupAutoplay = 'toggle group autoplay', - toggleGroupOneAtATime = 'toggle group one-at-a-time', - ToggleGroupDisable = 'toggle group disable', - ToggleGroupLock = 'toggle group lock', - ToggleGroupCollapse = 'toggle group collapse', - ToggleAllGroupsCollapse = 'toggle all groups collapse', - NewRundown = 'new rundown', - DeleteRundown = 'delete rundown', - OpenRundown = 'open rundown', - CloseRundown = 'close rundown', - RenameRundown = 'rename rundown', - MoveTimelineObjToNewLayer = 'move timeline object to new layer', - CreateMissingMapping = 'create missing layer', - DuplicateGroup = 'duplicate group', - DuplicatePart = 'duplicate part', + NewGroup = 'Create new group', + InsertGroups = 'Insert group(s)', + UpdateGroup = 'Update group', + DeletePart = 'Delete part', + DeleteGroup = 'Delete group', + MovePart = 'Move part', + MoveGroup = 'Move group', + UpdateTimelineObj = 'Update timeline object', + DeleteTimelineObj = 'Delete timeline object', + AddTimelineObj = 'Add timeline obj', + addResourcesToTimeline = 'Add resource to timeline', + ToggleGroupLoop = 'Toggle group loop', + ToggleGroupAutoplay = 'Toggle group autoplay', + toggleGroupOneAtATime = 'Toggle group one-at-a-time', + ToggleGroupDisable = 'Toggle group disable', + ToggleGroupLock = 'Toggle group lock', + ToggleGroupCollapse = 'Toggle group collapse', + ToggleAllGroupsCollapse = 'Toggle all groups collapse', + NewRundown = 'New rundown', + DeleteRundown = 'Delete rundown', + OpenRundown = 'Open rundown', + CloseRundown = 'Close rundown', + RenameRundown = 'Rename rundown', + MoveTimelineObjToNewLayer = 'Move timeline object to new layer', + CreateMissingMapping = 'Create missing layer', + DuplicateGroup = 'Duplicate group', + DuplicatePart = 'Duplicate part', AddPeripheralArea = 'Add button area', UpdatePeripheralArea = 'Update button area', RemovePeripheralArea = 'Remove button area', AssignAreaToGroup = 'Assign Area to Group', // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values SetApplicationTrigger = 'Assign trigger', - UpsertGroup = 'upsert group', - UpsertPart = 'upsert part', + UpsertGroup = 'Upsert group', + UpsertPart = 'Upsert part', } export type UndoFunction = () => Promise | void -export type UndoableResult = { undo: UndoFunction; description: ActionDescription; result?: T } +export type UndoableResult = { + ledgerKey: string | SpecialLedgers + undo: UndoFunction + description: ActionDescription + result?: T +} export type UndoableFunction = (...args: any[]) => Promise @@ -171,6 +175,10 @@ export interface Action { undo: UndoFunction } +export interface ElectronAPI { + updateUndoLedger: (key: string, data: SerializableLedger) => void +} + // --- legacy /** Methods that can be called on the server, by the client */ export interface IPCServerMethods { @@ -369,4 +377,5 @@ export enum RundownsEvents { } export enum ProjectsEvents { UPDATED = 'updated', + UNDO_LEDGERS_UPDATED = 'undo_ledgers_updated', } diff --git a/apps/app/src/main.ts b/apps/app/src/main.ts index 942576dc..86b88124 100644 --- a/apps/app/src/main.ts +++ b/apps/app/src/main.ts @@ -1,5 +1,5 @@ import { literal, stringifyError } from '@shared/lib' -import { app, BrowserWindow, dialog, Menu, shell, screen } from 'electron' +import { app, BrowserWindow, dialog, Menu, shell, screen, ipcMain } from 'electron' import isDev from 'electron-is-dev' import { autoUpdater } from 'electron-updater' import { CURRENT_VERSION } from './electron/bridgeHandler' @@ -9,6 +9,7 @@ import { createLoggers } from './lib/logging' import { baseFolder } from './lib/baseFolder' import path from 'path' import winston from 'winston' +import { SerializableLedger } from './models/project/Project' function createWindow(log: winston.Logger, superConductor: SuperConductor): void { const appData = superConductor.storage.getAppData() @@ -20,8 +21,9 @@ function createWindow(log: winston.Logger, superConductor: SuperConductor): void height: appData.windowPosition.height, webPreferences: { - nodeIntegration: true, - contextIsolation: false, + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js'), }, title: 'SuperConductor', }) @@ -64,12 +66,10 @@ function createWindow(log: winston.Logger, superConductor: SuperConductor): void undoEnabled: false, redoLabel: 'Redo', redoEnabled: false, - onUndoClick: () => { - superConductor.ipcServer.undo().catch(log.error) - }, - onRedoClick: () => { - superConductor.ipcServer.redo().catch(log.error) - }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onUndoClick: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onRedoClick: () => {}, onAboutClick: () => { // TODO: this should probably become a client-side only action // handler.ipcClient.displayAboutDialog() @@ -113,13 +113,17 @@ function createWindow(log: winston.Logger, superConductor: SuperConductor): void const menu = generateMenu(menuOpts, log) Menu.setApplicationMenu(menu) - superConductor.ipcServer.on('updatedUndoLedger', (undoLedger, undoPointer) => { - const undoAction = undoLedger[undoPointer] - const redoAction = undoLedger[undoPointer + 1] - menuOpts.undoLabel = undoAction ? `Undo ${undoAction.description}` : 'Undo' - menuOpts.undoEnabled = Boolean(undoAction) - menuOpts.redoLabel = redoAction ? `Redo ${redoAction.description}` : 'Redo' - menuOpts.redoEnabled = Boolean(redoAction) + ipcMain.on('updateUndoLedger', (_event, key: string, undoLedger: SerializableLedger) => { + menuOpts.undoLabel = undoLedger.undo ? `Undo ${undoLedger.undo.description}` : 'Undo' + menuOpts.undoEnabled = Boolean(undoLedger.undo) + menuOpts.onUndoClick = () => { + superConductor.ipcServer.undo(key).catch(log.error) + } + menuOpts.redoLabel = undoLedger.redo ? `Redo ${undoLedger.redo.description}` : 'Redo' + menuOpts.redoEnabled = Boolean(undoLedger.redo) + menuOpts.onRedoClick = () => { + superConductor.ipcServer.redo(key).catch(log.error) + } const menu = generateMenu(menuOpts, log) Menu.setApplicationMenu(menu) }) diff --git a/apps/app/src/models/project/Project.ts b/apps/app/src/models/project/Project.ts index 47501c2f..78f3cf0c 100644 --- a/apps/app/src/models/project/Project.ts +++ b/apps/app/src/models/project/Project.ts @@ -40,3 +40,17 @@ export interface AnalogInputSetting { relativeMaxCap?: number absoluteOffset?: number } + +export interface SerializableLedger { + undo?: { description?: string } + redo?: { description?: string } +} + +export interface SerializableLedgers { + [key: string]: SerializableLedger +} + +export enum SpecialLedgers { + APPLICATION = 'application', + PERIPHERALS = 'peripherals', +} diff --git a/apps/app/src/preload.ts b/apps/app/src/preload.ts new file mode 100644 index 00000000..5d176046 --- /dev/null +++ b/apps/app/src/preload.ts @@ -0,0 +1,8 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { ElectronAPI } from './ipc/IPCAPI' + +contextBridge.exposeInMainWorld('electronAPI', { + updateUndoLedger: (key, data) => { + ipcRenderer.send('updateUndoLedger', key, data) + }, +} satisfies ElectronAPI) diff --git a/apps/app/src/react/App.tsx b/apps/app/src/react/App.tsx index 65580273..4d0b3487 100644 --- a/apps/app/src/react/App.tsx +++ b/apps/app/src/react/App.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -// const { ipcRenderer } = window.require('electron') import '@fontsource/barlow/300.css' import '@fontsource/barlow/400.css' @@ -15,7 +14,7 @@ import { Sidebar } from './components/sidebar/Sidebar' import sorensen from '@sofie-automation/sorensen' import { RealtimeDataProvider } from './api/RealtimeDataProvider' import { ApiClient } from './api/ApiClient' -import { Project } from '../models/project/Project' +import { Project, SpecialLedgers } from '../models/project/Project' import { IPCServerContext } from './contexts/IPCServer' import { ProjectContext } from './contexts/Project' import { HotkeyContext, IHotkeyContext, TriggersEmitter } from './contexts/Hotkey' @@ -50,6 +49,7 @@ import { TextBtn } from './components/inputs/textBtn/TextBtn' import { HiOutlineX, HiDotsVertical } from 'react-icons/hi' import { protectString } from '@shared/models' import { PERIPHERAL_KEYBOARD } from '../models/project/Peripheral' +import { ElectronApi } from './api/ElectronApi' /** * Used to remove unnecessary cruft from error messages. @@ -491,6 +491,13 @@ export const App = observer(function App() { } }, [sorensenInitialized, handleError, gui, currentRundownId, deleteSelectedTimelineObjs]) + const undoLedgerKey = useMemoComputedValue(() => { + let key: string = SpecialLedgers.APPLICATION + if (gui.isPeripheralPopoverOpen) key = SpecialLedgers.PERIPHERALS + if (currentRundownId && !gui.isHomeSelected()) key = currentRundownId + return key + }, [gui, currentRundownId]) + useEffect(() => { if (!sorensenInitialized) { return @@ -521,13 +528,11 @@ export const App = observer(function App() { } function onUndo(): void { setUserAgreementScreenOpen(false) - // eslint-disable-next-line @typescript-eslint/unbound-method - serverAPI.undo().catch(handleError) + if (undoLedgerKey) serverAPI.undo({ key: undoLedgerKey }).catch(handleError) } function onRedo(): void { setUserAgreementScreenOpen(false) - // eslint-disable-next-line @typescript-eslint/unbound-method - serverAPI.redo().catch(handleError) + if (undoLedgerKey) serverAPI.redo({ key: undoLedgerKey }).catch(handleError) } sorensen.bind('Escape', onEscapeKey, { up: false, @@ -548,7 +553,14 @@ export const App = observer(function App() { sorensen.unbind('Control+KeyZ', onUndo) sorensen.unbind('Control+KeyY', onRedo) } - }, [sorensenInitialized, handleError, gui, currentRundownId, serverAPI]) + }, [sorensenInitialized, handleError, gui, currentRundownId, serverAPI, undoLedgerKey]) + + useEffect(() => { + if (ElectronApi) { + const ledger = store.appStore.undoLedgers[undoLedgerKey] ?? {} + ElectronApi.updateUndoLedger(undoLedgerKey, JSON.parse(JSON.stringify(ledger))) + } + }, [undoLedgerKey, store.appStore.undoLedgers]) useMemoComputedValue(() => { if (!project) return diff --git a/apps/app/src/react/api/ApiClient.ts b/apps/app/src/react/api/ApiClient.ts index f44b449f..380fe34f 100644 --- a/apps/app/src/react/api/ApiClient.ts +++ b/apps/app/src/react/api/ApiClient.ts @@ -21,9 +21,9 @@ type PartArg = Parameters = Parameters< ServiceTypes[ServiceName.RUNDOWNS][T] >[0] -// type ProjectArg = Parameters< -// ServiceTypes[ServiceName.PROJECTS][T] -// >[0] +type ProjectArg = Parameters< + ServiceTypes[ServiceName.PROJECTS][T] +>[0] type ReportingArg = Parameters< ServiceTypes[ServiceName.REPORTING][T] >[0] @@ -330,10 +330,10 @@ export class ApiClient { async setApplicationTrigger(...args: ServerArgs<'setApplicationTrigger'>): ServerReturn<'setApplicationTrigger'> { return this.invokeServerMethod('setApplicationTrigger', ...args) } - async undo(): Promise { - return this.projectService.undo() + async undo(data: ProjectArg<'undo'>): Promise { + return this.projectService.undo(data) } - async redo(): Promise { - return this.projectService.redo() + async redo(data: ProjectArg<'redo'>): Promise { + return this.projectService.redo(data) } } diff --git a/apps/app/src/react/api/ElectronApi.ts b/apps/app/src/react/api/ElectronApi.ts new file mode 100644 index 00000000..d26a3245 --- /dev/null +++ b/apps/app/src/react/api/ElectronApi.ts @@ -0,0 +1,3 @@ +import { ElectronAPI } from '../../ipc/IPCAPI' + +export const ElectronApi = (window as any).electronAPI ? ((window as any).electronAPI as ElectronAPI) : undefined diff --git a/apps/app/src/react/api/RealtimeDataProvider.ts b/apps/app/src/react/api/RealtimeDataProvider.ts index 01283b12..5f5b9102 100644 --- a/apps/app/src/react/api/RealtimeDataProvider.ts +++ b/apps/app/src/react/api/RealtimeDataProvider.ts @@ -13,6 +13,7 @@ import { AnalogInput } from '../../models/project/AnalogInput' import { BridgeId } from '@shared/api' import { BridgePeripheralId } from '@shared/lib' import { app } from './ApiClient' +import { SerializableLedgers } from '../../models/project/Project' /** This class is used client-side, to handle messages from the server */ export class RealtimeDataProvider { @@ -37,6 +38,7 @@ export class RealtimeDataProvider { updateDefiningArea?: (definingArea: DefiningArea | null) => void updateFailedGlobalTriggers?: (identifiers: string[]) => void updateAnalogInput?: (fullIdentifier: string, analogInput: AnalogInput | null) => void + updateUndoLedgers?: (data: SerializableLedgers) => void } ) { // this is new: @@ -44,7 +46,9 @@ export class RealtimeDataProvider { this.updateRundown(rundown.id, rundown) ) app.service(ServiceName.PROJECTS).on(ProjectsEvents.UPDATED, (project) => this.updateProject(project)) - // app.service('project').on(...) etc. + app.service(ServiceName.PROJECTS).on(ProjectsEvents.UNDO_LEDGERS_UPDATED, (undoLedgers) => + this.updateUndoLedgers(undoLedgers) + ) // this is temporary: app.service(ServiceName.LEGACY).on('callMethod', (args) => this.handleCallMethod(args[0], args.slice(1))) @@ -73,6 +77,9 @@ export class RealtimeDataProvider { updateRundown(fileName: string, rundown: Rundown): void { this.callbacks.updateRundown?.(fileName, rundown) } + updateUndoLedgers(data: SerializableLedgers): void { + this.callbacks.updateUndoLedgers?.(data) + } updateResourcesAndMetadata( resources: Array<{ id: ResourceId; resource: ResourceAny | null }>, metadata: SerializedProtectedMap diff --git a/apps/app/src/react/components/headerBar/deviceStatuses/DeviceStatuses.tsx b/apps/app/src/react/components/headerBar/deviceStatuses/DeviceStatuses.tsx index 5f1bd699..366dd99a 100644 --- a/apps/app/src/react/components/headerBar/deviceStatuses/DeviceStatuses.tsx +++ b/apps/app/src/react/components/headerBar/deviceStatuses/DeviceStatuses.tsx @@ -18,6 +18,7 @@ import { getPeripheralId } from '@shared/lib' export const DeviceStatuses: React.FC = observer(function DeviceStatuses() { const project = useContext(ProjectContext) const appStore = store.appStore + const gui = store.guiStore const [submenuPopover, setSubmenuPopover] = React.useState<{ anchorEl: HTMLAnchorElement @@ -26,7 +27,8 @@ export const DeviceStatuses: React.FC = observer(function DeviceStatuses() { } | null>(null) const closeSubMenu = useCallback(() => { setSubmenuPopover(null) - }, []) + gui.isPeripheralPopoverOpen = true + }, [gui]) const [disabledPeripheralsPopover, setDisabledPeripheralsPopover] = React.useState<{ anchorEl: HTMLButtonElement @@ -144,6 +146,7 @@ export const DeviceStatuses: React.FC = observer(function DeviceStatuses() { bridgeId: peripheral.bridgeId, deviceId: peripheral.id, }) + gui.isPeripheralPopoverOpen = true }} /> ) diff --git a/apps/app/src/react/components/pages/homePage/HomePage.tsx b/apps/app/src/react/components/pages/homePage/HomePage.tsx index 17a8c493..bc8ff8b2 100644 --- a/apps/app/src/react/components/pages/homePage/HomePage.tsx +++ b/apps/app/src/react/components/pages/homePage/HomePage.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Project } from 'src/models/project/Project' +import { Project } from '../../../../models/project/Project' import { observer } from 'mobx-react-lite' import { store } from '../../../mobx/store' import { ProjectPage } from './projectPage/ProjectPage' diff --git a/apps/app/src/react/mobx/AnalogStore.ts b/apps/app/src/react/mobx/AnalogStore.ts index 7e300d3e..c20f9de5 100644 --- a/apps/app/src/react/mobx/AnalogStore.ts +++ b/apps/app/src/react/mobx/AnalogStore.ts @@ -4,7 +4,6 @@ import { ActiveAnalog } from '../../models/rundown/Analog' import { RealtimeDataProvider } from '../api/RealtimeDataProvider' import { ApiClient } from '../api/ApiClient' import { ClientSideLogger } from '../api/logger' -// const { ipcRenderer } = window.require('electron') export class AnalogStore { private analogInputs = new Map() diff --git a/apps/app/src/react/mobx/AppStore.ts b/apps/app/src/react/mobx/AppStore.ts index 1f492138..312eb49b 100644 --- a/apps/app/src/react/mobx/AppStore.ts +++ b/apps/app/src/react/mobx/AppStore.ts @@ -9,10 +9,13 @@ import { setConstants } from '../constants' import { BridgeId } from '@shared/api' import { TSRDeviceId, protectString } from '@shared/models' import { BridgePeripheralId } from '@shared/lib' +import { SerializableLedgers, SpecialLedgers } from '../../models/project/Project' export class AppStore { bridgeStatuses = new Map() peripherals = new Map() + undoLedgers: SerializableLedgers = {} + undoLedgerCurrentKey: string = SpecialLedgers.APPLICATION serverAPI: ApiClient logger: ClientSideLogger @@ -30,6 +33,7 @@ export class AppStore { this.updateBridgeStatus(bridgeId, status), updatePeripheral: (peripheralId: BridgePeripheralId, peripheral: PeripheralStatus | null) => this.updatePeripheral(peripheralId, peripheral), + updateUndoLedgers: (data: SerializableLedgers) => this.updateUndoLedgers(data), }) makeAutoObservable(this) @@ -69,6 +73,10 @@ export class AppStore { } } + updateUndoLedgers(data: SerializableLedgers): void { + this.undoLedgers = data + } + private _updateAllDeviceStatuses() { for (const bridgeStatus of this.bridgeStatuses.values()) { for (const [deviceId, deviceStatus] of Object.entries(bridgeStatus.devices)) { diff --git a/apps/app/src/react/mobx/GDDValidatorStoreStore.ts b/apps/app/src/react/mobx/GDDValidatorStoreStore.ts index 2c95d336..cee7d1bd 100644 --- a/apps/app/src/react/mobx/GDDValidatorStoreStore.ts +++ b/apps/app/src/react/mobx/GDDValidatorStoreStore.ts @@ -1,7 +1,6 @@ import { makeAutoObservable, runInAction } from 'mobx' import { SchemaValidator, setupSchemaValidator, ValidatorCache } from 'graphics-data-definition' import { ApiClient } from '../api/ApiClient' -// const { ipcRenderer } = window.require('electron') export class GDDValidatorStore { private isInitialized = false diff --git a/apps/app/src/react/mobx/GroupPlayDataStore.ts b/apps/app/src/react/mobx/GroupPlayDataStore.ts index 4399deab..c2c43449 100644 --- a/apps/app/src/react/mobx/GroupPlayDataStore.ts +++ b/apps/app/src/react/mobx/GroupPlayDataStore.ts @@ -5,7 +5,6 @@ import { RealtimeDataProvider } from '../api/RealtimeDataProvider' import { Rundown } from '../../models/rundown/Rundown' import { ApiClient } from '../api/ApiClient' import { ClientSideLogger } from '../api/logger' -// const { ipcRenderer } = window.require('electron') export class GroupPlayDataStore { groups: Map = new Map() diff --git a/apps/app/src/react/mobx/GuiStore.ts b/apps/app/src/react/mobx/GuiStore.ts index b9610caf..5aebab84 100644 --- a/apps/app/src/react/mobx/GuiStore.ts +++ b/apps/app/src/react/mobx/GuiStore.ts @@ -10,7 +10,6 @@ import { } from '../../lib/GUI' import { DefiningArea } from '../../lib/triggers/keyDisplay/keyDisplay' import { ApiClient } from '../api/ApiClient' -// const { ipcRenderer } = window.require('electron') /** * Store contains only information about user interface @@ -91,6 +90,8 @@ export class GuiStore { this._activeTabId = id } + public isPeripheralPopoverOpen = false + /** A list of all selected items */ get selected(): Readonly { return this._selected diff --git a/apps/app/src/react/mobx/ResourcesAndMetadataStore.ts b/apps/app/src/react/mobx/ResourcesAndMetadataStore.ts index 4102380f..e51ab8a2 100644 --- a/apps/app/src/react/mobx/ResourcesAndMetadataStore.ts +++ b/apps/app/src/react/mobx/ResourcesAndMetadataStore.ts @@ -1,6 +1,5 @@ import { makeAutoObservable } from 'mobx' import { ApiClient } from '../api/ApiClient' -// const { ipcRenderer } = window.require('electron') import { RealtimeDataProvider } from '../api/RealtimeDataProvider' import { MetadataAny, diff --git a/yarn.lock b/yarn.lock index 9c970e7a..ef54bcc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6433,6 +6433,11 @@ eventemitter3@^4.0.0, eventemitter3@^4.0.4, eventemitter3@^4.0.7: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" From 10678b567bce5cca2aff0802d5fcfd799dff8945 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 31 Oct 2023 11:17:12 +0100 Subject: [PATCH 08/14] chore: remove completed todos --- apps/app/src/electron/ClientEventBus.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/app/src/electron/ClientEventBus.ts b/apps/app/src/electron/ClientEventBus.ts index d639f39b..e244a150 100644 --- a/apps/app/src/electron/ClientEventBus.ts +++ b/apps/app/src/electron/ClientEventBus.ts @@ -34,13 +34,13 @@ export class ClientEventBus extends EventEmitter implement this.emit('callMethod', 'updateAppData', appData) } updateProject(project: Project): void { - this.emit('updateProject', project) // TODO: some type safety, please + this.emit('updateProject', project) } updateRundown(_fileName: string, rundown: Rundown): void { - this.emit('updateRundown', rundown) // TODO: some type safety, please + this.emit('updateRundown', rundown) } updateUndoLedgers(data: SerializableLedgers): void { - this.emit('updateUndoLedgers', data) // TODO: some type safety, please + this.emit('updateUndoLedgers', data) } updateResourcesAndMetadata( resources: Array<{ id: ResourceId; resource: ResourceAny | null }>, From b4219aa39a6911d7b2c8e2f0caae1856cca3741c Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 31 Oct 2023 11:19:02 +0100 Subject: [PATCH 09/14] fix: make setting part trigger undoable --- apps/app/src/electron/EverythingService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 83fce8ad..10abf2f9 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -405,6 +405,7 @@ export class EverythingService implements ConvertToServerSide return rundown } + @Undoable async setPartTrigger(arg: { rundownId: string groupId: string From 40be119dbd176111ec3e818251a184797ae1f19d Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 31 Oct 2023 11:22:16 +0100 Subject: [PATCH 10/14] chore: remove commented out code --- apps/app/src/electron/EverythingService.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 10abf2f9..4b9aed01 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -83,13 +83,6 @@ import { SuperConductor } from './SuperConductor' import { UndoLedgerKey, UndoLedgerService } from './UndoService' import { SpecialLedgers } from '../models/project/Project' -// type IPCServerEvents = { -// updatedUndoLedger: ( -// undoLedger: Readonly<{ [key: string | symbol]: UndoLedger }>, -// undoPointer: Readonly -// ) => void -// } - export function isUndoable(result: unknown): result is UndoableResult { if (typeof result !== 'object' || result === null) { return false From bcd48c5e1c2582ff895e62a96071e931da312a35 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 24 Jan 2024 08:19:21 +0100 Subject: [PATCH 11/14] fix: bug in repeat weekly schedule. closes #182 --- apps/app/src/lib/__tests__/timeLib.test.ts | 35 ++++++++++++++++++++-- apps/app/src/lib/timeLib.ts | 16 ++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/app/src/lib/__tests__/timeLib.test.ts b/apps/app/src/lib/__tests__/timeLib.test.ts index 1bcb0cd8..8d050ef1 100644 --- a/apps/app/src/lib/__tests__/timeLib.test.ts +++ b/apps/app/src/lib/__tests__/timeLib.test.ts @@ -347,7 +347,7 @@ test('repeatTime', () => { ], }, { - now: strTime('2022-10-12 18:00:00'), + now: strTime('2022-10-12 18:12:34'), end: strTime('2022-10-21 18:00:00'), maxCount: 999, } @@ -376,7 +376,7 @@ test('repeatTime', () => { ], }, { - now: strTime('2022-10-21 18:00:00'), + now: strTime('2022-10-21 18:12:34'), end: strTime('2022-10-27 18:00:00'), maxCount: 999, } @@ -403,7 +403,7 @@ test('repeatTime', () => { ], }, { - now: strTime('2022-10-30 18:00:00'), + now: strTime('2022-10-30 18:12:34'), end: strTime('2022-12-31 18:00:00'), maxCount: 999, } @@ -416,6 +416,35 @@ test('repeatTime', () => { strTime('2022-11-21 18:00:00'), // mon strTime('2022-11-28 18:00:00'), // mon ]) + + expect( + repeatTime( + strDate('2022-10-12 09:00:00'), // A wednesday + { + type: RepeatingType.WEEKLY, + repeatUntil: strDate('2022-11-05 18:00:00'), + weekdays: [ + false, + false, + false, + true, // wednesday + false, + false, + false, + ], + }, + { + now: strTime('2022-10-12 08:12:34'), + end: strTime('2022-12-31 18:00:00'), + maxCount: 999, + } + ).startTimes + ).toStrictEqual([ + strTime('2022-10-12 09:00:00'), // wed + strTime('2022-10-19 09:00:00'), // wed + strTime('2022-10-26 09:00:00'), // wed + strTime('2022-11-02 09:00:00'), // wed + ]) }) test('parseDateTime', () => { const today = new Date() diff --git a/apps/app/src/lib/timeLib.ts b/apps/app/src/lib/timeLib.ts index 03219c31..3d017da6 100644 --- a/apps/app/src/lib/timeLib.ts +++ b/apps/app/src/lib/timeLib.ts @@ -450,10 +450,16 @@ export function repeatTime( const filterStart = options.now const filterEnd = options.end - const start = dateTimeObject(filterStart) - const startMonday = dateTimeAdvance(start, { date: -start.weekDay - 7 }) + const filterStartObj = dateTimeObject(filterStart) + const startMonday = dateTimeAdvance(filterStartObj, { date: -filterStartObj.weekDay - 7 }) if (!startMonday) return { startTimes: [], validUntil: undefined } + startMonday.hour = startTime.hour + startMonday.minute = startTime.minute + startMonday.second = startTime.second + startMonday.millisecond = startTime.millisecond + updateDateTimeObject(startMonday) + let days = 0 let prevTime = _.clone(startTime) const startTimes: number[] = [] @@ -468,7 +474,11 @@ export function repeatTime( if (time.unixTimestamp > filterEnd) break if (time.unixTimestamp > (settings.repeatUntil?.unixTimestamp || Number.POSITIVE_INFINITY)) break - if (startTimes.length === 0 && prevTime.unixTimestamp !== time.unixTimestamp) { + if ( + startTimes.length === 0 && + prevTime.unixTimestamp !== time.unixTimestamp && + prevTime.unixTimestamp >= start + ) { startTimes.push(prevTime.unixTimestamp) } startTimes.push(time.unixTimestamp) From 4fc72d6b56bd35dd076c0c73f38bc17ada377d81 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 24 Jan 2024 08:30:45 +0100 Subject: [PATCH 12/14] chore: improve unit tests --- apps/app/src/lib/__tests__/timeLib.test.ts | 44 +++++++++++++++++----- apps/app/src/lib/timeLib.ts | 4 ++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/app/src/lib/__tests__/timeLib.test.ts b/apps/app/src/lib/__tests__/timeLib.test.ts index 8d050ef1..861d89a7 100644 --- a/apps/app/src/lib/__tests__/timeLib.test.ts +++ b/apps/app/src/lib/__tests__/timeLib.test.ts @@ -6,6 +6,7 @@ import { repeatTime, RepeatingType, parseDateTime, + updateDateTimeObject, } from '../timeLib' test('formatDurationLabeled', () => { @@ -159,7 +160,7 @@ test('repeatTime', () => { type: RepeatingType.NO_REPEAT, }, { - now: strTime('2022-07-20 17:00:00'), + now: strTime('2022-07-20 17:01:23'), end: strTime('2022-07-21 17:00:00'), maxCount: 999, } @@ -174,7 +175,7 @@ test('repeatTime', () => { repeatUntil: strDate('2022-07-21 6:00:00'), }, { - now: strTime('2022-07-20 18:00:00'), + now: strTime('2022-07-20 18:01:23'), end: strTime('2022-07-21 17:00:00'), maxCount: 999, } @@ -204,7 +205,7 @@ test('repeatTime', () => { repeatUntil: strDate('2022-07-21 6:00:00'), }, { - now: strTime('2022-07-20 17:00:00'), + now: strTime('2022-07-20 17:01:23'), end: strTime('2022-07-20 20:00:00'), maxCount: 999, } @@ -219,7 +220,7 @@ test('repeatTime', () => { repeatUntil: strDate('2022-07-25 18:00:00'), }, { - now: strTime('2022-07-20 19:00:00'), + now: strTime('2022-07-20 19:01:23'), end: strTime('2022-07-31 18:00:00'), maxCount: 999, } @@ -241,7 +242,7 @@ test('repeatTime', () => { repeatUntil: strDate('2022-11-05 18:00:00'), }, { - now: strTime('2022-10-29 18:00:00'), + now: strTime('2022-10-29 18:01:23'), end: strTime('2022-12-31 23:59:59'), maxCount: 999, } @@ -261,7 +262,7 @@ test('repeatTime', () => { repeatUntil: undefined, }, { - now: strTime('2022-11-05 18:00:00'), + now: strTime('2022-11-05 18:01:23'), end: strTime('2022-11-11 23:59:59'), maxCount: 999, } @@ -281,7 +282,7 @@ test('repeatTime', () => { repeatUntil: strDate('2023-02-15 18:00:00'), }, { - now: strTime('2022-07-20 19:00:00'), + now: strTime('2022-07-20 19:01:23'), end: strTime('2023-07-31 18:00:00'), maxCount: 999, } @@ -304,7 +305,7 @@ test('repeatTime', () => { repeatUntil: strDate('2023-12-12 23:59:59'), }, { - now: strTime('2022-10-15 18:00:00'), + now: strTime('2022-10-15 18:01:23'), end: strTime('2023-05-31 18:00:00'), maxCount: 999, } @@ -324,7 +325,7 @@ test('repeatTime', () => { repeatUntil: undefined, }, { - now: strTime('2023-03-15 18:00:00'), + now: strTime('2023-03-15 18:01:23'), end: strTime('2023-05-31 18:00:00'), maxCount: 999, } @@ -474,6 +475,31 @@ test('parseDateTime', () => { expect(parseDateTime('2022-08-30 830')).toStrictEqual(dateTimeObject(new Date(`2022-08-30 08:30:00`))) expect(parseDateTime('2022-08-30 8')).toStrictEqual(dateTimeObject(new Date(`2022-08-30 08:00:00`))) }) +test('updateDateTimeObject', () => { + { + // No change + const d = strDate('2022-08-30 1:23:45') + const dOrg = { ...d } + updateDateTimeObject(d) + expect(d).toStrictEqual(dOrg) + } + { + // bad unix timestamp + const d = strDate('2022-08-30 1:23:45') + const dOrg = { ...d } + d.unixTimestamp = 1234 + updateDateTimeObject(d) + expect(d).toStrictEqual(dOrg) // expect unixTimestamp to have been corrected + } + { + // bad weekday + const d = strDate('2022-08-30 1:23:45') + const dOrg = { ...d } + d.weekDay = -1 + updateDateTimeObject(d) + expect(d).toStrictEqual(dOrg) // expect weekDay to have been corrected + } +}) function strTime(str: string) { return new Date(str).getTime() } diff --git a/apps/app/src/lib/timeLib.ts b/apps/app/src/lib/timeLib.ts index 3d017da6..835bba52 100644 --- a/apps/app/src/lib/timeLib.ts +++ b/apps/app/src/lib/timeLib.ts @@ -256,6 +256,10 @@ export interface DateTimeObjectBase { export interface DateTimeObject extends DateTimeObjectBase { unixTimestamp: number } +/** + * Recalculate the DateTimeObject. + * Essentially this updates the unixTimestamp, and the weekDay to match the year/month/date and hour/minute/second/millisecond. + */ export function updateDateTimeObject(d: DateTimeObject): void { const date = dateTimeObjectToDate(d) const d2 = dateTimeObject(date) From f0f81c19b610cdec8c141a2576f146136446ce7a Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 24 Jan 2024 09:09:49 +0100 Subject: [PATCH 13/14] fix: restore timelines upon startup. closes #185 --- apps/app/src/electron/SuperConductor.ts | 18 ++++++++++++ apps/app/src/electron/bridgeHandler.ts | 38 ++++++++++++++++++------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/apps/app/src/electron/SuperConductor.ts b/apps/app/src/electron/SuperConductor.ts index 749493a0..dd82ea78 100644 --- a/apps/app/src/electron/SuperConductor.ts +++ b/apps/app/src/electron/SuperConductor.ts @@ -33,6 +33,8 @@ import { ActiveAnalog } from '../models/rundown/Analog' import { AnalogHandler } from './analogHandler' import { AnalogInput } from '../models/project/AnalogInput' import { SystemMessageOptions } from '../ipc/IPCAPI' +import { getTimelineForGroup } from '../lib/timeline' +import { TSRTimeline } from 'timeline-state-resolver-types' export class SuperConductor { ipcServer: IPCServer @@ -256,6 +258,8 @@ export class SuperConductor { } else { this.httpAPI = new HTTPAPI(this.internalHttpApiPort, this.ipcServer, this.log) } + + this._restoreTimelines() } sendSystemMessage(message: string, options: SystemMessageOptions): void { this.clients.forEach((clients) => clients.ipcClient.systemMessage(message, options)) @@ -509,6 +513,20 @@ export class SuperConductor { return updateTimeline(this.storage, this.bridgeHandler, group) } + private _restoreTimelines() { + const project = this.storage.getProject() + + const openRundowns = this.storage.getAllRundowns() + + for (const openRundown of openRundowns) { + for (const group of openRundown.groups) { + const timeline = getTimelineForGroup(group, group.preparedPlayData, undefined) as TSRTimeline + this.bridgeHandler.updateTimeline(group.id, timeline) + } + } + + this.bridgeHandler.updateMappings(project.mappings) + } /** * Is called when the app is starting to shut down. diff --git a/apps/app/src/electron/bridgeHandler.ts b/apps/app/src/electron/bridgeHandler.ts index 48cb1291..ef25786d 100644 --- a/apps/app/src/electron/bridgeHandler.ts +++ b/apps/app/src/electron/bridgeHandler.ts @@ -25,6 +25,12 @@ export const SERVER_PORT = 5400 type AnyBridgeConnection = WebsocketBridgeConnection | LocalBridgeConnection +interface BridgeHandlerCallbacks { + updatedResources: (deviceId: string, resources: ResourceAny[]) => void + onVersionMismatch: (bridgeId: string, bridgeVersion: string, ourVersion: string) => void + onDeviceRefreshStatus: (deviceId: string, refreshing: boolean) => void +} + /** This handles connected bridges */ export class BridgeHandler { server: WebsocketServer @@ -55,7 +61,7 @@ export class BridgeHandler { private log: LoggerLike, private session: SessionHandler, private storage: StorageHandler, - private callbacks: BridgeConnectionCallbacks + private callbacks: BridgeHandlerCallbacks ) { this.server = new WebsocketServer(this.log, SERVER_PORT, (connection: WebsocketConnection) => { // On connection: @@ -65,7 +71,7 @@ export class BridgeHandler { this.session, this.storage, connection, - this.callbacks + this.getBridgeConnectionCallbacks() ) // Lookup and set the bridgeId, if it is an outgoing @@ -106,7 +112,12 @@ export class BridgeHandler { if (this.closed) return if (project.settings.enableInternalBridge) { if (!this.internalBridge) { - this.internalBridge = new LocalBridgeConnection(this.log, this.session, this.storage, this.callbacks) + this.internalBridge = new LocalBridgeConnection( + this.log, + this.session, + this.storage, + this.getBridgeConnectionCallbacks() + ) this.connectedBridges.push(this.internalBridge) } } else { @@ -292,12 +303,18 @@ export class BridgeHandler { bridgeConnection.refreshResources() } } + private getBridgeConnectionCallbacks(): BridgeConnectionCallbacks { + return { + ...this.callbacks, + getTimelines: () => this.timelines, + getMappings: () => this.mappings, + } + } } -interface BridgeConnectionCallbacks { - updatedResources: (deviceId: string, resources: ResourceAny[]) => void - onVersionMismatch: (bridgeId: string, bridgeVersion: string, ourVersion: string) => void - onDeviceRefreshStatus: (deviceId: string, refreshing: boolean) => void +interface BridgeConnectionCallbacks extends BridgeHandlerCallbacks { + getTimelines: () => { [timelineId: string]: TSRTimeline } + getMappings: () => Mappings } abstract class AbstractBridgeConnection { @@ -399,10 +416,11 @@ abstract class AbstractBridgeConnection { } else { this.log.error(`Error: Settings bridge "${this.bridgeId}" not found`) } - if (this.sentMappings) { - this.setMappings(this.sentMappings, true) + const mappings = this.callbacks.getMappings() + if (mappings) { + this.setMappings(mappings, true) } - for (const [timelineId, timeline] of Object.entries(this.sentTimelines)) { + for (const [timelineId, timeline] of Object.entries(this.callbacks.getTimelines())) { this.addTimeline(timelineId, timeline) } // Sync timelineIds: From 5a905ee4183e49ab127292b9817cd49d5ddbebb5 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 24 Jan 2024 09:13:42 +0100 Subject: [PATCH 14/14] v0.11.2 --- README.md | 16 ++++++++-------- apps/app/package.json | 12 ++++++------ apps/tsr-bridge/package.json | 10 +++++----- lerna.json | 2 +- shared/packages/api/package.json | 4 ++-- shared/packages/lib/package.json | 6 +++--- shared/packages/models/package.json | 2 +- shared/packages/peripherals/package.json | 6 +++--- shared/packages/server-lib/package.json | 6 +++--- shared/packages/tsr-bridge/package.json | 10 +++++----- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 66f92756..56f1d497 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,22 @@ A playout client for _Windows/Linux/macOS_ that will let you control _CasparCG&n ### Windows -- Download and open the [installer for SuperConductor](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.1/SuperConductor-0.11.1-Windows-Installer.exe). -- (Optional) Download and open the [installer for separate TSR-Bridge](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.1/TSR-Bridge-0.11.1-Windows-Installer.exe). +- Download and open the [installer for SuperConductor](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.2/SuperConductor-0.11.2-Windows-Installer.exe). +- (Optional) Download and open the [installer for separate TSR-Bridge](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.2/TSR-Bridge-0.11.2-Windows-Installer.exe). ### Linux (Ubuntu) -- Download the [.appImage file for SuperConductor](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.1/SuperConductor-0.11.1-Linux-Executable.AppImage).
+- Download the [.appImage file for SuperConductor](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.2/SuperConductor-0.11.2-Linux-Executable.AppImage).
Execute the following before running the file:
- `chmod +x Downloads/SuperConductor-0.11.1-Linux-Executable.AppImage` -- (Optional) Download the [.appImage file for TSR-Bridge](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.1/TSR-Bridge-0.11.1-Linux-Executable.AppImage).
+ `chmod +x Downloads/SuperConductor-0.11.2-Linux-Executable.AppImage` +- (Optional) Download the [.appImage file for TSR-Bridge](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.2/TSR-Bridge-0.11.2-Linux-Executable.AppImage).
Execute the following before running the file:
- `chmod +x Downloads/TSR-Bridge-0.11.1-Linux-Executable.AppImage` + `chmod +x Downloads/TSR-Bridge-0.11.2-Linux-Executable.AppImage` ### macOS -- Download and open the [installer for SuperConductor](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.1/SuperConductor-0.11.1-macOS-Installer.dmg). -- (Optional) Download and open the [installer for separate TSR-Bridge](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.1/TSR-Bridge-0.11.1-macOS-Installer.dmg). +- Download and open the [installer for SuperConductor](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.2/SuperConductor-0.11.2-macOS-Installer.dmg). +- (Optional) Download and open the [installer for separate TSR-Bridge](https://github.com/SuperFlyTV/SuperConductor/releases/download/v0.11.2/TSR-Bridge-0.11.2-macOS-Installer.dmg). ## Problems and Issues diff --git a/apps/app/package.json b/apps/app/package.json index 6929a786..3c732dd5 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,7 +1,7 @@ { "name": "superconductor", "private": true, - "version": "0.11.1", + "version": "0.11.2", "description": "A playout client for Windows, Linux or MacOS to control CasparCG, Atem, OBS and more!", "main": "dist/main.js", "engines": { @@ -72,11 +72,11 @@ "@koa/router": "^12.0.0", "@mui/icons-material": "^5.10.14", "@mui/material": "^5.10.14", - "@shared/api": "^0.11.1", - "@shared/lib": "^0.11.1", - "@shared/models": "^0.11.1", - "@shared/server-lib": "^0.11.1", - "@shared/tsr-bridge": "^0.11.1", + "@shared/api": "^0.11.2", + "@shared/lib": "^0.11.2", + "@shared/models": "^0.11.2", + "@shared/server-lib": "^0.11.2", + "@shared/tsr-bridge": "^0.11.2", "@sofie-automation/sorensen": "^1.4.2", "axios": "^1.1.3", "bufferutil": "^4.0.7", diff --git a/apps/tsr-bridge/package.json b/apps/tsr-bridge/package.json index 93715703..75f8cc22 100644 --- a/apps/tsr-bridge/package.json +++ b/apps/tsr-bridge/package.json @@ -1,7 +1,7 @@ { "name": "tsr-bridge", "private": true, - "version": "0.11.1", + "version": "0.11.2", "description": "A companion app to SuperConductor, to handle playout on another computer", "main": "dist/main.js", "engines": { @@ -50,10 +50,10 @@ }, "dependencies": { "@mui/material": "^5.10.14", - "@shared/api": "^0.11.1", - "@shared/lib": "^0.11.1", - "@shared/server-lib": "^0.11.1", - "@shared/tsr-bridge": "^0.11.1", + "@shared/api": "^0.11.2", + "@shared/lib": "^0.11.2", + "@shared/server-lib": "^0.11.2", + "@shared/tsr-bridge": "^0.11.2", "classnames": "^2.3.2", "electron-is-dev": "^2.0.0", "electron-updater": "^5.3.0", diff --git a/lerna.json b/lerna.json index bd757910..6d9526a8 100644 --- a/lerna.json +++ b/lerna.json @@ -4,7 +4,7 @@ "apps/**", "tests/**" ], - "version": "0.11.1", + "version": "0.11.2", "npmClient": "yarn", "useWorkspaces": true } diff --git a/shared/packages/api/package.json b/shared/packages/api/package.json index 1ddcab51..0f871b60 100644 --- a/shared/packages/api/package.json +++ b/shared/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@shared/api", - "version": "0.11.1", + "version": "0.11.2", "description": "", "author": { "name": "SuperFlyTV AB", @@ -41,7 +41,7 @@ ] }, "dependencies": { - "@shared/models": "^0.11.1" + "@shared/models": "^0.11.2" }, "devDependencies": { "@types/ws": "^8.2.2", diff --git a/shared/packages/lib/package.json b/shared/packages/lib/package.json index 343ecac8..1e771ca9 100644 --- a/shared/packages/lib/package.json +++ b/shared/packages/lib/package.json @@ -1,6 +1,6 @@ { "name": "@shared/lib", - "version": "0.11.1", + "version": "0.11.2", "description": "", "author": { "name": "SuperFlyTV AB", @@ -41,8 +41,8 @@ ] }, "dependencies": { - "@shared/api": "^0.11.1", - "@shared/models": "^0.11.1", + "@shared/api": "^0.11.2", + "@shared/models": "^0.11.2", "fast-copy": "^2.1.1", "lodash": "^4.17.21", "superfly-timeline": "^8.2.5" diff --git a/shared/packages/models/package.json b/shared/packages/models/package.json index 14657814..d449021f 100644 --- a/shared/packages/models/package.json +++ b/shared/packages/models/package.json @@ -1,6 +1,6 @@ { "name": "@shared/models", - "version": "0.11.1", + "version": "0.11.2", "description": "", "author": { "name": "SuperFlyTV AB", diff --git a/shared/packages/peripherals/package.json b/shared/packages/peripherals/package.json index d57d20cb..215cecf1 100644 --- a/shared/packages/peripherals/package.json +++ b/shared/packages/peripherals/package.json @@ -1,6 +1,6 @@ { "name": "@shared/peripherals", - "version": "0.11.1", + "version": "0.11.2", "description": "", "author": { "name": "SuperFlyTV AB", @@ -48,8 +48,8 @@ "@elgato-stream-deck/node": "^5.7.2", "@julusian/jpeg-turbo": "^2.1.0", "@julusian/midi": "^3.0.0", - "@shared/api": "^0.11.1", - "@shared/lib": "^0.11.1", + "@shared/api": "^0.11.2", + "@shared/lib": "^0.11.2", "lodash": "^4.17.21", "p-queue": "^6.6.2", "sharp": "^0.31.2", diff --git a/shared/packages/server-lib/package.json b/shared/packages/server-lib/package.json index 8541f6f6..e78d9a21 100644 --- a/shared/packages/server-lib/package.json +++ b/shared/packages/server-lib/package.json @@ -1,6 +1,6 @@ { "name": "@shared/server-lib", - "version": "0.11.1", + "version": "0.11.2", "description": "", "author": { "name": "SuperFlyTV AB", @@ -41,8 +41,8 @@ ] }, "dependencies": { - "@shared/api": "^0.11.1", - "@shared/models": "^0.11.1", + "@shared/api": "^0.11.2", + "@shared/models": "^0.11.2", "ws": "^8.4.2" }, "devDependencies": { diff --git a/shared/packages/tsr-bridge/package.json b/shared/packages/tsr-bridge/package.json index fb2d9704..aaa7cbbd 100644 --- a/shared/packages/tsr-bridge/package.json +++ b/shared/packages/tsr-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@shared/tsr-bridge", - "version": "0.11.1", + "version": "0.11.2", "description": "", "author": { "name": "SuperFlyTV AB", @@ -41,10 +41,10 @@ ] }, "dependencies": { - "@shared/api": "^0.11.1", - "@shared/lib": "^0.11.1", - "@shared/models": "^0.11.1", - "@shared/peripherals": "^0.11.1", + "@shared/api": "^0.11.2", + "@shared/lib": "^0.11.2", + "@shared/models": "^0.11.2", + "@shared/peripherals": "^0.11.2", "cheerio": "^1.0.0-rc.12", "got": "^11.8.5", "lodash": "^4.17.21",