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:
yz-yu
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 5317bf73b9
commit 2a6e2e0ef9
4 changed files with 314 additions and 234 deletions

View File

@@ -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",

View File

@@ -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);
}); });

View File

@@ -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);
} }

View File

@@ -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;
}