From b09226347df9aee2e772ff36288e88e5fc8794b7 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 18 Jan 2024 14:38:34 +0100 Subject: [PATCH] fix: rudimentary imtplementation of outputSettings and Controller message --- .../services/OutputSettingsService.ts | 32 ++-- .../src/data-stores/OutputSettingsStore.ts | 2 +- packages/apps/client/src/App.tsx | 10 ++ packages/apps/client/src/Output/Output.tsx | 83 +++++++-- packages/apps/client/src/TestController.tsx | 159 ++++++++++++------ packages/apps/client/src/main.tsx | 6 + packages/apps/client/src/stores/AppStore.ts | 5 +- .../client/src/stores/OutputSettingsStore.ts | 76 +++++++++ .../OutputSettingsService.ts | 23 +-- .../model/src/model/ControllerMessage.ts | 4 +- .../shared/model/src/model/OutputSettings.ts | 6 +- packages/shared/model/src/model/lib.ts | 10 ++ 12 files changed, 314 insertions(+), 102 deletions(-) create mode 100644 packages/apps/client/src/stores/OutputSettingsStore.ts diff --git a/packages/apps/backend/src/api-server/services/OutputSettingsService.ts b/packages/apps/backend/src/api-server/services/OutputSettingsService.ts index 224052b..a11bdae 100644 --- a/packages/apps/backend/src/api-server/services/OutputSettingsService.ts +++ b/packages/apps/backend/src/api-server/services/OutputSettingsService.ts @@ -31,9 +31,9 @@ export class OutputSettingsService extends EventEmitter imple return service } private static setupPublications(app: Application, service: OutputSettingsFeathersService) { - service.publish('created', (_data, _context) => { - return app.channel(PublishChannels.OutputSettings()) - }) + // service.publish('created', (_data, _context) => { + // return app.channel(PublishChannels.OutputSettings()) + // }) service.publish('updated', (_data, _context) => { return app.channel(PublishChannels.OutputSettings()) }) @@ -60,30 +60,28 @@ export class OutputSettingsService extends EventEmitter imple } } - public async find(_params?: Params & { paginate?: PaginationParams }): Promise { - return [this.store.outputSettings.outputSettings.get()] - } - public async get(id: Id, _params?: Params): Promise { + // public async find(_params?: Params & { paginate?: PaginationParams }): Promise { + // return [this.store.outputSettings.outputSettings.get()] + // } + public async get(id: null, _params?: Params): Promise { const data = this.store.outputSettings.outputSettings.get() if (!data) throw new NotFound(`OutputSettings "${id}" not found`) return data } - public async create(_data: Data, _params?: Params): Promise { - throw new NotImplemented(`TODO`) - } - public async update(id: NullId, data: Data, _params?: Params): Promise { - if (id === null) throw new BadRequest(`id must not be null`) - + // public async create(_data: Data, _params?: Params): Promise { + // throw new NotImplemented(`TODO`) + // } + public async update(_id: null, data: Data, _params?: Params): Promise { this.store.outputSettings.update(data) - return this.get('') + return this.get(null) } - public async subscribeToController(_: unknown, params: Params): Promise { + public async subscribe(_: unknown, params: Params): Promise { if (!params.connection) throw new Error('No connection!') this.app.channel(PublishChannels.OutputSettings()).join(params.connection) } } type Result = Definition.Result -type Id = Definition.Id -type NullId = Definition.NullId +// type Id = Definition.Id +// type NullId = Definition.NullId type Data = Definition.Data diff --git a/packages/apps/backend/src/data-stores/OutputSettingsStore.ts b/packages/apps/backend/src/data-stores/OutputSettingsStore.ts index 9096a0e..c35e935 100644 --- a/packages/apps/backend/src/data-stores/OutputSettingsStore.ts +++ b/packages/apps/backend/src/data-stores/OutputSettingsStore.ts @@ -4,7 +4,7 @@ import { OutputSettings } from '@sofie-prompter-editor/shared-model' export class OutputSettingsStore { public outputSettings = observable.box({ - _id: '', + // _id: '', // TODO: load these from persistent store upon startup? fontSize: 10, diff --git a/packages/apps/client/src/App.tsx b/packages/apps/client/src/App.tsx index f12291e..5628e63 100644 --- a/packages/apps/client/src/App.tsx +++ b/packages/apps/client/src/App.tsx @@ -31,6 +31,16 @@ function App(): React.JSX.Element { Editor playground + + + Output + + + + + Test Controller + + diff --git a/packages/apps/client/src/Output/Output.tsx b/packages/apps/client/src/Output/Output.tsx index d042eb9..858c971 100644 --- a/packages/apps/client/src/Output/Output.tsx +++ b/packages/apps/client/src/Output/Output.tsx @@ -1,18 +1,47 @@ +import React, { useEffect, useMemo, useReducer, useRef } from 'react' import { observer } from 'mobx-react-lite' -import React from 'react' +import { AppStore } from '../stores/AppStore' const Output = observer(function Output() { + const speed = useRef(0) + + // On startup + useEffect(() => { + AppStore.outputSettingsStore.initialize() // load and subscribe + + AppStore.connection.controller.on('message', (message) => { + console.log('received message', message) + + speed.current = message.speed + }) + AppStore.connection.controller.subscribeToMessages().catch(console.error) + + // don't do this, it's just for testing: + const interval = setInterval(() => { + window.scrollBy(0, speed.current) + }, 1000 / 60) + return () => { + AppStore.connection.controller.off('message') + clearInterval(interval) + } + }, []) + + const outputSettings = AppStore.outputSettingsStore.outputSettings + + const activeRundownPlaylistId = outputSettings?.activeRundownPlaylistId + + useEffect(() => { + if (activeRundownPlaylistId) { + AppStore.rundownStore.loadRundown(activeRundownPlaylistId) + } else { + // TODO: unload rundown? + } + }, [activeRundownPlaylistId]) + + const rundown = AppStore.rundownStore.openRundown + /* Implementation notes: - - - appStore.outputSettings.loadSettings() // load and subscribe - const outputSettings = appStore.outputSettings.outputSettings - - appStore.rundownStore.loadRundown(outputSettings.rundownId) - const rundown = appStore.rundownStore.openRundown - - // maybe sy appStore.controller.subscribe() @@ -32,13 +61,13 @@ const Output = observer(function Output() { // restore position on window reload const viewPort = await appStore.viewPort.get() handleControllerMessage(viewPort.lastKnownPosition) - + if (!isPrimary) { // Follow the primary viewport: appStore.viewPort.subscribe() appStore.viewPort.on('update', (viewPort) => { - + // viewPort.lastKnownPosition.timestamp // viewPort.lastKnownPosition.position // viewPort.lastKnownPosition.speed @@ -69,12 +98,38 @@ const Output = observer(function Output() { reportViewPortState(viewPort) }, [isPrimary, viewPort]) - + */ + const loremIpsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum` + const dummyContent: string[] = [] + for (let i = 0; i < 100; i++) { + dummyContent.push(loremIpsum) + } - return <>Prompter Output + return ( + <> +

Prompter output

+
Initialized: {AppStore.outputSettingsStore.initialized ? 'YES' : 'NO'}
+
{JSON.stringify(outputSettings)}
+ +
{rundown ? <>Rundown: {rundown.name} : <>No active rundown}
+ +
+ {dummyContent.map((line, i) => ( +
+ {line} +
+ ))} +
+ + ) }) Output.displayName = 'Output' diff --git a/packages/apps/client/src/TestController.tsx b/packages/apps/client/src/TestController.tsx index a7b3ab6..a162fd9 100644 --- a/packages/apps/client/src/TestController.tsx +++ b/packages/apps/client/src/TestController.tsx @@ -1,63 +1,116 @@ -import React from 'react' +import React, { useCallback, useEffect } from 'react' +import { observer } from 'mobx-react-lite' import { APIConnection } from './api/ApiConnection.ts' import { OutputSettings } from '@sofie-prompter-editor/shared-model' import { EditObject, useApiConnection } from './TestUtil.tsx' +import { AppStore } from './stores/AppStore.ts' +import { computed } from 'mobx' -export const TestController: React.FC<{ api: APIConnection }> = ({ api }) => { - const [ready, setReady] = React.useState(false) - const [connected, setConnected] = React.useState(false) - const [outputSettings, setOutputSettings] = React.useState(null) - - useApiConnection( - (connected) => { - if (!connected) { - setConnected(false) - return - } - setConnected(true) - - api.outputSettings - .subscribeToController() - .then(() => { - setReady(true) - }) - .catch(console.error) - - api.outputSettings.on('created', (data) => { - setOutputSettings(data) - }) - api.outputSettings.on('updated', (data) => { - setOutputSettings(data) - }) +const TestController: React.FC = observer(() => { + // const [ready, setReady] = React.useState(false) + // const [connected, setConnected] = React.useState(false) + // const [outputSettings, setOutputSettings] = React.useState(null) - // Also fetch initial settings: - api.outputSettings - .get('') - .then((data) => { - setOutputSettings(data) - }) - .catch(console.error) - }, - api, - [] - ) + // On startup + useEffect(() => { + AppStore.outputSettingsStore.initialize() // load and subscribe + }, []) + const outputSettings = computed(() => AppStore.outputSettingsStore.outputSettings).get() - return ( -
-

Controller

-
Connection status: {connected ? Connected : Not connected}
-
Subscription status: {ready ? Ready : Not ready}
+ console.log('outputSettings', outputSettings) + + // useApiConnection( + // (connected) => { + // if (!connected) { + // setConnected(false) + // return + // } + // setConnected(true) + + // api.outputSettings + // .subscribe() + // .then(() => { + // setReady(true) + // }) + // .catch(console.error) + + // // api.outputSettings.on('created', (data) => { + // // setOutputSettings(data) + // // }) + // api.outputSettings.on('updated', (data) => { + // setOutputSettings(data) + // }) + + // // Also fetch initial settings: + // api.outputSettings + // .get('') + // .then((data) => { + // setOutputSettings(data) + // }) + // .catch(console.error) + // }, + // api, + // [] + // ) - {ready && outputSettings && ( + // return ( + //
+ //

Controller

+ //
Connection status: {connected ? Connected : Not connected}
+ //
Subscription status: {ready ? Ready : Not ready}
+ + // {ready && outputSettings && ( + //
+ // { + // api.outputSettings.update('', newData).catch(console.error) + // }} + // /> + //
+ // )} + //
+ // ) + + const sendSpeed = useCallback((speed: number) => { + AppStore.connection.controller + .sendMessage({ + offset: null, + speed: speed, + }) + .catch(console.error) + }, []) + + return ( + <> +

Test controller

+
+

Settings

+
+ {outputSettings ? ( + { + // console.log('newdata', newData) + AppStore.connection.outputSettings.update(null, newData).catch(console.error) + }} + /> + ) : ( + loading... + )} +
+
+
+

Control the output

- { - api.outputSettings.update('', newData).catch(console.error) - }} - /> + + +
- )} -
+
+ ) -} +}) +TestController.displayName = 'TestController' + +export default TestController diff --git a/packages/apps/client/src/main.tsx b/packages/apps/client/src/main.tsx index 8ea690d..32a3c1e 100644 --- a/packages/apps/client/src/main.tsx +++ b/packages/apps/client/src/main.tsx @@ -13,6 +13,8 @@ import { RundownList } from './RundownList/RundownList.tsx' const RundownScript = React.lazy(() => import('./RundownScript/RundownScript.tsx')) // eslint-disable-next-line react-refresh/only-export-components const Output = React.lazy(() => import('./Output/Output.tsx')) +// eslint-disable-next-line react-refresh/only-export-components +const TestController = React.lazy(() => import('./TestController.tsx')) // TODO: temp const router = createBrowserRouter([ { @@ -23,6 +25,10 @@ const router = createBrowserRouter([ path: '/output', element: , }, + { + path: '/test-controller', + element: , + }, { path: '/', element: , diff --git a/packages/apps/client/src/stores/AppStore.ts b/packages/apps/client/src/stores/AppStore.ts index a6599fd..92dc72b 100644 --- a/packages/apps/client/src/stores/AppStore.ts +++ b/packages/apps/client/src/stores/AppStore.ts @@ -16,6 +16,7 @@ import { ExampleServiceDefinition, PartServiceDefinition, } from '@sofie-prompter-editor/shared-model' +import { OutputSettingsStore } from './OutputSettingsStore.ts' const USE_MOCK_CONNECTION = false @@ -23,6 +24,7 @@ class AppStoreClass { connected = false connection: APIConnection rundownStore: RundownStore + outputSettingsStore: OutputSettingsStore uiStore: UIStore constructor() { @@ -34,6 +36,7 @@ class AppStoreClass { const apiConnection = USE_MOCK_CONNECTION ? (new MockConnection() as any) : new APIConnectionImpl() this.connection = apiConnection this.rundownStore = new RundownStore(this, this.connection) + this.outputSettingsStore = new OutputSettingsStore(this, this.connection) this.uiStore = new UIStore() this.connection.on('disconnected', this.onDisconnected) @@ -64,7 +67,7 @@ export interface APIConnection extends EventEmitter { readonly segment: FeathersTypedService readonly part: FeathersTypedService - readonly controllerMessage: FeathersTypedService + readonly controller: FeathersTypedService readonly outputSettings: FeathersTypedService readonly viewPort: FeathersTypedService diff --git a/packages/apps/client/src/stores/OutputSettingsStore.ts b/packages/apps/client/src/stores/OutputSettingsStore.ts new file mode 100644 index 0000000..20b8b26 --- /dev/null +++ b/packages/apps/client/src/stores/OutputSettingsStore.ts @@ -0,0 +1,76 @@ +import { observable, action, flow, makeObservable, IReactionDisposer, reaction } from 'mobx' +import { OutputSettings } from '@sofie-prompter-editor/shared-model' +import { APIConnection, AppStore } from './AppStore' + +export class OutputSettingsStore { + // showingOnlyScripts = false + + // allRundowns = observable.map() + // openRundown: UIRundown | null = null + + outputSettings: OutputSettings | null = null // observable.ref({}) + + initialized = false + private initializing = false + + reactions: IReactionDisposer[] = [] + + constructor(public appStore: typeof AppStore, public connection: APIConnection) { + makeObservable(this, { + outputSettings: observable, + initialized: observable, + // openRundown: observable, + // showingOnlyScripts: observable, + }) + + // Note: we DON'T initialize here, + // instead, when anyone wants to use this store, they should call initialize() first. + } + + public initialize() { + if (!this.initializing && !this.initialized) { + this.initializing = true + + this.setupSubscription() + this.loadInitialData() + } + } + + private setupSubscription = action(() => { + this.reactions.push( + reaction( + () => this.appStore.connected, + async (connected) => { + if (!connected) return + + console.log('subscribing!') + await this.connection.outputSettings.subscribe() + }, + { + fireImmediately: true, + } + ) + ) + + this.connection.outputSettings.on('updated', this.onUpdatedOutputSettings) + // Note: updated and removed events are handled by the UIRundownEntry's themselves + }) + private loadInitialData = flow(function* (this: OutputSettingsStore) { + const outputSettings = yield this.connection.outputSettings.get(null) + this.onUpdatedOutputSettings(outputSettings) + + this.initialized = true + }) + + private onUpdatedOutputSettings = action('onUpdatedOutputSettings', (newData: OutputSettings) => { + this.outputSettings = newData + // for (const key in newData) { + // // @ts-expect-error hack? + // // this.outputSettings[key] = newData[key] + // } + }) + + destroy = () => { + this.reactions.forEach((dispose) => dispose()) + } +} diff --git a/packages/shared/model/src/client-server-api/OutputSettingsService.ts b/packages/shared/model/src/client-server-api/OutputSettingsService.ts index 7ca394a..5d83019 100644 --- a/packages/shared/model/src/client-server-api/OutputSettingsService.ts +++ b/packages/shared/model/src/client-server-api/OutputSettingsService.ts @@ -10,30 +10,31 @@ import { OutputSettings } from '../model/index.js' /** List of all method names */ export const ALL_METHODS = [ - 'find', + // 'find', 'get', // 'create', 'update', // 'patch', // 'remove', // - 'subscribeToController', + 'subscribe', ] as const /** The methods exposed by this class are exposed in the API */ -interface Methods extends Omit { - find(params?: Params & { paginate?: PaginationParams }): Promise - get(id: Id, params?: Params): Promise +interface Methods { + // extends Omit + // find(params?: Params & { paginate?: PaginationParams }): Promise + get(id: null, params?: Params): Promise // create(data: Data, params?: Params): Promise - update(id: NullId, data: Data, params?: Params): Promise + update(id: null, data: Data, params?: Params): Promise /** Subscribe to Controller data */ - subscribeToController(_?: unknown, params?: Params): Promise + subscribe(_?: unknown, params?: Params): Promise } export interface Service extends Methods, EventEmitter {} /** List of all event names */ export const ALL_EVENTS = [ - 'created', + // 'created', 'updated', // 'patched', // 'removed', @@ -42,7 +43,7 @@ export const ALL_EVENTS = [ /** Definitions of all events */ export interface Events { - created: [data: Data] + // created: [data: Data] updated: [data: Data] // patched: [data: PatchData] // removed: [data: RemovedData] @@ -54,8 +55,8 @@ export type Data = OutputSettings // export type PatchData = Diff // export type RemovedData = { _id: Id; playlistId: Data['playlistId']; rundownId: Data['rundownId'] } export type Result = Data -export type Id = '' -export type NullId = Id | null +// export type Id = '' +// export type NullId = Id | null // ============================================================================ // Type check: ensure that Methods and ALL_METHODS are in sync: diff --git a/packages/shared/model/src/model/ControllerMessage.ts b/packages/shared/model/src/model/ControllerMessage.ts index 82bd278..69b8744 100644 --- a/packages/shared/model/src/model/ControllerMessage.ts +++ b/packages/shared/model/src/model/ControllerMessage.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { ZodProtectedString } from './lib.js' +import { ZodProtectedStringOrNull } from './lib.js' import { SegmentId } from './Segment.js' import { PartId } from './Part.js' import { ProtectedString } from '../ProtectedString.js' @@ -15,7 +15,7 @@ export const ControllerMessageSchema = z.object({ * The prompter object/element which the current offset is calculated from. * `null` means "top of page" */ - target: ZodProtectedString().nullable(), + target: ZodProtectedStringOrNull(), /** The offset from the `target` (unit: viewportUnits) */ offset: z.number(), diff --git a/packages/shared/model/src/model/OutputSettings.ts b/packages/shared/model/src/model/OutputSettings.ts index 6ef8cc7..584ea42 100644 --- a/packages/shared/model/src/model/OutputSettings.ts +++ b/packages/shared/model/src/model/OutputSettings.ts @@ -1,12 +1,12 @@ import { z } from 'zod' -import { ZodProtectedString } from './lib.js' +import { ZodProtectedStringOrNull } from './lib.js' import { RundownPlaylistId } from './RundownPlaylist.js' /** Set by a user */ export type OutputSettings = z.infer export const OutputSettingsSchema = z.object({ - _id: z.literal(''), + // _id: z.literal(''), fontSize: z.number().min(0).max(100), @@ -22,5 +22,5 @@ export const OutputSettingsSchema = z.object({ marginVertical: z.number().min(0).max(100), /** If set, defines the rundown that is to be displayed in the Output */ - activeRundownPlaylistId: ZodProtectedString().nullable(), + activeRundownPlaylistId: ZodProtectedStringOrNull(), }) diff --git a/packages/shared/model/src/model/lib.ts b/packages/shared/model/src/model/lib.ts index 710ee08..c64a9fb 100644 --- a/packages/shared/model/src/model/lib.ts +++ b/packages/shared/model/src/model/lib.ts @@ -18,3 +18,13 @@ export function ZodProtectedString(): Omit< } { return z.string() as any } +export function ZodProtectedStringOrNull(): Omit< + z.ZodString, + '_type' | '_output' | '_input' +> & { + _type: T | null + _output: T | null + _input: T | null +} { + return z.string() as any +}