impl basic player state machine (#198)

This commit is contained in:
yz-yu
2026-04-01 12:00:00 +08:00
committed by GitHub
parent a7d857c9e4
commit bfc0c43aa7
5 changed files with 206 additions and 18 deletions

View File

@@ -59,6 +59,7 @@
}, },
"dependencies": { "dependencies": {
"@types/smoothscroll-polyfill": "^0.3.0", "@types/smoothscroll-polyfill": "^0.3.0",
"@xstate/fsm": "^1.3.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",

22
src/declarations/@xstate/fsm/index.d.ts vendored Normal file
View 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>;
}
}
}

View File

@@ -2,6 +2,7 @@ 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 { import {
EventType, EventType,
IncrementalSource, IncrementalSource,
@@ -35,6 +36,19 @@ const mitt = (mittProxy as any).default || mittProxy;
const REPLAY_CONSOLE_PREFIX = '[replayer]'; 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 { export class Replayer {
public wrapper: HTMLDivElement; public wrapper: HTMLDivElement;
public iframe: HTMLIFrameElement; public iframe: HTMLIFrameElement;
@@ -57,7 +71,7 @@ export class Replayer {
private missingNodeRetryMap: missingNodeMap = {}; private missingNodeRetryMap: missingNodeMap = {};
private playing: boolean = false; private service!: ReturnType<typeof createPlayerService>;
constructor( constructor(
events: Array<eventWithTime | string>, events: Array<eventWithTime | string>,
@@ -66,6 +80,17 @@ export class Replayer {
if (events.length < 2) { if (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) => { this.events = events.map((e) => {
if (config && config.unpackFn) { if (config && config.unpackFn) {
return config.unpackFn(e as string); return config.unpackFn(e as string);
@@ -74,18 +99,6 @@ export class Replayer {
}); });
this.handleResize = this.handleResize.bind(this); 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.config = Object.assign({}, defaultConfig, config);
this.timer = new Timer(this.config); this.timer = new Timer(this.config);
@@ -155,13 +168,13 @@ export class Replayer {
} }
this.timer.addActions(actions); this.timer.addActions(actions);
this.timer.start(); this.timer.start();
this.playing = true; this.service.send({ type: 'PLAY' });
this.emitter.emit(ReplayerEvents.Start); this.emitter.emit(ReplayerEvents.Start);
} }
public pause() { public pause() {
this.timer.clear(); this.timer.clear();
this.playing = false; this.service.send({ type: 'PAUSE' });
this.emitter.emit(ReplayerEvents.Pause); this.emitter.emit(ReplayerEvents.Pause);
} }
@@ -184,7 +197,7 @@ export class Replayer {
} }
this.timer.addActions(actions); this.timer.addActions(actions);
this.timer.start(); this.timer.start();
this.playing = true; this.service.send({ type: 'RESUME' });
this.emitter.emit(ReplayerEvents.Resume); this.emitter.emit(ReplayerEvents.Resume);
} }
@@ -344,7 +357,7 @@ export class Replayer {
this.timer.clear(); // artificial pause this.timer.clear(); // artificial pause
this.emitter.emit(ReplayerEvents.LoadStylesheetStart); this.emitter.emit(ReplayerEvents.LoadStylesheetStart);
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
if (this.playing) { if (this.service.state.matches('playing')) {
this.resume(this.getCurrentTime()); this.resume(this.getCurrentTime());
} }
// mark timer was called // mark timer was called
@@ -355,7 +368,7 @@ export class Replayer {
css.addEventListener('load', () => { css.addEventListener('load', () => {
unloadSheets.delete(css); unloadSheets.delete(css);
if (unloadSheets.size === 0 && timer !== -1) { if (unloadSheets.size === 0 && timer !== -1) {
if (this.playing) { if (this.service.state.matches('playing')) {
this.resume(this.getCurrentTime()); this.resume(this.getCurrentTime());
} }
this.emitter.emit(ReplayerEvents.LoadStylesheetEnd); this.emitter.emit(ReplayerEvents.LoadStylesheetEnd);

151
src/replay/machine.ts Normal file
View 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);
}

View File

@@ -1,6 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "commonjs",
"target": "ES5",
"noImplicitAny": true, "noImplicitAny": true,
"strictNullChecks": true, "strictNullChecks": true,
"removeComments": true, "removeComments": true,