import { createMachine, interpret, assign, StateMachine } from '@xstate/fsm'; import { playerConfig, eventWithTime, actionWithDelay, ReplayerEvents, EventType, Emitter, IncrementalSource, } from '../types'; import { Timer, addDelay } from './timer'; import { needCastInSyncMode } from '../utils'; export type PlayerContext = { events: eventWithTime[]; timer: Timer; timeOffset: number; baselineTime: number; lastPlayedEvent: eventWithTime | null; }; export type PlayerEvent = | { type: 'PLAY'; payload: { timeOffset: number; }; } | { type: 'CAST_EVENT'; payload: { event: eventWithTime; }; } | { type: 'PAUSE' } | { type: 'TO_LIVE'; payload: { baselineTime?: number } } | { type: 'ADD_EVENT'; payload: { event: eventWithTime; }; } | { type: 'END'; }; export type PlayerState = | { value: 'playing'; context: PlayerContext; } | { value: 'paused'; context: PlayerContext; } | { value: 'live'; context: PlayerContext; }; /** * If the array have multiple meta and fullsnapshot events, * return the events from last meta to the end. */ export function discardPriorSnapshots( events: eventWithTime[], baselineTime: number, ): eventWithTime[] { for (let idx = events.length - 1; idx >= 0; idx--) { const event = events[idx]; if (event.type === EventType.Meta) { if (event.timestamp <= baselineTime) { return events.slice(idx); } } } return events; } type PlayerAssets = { emitter: Emitter; getCastFn(event: eventWithTime, isSync: boolean): () => void; }; export function createPlayerService( context: PlayerContext, { getCastFn, emitter }: PlayerAssets, ) { const playerMachine = createMachine( { id: 'player', context, initial: 'paused', states: { playing: { on: { PAUSE: { target: 'paused', actions: ['pause'], }, CAST_EVENT: { target: 'playing', actions: 'castEvent', }, END: { target: 'paused', actions: ['resetLastPlayedEvent', 'pause'], }, ADD_EVENT: { target: 'playing', actions: ['addEvent'], }, }, }, paused: { on: { PLAY: { target: 'playing', actions: ['recordTimeOffset', 'play'], }, CAST_EVENT: { target: 'paused', actions: 'castEvent', }, TO_LIVE: { target: 'live', actions: ['startLive'], }, ADD_EVENT: { target: 'paused', actions: ['addEvent'], }, }, }, live: { on: { ADD_EVENT: { target: 'live', actions: ['addEvent'], }, CAST_EVENT: { target: 'live', actions: ['castEvent'], }, }, }, }, }, { actions: { castEvent: assign({ lastPlayedEvent: (ctx, event) => { if (event.type === 'CAST_EVENT') { return event.payload.event; } return ctx.lastPlayedEvent; }, }), recordTimeOffset: assign((ctx, event) => { let timeOffset = ctx.timeOffset; if ('payload' in event && 'timeOffset' in event.payload) { timeOffset = event.payload.timeOffset; } return { ...ctx, timeOffset, baselineTime: ctx.events[0].timestamp + timeOffset, }; }), play(ctx) { const { timer, events, baselineTime, lastPlayedEvent } = ctx; timer.clear(); for (const event of events) { // TODO: improve this API addDelay(event, baselineTime); } const neededEvents = discardPriorSnapshots(events, baselineTime); const actions = new Array(); for (const event of neededEvents) { let lastPlayedTimestamp = lastPlayedEvent?.timestamp; if ( lastPlayedEvent?.type === EventType.IncrementalSnapshot && lastPlayedEvent.data.source === IncrementalSource.MouseMove ) { lastPlayedTimestamp = lastPlayedEvent.timestamp + lastPlayedEvent.data.positions[0]?.timeOffset; } if ( lastPlayedTimestamp && lastPlayedTimestamp < baselineTime && (event.timestamp <= lastPlayedTimestamp || event === lastPlayedEvent) ) { continue; } const isSync = event.timestamp < baselineTime; if (isSync && !needCastInSyncMode(event)) { continue; } const castFn = getCastFn(event, isSync); if (isSync) { castFn(); } else { actions.push({ doAction: () => { castFn(); emitter.emit(ReplayerEvents.EventCast, event); }, delay: event.delay!, }); } } emitter.emit(ReplayerEvents.Flush); timer.addActions(actions); timer.start(); }, pause(ctx) { ctx.timer.clear(); }, resetLastPlayedEvent: assign((ctx) => { return { ...ctx, lastPlayedEvent: null, }; }), startLive: assign({ baselineTime: (ctx, event) => { ctx.timer.toggleLiveMode(true); ctx.timer.start(); if (event.type === 'TO_LIVE' && event.payload.baselineTime) { return event.payload.baselineTime; } return Date.now(); }, }), addEvent: assign((ctx, machineEvent) => { const { baselineTime, timer, events } = ctx; if (machineEvent.type === 'ADD_EVENT') { const { event } = machineEvent.payload; addDelay(event, baselineTime); events.push(event); const isSync = event.timestamp < baselineTime; const castFn = getCastFn(event, isSync); if (isSync) { castFn(); } else { timer.addAction({ doAction: () => { castFn(); emitter.emit(ReplayerEvents.EventCast, event); }, delay: event.delay!, }); if (!timer.isActive()) { timer.start(); } } } return { ...ctx, events }; }), }, }, ); return interpret(playerMachine); } export type SpeedContext = { normalSpeed: playerConfig['speed']; timer: Timer; }; export type SpeedEvent = | { type: 'FAST_FORWARD'; payload: { speed: playerConfig['speed'] }; } | { type: 'BACK_TO_NORMAL'; } | { type: 'SET_SPEED'; payload: { speed: playerConfig['speed'] }; }; export type SpeedState = | { value: 'normal'; context: SpeedContext; } | { value: 'skipping'; context: SpeedContext; }; export function createSpeedService(context: SpeedContext) { const speedMachine = createMachine( { id: 'speed', context, initial: 'normal', states: { normal: { on: { FAST_FORWARD: { target: 'skipping', actions: ['recordSpeed', 'setSpeed'], }, SET_SPEED: { target: 'normal', actions: ['setSpeed'], }, }, }, skipping: { on: { BACK_TO_NORMAL: { target: 'normal', actions: ['restoreSpeed'], }, SET_SPEED: { target: 'normal', actions: ['setSpeed'], }, }, }, }, }, { actions: { setSpeed: (ctx, event) => { if ('payload' in event) { ctx.timer.setSpeed(event.payload.speed); } }, recordSpeed: assign({ normalSpeed: (ctx) => ctx.timer.speed, }), restoreSpeed: (ctx) => { ctx.timer.setSpeed(ctx.normalSpeed); }, }, }, ); return interpret(speedMachine); } export type PlayerMachineState = StateMachine.State< PlayerContext, PlayerEvent, PlayerState >; export type SpeedMachineState = StateMachine.State< SpeedContext, SpeedEvent, SpeedState >;