mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 16:49:41 +00:00
feat(telemprompter): remote control
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user