diff --git a/package.json b/package.json index 33d1312f..d7f77e53 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/declarations/@xstate/fsm/index.d.ts b/src/declarations/@xstate/fsm/index.d.ts new file mode 100644 index 00000000..f9044d6f --- /dev/null +++ b/src/declarations/@xstate/fsm/index.d.ts @@ -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 = any + > { + send: (event: TEvent | TEvent['type']) => void; + subscribe: ( + listener: StateListener>, + ) => { + unsubscribe: () => void; + }; + start: () => Service; + stop: () => Service; + readonly status: fsm.InterpreterStatus; + readonly state: State; + } + } +} diff --git a/src/replay/index.ts b/src/replay/index.ts index 54767823..d1e58b40 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -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; constructor( events: Array, @@ -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); diff --git a/src/replay/machine.ts b/src/replay/machine.ts new file mode 100644 index 00000000..093185bf --- /dev/null +++ b/src/replay/machine.ts @@ -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( + 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 = any +>( + state: StateMachine.State, + 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 = any +>( + machine: StateMachine.Machine, +): StateMachine.Service { + let state = machine.initialState; + let status = InterpreterStatus.NotStarted; + const listeners = new Set>(); + + 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) => { + 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({ + 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); +} diff --git a/tsconfig.json b/tsconfig.json index 32281a27..17d29926 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "module": "commonjs", + "target": "ES5", "noImplicitAny": true, "strictNullChecks": true, "removeComments": true,