Live mode 2 (#226)
* refactoring play, pause, resume, load style sheet to subscribe style code * support live mode in state machine * 1. upgrade @xstate/fsm 2. add toggle interact methods to the player
This commit is contained in:
@@ -59,7 +59,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/smoothscroll-polyfill": "^0.3.0",
|
"@types/smoothscroll-polyfill": "^0.3.0",
|
||||||
"@xstate/fsm": "^1.3.0",
|
"@xstate/fsm": "^1.4.0",
|
||||||
"mitt": "^1.1.3",
|
"mitt": "^1.1.3",
|
||||||
"pako": "^1.0.11",
|
"pako": "^1.0.11",
|
||||||
"rrweb-snapshot": "^0.7.26",
|
"rrweb-snapshot": "^0.7.26",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { rebuild, buildNodeWithSN } from 'rrweb-snapshot';
|
import { rebuild, buildNodeWithSN } from 'rrweb-snapshot';
|
||||||
import * as mittProxy from 'mitt';
|
import * as mittProxy from 'mitt';
|
||||||
import * as smoothscroll from 'smoothscroll-polyfill';
|
import * as smoothscroll from 'smoothscroll-polyfill';
|
||||||
import Timer from './timer';
|
import { Timer } from './timer';
|
||||||
import { createPlayerService } from './machine';
|
import { createPlayerService } from './machine';
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
missingNodeMap,
|
missingNodeMap,
|
||||||
addedNodeMutation,
|
addedNodeMutation,
|
||||||
missingNode,
|
missingNode,
|
||||||
actionWithDelay,
|
|
||||||
incrementalSnapshotEvent,
|
incrementalSnapshotEvent,
|
||||||
incrementalData,
|
incrementalData,
|
||||||
ReplayerEvents,
|
ReplayerEvents,
|
||||||
@@ -53,19 +52,16 @@ export class Replayer {
|
|||||||
public wrapper: HTMLDivElement;
|
public wrapper: HTMLDivElement;
|
||||||
public iframe: HTMLIFrameElement;
|
public iframe: HTMLIFrameElement;
|
||||||
|
|
||||||
public timer: Timer;
|
public get timer() {
|
||||||
|
return this.service.state.context.timer;
|
||||||
|
}
|
||||||
|
|
||||||
private events: eventWithTime[] = [];
|
|
||||||
private config: playerConfig;
|
private config: playerConfig;
|
||||||
|
|
||||||
private mouse: HTMLDivElement;
|
private mouse: HTMLDivElement;
|
||||||
|
|
||||||
private emitter: Emitter = mitt();
|
private emitter: Emitter = mitt();
|
||||||
|
|
||||||
private baselineTime: number = 0;
|
|
||||||
// record last played event timestamp when paused
|
|
||||||
private lastPlayedEvent: eventWithTime;
|
|
||||||
|
|
||||||
private nextUserInteractionEvent: eventWithTime | null;
|
private nextUserInteractionEvent: eventWithTime | null;
|
||||||
private noramlSpeed: number = -1;
|
private noramlSpeed: number = -1;
|
||||||
|
|
||||||
@@ -77,35 +73,45 @@ export class Replayer {
|
|||||||
events: Array<eventWithTime | string>,
|
events: Array<eventWithTime | string>,
|
||||||
config?: Partial<playerConfig>,
|
config?: Partial<playerConfig>,
|
||||||
) {
|
) {
|
||||||
if (events.length < 2) {
|
if (!config?.liveMode && events.length < 2) {
|
||||||
throw new Error('Replayer need at least 2 events.');
|
throw new Error('Replayer need at least 2 events.');
|
||||||
}
|
}
|
||||||
this.service = createPlayerService({
|
|
||||||
events: events.map((e) => {
|
|
||||||
if (config && config.unpackFn) {
|
|
||||||
return config.unpackFn(e as string);
|
|
||||||
}
|
|
||||||
return e as eventWithTime;
|
|
||||||
}),
|
|
||||||
timeOffset: 0,
|
|
||||||
speed: config?.speed || defaultConfig.speed,
|
|
||||||
});
|
|
||||||
this.service.start();
|
|
||||||
this.events = events.map((e) => {
|
|
||||||
if (config && config.unpackFn) {
|
|
||||||
return config.unpackFn(e as string);
|
|
||||||
}
|
|
||||||
return e as eventWithTime;
|
|
||||||
});
|
|
||||||
this.handleResize = this.handleResize.bind(this);
|
|
||||||
|
|
||||||
this.config = Object.assign({}, defaultConfig, config);
|
this.config = Object.assign({}, defaultConfig, config);
|
||||||
|
|
||||||
this.timer = new Timer(this.config);
|
this.handleResize = this.handleResize.bind(this);
|
||||||
|
this.getCastFn = this.getCastFn.bind(this);
|
||||||
|
this.emitter.on('resize', this.handleResize as Handler);
|
||||||
|
|
||||||
smoothscroll.polyfill();
|
smoothscroll.polyfill();
|
||||||
polyfill();
|
polyfill();
|
||||||
this.setupDom();
|
this.setupDom();
|
||||||
this.emitter.on('resize', this.handleResize as Handler);
|
|
||||||
|
this.service = createPlayerService(
|
||||||
|
{
|
||||||
|
events: events.map((e) => {
|
||||||
|
if (config && config.unpackFn) {
|
||||||
|
return config.unpackFn(e as string);
|
||||||
|
}
|
||||||
|
return e as eventWithTime;
|
||||||
|
}),
|
||||||
|
timer: new Timer(this.config),
|
||||||
|
speed: config?.speed || defaultConfig.speed,
|
||||||
|
timeOffset: 0,
|
||||||
|
baselineTime: 0,
|
||||||
|
lastPlayedEvent: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getCastFn: this.getCastFn,
|
||||||
|
emitter: this.emitter,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.service.start();
|
||||||
|
this.service.subscribe((state) => {
|
||||||
|
if (!state.changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// publish via emitter
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public on(event: string, handler: Handler) {
|
public on(event: string, handler: Handler) {
|
||||||
@@ -123,8 +129,9 @@ export class Replayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getMetaData(): playerMetaData {
|
public getMetaData(): playerMetaData {
|
||||||
const firstEvent = this.events[0];
|
const { events } = this.service.state.context;
|
||||||
const lastEvent = this.events[this.events.length - 1];
|
const firstEvent = events[0];
|
||||||
|
const lastEvent = events[events.length - 1];
|
||||||
return {
|
return {
|
||||||
totalTime: lastEvent.timestamp - firstEvent.timestamp,
|
totalTime: lastEvent.timestamp - firstEvent.timestamp,
|
||||||
};
|
};
|
||||||
@@ -135,7 +142,8 @@ export class Replayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getTimeOffset(): number {
|
public getTimeOffset(): number {
|
||||||
return this.baselineTime - this.events[0].timestamp;
|
const { baselineTime, events } = this.service.state.context;
|
||||||
|
return baselineTime - events[0].timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,65 +156,41 @@ export class Replayer {
|
|||||||
* @param timeOffset number
|
* @param timeOffset number
|
||||||
*/
|
*/
|
||||||
public play(timeOffset = 0) {
|
public play(timeOffset = 0) {
|
||||||
this.timer.clear();
|
this.service.send({ type: 'PLAY', payload: { timeOffset } });
|
||||||
this.baselineTime = this.events[0].timestamp + timeOffset;
|
|
||||||
const actions = new Array<actionWithDelay>();
|
|
||||||
for (const event of this.events) {
|
|
||||||
const isSync = event.timestamp < this.baselineTime;
|
|
||||||
const castFn = this.getCastFn(event, isSync);
|
|
||||||
if (isSync) {
|
|
||||||
castFn();
|
|
||||||
} else {
|
|
||||||
actions.push({
|
|
||||||
doAction: () => {
|
|
||||||
castFn();
|
|
||||||
this.emitter.emit(ReplayerEvents.EventCast, event);
|
|
||||||
},
|
|
||||||
delay: this.getDelay(event),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.timer.addActions(actions);
|
|
||||||
this.timer.start();
|
|
||||||
this.service.send({ type: 'PLAY' });
|
|
||||||
this.emitter.emit(ReplayerEvents.Start);
|
this.emitter.emit(ReplayerEvents.Start);
|
||||||
}
|
}
|
||||||
|
|
||||||
public pause() {
|
public pause() {
|
||||||
this.timer.clear();
|
|
||||||
this.service.send({ type: 'PAUSE' });
|
this.service.send({ type: 'PAUSE' });
|
||||||
this.emitter.emit(ReplayerEvents.Pause);
|
this.emitter.emit(ReplayerEvents.Pause);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resume(timeOffset = 0) {
|
public resume(timeOffset = 0) {
|
||||||
this.timer.clear();
|
this.service.send({ type: 'RESUME', payload: { timeOffset } });
|
||||||
this.baselineTime = this.events[0].timestamp + timeOffset;
|
|
||||||
const actions = new Array<actionWithDelay>();
|
|
||||||
for (const event of this.events) {
|
|
||||||
if (
|
|
||||||
event.timestamp <= this.lastPlayedEvent.timestamp ||
|
|
||||||
event === this.lastPlayedEvent
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const castFn = this.getCastFn(event);
|
|
||||||
actions.push({
|
|
||||||
doAction: castFn,
|
|
||||||
delay: this.getDelay(event),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.timer.addActions(actions);
|
|
||||||
this.timer.start();
|
|
||||||
this.service.send({ type: 'RESUME' });
|
|
||||||
this.emitter.emit(ReplayerEvents.Resume);
|
this.emitter.emit(ReplayerEvents.Resume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public startLive(baselineTime?: number) {
|
||||||
|
this.service.send({ type: 'TO_LIVE', payload: { baselineTime } });
|
||||||
|
}
|
||||||
|
|
||||||
public addEvent(rawEvent: eventWithTime | string) {
|
public addEvent(rawEvent: eventWithTime | string) {
|
||||||
const event = this.config.unpackFn
|
const event = this.config.unpackFn
|
||||||
? this.config.unpackFn(rawEvent as string)
|
? this.config.unpackFn(rawEvent as string)
|
||||||
: (rawEvent as eventWithTime);
|
: (rawEvent as eventWithTime);
|
||||||
const castFn = this.getCastFn(event, true);
|
Promise.resolve().then(() =>
|
||||||
castFn();
|
this.service.send({ type: 'ADD_EVENT', payload: { event } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enableInteract() {
|
||||||
|
this.iframe.setAttribute('scrolling', 'auto');
|
||||||
|
this.iframe.style.pointerEvents = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
public disableInteract() {
|
||||||
|
this.iframe.setAttribute('scrolling', 'no');
|
||||||
|
this.iframe.style.pointerEvents = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupDom() {
|
private setupDom() {
|
||||||
@@ -220,8 +204,7 @@ export class Replayer {
|
|||||||
|
|
||||||
this.iframe = document.createElement('iframe');
|
this.iframe = document.createElement('iframe');
|
||||||
this.iframe.setAttribute('sandbox', 'allow-same-origin');
|
this.iframe.setAttribute('sandbox', 'allow-same-origin');
|
||||||
this.iframe.setAttribute('scrolling', 'no');
|
this.disableInteract();
|
||||||
this.iframe.setAttribute('style', 'pointer-events: none');
|
|
||||||
this.wrapper.appendChild(this.iframe);
|
this.wrapper.appendChild(this.iframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,25 +213,8 @@ export class Replayer {
|
|||||||
this.iframe.setAttribute('height', String(dimension.height));
|
this.iframe.setAttribute('height', String(dimension.height));
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: add speed to mouse move timestamp calculation
|
|
||||||
private getDelay(event: eventWithTime): number {
|
|
||||||
// Mouse move events was recorded in a throttle function,
|
|
||||||
// so we need to find the real timestamp by traverse the time offsets.
|
|
||||||
if (
|
|
||||||
event.type === EventType.IncrementalSnapshot &&
|
|
||||||
event.data.source === IncrementalSource.MouseMove
|
|
||||||
) {
|
|
||||||
const firstOffset = event.data.positions[0].timeOffset;
|
|
||||||
// timeOffset is a negative offset to event.timestamp
|
|
||||||
const firstTimestamp = event.timestamp + firstOffset;
|
|
||||||
event.delay = firstTimestamp - this.baselineTime;
|
|
||||||
return firstTimestamp - this.baselineTime;
|
|
||||||
}
|
|
||||||
event.delay = event.timestamp - this.baselineTime;
|
|
||||||
return event.timestamp - this.baselineTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCastFn(event: eventWithTime, isSync = false) {
|
private getCastFn(event: eventWithTime, isSync = false) {
|
||||||
|
const { events } = this.service.state.context;
|
||||||
let castFn: undefined | (() => void);
|
let castFn: undefined | (() => void);
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case EventType.DomContentLoaded:
|
case EventType.DomContentLoaded:
|
||||||
@@ -257,7 +223,7 @@ export class Replayer {
|
|||||||
case EventType.Custom:
|
case EventType.Custom:
|
||||||
castFn = () => {
|
castFn = () => {
|
||||||
/**
|
/**
|
||||||
* emit custom-event and pass the event object.
|
* emit custom-event and pass the event object.
|
||||||
*
|
*
|
||||||
* This will add more value to the custom event and allows the client to react for custom-event.
|
* This will add more value to the custom event and allows the client to react for custom-event.
|
||||||
*/
|
*/
|
||||||
@@ -285,7 +251,7 @@ export class Replayer {
|
|||||||
this.restoreSpeed();
|
this.restoreSpeed();
|
||||||
}
|
}
|
||||||
if (this.config.skipInactive && !this.nextUserInteractionEvent) {
|
if (this.config.skipInactive && !this.nextUserInteractionEvent) {
|
||||||
for (const _event of this.events) {
|
for (const _event of events) {
|
||||||
if (_event.timestamp! <= event.timestamp!) {
|
if (_event.timestamp! <= event.timestamp!) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -318,9 +284,10 @@ export class Replayer {
|
|||||||
if (castFn) {
|
if (castFn) {
|
||||||
castFn();
|
castFn();
|
||||||
}
|
}
|
||||||
this.lastPlayedEvent = event;
|
this.service.send({ type: 'CAST_EVENT', payload: { event } });
|
||||||
if (event === this.events[this.events.length - 1]) {
|
if (event === events[events.length - 1]) {
|
||||||
this.restoreSpeed();
|
this.restoreSpeed();
|
||||||
|
this.service.send('END');
|
||||||
this.emitter.emit(ReplayerEvents.Finish);
|
this.emitter.emit(ReplayerEvents.Finish);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -359,26 +326,17 @@ export class Replayer {
|
|||||||
if (head) {
|
if (head) {
|
||||||
const unloadSheets: Set<HTMLLinkElement> = new Set();
|
const unloadSheets: Set<HTMLLinkElement> = new Set();
|
||||||
let timer: number;
|
let timer: number;
|
||||||
|
let beforeLoadState = this.service.state;
|
||||||
head
|
head
|
||||||
.querySelectorAll('link[rel="stylesheet"]')
|
.querySelectorAll('link[rel="stylesheet"]')
|
||||||
.forEach((css: HTMLLinkElement) => {
|
.forEach((css: HTMLLinkElement) => {
|
||||||
if (!css.sheet) {
|
if (!css.sheet) {
|
||||||
if (unloadSheets.size === 0) {
|
|
||||||
this.timer.clear(); // artificial pause
|
|
||||||
this.emitter.emit(ReplayerEvents.LoadStylesheetStart);
|
|
||||||
timer = window.setTimeout(() => {
|
|
||||||
if (this.service.state.matches('playing')) {
|
|
||||||
this.resume(this.getCurrentTime());
|
|
||||||
}
|
|
||||||
// mark timer was called
|
|
||||||
timer = -1;
|
|
||||||
}, this.config.loadTimeout);
|
|
||||||
}
|
|
||||||
unloadSheets.add(css);
|
unloadSheets.add(css);
|
||||||
css.addEventListener('load', () => {
|
css.addEventListener('load', () => {
|
||||||
unloadSheets.delete(css);
|
unloadSheets.delete(css);
|
||||||
|
// all loaded and timer not released yet
|
||||||
if (unloadSheets.size === 0 && timer !== -1) {
|
if (unloadSheets.size === 0 && timer !== -1) {
|
||||||
if (this.service.state.matches('playing')) {
|
if (beforeLoadState.matches('playing')) {
|
||||||
this.resume(this.getCurrentTime());
|
this.resume(this.getCurrentTime());
|
||||||
}
|
}
|
||||||
this.emitter.emit(ReplayerEvents.LoadStylesheetEnd);
|
this.emitter.emit(ReplayerEvents.LoadStylesheetEnd);
|
||||||
@@ -389,6 +347,19 @@ export class Replayer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (unloadSheets.size > 0) {
|
||||||
|
// find some unload sheets after iterate
|
||||||
|
this.service.send({ type: 'PAUSE' });
|
||||||
|
this.emitter.emit(ReplayerEvents.LoadStylesheetStart);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
if (beforeLoadState.matches('playing')) {
|
||||||
|
this.resume(this.getCurrentTime());
|
||||||
|
}
|
||||||
|
// mark timer was called
|
||||||
|
timer = -1;
|
||||||
|
}, this.config.loadTimeout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +367,7 @@ export class Replayer {
|
|||||||
e: incrementalSnapshotEvent & { timestamp: number },
|
e: incrementalSnapshotEvent & { timestamp: number },
|
||||||
isSync: boolean,
|
isSync: boolean,
|
||||||
) {
|
) {
|
||||||
|
const { baselineTime } = this.service.state.context;
|
||||||
const { data: d } = e;
|
const { data: d } = e;
|
||||||
switch (d.source) {
|
switch (d.source) {
|
||||||
case IncrementalSource.Mutation: {
|
case IncrementalSource.Mutation: {
|
||||||
@@ -535,7 +507,7 @@ export class Replayer {
|
|||||||
doAction: () => {
|
doAction: () => {
|
||||||
this.moveAndHover(d, p.x, p.y, p.id);
|
this.moveAndHover(d, p.x, p.y, p.id);
|
||||||
},
|
},
|
||||||
delay: p.timeOffset + e.timestamp - this.baselineTime,
|
delay: p.timeOffset + e.timestamp - baselineTime,
|
||||||
};
|
};
|
||||||
this.timer.addAction(action);
|
this.timer.addAction(action);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,53 @@
|
|||||||
|
import { createMachine, interpret, assign } from '@xstate/fsm';
|
||||||
import {
|
import {
|
||||||
createMachine,
|
playerConfig,
|
||||||
EventObject,
|
eventWithTime,
|
||||||
Typestate,
|
actionWithDelay,
|
||||||
InterpreterStatus,
|
ReplayerEvents,
|
||||||
StateMachine,
|
Emitter,
|
||||||
} from '@xstate/fsm';
|
} from '../types';
|
||||||
import { playerConfig, eventWithTime } from '../types';
|
import { Timer, getDelay } from './timer';
|
||||||
|
|
||||||
type PlayerContext = {
|
export type PlayerContext = {
|
||||||
events: eventWithTime[];
|
events: eventWithTime[];
|
||||||
timeOffset: number;
|
timer: Timer;
|
||||||
speed: playerConfig['speed'];
|
speed: playerConfig['speed'];
|
||||||
|
timeOffset: number;
|
||||||
|
baselineTime: number;
|
||||||
|
lastPlayedEvent: eventWithTime | null;
|
||||||
};
|
};
|
||||||
type PlayerEvent =
|
export type PlayerEvent =
|
||||||
| { type: 'PLAY' }
|
| {
|
||||||
|
type: 'PLAY';
|
||||||
|
payload: {
|
||||||
|
timeOffset: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'CAST_EVENT';
|
||||||
|
payload: {
|
||||||
|
event: eventWithTime;
|
||||||
|
};
|
||||||
|
}
|
||||||
| { type: 'PAUSE' }
|
| { type: 'PAUSE' }
|
||||||
| { type: 'RESUME' }
|
| {
|
||||||
|
type: 'RESUME';
|
||||||
|
payload: {
|
||||||
|
timeOffset: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
| { type: 'END' }
|
| { type: 'END' }
|
||||||
| { type: 'REPLAY' }
|
| { type: 'REPLAY' }
|
||||||
| { type: 'FAST_FORWARD' }
|
| { type: 'FAST_FORWARD' }
|
||||||
| { type: 'BACK_TO_NORMAL' };
|
| { type: 'BACK_TO_NORMAL' }
|
||||||
type PlayerState =
|
| { type: 'TO_LIVE'; payload: { baselineTime?: number } }
|
||||||
|
| {
|
||||||
|
type: 'ADD_EVENT';
|
||||||
|
payload: {
|
||||||
|
event: eventWithTime;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type PlayerState =
|
||||||
| {
|
| {
|
||||||
value: 'inited';
|
value: 'inited';
|
||||||
context: PlayerContext;
|
context: PlayerContext;
|
||||||
@@ -40,112 +67,169 @@ type PlayerState =
|
|||||||
| {
|
| {
|
||||||
value: 'skipping';
|
value: 'skipping';
|
||||||
context: PlayerContext;
|
context: PlayerContext;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
value: 'live';
|
||||||
|
context: PlayerContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: import interpret when this relased
|
type PlayerAssets = {
|
||||||
// https://github.com/davidkpiano/xstate/issues/1080
|
emitter: Emitter;
|
||||||
// tslint:disable no-any
|
getCastFn(event: eventWithTime, isSync: boolean): () => void;
|
||||||
function toEventObject<TEvent extends EventObject>(
|
};
|
||||||
event: TEvent['type'] | TEvent,
|
export function createPlayerService(
|
||||||
): TEvent {
|
context: PlayerContext,
|
||||||
return (typeof event === 'string' ? { type: event } : event) as TEvent;
|
{ getCastFn, emitter }: PlayerAssets,
|
||||||
}
|
) {
|
||||||
const INIT_EVENT = { type: 'xstate.init' };
|
const playerMachine = createMachine<PlayerContext, PlayerEvent, PlayerState>(
|
||||||
const executeStateActions = <
|
{
|
||||||
TContext extends object,
|
id: 'player',
|
||||||
TEvent extends EventObject = any,
|
context,
|
||||||
TState extends Typestate<TContext> = any
|
initial: 'inited',
|
||||||
>(
|
states: {
|
||||||
state: StateMachine.State<TContext, TEvent, TState>,
|
inited: {
|
||||||
event: TEvent | typeof INIT_EVENT,
|
on: {
|
||||||
) =>
|
PLAY: {
|
||||||
state.actions.forEach(
|
target: 'playing',
|
||||||
({ exec }) => exec && exec(state.context, event as any),
|
actions: ['recordTimeOffset', 'play'],
|
||||||
|
},
|
||||||
|
TO_LIVE: {
|
||||||
|
target: 'live',
|
||||||
|
actions: ['startLive'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
playing: {
|
||||||
|
on: {
|
||||||
|
PAUSE: {
|
||||||
|
target: 'paused',
|
||||||
|
actions: ['pause'],
|
||||||
|
},
|
||||||
|
END: 'ended',
|
||||||
|
FAST_FORWARD: 'skipping',
|
||||||
|
CAST_EVENT: {
|
||||||
|
target: 'playing',
|
||||||
|
actions: 'castEvent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paused: {
|
||||||
|
on: {
|
||||||
|
RESUME: {
|
||||||
|
target: 'playing',
|
||||||
|
actions: ['recordTimeOffset', 'play'],
|
||||||
|
},
|
||||||
|
CAST_EVENT: {
|
||||||
|
target: 'paused',
|
||||||
|
actions: 'castEvent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skipping: {
|
||||||
|
on: {
|
||||||
|
BACK_TO_NORMAL: 'playing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ended: {
|
||||||
|
on: {
|
||||||
|
REPLAY: 'playing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
live: {
|
||||||
|
on: {
|
||||||
|
ADD_EVENT: {
|
||||||
|
target: 'live',
|
||||||
|
actions: ['addEvent'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: {
|
||||||
|
castEvent: assign({
|
||||||
|
lastPlayedEvent: (ctx, event) => {
|
||||||
|
if (event.type === 'CAST_EVENT') {
|
||||||
|
return event.payload.event;
|
||||||
|
}
|
||||||
|
return context.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();
|
||||||
|
const actions = new Array<actionWithDelay>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (
|
||||||
|
lastPlayedEvent &&
|
||||||
|
(event.timestamp <= lastPlayedEvent.timestamp ||
|
||||||
|
event === lastPlayedEvent)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isSync = event.timestamp < baselineTime;
|
||||||
|
const castFn = getCastFn(event, isSync);
|
||||||
|
if (isSync) {
|
||||||
|
castFn();
|
||||||
|
} else {
|
||||||
|
actions.push({
|
||||||
|
doAction: () => {
|
||||||
|
castFn();
|
||||||
|
emitter.emit(ReplayerEvents.EventCast, event);
|
||||||
|
},
|
||||||
|
delay: getDelay(event, baselineTime),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.addActions(actions);
|
||||||
|
timer.start();
|
||||||
|
},
|
||||||
|
pause(ctx) {
|
||||||
|
ctx.timer.clear();
|
||||||
|
},
|
||||||
|
startLive: assign({
|
||||||
|
baselineTime: (ctx, event) => {
|
||||||
|
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;
|
||||||
|
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: getDelay(event, baselineTime),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...ctx, events };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
function interpret<
|
|
||||||
TContext extends object,
|
|
||||||
TEvent extends EventObject = EventObject,
|
|
||||||
TState extends Typestate<TContext> = any
|
|
||||||
>(
|
|
||||||
machine: StateMachine.Machine<TContext, TEvent, TState>,
|
|
||||||
): StateMachine.Service<TContext, TEvent, TState> {
|
|
||||||
let state = machine.initialState;
|
|
||||||
let status = InterpreterStatus.NotStarted;
|
|
||||||
const listeners = new Set<StateMachine.StateListener<typeof state>>();
|
|
||||||
|
|
||||||
const service = {
|
|
||||||
_machine: machine,
|
|
||||||
send: (event: TEvent | TEvent['type']): void => {
|
|
||||||
if (status !== InterpreterStatus.Running) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state = machine.transition(state, event);
|
|
||||||
executeStateActions(state, toEventObject(event));
|
|
||||||
listeners.forEach((listener) => listener(state));
|
|
||||||
},
|
|
||||||
subscribe: (listener: StateMachine.StateListener<typeof state>) => {
|
|
||||||
listeners.add(listener);
|
|
||||||
listener(state);
|
|
||||||
|
|
||||||
return {
|
|
||||||
unsubscribe: () => listeners.delete(listener),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
start: () => {
|
|
||||||
status = InterpreterStatus.Running;
|
|
||||||
executeStateActions(state, INIT_EVENT);
|
|
||||||
return service;
|
|
||||||
},
|
|
||||||
stop: () => {
|
|
||||||
status = InterpreterStatus.Stopped;
|
|
||||||
listeners.clear();
|
|
||||||
return service;
|
|
||||||
},
|
|
||||||
get state() {
|
|
||||||
return state;
|
|
||||||
},
|
|
||||||
get status() {
|
|
||||||
return status;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return service;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPlayerService(context: PlayerContext) {
|
|
||||||
const playerMachine = createMachine<PlayerContext, PlayerEvent, PlayerState>({
|
|
||||||
id: 'player',
|
|
||||||
context,
|
|
||||||
initial: 'inited',
|
|
||||||
states: {
|
|
||||||
inited: {
|
|
||||||
on: {
|
|
||||||
PLAY: 'playing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
playing: {
|
|
||||||
on: {
|
|
||||||
PAUSE: 'paused',
|
|
||||||
END: 'ended',
|
|
||||||
FAST_FORWARD: 'skipping',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
paused: {
|
|
||||||
on: {
|
|
||||||
RESUME: 'playing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
skipping: {
|
|
||||||
on: {
|
|
||||||
BACK_TO_NORMAL: 'playing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ended: {
|
|
||||||
on: {
|
|
||||||
REPLAY: 'playing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return interpret(playerMachine);
|
return interpret(playerMachine);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { playerConfig, actionWithDelay } from '../types';
|
import {
|
||||||
|
playerConfig,
|
||||||
|
actionWithDelay,
|
||||||
|
eventWithTime,
|
||||||
|
EventType,
|
||||||
|
IncrementalSource,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
export default class Timer {
|
export class Timer {
|
||||||
public timeOffset: number = 0;
|
public timeOffset: number = 0;
|
||||||
|
|
||||||
private actions: actionWithDelay[];
|
private actions: actionWithDelay[];
|
||||||
@@ -75,3 +81,21 @@ export default class Timer {
|
|||||||
return start;
|
return start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: add speed to mouse move timestamp calculation
|
||||||
|
export function getDelay(event: eventWithTime, baselineTime: number): number {
|
||||||
|
// Mouse move events was recorded in a throttle function,
|
||||||
|
// so we need to find the real timestamp by traverse the time offsets.
|
||||||
|
if (
|
||||||
|
event.type === EventType.IncrementalSnapshot &&
|
||||||
|
event.data.source === IncrementalSource.MouseMove
|
||||||
|
) {
|
||||||
|
const firstOffset = event.data.positions[0].timeOffset;
|
||||||
|
// timeOffset is a negative offset to event.timestamp
|
||||||
|
const firstTimestamp = event.timestamp + firstOffset;
|
||||||
|
event.delay = firstTimestamp - baselineTime;
|
||||||
|
return firstTimestamp - baselineTime;
|
||||||
|
}
|
||||||
|
event.delay = event.timestamp - baselineTime;
|
||||||
|
return event.timestamp - baselineTime;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user