impl basic player state machine (#198)
This commit is contained in:
@@ -59,6 +59,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/smoothscroll-polyfill": "^0.3.0",
|
||||
"@xstate/fsm": "^1.3.0",
|
||||
"mitt": "^1.1.3",
|
||||
"pako": "^1.0.11",
|
||||
"rrweb-snapshot": "^0.7.26",
|
||||
|
||||
22
src/declarations/@xstate/fsm/index.d.ts
vendored
Normal file
22
src/declarations/@xstate/fsm/index.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as fsm from '@xstate/fsm';
|
||||
|
||||
declare module '@xstate/fsm' {
|
||||
export namespace StateMachine {
|
||||
interface Service<
|
||||
TContext extends object,
|
||||
TEvent extends fsm.EventObject,
|
||||
TState extends fsm.Typestate<TContext> = any
|
||||
> {
|
||||
send: (event: TEvent | TEvent['type']) => void;
|
||||
subscribe: (
|
||||
listener: StateListener<State<TContext, TEvent, TState>>,
|
||||
) => {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
start: () => Service<TContext, TEvent, TState>;
|
||||
stop: () => Service<TContext, TEvent, TState>;
|
||||
readonly status: fsm.InterpreterStatus;
|
||||
readonly state: State<TContext, TEvent, TState>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { rebuild, buildNodeWithSN } from 'rrweb-snapshot';
|
||||
import * as mittProxy from 'mitt';
|
||||
import * as smoothscroll from 'smoothscroll-polyfill';
|
||||
import Timer from './timer';
|
||||
import { createPlayerService } from './machine';
|
||||
import {
|
||||
EventType,
|
||||
IncrementalSource,
|
||||
@@ -35,6 +36,19 @@ const mitt = (mittProxy as any).default || mittProxy;
|
||||
|
||||
const REPLAY_CONSOLE_PREFIX = '[replayer]';
|
||||
|
||||
const defaultConfig: playerConfig = {
|
||||
speed: 1,
|
||||
root: document.body,
|
||||
loadTimeout: 0,
|
||||
skipInactive: false,
|
||||
showWarning: true,
|
||||
showDebug: false,
|
||||
blockClass: 'rr-block',
|
||||
liveMode: false,
|
||||
insertStyleRules: [],
|
||||
triggerFocus: true,
|
||||
};
|
||||
|
||||
export class Replayer {
|
||||
public wrapper: HTMLDivElement;
|
||||
public iframe: HTMLIFrameElement;
|
||||
@@ -57,7 +71,7 @@ export class Replayer {
|
||||
|
||||
private missingNodeRetryMap: missingNodeMap = {};
|
||||
|
||||
private playing: boolean = false;
|
||||
private service!: ReturnType<typeof createPlayerService>;
|
||||
|
||||
constructor(
|
||||
events: Array<eventWithTime | string>,
|
||||
@@ -66,6 +80,17 @@ export class Replayer {
|
||||
if (events.length < 2) {
|
||||
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);
|
||||
@@ -74,18 +99,6 @@ export class Replayer {
|
||||
});
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
|
||||
const defaultConfig: playerConfig = {
|
||||
speed: 1,
|
||||
root: document.body,
|
||||
loadTimeout: 0,
|
||||
skipInactive: false,
|
||||
showWarning: true,
|
||||
showDebug: false,
|
||||
blockClass: 'rr-block',
|
||||
liveMode: false,
|
||||
insertStyleRules: [],
|
||||
triggerFocus: true,
|
||||
};
|
||||
this.config = Object.assign({}, defaultConfig, config);
|
||||
|
||||
this.timer = new Timer(this.config);
|
||||
@@ -155,13 +168,13 @@ export class Replayer {
|
||||
}
|
||||
this.timer.addActions(actions);
|
||||
this.timer.start();
|
||||
this.playing = true;
|
||||
this.service.send({ type: 'PLAY' });
|
||||
this.emitter.emit(ReplayerEvents.Start);
|
||||
}
|
||||
|
||||
public pause() {
|
||||
this.timer.clear();
|
||||
this.playing = false;
|
||||
this.service.send({ type: 'PAUSE' });
|
||||
this.emitter.emit(ReplayerEvents.Pause);
|
||||
}
|
||||
|
||||
@@ -184,7 +197,7 @@ export class Replayer {
|
||||
}
|
||||
this.timer.addActions(actions);
|
||||
this.timer.start();
|
||||
this.playing = true;
|
||||
this.service.send({ type: 'RESUME' });
|
||||
this.emitter.emit(ReplayerEvents.Resume);
|
||||
}
|
||||
|
||||
@@ -344,7 +357,7 @@ export class Replayer {
|
||||
this.timer.clear(); // artificial pause
|
||||
this.emitter.emit(ReplayerEvents.LoadStylesheetStart);
|
||||
timer = window.setTimeout(() => {
|
||||
if (this.playing) {
|
||||
if (this.service.state.matches('playing')) {
|
||||
this.resume(this.getCurrentTime());
|
||||
}
|
||||
// mark timer was called
|
||||
@@ -355,7 +368,7 @@ export class Replayer {
|
||||
css.addEventListener('load', () => {
|
||||
unloadSheets.delete(css);
|
||||
if (unloadSheets.size === 0 && timer !== -1) {
|
||||
if (this.playing) {
|
||||
if (this.service.state.matches('playing')) {
|
||||
this.resume(this.getCurrentTime());
|
||||
}
|
||||
this.emitter.emit(ReplayerEvents.LoadStylesheetEnd);
|
||||
|
||||
151
src/replay/machine.ts
Normal file
151
src/replay/machine.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
createMachine,
|
||||
EventObject,
|
||||
Typestate,
|
||||
InterpreterStatus,
|
||||
StateMachine,
|
||||
} from '@xstate/fsm';
|
||||
import { playerConfig, eventWithTime } from '../types';
|
||||
|
||||
type PlayerContext = {
|
||||
events: eventWithTime[];
|
||||
timeOffset: number;
|
||||
speed: playerConfig['speed'];
|
||||
};
|
||||
type PlayerEvent =
|
||||
| { type: 'PLAY' }
|
||||
| { type: 'PAUSE' }
|
||||
| { type: 'RESUME' }
|
||||
| { type: 'END' }
|
||||
| { type: 'REPLAY' }
|
||||
| { type: 'FAST_FORWARD' }
|
||||
| { type: 'BACK_TO_NORMAL' };
|
||||
type PlayerState =
|
||||
| {
|
||||
value: 'inited';
|
||||
context: PlayerContext;
|
||||
}
|
||||
| {
|
||||
value: 'playing';
|
||||
context: PlayerContext;
|
||||
}
|
||||
| {
|
||||
value: 'paused';
|
||||
context: PlayerContext;
|
||||
}
|
||||
| {
|
||||
value: 'ended';
|
||||
context: PlayerContext;
|
||||
}
|
||||
| {
|
||||
value: 'skipping';
|
||||
context: PlayerContext;
|
||||
};
|
||||
|
||||
// TODO: import interpret when this relased
|
||||
// https://github.com/davidkpiano/xstate/issues/1080
|
||||
// tslint:disable no-any
|
||||
function toEventObject<TEvent extends EventObject>(
|
||||
event: TEvent['type'] | TEvent,
|
||||
): TEvent {
|
||||
return (typeof event === 'string' ? { type: event } : event) as TEvent;
|
||||
}
|
||||
const INIT_EVENT = { type: 'xstate.init' };
|
||||
const executeStateActions = <
|
||||
TContext extends object,
|
||||
TEvent extends EventObject = any,
|
||||
TState extends Typestate<TContext> = any
|
||||
>(
|
||||
state: StateMachine.State<TContext, TEvent, TState>,
|
||||
event: TEvent | typeof INIT_EVENT,
|
||||
) =>
|
||||
state.actions.forEach(
|
||||
({ exec }) => exec && exec(state.context, event as any),
|
||||
);
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES5",
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
|
||||
Reference in New Issue
Block a user