From 2a6e2e0ef9ce8af338516661d71661b565e33301 Mon Sep 17 00:00:00 2001 From: yz-yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] 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 --- package.json | 2 +- src/replay/index.ts | 198 +++++++++++--------------- src/replay/machine.ts | 320 ++++++++++++++++++++++++++---------------- src/replay/timer.ts | 28 +++- 4 files changed, 314 insertions(+), 234 deletions(-) diff --git a/package.json b/package.json index cd1d0c3f..526a39f4 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ }, "dependencies": { "@types/smoothscroll-polyfill": "^0.3.0", - "@xstate/fsm": "^1.3.0", + "@xstate/fsm": "^1.4.0", "mitt": "^1.1.3", "pako": "^1.0.11", "rrweb-snapshot": "^0.7.26", diff --git a/src/replay/index.ts b/src/replay/index.ts index da3664d0..e4413676 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -1,7 +1,7 @@ import { rebuild, buildNodeWithSN } from 'rrweb-snapshot'; import * as mittProxy from 'mitt'; import * as smoothscroll from 'smoothscroll-polyfill'; -import Timer from './timer'; +import { Timer } from './timer'; import { createPlayerService } from './machine'; import { EventType, @@ -15,7 +15,6 @@ import { missingNodeMap, addedNodeMutation, missingNode, - actionWithDelay, incrementalSnapshotEvent, incrementalData, ReplayerEvents, @@ -53,19 +52,16 @@ export class Replayer { public wrapper: HTMLDivElement; public iframe: HTMLIFrameElement; - public timer: Timer; + public get timer() { + return this.service.state.context.timer; + } - private events: eventWithTime[] = []; private config: playerConfig; private mouse: HTMLDivElement; private emitter: Emitter = mitt(); - private baselineTime: number = 0; - // record last played event timestamp when paused - private lastPlayedEvent: eventWithTime; - private nextUserInteractionEvent: eventWithTime | null; private noramlSpeed: number = -1; @@ -77,35 +73,45 @@ export class Replayer { events: Array, config?: Partial, ) { - if (events.length < 2) { + if (!config?.liveMode && 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); - } - return e as eventWithTime; - }); - this.handleResize = this.handleResize.bind(this); - 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(); polyfill(); 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) { @@ -123,8 +129,9 @@ export class Replayer { } public getMetaData(): playerMetaData { - const firstEvent = this.events[0]; - const lastEvent = this.events[this.events.length - 1]; + const { events } = this.service.state.context; + const firstEvent = events[0]; + const lastEvent = events[events.length - 1]; return { totalTime: lastEvent.timestamp - firstEvent.timestamp, }; @@ -135,7 +142,8 @@ export class Replayer { } 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 */ public play(timeOffset = 0) { - this.timer.clear(); - this.baselineTime = this.events[0].timestamp + timeOffset; - const actions = new Array(); - 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.service.send({ type: 'PLAY', payload: { timeOffset } }); this.emitter.emit(ReplayerEvents.Start); } public pause() { - this.timer.clear(); this.service.send({ type: 'PAUSE' }); this.emitter.emit(ReplayerEvents.Pause); } public resume(timeOffset = 0) { - this.timer.clear(); - this.baselineTime = this.events[0].timestamp + timeOffset; - const actions = new Array(); - 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.service.send({ type: 'RESUME', payload: { timeOffset } }); this.emitter.emit(ReplayerEvents.Resume); } + public startLive(baselineTime?: number) { + this.service.send({ type: 'TO_LIVE', payload: { baselineTime } }); + } + public addEvent(rawEvent: eventWithTime | string) { const event = this.config.unpackFn ? this.config.unpackFn(rawEvent as string) : (rawEvent as eventWithTime); - const castFn = this.getCastFn(event, true); - castFn(); + Promise.resolve().then(() => + 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() { @@ -220,8 +204,7 @@ export class Replayer { this.iframe = document.createElement('iframe'); this.iframe.setAttribute('sandbox', 'allow-same-origin'); - this.iframe.setAttribute('scrolling', 'no'); - this.iframe.setAttribute('style', 'pointer-events: none'); + this.disableInteract(); this.wrapper.appendChild(this.iframe); } @@ -230,25 +213,8 @@ export class Replayer { 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) { + const { events } = this.service.state.context; let castFn: undefined | (() => void); switch (event.type) { case EventType.DomContentLoaded: @@ -257,7 +223,7 @@ export class Replayer { case EventType.Custom: 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. */ @@ -285,7 +251,7 @@ export class Replayer { this.restoreSpeed(); } if (this.config.skipInactive && !this.nextUserInteractionEvent) { - for (const _event of this.events) { + for (const _event of events) { if (_event.timestamp! <= event.timestamp!) { continue; } @@ -318,9 +284,10 @@ export class Replayer { if (castFn) { castFn(); } - this.lastPlayedEvent = event; - if (event === this.events[this.events.length - 1]) { + this.service.send({ type: 'CAST_EVENT', payload: { event } }); + if (event === events[events.length - 1]) { this.restoreSpeed(); + this.service.send('END'); this.emitter.emit(ReplayerEvents.Finish); } }; @@ -359,26 +326,17 @@ export class Replayer { if (head) { const unloadSheets: Set = new Set(); let timer: number; + let beforeLoadState = this.service.state; head .querySelectorAll('link[rel="stylesheet"]') .forEach((css: HTMLLinkElement) => { 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); css.addEventListener('load', () => { unloadSheets.delete(css); + // all loaded and timer not released yet if (unloadSheets.size === 0 && timer !== -1) { - if (this.service.state.matches('playing')) { + if (beforeLoadState.matches('playing')) { this.resume(this.getCurrentTime()); } 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 }, isSync: boolean, ) { + const { baselineTime } = this.service.state.context; const { data: d } = e; switch (d.source) { case IncrementalSource.Mutation: { @@ -535,7 +507,7 @@ export class Replayer { doAction: () => { 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); }); diff --git a/src/replay/machine.ts b/src/replay/machine.ts index 093185bf..936ffb33 100644 --- a/src/replay/machine.ts +++ b/src/replay/machine.ts @@ -1,26 +1,53 @@ +import { createMachine, interpret, assign } from '@xstate/fsm'; import { - createMachine, - EventObject, - Typestate, - InterpreterStatus, - StateMachine, -} from '@xstate/fsm'; -import { playerConfig, eventWithTime } from '../types'; + playerConfig, + eventWithTime, + actionWithDelay, + ReplayerEvents, + Emitter, +} from '../types'; +import { Timer, getDelay } from './timer'; -type PlayerContext = { +export type PlayerContext = { events: eventWithTime[]; - timeOffset: number; + timer: Timer; speed: playerConfig['speed']; + timeOffset: number; + baselineTime: number; + lastPlayedEvent: eventWithTime | null; }; -type PlayerEvent = - | { type: 'PLAY' } +export type PlayerEvent = + | { + type: 'PLAY'; + payload: { + timeOffset: number; + }; + } + | { + type: 'CAST_EVENT'; + payload: { + event: eventWithTime; + }; + } | { type: 'PAUSE' } - | { type: 'RESUME' } + | { + type: 'RESUME'; + payload: { + timeOffset: number; + }; + } | { type: 'END' } | { type: 'REPLAY' } | { type: 'FAST_FORWARD' } - | { type: 'BACK_TO_NORMAL' }; -type PlayerState = + | { type: 'BACK_TO_NORMAL' } + | { type: 'TO_LIVE'; payload: { baselineTime?: number } } + | { + type: 'ADD_EVENT'; + payload: { + event: eventWithTime; + }; + }; +export type PlayerState = | { value: 'inited'; context: PlayerContext; @@ -40,112 +67,169 @@ type PlayerState = | { value: 'skipping'; context: PlayerContext; + } + | { + value: 'live'; + 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), +type PlayerAssets = { + emitter: Emitter; + getCastFn(event: eventWithTime, isSync: boolean): () => void; +}; +export function createPlayerService( + context: PlayerContext, + { getCastFn, emitter }: PlayerAssets, +) { + const playerMachine = createMachine( + { + id: 'player', + context, + initial: 'inited', + states: { + inited: { + on: { + PLAY: { + target: 'playing', + 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(); + 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 = 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/src/replay/timer.ts b/src/replay/timer.ts index 68718656..38e1d3b5 100644 --- a/src/replay/timer.ts +++ b/src/replay/timer.ts @@ -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; private actions: actionWithDelay[]; @@ -75,3 +81,21 @@ export default class Timer { 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; +}