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 20d37c00..ed7d22c9 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..e244a150 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 } @@ -26,10 +34,13 @@ export class ClientEventBus extends EventEmitter implements IPCClientMethods { 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) } updateResourcesAndMetadata( resources: Array<{ id: ResourceId; resource: ResourceAny | null }>, diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 86358d69..737b3840 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' @@ -41,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' @@ -65,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, @@ -89,21 +80,8 @@ import { TriggersHandler } from './triggersHandler' import { GDDSchema, ValidatorCache } from 'graphics-data-definition' import * as RundownActions from './rundownActions' import { SuperConductor } from './SuperConductor' - -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 -} +import { UndoLedgerKey, UndoLedgerService } from './UndoService' +import { SpecialLedgers } from '../models/project/Project' export function isUndoable(result: unknown): result is UndoableResult { if (typeof result !== 'object' || result === null) { @@ -129,25 +107,32 @@ type ConvertToServerSide = { : T[K] } +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) + if (isUndoable(result)) { + target.pushUndoable.call(this, result.ledgerKey, 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 */ -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( - ipcMain: Electron.IpcMain, private _log: LoggerLike, private _renderLog: LoggerLike, private storage: StorageHandler, private superConductor: SuperConductor, private session: SessionHandler, + private undoService: UndoLedgerService, private callbacks: { onClientConnected: () => void installUpdate: () => void @@ -161,49 +146,12 @@ export class EverythingService onAgreeToUserAgreement: () => void handleError: (error: string, stack?: string) => void } - ) { - super() - 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 - } - }) - } - } - } + public pushUndoable(key: UndoLedgerKey, args: unknown[], fcn: () => any, result: UndoableResult): void { + this.undoService.pushUndoable(key, args, fcn, result) } + public getProject(): Project { return this.storage.getProject() } @@ -268,34 +216,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 { @@ -473,6 +398,7 @@ export class EverythingService return rundown } + @Undoable async setPartTrigger(arg: { rundownId: string groupId: string @@ -512,6 +438,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 { @@ -607,6 +534,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" */ @@ -667,8 +595,10 @@ export class EverythingService }, description: ActionDescription.NewPart, result, + ledgerKey: arg.rundownId, } } + @Undoable async insertParts(arg: { rundownId: string groupId: string | null @@ -765,6 +695,7 @@ export class EverythingService }, description: ActionDescription.InsertParts, result: inserted, + ledgerKey: arg.rundownId, } } else { return this._insertPartsAsTransparentGroup({ @@ -774,6 +705,7 @@ export class EverythingService }) } } + @Undoable private async _insertPartsAsTransparentGroup(arg: { rundownId: string parts: { part: Part; resources: ResourceAny[] }[] @@ -825,8 +757,10 @@ export class EverythingService return { ...r, result: inserted, + ledgerKey: arg.rundownId, } } + @Undoable async updatePart(arg: { rundownId: string groupId: string @@ -868,8 +802,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.UpdatePart, + ledgerKey: arg.rundownId, } } + @Undoable async upsertPart(arg: { rundownId: string groupId: string @@ -929,8 +865,10 @@ export class EverythingService } }, description: ActionDescription.UpsertPart, + ledgerKey: arg.rundownId, } } + @Undoable async upsertPartByExternalId(arg: { rundownId: string groupId: string @@ -952,6 +890,7 @@ export class EverythingService part: arg.part, }) } + @Undoable async newGroup(arg: { rundownId: string; name: string }): Promise> { const newGroup: Group = { ...getDefaultGroup(), @@ -971,8 +910,10 @@ export class EverythingService }, description: ActionDescription.NewGroup, result: newGroup.id, + ledgerKey: arg.rundownId, } } + @Undoable async insertGroups(arg: { rundownId: string groups: { @@ -1062,8 +1003,10 @@ export class EverythingService }, description: ActionDescription.InsertGroups, result: inserted, + ledgerKey: arg.rundownId, } } + @Undoable async updateGroup(arg: { rundownId: string groupId: string @@ -1111,8 +1054,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.UpdateGroup, + ledgerKey: arg.rundownId, } } + @Undoable async upsertGroup(arg: { rundownId: string groupId: string | undefined @@ -1200,8 +1145,10 @@ export class EverythingService } }, description: ActionDescription.UpsertGroup, + ledgerKey: arg.rundownId, } } + @Undoable async upsertGroupByExternalId(arg: { rundownId: string externalId: string @@ -1222,6 +1169,7 @@ export class EverythingService useExternalIdForParts: true, }) } + @Undoable async deletePart(arg: { rundownId: string groupId: string @@ -1264,8 +1212,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.DeletePart, + ledgerKey: arg.rundownId, } } + @Undoable async deleteGroup(arg: { rundownId: string; groupId: string }): Promise | undefined> { const { rundown, group } = this.getGroup(arg) @@ -1294,8 +1244,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group: deletedGroup }) }, description: ActionDescription.DeleteGroup, + ledgerKey: arg.rundownId, } } + @Undoable async moveParts(arg: { parts: { rundownId: string; partId: string }[] to: { rundownId: string; groupId: string | null; target: MoveTarget } @@ -1479,8 +1431,10 @@ export class EverythingService }, description: ActionDescription.MovePart, result: resultingParts, + ledgerKey: arg.to.rundownId, } } + @Undoable async duplicatePart(arg: { rundownId: string; groupId: string; partId: string }): Promise> { const { rundown, group, part } = this.getPart(arg) @@ -1528,8 +1482,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group: newGroup ? undefined : group }) }, description: ActionDescription.DuplicatePart, + ledgerKey: arg.rundownId, } } + @Undoable async moveGroups(arg: { rundownId: string groupIds: string[] @@ -1571,8 +1527,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.MoveGroup, + ledgerKey: arg.rundownId, } } + @Undoable async duplicateGroup(arg: { rundownId: string; groupId: string }): Promise> { const { rundown, group } = this.getGroup(arg) @@ -1596,17 +1554,17 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown }) }, description: ActionDescription.DuplicateGroup, + ledgerKey: arg.rundownId, } } + @Undoable async updateTimelineObj(arg: { rundownId: string groupId: string partId: string timelineObjId: string - timelineObj: { - obj: PartialDeep - } + timelineObj: PartialDeep> }): Promise | undefined> { const { rundown, group, part } = this.getPart(arg) @@ -1622,6 +1580,7 @@ export class EverythingService 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 }) @@ -1636,8 +1595,11 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.UpdateTimelineObj, + ledgerKey: arg.rundownId, } } + + @Undoable async deleteTimelineObj(arg: { rundownId: string groupId: string @@ -1648,7 +1610,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 +1618,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 +1633,16 @@ 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, + ledgerKey: arg.rundownId, } } + @Undoable async insertTimelineObjs(arg: { rundownId: string groupId: string @@ -1799,8 +1766,10 @@ export class EverythingService }, description: ActionDescription.AddTimelineObj, result: inserted, + ledgerKey: arg.rundownId, } } + @Undoable async moveTimelineObjToNewLayer(arg: { rundownId: string groupId: string @@ -1852,9 +1821,11 @@ export class EverythingService this._saveUpdates({ project: updatedProject, rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.MoveTimelineObjToNewLayer, + ledgerKey: arg.rundownId, } } + @Undoable async addResourcesToTimeline(arg: { rundownId: string groupId: string @@ -1974,8 +1945,10 @@ export class EverythingService this._saveUpdates({ project: updatedProject, rundownId: arg.rundownId, rundown }) }, description: ActionDescription.addResourcesToTimeline, + ledgerKey: arg.rundownId, } } + @Undoable async toggleGroupLoop(arg: { rundownId: string groupId: string @@ -2004,8 +1977,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.ToggleGroupLoop, + ledgerKey: arg.rundownId, } } + @Undoable async toggleGroupAutoplay(arg: { rundownId: string groupId: string @@ -2034,8 +2009,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.ToggleGroupAutoplay, + ledgerKey: arg.rundownId, } } + @Undoable async toggleGroupOneAtATime(arg: { rundownId: string groupId: string @@ -2075,8 +2052,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.toggleGroupOneAtATime, + ledgerKey: arg.rundownId, } } + @Undoable async toggleGroupDisable(arg: { rundownId: string groupId: string @@ -2105,8 +2084,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group }) }, description: ActionDescription.ToggleGroupDisable, + ledgerKey: arg.rundownId, } } + @Undoable async toggleGroupLock(arg: { rundownId: string; groupId: string; value: boolean }): Promise> { const { rundown, group } = this.getGroup(arg) const originalValue = group.locked @@ -2124,8 +2105,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group, noEffectOnPlayout: true }) }, description: ActionDescription.ToggleGroupLock, + ledgerKey: arg.rundownId, } } + @Undoable async toggleGroupCollapse(arg: { rundownId: string groupId: string @@ -2147,8 +2130,10 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, group, noEffectOnPlayout: true }) }, description: ActionDescription.ToggleGroupCollapse, + ledgerKey: arg.rundownId, } } + @Undoable async toggleAllGroupsCollapse(arg: { rundownId: string; value: boolean }): Promise> { const { rundown } = this.getRundown(arg) @@ -2174,6 +2159,7 @@ export class EverythingService this._saveUpdates({ rundownId: arg.rundownId, rundown, noEffectOnPlayout: true }) }, description: ActionDescription.ToggleAllGroupsCollapse, + ledgerKey: arg.rundownId, } } async setGroupViewMode(arg: { @@ -2266,6 +2252,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 @@ -2278,6 +2265,7 @@ export class EverythingService }, description: ActionDescription.NewRundown, result: rundown, + ledgerKey: rundown.id, } } async deleteRundown(arg: { rundownId: string }): Promise { @@ -2297,6 +2285,7 @@ export class EverythingService // Note: This is not undoable } + @Undoable async openRundown(arg: { rundownId: string }): Promise> { this.storage.openRundown(arg.rundownId) this._saveUpdates({}) @@ -2307,8 +2296,10 @@ export class EverythingService this._saveUpdates({}) }, description: ActionDescription.OpenRundown, + ledgerKey: SpecialLedgers.APPLICATION, } } + @Undoable async closeRundown(arg: { rundownId: string }): Promise> { const { rundown } = this.getRundown(arg) if (!rundown) { @@ -2329,9 +2320,11 @@ export class EverythingService this._saveUpdates({}) }, description: ActionDescription.CloseRundown, + ledgerKey: SpecialLedgers.APPLICATION, } } + @Undoable async renameRundown(arg: { rundownId: string; newName: string }): Promise> { const rundown = this.storage.getRundown(arg.rundownId) if (!rundown) { @@ -2349,6 +2342,7 @@ export class EverythingService }, description: ActionDescription.RenameRundown, result: newRundownId, + ledgerKey: arg.rundownId, } } async isRundownPlaying(arg: { rundownId: string }): Promise { @@ -2376,6 +2370,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) @@ -2458,9 +2453,11 @@ export class EverythingService } }, description: ActionDescription.CreateMissingMapping, + ledgerKey: arg.rundownId, } } + @Undoable async addPeripheralArea(arg: { bridgeId: BridgeId; deviceId: PeripheralId }): Promise> { const bridgeIdStr = unprotectString(arg.bridgeId) const deviceIdStr = unprotectString(arg.deviceId) @@ -2504,8 +2501,10 @@ export class EverythingService } }, description: ActionDescription.AddPeripheralArea, + ledgerKey: SpecialLedgers.PERIPHERALS, } } + @Undoable async removePeripheralArea(data: { bridgeId: BridgeId deviceId: PeripheralId @@ -2546,8 +2545,10 @@ export class EverythingService } }, description: ActionDescription.RemovePeripheralArea, + ledgerKey: SpecialLedgers.PERIPHERALS, } } + @Undoable async updatePeripheralArea(arg: { bridgeId: BridgeId deviceId: PeripheralId @@ -2593,8 +2594,10 @@ export class EverythingService } }, description: ActionDescription.UpdatePeripheralArea, + ledgerKey: SpecialLedgers.PERIPHERALS, } } + @Undoable async assignAreaToGroup(arg: { groupId: string | undefined areaId: string @@ -2631,6 +2634,7 @@ export class EverythingService this._saveUpdates({ project }) }, description: ActionDescription.AssignAreaToGroup, + ledgerKey: SpecialLedgers.PERIPHERALS, } } async startDefiningArea(arg: { bridgeId: BridgeId; deviceId: PeripheralId; areaId: string }): Promise { @@ -2658,6 +2662,7 @@ export class EverythingService async finishDefiningArea(): Promise { this._saveUpdates({ definingArea: null }) } + @Undoable async setApplicationTrigger(arg: { triggerAction: ApplicationTrigger['action'] trigger: ApplicationTrigger | null @@ -2697,6 +2702,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 360f4b30..aef88ef9 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' @@ -41,6 +41,9 @@ 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' +import { UndoLedgerService } from './UndoService' export class SuperConductor { ipcServer: EverythingService @@ -225,53 +228,66 @@ export class SuperConductor { }, }) - this.ipcServer = new EverythingService(ipcMain, 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) => { @@ -288,6 +304,8 @@ export class SuperConductor { } else { this.httpAPI = new ApiServer(this.internalHttpApiPort, this.ipcServer, this.clientEventBus, this.log) } + + this._restoreTimelines() } sendSystemMessage(message: string, options: SystemMessageOptions): void { this.clientEventBus.systemMessage(message, options) @@ -537,6 +555,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/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 21c1a0da..3ab73c58 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()) @@ -40,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), { @@ -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..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 { @@ -65,4 +69,16 @@ export class ProjectService extends EventEmitter { // TODO: access control return await this.everythingService.importProject() } + + async undo(data: { key: string }): Promise { + // TODO: likely not the best place for this method + // TODO: access control + return await this.everythingService.undo(data.key) + } + + async redo(data: { key: string }): Promise { + // TODO: likely not the best place for this method + // TODO: access control + return await this.everythingService.redo(data.key) + } } diff --git a/apps/app/src/electron/api/RundownService.ts b/apps/app/src/electron/api/RundownService.ts index 09eaade7..e93b7347 100644 --- a/apps/app/src/electron/api/RundownService.ts +++ b/apps/app/src/electron/api/RundownService.ts @@ -14,9 +14,7 @@ interface TimelineObjectUpdate { groupId: string partId: string timelineObjId: string - timelineObj: { - obj: PartialDeep - } + timelineObj: PartialDeep } interface TimelineObjectMove { diff --git a/apps/app/src/electron/bridgeHandler.ts b/apps/app/src/electron/bridgeHandler.ts index 60dc8f34..98840c64 100644 --- a/apps/app/src/electron/bridgeHandler.ts +++ b/apps/app/src/electron/bridgeHandler.ts @@ -33,7 +33,11 @@ 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 +interface BridgeHandlerCallbacks { + updatedResourcesAndMetadata: (deviceId: TSRDeviceId, resources: ResourceAny[], metadata: MetadataAny | null) => void + onVersionMismatch: (bridgeId: BridgeId, bridgeVersion: string, ourVersion: string) => void + onDeviceRefreshStatus: (deviceId: TSRDeviceId, refreshing: boolean) => void +} /** This handles connected bridges */ export class BridgeHandler { @@ -48,7 +52,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() @@ -60,20 +64,15 @@ export class BridgeHandler { } = {} private datastore: Datastore = {} private closed = false - private connectionCallbacks: BridgeConnectionCallbacks + reconnectToBridgesInterval: NodeJS.Timer constructor( private log: LoggerLike, private session: SessionHandler, private storage: StorageHandler, - callbacks: BridgeHandlerCallbacks + private callbacks: BridgeHandlerCallbacks ) { - this.connectionCallbacks = { - ...callbacks, - getMappings: () => this.mappings, - getTimelines: () => this.timelines, - } this.server = new WebsocketServer(this.log, SERVER_PORT, (connection: WebsocketConnection) => { // On connection: @@ -82,17 +81,16 @@ export class BridgeHandler { this.session, this.storage, connection, - this.connectionCallbacks + this.getBridgeConnectionCallbacks() ) // Lookup and set the bridgeId, if it is an outgoing 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 +106,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) { @@ -127,20 +125,15 @@ export class BridgeHandler { this.log, this.session, this.storage, - this.connectionCallbacks + this.getBridgeConnectionCallbacks() ) - 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 +211,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 +221,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,26 +296,41 @@ 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() } } -} - -interface BridgeHandlerCallbacks { - updatedResourcesAndMetadata: (deviceId: TSRDeviceId, resources: ResourceAny[], metadata: MetadataAny | null) => void - onVersionMismatch: (bridgeId: BridgeId, bridgeVersion: string, ourVersion: string) => void - onDeviceRefreshStatus: (deviceId: TSRDeviceId, refreshing: boolean) => void + private getBridgeConnectionCallbacks(): BridgeConnectionCallbacks { + return { + ...this.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) + } + }, + } + } } interface BridgeConnectionCallbacks extends BridgeHandlerCallbacks { - getMappings: () => Mappings getTimelines: () => { [timelineId: string]: TSRTimeline } + getMappings: () => Mappings + onInitialized: (connection: AbstractBridgeConnection) => void + onDisconnected: (connection: AbstractBridgeConnection) => void } abstract class AbstractBridgeConnection { @@ -412,6 +420,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) @@ -430,6 +439,7 @@ abstract class AbstractBridgeConnection { this.log.error(`Error: Settings bridge "${this.bridgeId}" not found`) } this.setMappings(this.callbacks.getMappings(), true) + for (const [timelineId, timeline] of Object.entries(this.callbacks.getTimelines())) { this.addTimeline(timelineId, timeline) } @@ -625,6 +635,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/apps/app/src/ipc/IPCAPI.ts b/apps/app/src/ipc/IPCAPI.ts index e501bff8..d1ca4f88 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', @@ -51,6 +50,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', @@ -84,7 +84,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', @@ -101,74 +112,61 @@ 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', ], } 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 @@ -179,6 +177,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 { @@ -267,9 +269,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: { @@ -379,4 +379,5 @@ export enum RundownsEvents { } export enum ProjectsEvents { UPDATED = 'updated', + UNDO_LEDGERS_UPDATED = 'undo_ledgers_updated', } 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/__tests__/timeLib.test.ts b/apps/app/src/lib/__tests__/timeLib.test.ts index 1bcb0cd8..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, } @@ -347,7 +348,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 +377,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 +404,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 +417,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() @@ -445,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/__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/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 136913e4..574c1bcc 100644 --- a/apps/app/src/lib/playout/__tests__/preparedGroupPlayData.test.ts +++ b/apps/app/src/lib/playout/__tests__/preparedGroupPlayData.test.ts @@ -315,6 +315,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', () => { @@ -679,7 +756,7 @@ describe('prepareGroupPlayData', () => { const partB = getPart(group0, 'partB') partB.loop = true - + const partC = getPart(group0, 'partC') // Play Part C: @@ -785,6 +862,187 @@ describe('prepareGroupPlayData', () => { } } }) + 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: @@ -795,9 +1053,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 { diff --git a/apps/app/src/lib/timeLib.ts b/apps/app/src/lib/timeLib.ts index 03219c31..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) @@ -450,10 +454,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 +478,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) diff --git a/apps/app/src/lib/util.ts b/apps/app/src/lib/util.ts index a94c5dce..a95feade 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 @@ -833,7 +867,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/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/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/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 1d23b193..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 @@ -519,16 +526,41 @@ export const App = observer(function App() { handleError(error) } } + function onUndo(): void { + setUserAgreementScreenOpen(false) + if (undoLedgerKey) serverAPI.undo({ key: undoLedgerKey }).catch(handleError) + } + function onRedo(): void { + setUserAgreementScreenOpen(false) + if (undoLedgerKey) serverAPI.redo({ key: undoLedgerKey }).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, serverAPI, undoLedgerKey]) + + useEffect(() => { + if (ElectronApi) { + const ledger = store.appStore.undoLedgers[undoLedgerKey] ?? {} + ElectronApi.updateUndoLedger(undoLedgerKey, JSON.parse(JSON.stringify(ledger))) } - }, [sorensenInitialized, handleError, gui, currentRundownId]) + }, [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 fa8ad677..9a122706 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' @@ -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] @@ -61,6 +61,16 @@ app.use( } ) +app.hooks({ + before: { + all: [ + async (context: HookContext) => { + context.data = replaceUndefined(context.data) + }, + ], + }, +}) + type ServerArgs = Parameters type ServerReturn = Promise> @@ -326,4 +336,10 @@ export class ApiClient { async setApplicationTrigger(...args: ServerArgs<'setApplicationTrigger'>): ServerReturn<'setApplicationTrigger'> { return this.invokeServerMethod('setApplicationTrigger', ...args) } + async undo(data: ProjectArg<'undo'>): Promise { + return this.projectService.undo(data) + } + 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/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} + /> +
+ )} + + () 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/apps/app/src/react/styles/sidebar/sidebar.scss b/apps/app/src/react/styles/sidebar/sidebar.scss index b79fafd4..e9f6da9d 100644 --- a/apps/app/src/react/styles/sidebar/sidebar.scss +++ b/apps/app/src/react/styles/sidebar/sidebar.scss @@ -23,6 +23,15 @@ display: flex; justify-content: space-between; padding: 0.6em; + + a { + color: inherit; + text-decoration: none; + } + a:hover { + text-decoration: underline; + cursor: text; + } } } > .sidebar__content { diff --git a/shared/packages/tsr-bridge/src/TSR.ts b/shared/packages/tsr-bridge/src/TSR.ts index b78d8de4..3a1746df 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() @@ -106,17 +113,21 @@ 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)) } + await this._removeSideloadDevice(deviceId) - this.devices.set(deviceId, newDevice) + const abortController = new AbortController() + + this.devices.set(deviceId, { options: newDeviceOptions, abortController }) this.onDeviceStatus(deviceId, { statusCode: StatusCode.UNKNOWN, messages: ['Initializing'], @@ -125,10 +136,12 @@ 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, + }) 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), newDeviceOptions, 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 { @@ -290,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({ 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'. */ 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"