From 967aa362950c9d8dea45c868c31b391941310138 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 27 Feb 2024 16:22:31 +0100 Subject: [PATCH] wip: smoothify --- .../api-server/services/ControllerService.ts | 25 +- .../src/data-stores/ControllerStore.ts | 7 +- .../backend/src/data-stores/ViewPortStore.ts | 3 +- packages/apps/backend/src/lib/config.ts | 7 +- .../src/components/RundownOutput/Line.tsx | 4 +- .../RundownOutput/RundownOutput.tsx | 4 +- .../src/components/RundownOutput/Segment.tsx | 4 +- .../client/src/hooks/useControllerMessages.ts | 259 +++++++++++++----- .../hooks/useKeepRundownOutputInPosition.ts | 31 ++- .../apps/client/src/lib/anchorElements.ts | 29 ++ .../apps/client/src/lib/findClosestElement.ts | 22 +- .../triggerActions/TriggerActionHandler.ts | 23 +- .../src/lib/triggerActions/triggerActions.ts | 17 +- .../triggerHandlers/TriggerHandler.ts | 21 +- .../triggerHandlers/TriggerHandlerMidi.ts | 2 +- .../TriggerHandlerSpaceMouse.ts | 5 +- .../triggerHandlers/TriggerHandlerXKeys.ts | 6 +- .../apps/client/src/lib/triggers/triggers.ts | 77 +++++- .../apps/client/src/stores/ControlStore.ts | 46 +++- .../apps/client/src/stores/RundownStore.ts | 5 +- .../apps/client/src/views/Output/Output.tsx | 60 ++-- .../src/views/RundownScript/PreviewPanel.tsx | 3 +- .../views/TestController/TestController.tsx | 1 - .../model/src/model/ControllerMessage.ts | 15 +- packages/shared/model/src/model/ViewPort.ts | 8 +- scripts/watch-client-deps-changed.js | 18 +- 26 files changed, 541 insertions(+), 161 deletions(-) diff --git a/packages/apps/backend/src/api-server/services/ControllerService.ts b/packages/apps/backend/src/api-server/services/ControllerService.ts index f564ad1..b61f42e 100644 --- a/packages/apps/backend/src/api-server/services/ControllerService.ts +++ b/packages/apps/backend/src/api-server/services/ControllerService.ts @@ -8,6 +8,7 @@ import { Store } from '../../data-stores/Store.js' import { Lambda, observe } from 'mobx' import { LoggerInstance } from '../../lib/logger.js' import { BadRequest, NotFound, NotImplemented } from '@feathersjs/errors' +import isEqual from 'lodash.isequal' export type ControllerFeathersService = CustomFeathersService @@ -36,9 +37,10 @@ export class ControllerService extends EventEmitter implement constructor(private log: LoggerInstance, private app: Application, private store: Store) { super() + // console.log('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') this.observers.push( observe(this.store.controller.message, (change) => { - this.log.debug('observed change', change) + console.log('observed change', change) if (change.type === 'add') { this.emit('message', change.newValue) @@ -54,12 +56,31 @@ export class ControllerService extends EventEmitter implement obs() } } - + private _hackyDebouncer: { + timestamp: number + message: Data | null + } = { + timestamp: 0, + message: null, + } public async sendMessage(message: Data, _params?: Params): Promise { + console.log('sendMessage', message) + const timeSinceLast = Date.now() - this._hackyDebouncer.timestamp + + if (timeSinceLast < 100 && isEqual(this._hackyDebouncer.message, message)) { + // The same message was sent twice in rapid succession, ignore + return + } + this._hackyDebouncer = { + timestamp: Date.now(), + message, + } + this.store.controller.updateMessage(message) } public async subscribeToMessages(_: unknown, params: Params): Promise { + console.log('subscribeToMessages') if (!params.connection) throw new Error('No connection!') this.app.channel(PublishChannels.ControllerMessages()).join(params.connection) } diff --git a/packages/apps/backend/src/data-stores/ControllerStore.ts b/packages/apps/backend/src/data-stores/ControllerStore.ts index 9b15ecb..da96b9a 100644 --- a/packages/apps/backend/src/data-stores/ControllerStore.ts +++ b/packages/apps/backend/src/data-stores/ControllerStore.ts @@ -6,7 +6,6 @@ export class ControllerStore { public message = observable( { message: literal({ - offset: null, speed: 0, }), }, @@ -20,6 +19,12 @@ export class ControllerStore { }) } updateMessage(message: ControllerMessage) { + // Persist speed if undefined, + // to allow for smooth jump while keeping the speed. + // if (message.speed === undefined) { + // message.speed = this.message.message.speed + // } + this.message.message = message } } diff --git a/packages/apps/backend/src/data-stores/ViewPortStore.ts b/packages/apps/backend/src/data-stores/ViewPortStore.ts index a376012..df83081 100644 --- a/packages/apps/backend/src/data-stores/ViewPortStore.ts +++ b/packages/apps/backend/src/data-stores/ViewPortStore.ts @@ -10,12 +10,13 @@ export class ViewPortStore { _id: '', lastKnownState: { - controllerMessage: { + state: { offset: { offset: 0, target: null, }, speed: 0, + animatedOffset: 0, }, timestamp: getCurrentTime(), }, diff --git a/packages/apps/backend/src/lib/config.ts b/packages/apps/backend/src/lib/config.ts index feca010..1ba17e9 100644 --- a/packages/apps/backend/src/lib/config.ts +++ b/packages/apps/backend/src/lib/config.ts @@ -62,12 +62,13 @@ export async function getConfigOptions(): Promise { return { logLevel: argv.logLevel, - unsafeSSL: argv.unsafeSSL, + unsafeSSL: true, //argv.unsafeSSL, certificates: ((argv.certificates || process.env.CERTIFICATES || '').split(';') || []).filter(Boolean), noCore: argv.noCore, - coreHost: argv.coreHost, - corePort: argv.corePort, + // nocommit hack + coreHost: 'malxsofietest01', // argv.coreHost, + corePort: 443, // argv.corePort, deviceId: argv.deviceId, deviceToken: argv.deviceToken, } diff --git a/packages/apps/client/src/components/RundownOutput/Line.tsx b/packages/apps/client/src/components/RundownOutput/Line.tsx index f3b85a7..5537ce4 100644 --- a/packages/apps/client/src/components/RundownOutput/Line.tsx +++ b/packages/apps/client/src/components/RundownOutput/Line.tsx @@ -10,7 +10,9 @@ export const Line = observer(function Line({ line }: { line: UILine }): React.Re return ( <> -

{line.slug}

+

+ {line.slug} +

{!script ?

 

: isMdIsh ? : } ) diff --git a/packages/apps/client/src/components/RundownOutput/RundownOutput.tsx b/packages/apps/client/src/components/RundownOutput/RundownOutput.tsx index a740187..90f8d91 100644 --- a/packages/apps/client/src/components/RundownOutput/RundownOutput.tsx +++ b/packages/apps/client/src/components/RundownOutput/RundownOutput.tsx @@ -7,7 +7,9 @@ import { Segment } from './Segment' export const RundownOutput = observer(function RundownOutput({ rundown }: { rundown: UIRundown }): React.ReactNode { return (
-

{rundown.name}

+

+ {rundown.name} +

{rundown.segmentsInOrder.map((segment) => ( ))} diff --git a/packages/apps/client/src/components/RundownOutput/Segment.tsx b/packages/apps/client/src/components/RundownOutput/Segment.tsx index 9d202c7..95911cb 100644 --- a/packages/apps/client/src/components/RundownOutput/Segment.tsx +++ b/packages/apps/client/src/components/RundownOutput/Segment.tsx @@ -5,7 +5,9 @@ import { Line } from './Line' export const Segment = observer(function Segment({ segment }: { segment: UISegment }): React.ReactElement { return ( <> -

{segment.name}

+

+ {segment.name} +

{segment.linesInOrder.map((line) => ( ))} diff --git a/packages/apps/client/src/hooks/useControllerMessages.ts b/packages/apps/client/src/hooks/useControllerMessages.ts index e1f3ea6..ed050a4 100644 --- a/packages/apps/client/src/hooks/useControllerMessages.ts +++ b/packages/apps/client/src/hooks/useControllerMessages.ts @@ -1,12 +1,21 @@ -import { ControllerMessage, ViewPortLastKnownState, ViewPortState } from '@sofie-prompter-editor/shared-model' +import { offset } from '@popperjs/core' +import { + ControllerMessage, + ViewPortLastKnownState, + ViewPortState, + protectString, +} from '@sofie-prompter-editor/shared-model' import { toJS } from 'mobx' import { useCallback, useEffect, useRef } from 'react' -import { getAnchorElementById } from 'src/lib/anchorElements' +import { getAllAnchorElementsByType, getAnchorElementById, getAnchorAbovePositionIndex } from 'src/lib/anchorElements' import { getCurrentTime } from 'src/lib/getCurrentTime' import { RootAppStore } from 'src/stores/RootAppStore' export const SPEED_CONSTANT = 300 // this is an arbitrary number to scale a reasonable speed number * font size to pixels/frame +export const pointOfFocus = 0 // TODO +const animateOffsetFactor = 0.1 + type SetBaseViewPortState = (state: ViewPortLastKnownState) => void /** @@ -31,12 +40,14 @@ export function useControllerMessages( fontSizePx: number, opts?: { enableControl?: boolean - onStateChange?: (message: ViewPortState) => void + onStateChange?: (timestamp: number, position: number, speed: number, animatedOffset: number) => void } ): { lastKnownState: React.RefObject position: React.MutableRefObject + scrolledPosition: React.MutableRefObject speed: React.MutableRefObject + animatedOffset: React.MutableRefObject setBaseViewPortState: SetBaseViewPortState } { const enableControl = opts?.enableControl ?? true @@ -44,22 +55,64 @@ export function useControllerMessages( const speed = useRef(0) const position = useRef(0) - const lastRequest = useRef(null) + const scrolledPosition = useRef(0) + const lastResultingSpeed = useRef(0) + + const animatedOffset = useRef(0) + + const lastRequestAnimationFrame = useRef(null) const lastFrameTime = useRef(Number(document.timeline.currentTime)) const lastKnownState = useRef(null) + const onFrame = useCallback( + (now: number) => { + // Ensure that no more than one onFrame is queued + // if (lastRequestAnimationFrame.current) window.cancelAnimationFrame(lastRequestAnimationFrame.current) + lastRequestAnimationFrame.current = null + + // console.log('onFrame') + const el = ref.current + if (!el) return + + const frameTime = now - lastFrameTime.current + + const controlSpeed = ((speed.current * fontSizePx) / SPEED_CONSTANT) * frameTime + const positioningSpeed0 = animatedOffset.current * animateOffsetFactor + const positioningSpeed = ((positioningSpeed0 * fontSizePx) / SPEED_CONSTANT) * frameTime + const resultingSpeed = controlSpeed + positioningSpeed + + lastResultingSpeed.current = resultingSpeed + position.current = Math.min(Math.max(0, position.current + resultingSpeed), el.scrollHeight - el.offsetHeight) + + animatedOffset.current = animatedOffset.current - positioningSpeed + + if (Math.abs(animatedOffset.current) < 0.1) { + animatedOffset.current = 0 + } + + if (scrolledPosition.current !== position.current) { + el.scrollTo({ + top: position.current, + behavior: 'instant', + }) + scrolledPosition.current = position.current + } + + lastFrameTime.current = now + lastRequestAnimationFrame.current = window.requestAnimationFrame(onFrame) + }, + [fontSizePx, ref] + ) + const applyControllerMessage = useCallback( (message: ControllerMessage, timestamp = getCurrentTime()) => { if (!ref.current) return - speed.current = message.speed ?? speed.current - const container = ref.current - let targetTop = position.current + const container = ref.current + // console.log('message', toJS(message)) if (message.offset) { - targetTop = 0 - if (message.offset.target !== null) { const targetEl = getAnchorElementById(container, message.offset.target) if (!targetEl) { @@ -68,101 +121,173 @@ export function useControllerMessages( } const targetRect = targetEl.getBoundingClientRect() - targetTop = position.current + targetRect.top - message.offset.offset * fontSizePx + position.current = scrolledPosition.current + targetRect.top - message.offset.offset * fontSizePx } else { - targetTop = message.offset.offset * fontSizePx + position.current = message.offset.offset * fontSizePx + } + animatedOffset.current = 0 + } + if (message.speed !== undefined) { + speed.current = message.speed + } + if (message.jumpBy !== undefined) { + animatedOffset.current += message.jumpBy // TODO: * fontSizePx + // targetTop += message.jumpBy * fontSizePx + } + if (message.jumpTarget) { + const allAnchors = getAllAnchorElementsByType(container, message.jumpTarget.type) + console.log('allAnchors', allAnchors) + const evenutalPosition = pointOfFocus + 10 + animatedOffset.current + const aboveAnchorIndex = getAnchorAbovePositionIndex(evenutalPosition, Array.from(allAnchors)) + + const aboveAnchorEl = allAnchors[aboveAnchorIndex] + let aboveAnchorPosition = 0 + if (aboveAnchorEl) { + const aboveAnchorElRect = aboveAnchorEl.getBoundingClientRect() + aboveAnchorPosition = aboveAnchorElRect.top + } + + let jumpIndex = message.jumpTarget.index + if (jumpIndex === -1 && aboveAnchorPosition < -50) { + jumpIndex = 0 } + + const jumpToAnchorIndex = Math.min(allAnchors.length - 1, Math.max(0, aboveAnchorIndex + jumpIndex)) + const anchorEl = allAnchors[jumpToAnchorIndex] + const targetRect = anchorEl.getBoundingClientRect() + console.log('elements', aboveAnchorEl, anchorEl) + console.log('jump', aboveAnchorPosition, jumpIndex, targetRect.top, position.current) + // position.current = scrolledPosition.current + targetRect.top + animatedOffset.current = scrolledPosition.current + targetRect.top - position.current } - const timeDifference = getCurrentTime() - timestamp - targetTop = targetTop + ((speed.current * fontSizePx) / SPEED_CONSTANT) * timeDifference + // const timeDifference = getCurrentTime() - timestamp + // targetTop = targetTop + ((speed.current * fontSizePx) / SPEED_CONSTANT) * timeDifference - position.current = targetTop + // position.current = targetTop - container.scrollTo({ - top: targetTop, - behavior: 'instant', - }) + // triggerOnFrame() + + // container.scrollTo({ + // top: targetTop, + // behavior: 'instant', + // }) }, [ref, fontSizePx] ) const setBaseViewPortState = useCallback( (state: ViewPortLastKnownState) => { - console.log(`Received a new lastKnownState`, toJS(state)) - - applyControllerMessage(state.controllerMessage, state.timestamp) - }, - [applyControllerMessage] - ) + console.log(`Received a new lastKnownState`, toJS(state.state.offset), toJS(state.state.animatedOffset)) + if (!ref.current) return - useEffect(() => { - if (!enableControl) return + const container = ref.current - const onMessage = (message: ControllerMessage) => { - console.log('received message', message) + let newPosition = scrolledPosition.current - applyControllerMessage(message) + if (state.state.offset.target !== null) { + const targetEl = getAnchorElementById(container, state.state.offset.target) + if (!targetEl) { + console.error(`Could not find target "${state.state.offset.target}"`) + return + } - const combinedMessage = { - offset: message.offset ?? - lastKnownState.current?.controllerMessage.offset ?? { - target: null, - offset: 0, - }, - speed: speed.current, + const targetRect = targetEl.getBoundingClientRect() + console.log('AAA', scrolledPosition.current, targetRect.top, state.state.offset.offset * fontSizePx, fontSizePx) + newPosition = scrolledPosition.current + targetRect.top - state.state.offset.offset * fontSizePx + // position.current = scrolledPosition.current + targetRect.top + console.log('position.current', position.current) + } else { + newPosition = state.state.offset.offset * fontSizePx } - - lastKnownState.current = { - timestamp: getCurrentTime(), - controllerMessage: combinedMessage, + speed.current = state.state.speed + animatedOffset.current = state.state.animatedOffset * fontSizePx + + // Determine if we're going to jump or animate: + if (Math.abs(newPosition - scrolledPosition.current) < fontSizePx * 0.5 && speed.current === 0) { + animatedOffset.current += newPosition - scrolledPosition.current + } else { + position.current = newPosition } - onStateChange?.(combinedMessage) - } - - RootAppStore.connection.controller.on('message', onMessage) - RootAppStore.connection.controller.subscribeToMessages().catch(console.error) + const timeDifference = getCurrentTime() - state.timestamp + // Advance position from speed: + position.current += ((speed.current * fontSizePx) / SPEED_CONSTANT) * timeDifference - const onFrame = (now: number) => { - const el = ref.current - if (!el) return + // Advance position from animatedOffset: + if (timeDifference > 1000) { + // Just set it to the end position if enough time has passed: + position.current += animatedOffset.current + animatedOffset.current = 0 + } + // onFrame(Date.now()) - const frameTime = now - lastFrameTime.current - const scrollBy = ((speed.current * fontSizePx) / SPEED_CONSTANT) * frameTime + // applyControllerMessage(state.controllerMessage, state.timestamp) + }, + [fontSizePx, ref] + ) - if (scrollBy === 0) { - lastFrameTime.current = now - lastRequest.current = window.requestAnimationFrame(onFrame) - return - } + const reportState = useCallback(() => { + // const currentOffset + // const + // const combinedMessage = { + // offset: { + // target: anchorEl.someId, + // offset: position.current - targetRect.top + // }, + + // // message?.offset ?? + // // lastKnownState.current?.controllerMessage.offset ?? { + // // target: null, + // // offset: 0, + // // }, + // speed: speed.current, + // } + + // lastKnownState.current = { + // timestamp: getCurrentTime(), + // controllerMessage: combinedMessage, + // } + + onStateChange?.(getCurrentTime(), position.current, speed.current, animatedOffset.current) + }, [onStateChange]) - position.current = Math.min(Math.max(0, position.current + scrollBy), el.scrollHeight - el.offsetHeight) + useEffect(() => { + if (!enableControl) return - // console.log(position.current) + const onMessage = (message: ControllerMessage) => { + console.log('received message', message) - el.scrollTo({ - top: position.current, - behavior: 'instant', - }) + applyControllerMessage(message) - lastFrameTime.current = now - lastRequest.current = window.requestAnimationFrame(onFrame) + // wait for OnFrame to trigger first: + window.requestAnimationFrame(reportState) } - lastRequest.current = window.requestAnimationFrame(onFrame) + RootAppStore.control.on('message', onMessage) + RootAppStore.control.initialize() return () => { - RootAppStore.connection.controller.off('message', onMessage) + RootAppStore.control.off('message', onMessage) + } + }, [enableControl, applyControllerMessage, reportState]) - if (lastRequest.current !== null) window.cancelAnimationFrame(lastRequest.current) + useEffect(() => { + console.log('enableControl', enableControl) + if (!enableControl) return + + onFrame(Date.now()) + return () => { + if (lastRequestAnimationFrame.current !== null) window.cancelAnimationFrame(lastRequestAnimationFrame.current) } - }, [enableControl, ref, fontSizePx, onStateChange, applyControllerMessage]) + }, [enableControl, ref, onFrame]) return { lastKnownState, setBaseViewPortState, + scrolledPosition, position, speed, + animatedOffset, } } diff --git a/packages/apps/client/src/hooks/useKeepRundownOutputInPosition.ts b/packages/apps/client/src/hooks/useKeepRundownOutputInPosition.ts index a39a3ec..3be1533 100644 --- a/packages/apps/client/src/hooks/useKeepRundownOutputInPosition.ts +++ b/packages/apps/client/src/hooks/useKeepRundownOutputInPosition.ts @@ -5,7 +5,7 @@ import { UISegment, UISegmentId } from 'src/model/UISegment' import { UILine, UILineId } from 'src/model/UILine' import { SPEED_CONSTANT } from './useControllerMessages' import { findClosestElement } from 'src/lib/findClosestElement' -import { getAllAnchorElements } from 'src/lib/anchorElements' +import { getAllAnchorElements, getAllAnchorElementsByType } from 'src/lib/anchorElements' export type UpdateProps = { element: HTMLElement @@ -30,6 +30,7 @@ export function useKeepRundownOutputInPosition( rundown: UIRundown | null, fontSizePx: number, speedRef: React.RefObject, + scrollPositionRef: React.MutableRefObject, positionRef: React.MutableRefObject, focusPosition: number, opts?: { @@ -43,15 +44,19 @@ export function useKeepRundownOutputInPosition( useEffect( () => observeUIRundown(rundown, () => { + console.log('observeUIRundown') if (frameRequest.current) return + console.log('a') if (!ref.current) return + console.log('b') const speed = speedRef.current if (speed === null) return - const els = getAllAnchorElements() - const [anchorOffset, anchorEl] = findClosestElement(els, focusPosition, positionRef.current) - + const els = getAllAnchorElementsByType(ref.current, null) + const result = findClosestElement(els, focusPosition, scrollPositionRef.current) + if (!result) return + console.log('result', result) // console.log('Chosen anchor is: ', anchorOffset, anchorEl, 'position is: ', positionRef.current) const beforeTime = Number(document.timeline.currentTime) @@ -59,26 +64,30 @@ export function useKeepRundownOutputInPosition( const onNextFrame = (now: number) => { frameRequest.current = null - const frameTime = now - beforeTime - const scrollBy = ((speed * fontSizePx) / SPEED_CONSTANT) * frameTime + // const frameTime = now - beforeTime + // const scrollBy = ((speed * fontSizePx) / SPEED_CONSTANT) * frameTime + const scrollBy = 0 - const newBox = anchorEl.getBoundingClientRect() - const diff = newBox.y - anchorOffset + const newBox = result.anchorEl.getBoundingClientRect() + const diff = newBox.y - result.offset + console.log('diff', diff) if (diff === 0) return if (!ref.current) return const boxEl = ref.current - positionRef.current = positionRef.current + diff + scrollBy + positionRef.current += diff + scrollBy + boxEl.scrollTo({ top: positionRef.current, behavior: 'instant', }) + scrollPositionRef.current = positionRef.current // console.log('Position now is: ', positionRef.current, diff) onUpdate?.({ - element: anchorEl, - offset: anchorOffset, + element: result.anchorEl, + offset: result.offset, }) } diff --git a/packages/apps/client/src/lib/anchorElements.ts b/packages/apps/client/src/lib/anchorElements.ts index 8cc83bc..99a1631 100644 --- a/packages/apps/client/src/lib/anchorElements.ts +++ b/packages/apps/client/src/lib/anchorElements.ts @@ -12,3 +12,32 @@ export function getAnchorElementById( ): HTMLElement | null { return container.querySelector(`[data-obj-id="${id}"]`) } +export function getAllAnchorElementsByType( + container: HTMLElement | Document, + type: 'rundown' | 'segment' | 'line' | null +): NodeListOf { + if (type === null) return container.querySelectorAll(`[data-anchor]`) + else return container.querySelectorAll(`[data-anchor="${type}"]`) +} + +/** + * Returns the index of the closest anchor above the topPosition. -1 if no anchor is above the topPosition. + */ +export function getAnchorAbovePositionIndex(topPosition: number, anchors: HTMLElement[]): number { + // Binary search + let leftIndex = 0 + let rightIndex = anchors.length - 1 + while (leftIndex <= rightIndex) { + const midIndex = Math.floor((leftIndex + rightIndex) / 2) + const midPosition = anchors[midIndex].getBoundingClientRect().top + + if (midPosition > topPosition) { + rightIndex = midIndex - 1 + } else if (midPosition < topPosition) { + leftIndex = midIndex + 1 + } else { + return midIndex + } + } + return rightIndex +} diff --git a/packages/apps/client/src/lib/findClosestElement.ts b/packages/apps/client/src/lib/findClosestElement.ts index bd49f62..5c6634f 100644 --- a/packages/apps/client/src/lib/findClosestElement.ts +++ b/packages/apps/client/src/lib/findClosestElement.ts @@ -2,7 +2,8 @@ export function findClosestElement( elements: NodeListOf, yTarget: number, scrollTop: number -): [number, HTMLElement] { +): { offset: number; anchorEl: HTMLElement } | null { + // TODO: change binary search return binarySearch(elements, 0, elements.length - 1, yTarget, scrollTop) } @@ -12,17 +13,18 @@ function binarySearch( b: number, yTarget: number, scrollTop: number -): [number, HTMLElement] { +): { offset: number; anchorEl: HTMLElement } | null { const elementsLength = elements.length + if (elementsLength === 0) return null const elA = elements.item(a) const elB = elements.item(b) if (!elA && elB) { const boxB = elB.getBoundingClientRect() - return [boxB.y, elB] + return { offset: boxB.y, anchorEl: elB } } if ((!elB && elA) || elA === elB) { const boxA = elA.getBoundingClientRect() - return [boxA.y, elA] + return { offset: boxA.y, anchorEl: elA } } const boxA = elA.getBoundingClientRect() const boxB = elB.getBoundingClientRect() @@ -32,20 +34,20 @@ function binarySearch( // elA is the top-most valid element and it's still below yTarget, choose elA if (a === 0 && boxA.y > scrollTop + yTarget) { - return [boxA.y, elA] + return { offset: boxA.y, anchorEl: elA } } // elB is the bottom-most valid element and it's still above yTarget, choose elB if (b === elementsLength - 1 && boxB.y < scrollTop + yTarget) { - return [boxB.y, elB] + return { offset: boxB.y, anchorEl: elB } } const range = Math.abs(a - b) - if (range === 0) return [boxA.y, elA] - if (range === 1 && distanceA < distanceB) return [boxA.y, elA] - if (range === 1 && distanceB < distanceA) return [boxB.y, elB] + if (range === 0) return { offset: boxA.y, anchorEl: elA } + if (range === 1 && distanceA < distanceB) return { offset: boxA.y, anchorEl: elA } + if (range === 1 && distanceB < distanceA) return { offset: boxB.y, anchorEl: elB } if (range > 1 && distanceA < distanceB) return binarySearch(elements, a, Math.min(elementsLength - 1, a + Math.ceil((b - a) / 2)), yTarget, scrollTop) if (range > 1 && distanceB < distanceA) return binarySearch(elements, Math.min(elementsLength - 1, a + Math.floor((b - a) / 2)), b, yTarget, scrollTop) - return [boxA.y, elA] + return { offset: boxA.y, anchorEl: elA } } diff --git a/packages/apps/client/src/lib/triggerActions/TriggerActionHandler.ts b/packages/apps/client/src/lib/triggerActions/TriggerActionHandler.ts index d319d59..c289b95 100644 --- a/packages/apps/client/src/lib/triggerActions/TriggerActionHandler.ts +++ b/packages/apps/client/src/lib/triggerActions/TriggerActionHandler.ts @@ -28,10 +28,21 @@ export class TriggerActionHandler { this.sendPrompterSpeed() } else if (action.type === 'prompterJump') { // TODO - // this.store.connection.controller.sendMessage({ - // speed: 0, - // offset: action.payload.offset, - // }) + this.store.connection.controller.sendMessage({ + speed: 0, + offset: action.payload.offset, + }) + } else if (action.type === 'prompterJumpBy') { + this.store.connection.controller.sendMessage({ + jumpBy: action.payload.offset, + }) + } else if (action.type === 'jumpByEntity') { + this.store.connection.controller.sendMessage({ + jumpTarget: { + type: action.payload.type, + index: action.payload.deltaIndex, + }, + }) } else if (action.type === 'movePrompterToHere') { // Not handled here } else if (action.type === 'prompterAddSavedSpeed') { @@ -56,12 +67,12 @@ export class TriggerActionHandler { // Modify the value according to an attack curve: const speed = this.prompterSpeedUseSaved === false - ? this.attackCurve(this.prompterSpeed, 5, 0.7) + ? // ? this.attackCurve(this.prompterSpeed, 2, 0.7) + this.prompterSpeed : savedSpeed * this.prompterSpeedUseSaved this.store.connection.controller.sendMessage({ speed: speed, - offset: null, }) } destroy(): void {} diff --git a/packages/apps/client/src/lib/triggerActions/triggerActions.ts b/packages/apps/client/src/lib/triggerActions/triggerActions.ts index 7f4c2cf..8c02f25 100644 --- a/packages/apps/client/src/lib/triggerActions/triggerActions.ts +++ b/packages/apps/client/src/lib/triggerActions/triggerActions.ts @@ -19,13 +19,28 @@ export type AnyTriggerAction = deltaSpeed: number } > - // "Jump the prompter by the offset" + // "Jump the prompter to the offset" | TriggerAction< 'prompterJump', { offset: ControllerMessage['offset'] } > + // "Jump the prompter by the offset" + | TriggerAction< + 'prompterJumpBy', + { + offset: number + } + > + // "Jump the prompter by the offset" + | TriggerAction< + 'jumpByEntity', + { + type: 'rundown' | 'segment' | 'line' | null + deltaIndex: number + } + > // "Make the prompter jump to the currently selected Part" | TriggerAction< 'movePrompterToHere', diff --git a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandler.ts b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandler.ts index ac72715..0f6aa97 100644 --- a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandler.ts +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandler.ts @@ -59,7 +59,13 @@ export abstract class TriggerHandler extends type: trigger.action.type, payload: { deltaSpeed: normalValue }, } + } else if (trigger.action.type === 'prompterJumpBy') { + return { + type: trigger.action.type, + payload: { offset: normalValue }, + } } else if ( + trigger.action.type === 'jumpByEntity' || trigger.action.type === 'prompterJump' || trigger.action.type === 'prompterUseSavedSpeed' || trigger.action.type === 'movePrompterToHere' @@ -75,15 +81,24 @@ export abstract class TriggerHandler extends filterTrigger: (trigger: Trigger) => boolean, _xyz: { x: number; y: number; z?: number }, /** calculated from xyz */ - resultingValue: number + resultingValue: number, + options: { + scaleMaxValue?: number + zeroValue?: number + invert?: boolean + } ): AnyTriggerAction | undefined { const trigger: Trigger | undefined = this.triggerXYZ.find(filterTrigger) if (!trigger) return undefined if ('payload' in trigger.action) return trigger.action // Already defined, just pass through + const scaleMaxValue = options?.scaleMaxValue ?? 1 + const zeroValue = options?.zeroValue ?? 0 + const invert = options?.invert ?? 0 + const scale = trigger.modifier?.scale ?? 1 - const normalValue = resultingValue * scale + const normalValue = ((resultingValue - zeroValue) / scaleMaxValue) * (invert ? -1 : 1) * scale if (trigger.action.type === 'prompterSetSpeed') { return { @@ -96,6 +111,8 @@ export abstract class TriggerHandler extends payload: { deltaSpeed: normalValue }, } } else if ( + trigger.action.type === 'jumpByEntity' || + trigger.action.type === 'prompterJumpBy' || trigger.action.type === 'prompterJump' || trigger.action.type === 'prompterUseSavedSpeed' || trigger.action.type === 'movePrompterToHere' diff --git a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerMidi.ts b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerMidi.ts index 986fc9d..d4ed2da 100644 --- a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerMidi.ts +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerMidi.ts @@ -188,7 +188,7 @@ export class TriggerHandlerMidi extends TriggerHandler { zeroValue, } ) - console.log(this.triggerAnalog) + // console.log(this.triggerAnalog) if (action) this.emit('action', action) else console.log('MIDI', 'analog', midiInfo.fullName, channel, index) } diff --git a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse.ts b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse.ts index c3d028c..7a1a4f1 100644 --- a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse.ts +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse.ts @@ -154,8 +154,11 @@ export class TriggerHandlerSpaceMouse extends TriggerHandler (t.productId === null || t.productId === panel.info.productId) && t.eventType === eventType && t.index === 0, xyz, - xyz.x + xyz.y + xyz.z + xyz.x + xyz.y + xyz.z, { + scaleMaxValue: 127 + } ) + if (action) this.emit('action', action) else console.log('SpaceMouse', eventType, panel.info.productId, xyz) } diff --git a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerXKeys.ts b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerXKeys.ts index 0934ccb..28be832 100644 --- a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerXKeys.ts +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerXKeys.ts @@ -190,6 +190,7 @@ export class TriggerHandlerXKeys extends TriggerHandler { zeroValue, } ) + console.log('actiuoion', action) if (action) this.emit('action', action) else console.log('Xkeys', eventType, xkeys.info.productId, xkeys.info.unitId, index, value) @@ -208,7 +209,10 @@ export class TriggerHandlerXKeys extends TriggerHandler { t.eventType === eventType && t.index === index, xyz, - xyz.y + xyz.y, + { + scaleMaxValue: 127 + } ) if (action) this.emit('action', action) diff --git a/packages/apps/client/src/lib/triggers/triggers.ts b/packages/apps/client/src/lib/triggers/triggers.ts index 5c7056f..a199bdf 100644 --- a/packages/apps/client/src/lib/triggers/triggers.ts +++ b/packages/apps/client/src/lib/triggers/triggers.ts @@ -64,7 +64,48 @@ export const hardCodedTriggers: TriggerConfig[] = [ action: { type: 'prompterSetSpeed', payload: { - speed: 3, + speed: 0.5, + }, + }, + }, + { + type: TriggerConfigType.XKEYS, + productId: null, + unitId: null, + eventType: 'up', + index: 2, + action: { + type: 'prompterSetSpeed', + payload: { + speed: 0, + }, + }, + }, + { + type: TriggerConfigType.XKEYS, + productId: null, + unitId: null, + eventType: 'down', + index: 4, + action: { + type: 'jumpByEntity', + payload: { + type: null, + deltaIndex: -1, + }, + }, + }, + { + type: TriggerConfigType.XKEYS, + productId: null, + unitId: null, + eventType: 'down', + index: 5, + action: { + type: 'jumpByEntity', + payload: { + type: null, + deltaIndex: 1, }, }, }, @@ -87,6 +128,9 @@ export const hardCodedTriggers: TriggerConfig[] = [ action: { type: 'prompterAddSpeed', }, + modifier: { + scale: 3.5, + }, }, { type: TriggerConfigType.XKEYS, @@ -118,7 +162,23 @@ export const hardCodedTriggers: TriggerConfig[] = [ eventType: 'rotate', index: 0, action: { - type: 'prompterSetSpeed', + type: 'prompterAddSpeed', + }, + modifier: { + scale: 0.1, + }, + }, + { + type: TriggerConfigType.STREAMDECK, + modelId: null, + serialNumber: null, + eventType: 'rotate', + index: 1, + action: { + type: 'prompterJumpBy', + }, + modifier: { + scale: 100, }, }, { @@ -129,6 +189,9 @@ export const hardCodedTriggers: TriggerConfig[] = [ action: { type: 'prompterSetSpeed', }, + modifier: { + scale: 0.25, + }, }, { type: TriggerConfigType.JOYCON, @@ -137,9 +200,9 @@ export const hardCodedTriggers: TriggerConfig[] = [ action: { type: 'prompterSetSpeed', }, - // modifier: { - // scale: 1, - // }, + modifier: { + scale: -1, + }, }, { type: TriggerConfigType.JOYCON, @@ -238,7 +301,7 @@ export const hardCodedTriggers: TriggerConfig[] = [ action: { type: 'prompterSetSpeed', payload: { - speed: -1.5, + speed: -10, }, }, modifier: {}, @@ -303,7 +366,7 @@ export const hardCodedTriggers: TriggerConfig[] = [ action: { type: 'prompterSetSpeed', payload: { - speed: 1.5, + speed: 10, }, }, modifier: {}, diff --git a/packages/apps/client/src/stores/ControlStore.ts b/packages/apps/client/src/stores/ControlStore.ts index 873371f..771e2de 100644 --- a/packages/apps/client/src/stores/ControlStore.ts +++ b/packages/apps/client/src/stores/ControlStore.ts @@ -1,8 +1,20 @@ -import { PartId, SegmentId, TextMarkerId } from '@sofie-prompter-editor/shared-model' +import { ControllerMessage, PartId, SegmentId, TextMarkerId } from '@sofie-prompter-editor/shared-model' import { APIConnection, RootAppStore } from './RootAppStore' +import { IReactionDisposer, action } from 'mobx' +import EventEmitter from 'eventemitter3' -export class ControlStore { - constructor(public appStore: typeof RootAppStore, public connection: APIConnection) {} +interface ControlStoreEvents { + message: [ControllerMessage] +} +export class ControlStore extends EventEmitter { + initialized = false + private initializing = false + + reactions: IReactionDisposer[] = [] + + constructor(public appStore: typeof RootAppStore, public connection: APIConnection) { + super() + } jumpToObject(objectId: SegmentId | PartId | TextMarkerId, offset: number = 0): void { console.log('jumpToObject', objectId) @@ -12,9 +24,35 @@ export class ControlStore { target: objectId, offset, }, - speed: null, }) .then(() => console.log('sent!')) .catch(console.error) } + public initialize() { + if (this.initializing || this.initialized) return + this.initializing = true + + this.setupSubscription() + } + + private setupSubscription = action(() => { + this.reactions.push( + this.appStore.whenConnected(async () => { + // Setup subscription and NOT load initial data: + RootAppStore.connection.controller.subscribeToMessages().catch(console.error) + + this.initialized = true + }) + ) + + RootAppStore.connection.controller.on('message', (message) => { + console.log('MESSAGEYO', message) + this.emit('message', message) + }) + }) + dispose() { + for (const dispose of this.reactions) { + dispose() + } + } } diff --git a/packages/apps/client/src/stores/RundownStore.ts b/packages/apps/client/src/stores/RundownStore.ts index 14e09ad..56a366e 100644 --- a/packages/apps/client/src/stores/RundownStore.ts +++ b/packages/apps/client/src/stores/RundownStore.ts @@ -94,7 +94,7 @@ export class RundownStore { // Close and load a new Rundown: this.openRundown?.close() const newRundown = new UIRundown(this, playlist._id) - newRundown.onPlaylistUpdated(playlist) + newRundown.updateFromJson(playlist) this.openRundown = newRundown }) @@ -102,12 +102,13 @@ export class RundownStore { if (!this.outputSettings) return this.connection.viewPort.patch(null, { lastKnownState: { - controllerMessage: { + state: { offset: { target: null, offset: 0, }, speed: 0, + animatedOffset: 0, }, timestamp: getCurrentTime(), }, diff --git a/packages/apps/client/src/views/Output/Output.tsx b/packages/apps/client/src/views/Output/Output.tsx index f10ccb4..39a9655 100644 --- a/packages/apps/client/src/views/Output/Output.tsx +++ b/packages/apps/client/src/views/Output/Output.tsx @@ -9,51 +9,56 @@ import { getCurrentTime } from 'src/lib/getCurrentTime' import { useQueryParam } from 'src/lib/useQueryParam' import classes from './Output.module.scss' -import { useControllerMessages } from 'src/hooks/useControllerMessages' +import { pointOfFocus, useControllerMessages } from 'src/hooks/useControllerMessages' import { reaction, toJS } from 'mobx' import { PartId, SegmentId, TextMarkerId, ViewPortLastKnownState, - ViewPortState, protectString, } from '@sofie-prompter-editor/shared-model' import { UpdateProps, useKeepRundownOutputInPosition } from 'src/hooks/useKeepRundownOutputInPosition' import { combineDisposers } from 'src/lib/lib' +import { getAllAnchorElementsByType, getAnchorAbovePositionIndex } from '../../lib/anchorElements' +import { findClosestElement } from '../../lib/findClosestElement' type AnyElementId = SegmentId | PartId | TextMarkerId function createState( rootEl: HTMLElement, fontSizePx: number, + positionPx: number, speed: number, - target: { - element: HTMLElement - offset: number - } | null + animatedOffsetPx: number ): ViewPortLastKnownState { + if (!fontSizePx) throw new Error('fontSizePx is not set') let targetEl = null let offset = 0 - if (target !== null) { - targetEl = protectString(target.element.dataset['objId']) ?? null - offset = target.offset / fontSizePx - } + const allAnchors = getAllAnchorElementsByType(rootEl, null) + // const closestAnchorIndex = getAnchorAbovePositionIndex(pointOfFocus + 0.0000001, allAnchors) + // const anchorEl = allAnchors[closestAnchorIndex] + + const result = findClosestElement(allAnchors, pointOfFocus, positionPx) - if (targetEl === null) { - offset = rootEl.scrollTop / fontSizePx - if (!Number.isFinite(offset)) offset = 0 + if (result?.anchorEl) { + targetEl = protectString(result.anchorEl.dataset['objId']) ?? null + offset = result.offset / fontSizePx + } else { + targetEl = null + offset = positionPx / fontSizePx } return { timestamp: getCurrentTime(), - controllerMessage: { + state: { speed, offset: { target: targetEl, offset, }, + animatedOffset: animatedOffsetPx / fontSizePx, }, } } @@ -84,13 +89,13 @@ const Output = observer(function Output(): React.ReactElement { const fontSizePx = (fontSize * size.width) / 100 const onStateChange = useCallback( - (viewPortState: ViewPortState) => { + (timestamp: number, position: number, speed: number, animatedOffset: number) => { if (!isPrimary) return if (!rootEl.current) return const aspectRatio = size.width / size.height - const state = createState(rootEl.current, fontSizePx, viewPortState.speed, null) - + const state = createState(rootEl.current, fontSizePx, position, speed, animatedOffset) + console.log('onStateChange', state.state.offset) RootAppStore.viewportStore.update(aspectRatio, state) }, [rootEl, size, fontSizePx, isPrimary] @@ -101,29 +106,28 @@ const Output = observer(function Output(): React.ReactElement { const { lastKnownState: viewportState, setBaseViewPortState: setLastKnownState, + scrolledPosition, position, speed, + animatedOffset, } = useControllerMessages(rootEl, fontSizePx, { onStateChange, }) const onUpdate = useCallback( - (change: UpdateProps) => { + (_change: UpdateProps) => { if (!isPrimary) return if (!rootEl.current) return const aspectRatio = size.width / size.height - const state = createState(rootEl.current, fontSizePx, viewportState.current?.controllerMessage.speed ?? 0, { - element: change.element, - offset: change.offset, - }) + const state = createState(rootEl.current, fontSizePx, position.current, speed.current, animatedOffset.current) RootAppStore.viewportStore.update(aspectRatio, state) }, - [rootEl, size, fontSizePx, isPrimary, viewportState] + [rootEl, size, fontSizePx, isPrimary, animatedOffset, position, speed] ) - useKeepRundownOutputInPosition(rootEl, rundown, fontSizePx, speed, position, 0, { + useKeepRundownOutputInPosition(rootEl, rundown, fontSizePx, speed, scrolledPosition, position, 0, { onUpdate, }) @@ -136,12 +140,13 @@ const Output = observer(function Output(): React.ReactElement { () => { if (!isPrimary) return setLastKnownState({ - controllerMessage: { + state: { offset: { target: null, offset: 0, }, speed: 0, + animatedOffset: 0, }, timestamp: getCurrentTime(), }) @@ -169,11 +174,12 @@ const Output = observer(function Output(): React.ReactElement { const aspectRatio = width / height if (!isPrimary) return + if (!fontSizePx) return - const state = createState(rootEl.current, fontSizePx, viewportState.current?.controllerMessage.speed ?? 0, null) + const state = createState(rootEl.current, fontSizePx, position.current, speed.current, animatedOffset.current) RootAppStore.viewportStore.update(aspectRatio, state) - }, [rootEl, fontSizePx, viewportState, isPrimary]) + }, [rootEl, fontSizePx, isPrimary, position, speed, animatedOffset]) useLayoutEffect(() => { window.addEventListener('resize', onViewPortSizeChanged) diff --git a/packages/apps/client/src/views/RundownScript/PreviewPanel.tsx b/packages/apps/client/src/views/RundownScript/PreviewPanel.tsx index a73040f..3151270 100644 --- a/packages/apps/client/src/views/RundownScript/PreviewPanel.tsx +++ b/packages/apps/client/src/views/RundownScript/PreviewPanel.tsx @@ -40,12 +40,13 @@ export const PreviewPanel = observer(function PreviewPanel(): React.ReactNode { const { setBaseViewPortState: setBaseState, + scrolledPosition, position, speed, } = useControllerMessages(rootEl, fontSizePx, { enableControl: rundownIsInOutput, }) - useKeepRundownOutputInPosition(rootEl, rundown, fontSizePx, speed, position, 0) + useKeepRundownOutputInPosition(rootEl, rundown, fontSizePx, speed, scrolledPosition, position, 0) useEffect(() => { if (!lastKnownState) return diff --git a/packages/apps/client/src/views/TestController/TestController.tsx b/packages/apps/client/src/views/TestController/TestController.tsx index c4a40b6..5611d27 100644 --- a/packages/apps/client/src/views/TestController/TestController.tsx +++ b/packages/apps/client/src/views/TestController/TestController.tsx @@ -75,7 +75,6 @@ const TestController: React.FC = observer(() => { const sendSpeed = useCallback((speed: number) => { RootAppStore.connection.controller .sendMessage({ - offset: null, speed: speed, }) .catch(console.error) diff --git a/packages/shared/model/src/model/ControllerMessage.ts b/packages/shared/model/src/model/ControllerMessage.ts index d98b628..8f2af87 100644 --- a/packages/shared/model/src/model/ControllerMessage.ts +++ b/packages/shared/model/src/model/ControllerMessage.ts @@ -20,10 +20,21 @@ export const ControllerMessageSchema = z.object({ /** The offset from the `target` (unit: viewportUnits) */ offset: z.number(), }) - .nullable(), + .optional(), /** When set, change the speed of scrolling */ - speed: z.number().nullable(), + speed: z.number().optional(), + + /** When set, make the prompter jump by a distance */ + jumpBy: z.number().optional(), + + /** When set, make the prompter jump to the next (or previous, depending of index) anchor point */ + jumpTarget: z + .object({ + type: z.enum(['rundown', 'segment', 'line']).nullable(), + index: z.number(), + }) + .optional(), }) /** TBD, something used to mark places in ScriptContents */ diff --git a/packages/shared/model/src/model/ViewPort.ts b/packages/shared/model/src/model/ViewPort.ts index 145b627..a698b61 100644 --- a/packages/shared/model/src/model/ViewPort.ts +++ b/packages/shared/model/src/model/ViewPort.ts @@ -20,6 +20,12 @@ export const ViewPortStateSchema = z.object({ /** When set, change the speed of scrolling */ speed: z.number(), + + /** + * The offset to be applied over time (smoothly). + * Can be added to offset.offset for the eventual position. + */ + animatedOffset: z.number(), }) export type ViewPortState = z.infer @@ -28,7 +34,7 @@ export type ViewPortState = z.infer export type ViewPortLastKnownState = z.infer export const ViewPortLastKnownStateSchema = z.object({ - controllerMessage: ViewPortStateSchema, + state: ViewPortStateSchema, timestamp: z.number(), }) diff --git a/scripts/watch-client-deps-changed.js b/scripts/watch-client-deps-changed.js index 94bea89..fd99296 100644 --- a/scripts/watch-client-deps-changed.js +++ b/scripts/watch-client-deps-changed.js @@ -18,7 +18,7 @@ const clientPackage = require(path.join(basePath, 'packages/apps/client/package. const viteConfigFile = path.join(basePath, 'packages/apps/client/vite.config.ts') -console.log('basePath', basePath) +// console.log('basePath', basePath) async function main() { @@ -56,12 +56,14 @@ async function main() { let timeout = null const watcher = chokidar.watch(watchGlobs) - watcher.on('all', () => { + watcher.on('all', (event,path) => { if (timeout) clearTimeout(timeout) timeout = setTimeout(() => { timeout = null + // console.log(event,path) + triggerViteReload().catch(console.error) }, 500) }) @@ -78,10 +80,14 @@ async function triggerViteReload() { } +// Wait before starting to watch, to let the initial build finish: +setTimeout(() => { + console.log('Starting to watch for changes...') + main().catch((err) => { + console.error(err) + process.exit(1) + }) -main().catch((err) => { - console.error(err) - process.exit(1) -}) +}, 30 * 1000)