feat(telemprompter): remote control

This commit is contained in:
Carlos Valente
2026-09-09 20:33:11 +02:00
parent 150e0c83e9
commit e1190b1ec7
17 changed files with 445 additions and 111 deletions
+25 -2
View File
@@ -31,6 +31,7 @@ import { WebSocket, WebSocketServer } from 'ws';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { logger } from '../classes/Logger.js';
import { authenticateSocket } from '../middleware/authenticate.js';
import { teleprompterService } from '../services/teleprompter-service/TeleprompterService.js';
import { eventStore } from '../stores/EventStore.js';
import getRandomName from '../utils/getRandomName.js';
import type { IAdapter } from './IAdapter.js';
@@ -43,6 +44,7 @@ class SocketServer implements IAdapter {
private wss: WebSocketServer | null;
private readonly clients: Map<ClientId, Client>;
private readonly connections: Map<ClientId, WebSocket>;
private lastConnection: Date | null = null;
private shouldShowWelcome = true;
@@ -54,6 +56,7 @@ class SocketServer implements IAdapter {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.clients = new Map<ClientId, Client>();
this.connections = new Map<ClientId, WebSocket>();
this.wss = null;
}
@@ -83,6 +86,8 @@ class SocketServer implements IAdapter {
origin: '',
path: '',
});
this.connections.set(clientId, ws);
teleprompterService.register(clientId);
this.lastConnection = new Date();
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`);
@@ -98,6 +103,8 @@ class SocketServer implements IAdapter {
ws.on('close', () => {
this.clients.delete(clientId);
this.connections.delete(clientId);
teleprompterService.remove(clientId);
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`);
this.sendClientList();
});
@@ -114,7 +121,9 @@ class SocketServer implements IAdapter {
}
case MessageTag.ClientSet: {
const previousData = this.getOrCreateClient(clientId);
const updatedClient = { ...previousData, ...payload };
const { teleprompter, ...clientPatch } = payload;
if (teleprompter) teleprompterService.report(clientId, teleprompter);
const updatedClient = { ...previousData, ...clientPatch };
this.clients.set(clientId, updatedClient);
if (this.shouldShowWelcome && updatedClient.path?.toLowerCase().includes('editor')) {
this.shouldShowWelcome = false;
@@ -185,7 +194,11 @@ class SocketServer implements IAdapter {
}
private sendClientList(): void {
const payload = Object.fromEntries(this.clients.entries());
const payload = Object.fromEntries(
this.clients
.entries()
.map(([clientId, client]) => [clientId, { ...client, teleprompter: teleprompterService.getState(clientId) }]),
);
this.sendAsJson(MessageTag.ClientList, payload);
}
@@ -193,6 +206,16 @@ class SocketServer implements IAdapter {
return Array.from(this.clients.keys());
}
public sendToClient<T extends MessageTag>(
target: ClientId,
tag: T,
payload: Extract<WsPacketToClient, { tag: T }>['payload'],
) {
const client = this.connections.get(target);
if (!client || client.readyState !== WebSocket.OPEN) throw new Error(`Client "${target}" not found`);
client.send(JSON.stringify({ tag, payload }));
}
public renameClient(target: ClientId, name: string) {
const previousData = this.clients.get(target);
if (!previousData) {
@@ -1,5 +1,6 @@
import {
ApiActionTag,
MessageTag,
MessageState,
OffsetMode,
OntimeEvent,
@@ -19,6 +20,7 @@ import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.j
import * as messageService from '../services/message-service/message.service.js';
import { validateMessage, validateTimerMessage } from '../services/message-service/message.utils.js';
import { runtimeService } from '../services/runtime-service/runtime.service.js';
import { teleprompterService } from '../services/teleprompter-service/TeleprompterService.js';
import { eventStore } from '../stores/EventStore.js';
import * as assert from '../utils/assert.js';
import { coerceEnum } from '../utils/coerceType.js';
@@ -307,8 +309,50 @@ const actionHandlers: Record<ApiActionTag, ActionHandler> = {
runtimeService.setOffsetMode(mode);
return { payload: 'success' };
},
teleprompter: (payload) => {
assert.isObject(payload);
const request = payload as Record<string, unknown>;
if (
typeof request.target !== 'string' ||
typeof request.commandId !== 'string' ||
request.commandId.trim().length === 0 ||
!isTeleprompterCommand(request.command)
) {
throw new Error('Invalid teleprompter command');
}
teleprompterService.deliver(request.target, request.commandId, request.command, (target, commandId, command) => {
socket.sendToClient(target, MessageTag.TeleprompterCommand, { commandId, command });
});
return { payload: { commandId: request.commandId, status: 'delivered' } };
},
};
export function isTeleprompterCommand(command: unknown): command is import('ontime-types').TeleprompterCommand {
if (!command || typeof command !== 'object' || !('type' in command)) return false;
switch (command.type) {
case 'play':
case 'pause':
return true;
case 'setSpeed':
return (
'linesPerMinute' in command &&
typeof command.linesPerMinute === 'number' &&
Number.isInteger(command.linesPerMinute) &&
command.linesPerMinute >= 1 &&
command.linesPerMinute <= 40
);
case 'nudge':
return (
'lines' in command &&
typeof command.lines === 'number' &&
Number.isInteger(command.lines) &&
Math.abs(command.lines) <= 1000
);
default:
return false;
}
}
/**
* Returns a value of type number, converting if necessary
* Otherwise throws
@@ -0,0 +1,48 @@
import type { TeleprompterCommand, TeleprompterControlState } from 'ontime-types';
type ClientId = string;
type DeliverCommand = (target: ClientId, commandId: string, command: TeleprompterCommand) => void;
/**
* Ephemeral state for connected teleprompter views. The view reports observed
* transport state; this service never attempts to own document geometry or
* replay a relative nudge after a reconnect.
*/
export class TeleprompterService {
private readonly states = new Map<ClientId, TeleprompterControlState>();
private readonly commandReceipts = new Map<ClientId, Map<string, string>>();
register(clientId: ClientId) {
this.commandReceipts.set(clientId, new Map());
}
remove(clientId: ClientId) {
this.states.delete(clientId);
this.commandReceipts.delete(clientId);
}
report(clientId: ClientId, state: TeleprompterControlState) {
this.states.set(clientId, state);
}
getState(clientId: ClientId) {
return this.states.get(clientId);
}
deliver(clientId: ClientId, commandId: string, command: TeleprompterCommand, deliver: DeliverCommand) {
const receipts = this.commandReceipts.get(clientId);
if (!receipts) throw new Error(`Client "${clientId}" not found`);
const serialized = JSON.stringify(command);
const previous = receipts.get(commandId);
if (previous === serialized) return;
if (previous !== undefined)
throw new Error(`Teleprompter command id "${commandId}" was reused with different data`);
deliver(clientId, commandId, command);
receipts.set(commandId, serialized);
if (receipts.size > 100) receipts.delete(receipts.keys().next().value as string);
}
}
export const teleprompterService = new TeleprompterService();
@@ -0,0 +1,23 @@
import { TeleprompterService } from '../TeleprompterService.js';
describe('TeleprompterService', () => {
test('keeps reported state and makes retries idempotent', () => {
const service = new TeleprompterService();
const deliver = vi.fn();
service.register('view');
service.report('view', {
mode: 'controlled',
playback: 'paused',
speed: 14,
isFollowingLoadedEvent: true,
parkedAt: null,
});
service.deliver('view', 'command-1', { type: 'nudge', lines: 1 }, deliver);
service.deliver('view', 'command-1', { type: 'nudge', lines: 1 }, deliver);
expect(deliver).toHaveBeenCalledTimes(1);
expect(service.getState('view')?.mode).toBe('controlled');
expect(() => service.deliver('view', 'command-1', { type: 'nudge', lines: 2 }, deliver)).toThrow('reused');
});
});