358 lines
8.8 KiB
TypeScript
358 lines
8.8 KiB
TypeScript
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<PlayerContext, PlayerEvent, PlayerState>(
|
|
{
|
|
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<actionWithDelay>();
|
|
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<SpeedContext, SpeedEvent, SpeedState>(
|
|
{
|
|
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
|
|
>;
|