Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cool-grapes-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rrdom': patch
---

Support `loop` in `RRMediaElement`
5 changes: 5 additions & 0 deletions .changeset/dirty-rules-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rrweb-snapshot': minor
---

Video and Audio elements now also capture `playbackRate`, `muted`, `loop`, `volume`.
5 changes: 5 additions & 0 deletions .changeset/mighty-ads-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rrweb': minor
---

Full overhawl of `video` and `audio` element playback. More robust and fixes lots of bugs related to pausing/playing/skipping/muting/playbackRate etc.
5 changes: 5 additions & 0 deletions .changeset/silver-pots-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rrweb/types': patch
---

Add `loop` to `mediaInteractionParam`
5 changes: 5 additions & 0 deletions .changeset/smart-geckos-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rrweb': patch
---

Record `loop` on `<audio>` & `<video>` elements.
2 changes: 2 additions & 0 deletions packages/rrdom/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ function diffAfterUpdatingChildren(
oldMediaElement.currentTime = newMediaRRElement.currentTime;
if (newMediaRRElement.playbackRate !== undefined)
oldMediaElement.playbackRate = newMediaRRElement.playbackRate;
if (newMediaRRElement.loop !== undefined)
oldMediaElement.loop = newMediaRRElement.loop;
break;
}
case 'CANVAS': {
Expand Down
1 change: 1 addition & 0 deletions packages/rrdom/src/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ export function BaseRRMediaElementImpl<
public paused?: boolean;
public muted?: boolean;
public playbackRate?: number;
public loop?: boolean;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
attachShadow(_init: ShadowRootInit): IRRElement {
throw new Error(
Expand Down
2 changes: 2 additions & 0 deletions packages/rrdom/test/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,15 @@ describe('diff algorithm for rrdom', () => {
rrMedia.muted = true;
rrMedia.paused = false;
rrMedia.playbackRate = 0.5;
rrMedia.loop = false;

diff(element, rrMedia, replayer);
expect(element.volume).toEqual(0.5);
expect(element.currentTime).toEqual(100);
expect(element.muted).toEqual(true);
expect(element.paused).toEqual(false);
expect(element.playbackRate).toEqual(0.5);
expect(element.loop).toEqual(false);

rrMedia.paused = true;
diff(element, rrMedia, replayer);
Expand Down
1 change: 1 addition & 0 deletions packages/rrdom/test/document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.paused).toBeUndefined();
expect(node.muted).toBeUndefined();
expect(node.playbackRate).toBeUndefined();
expect(node.loop).toBeUndefined();
expect(node.play).toBeDefined();
expect(node.pause).toBeDefined();
expect(node.toString()).toEqual('VIDEO ');
Expand Down
11 changes: 11 additions & 0 deletions packages/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,17 @@ function buildNode(
break;
default:
}
} else if (
name === 'rr_mediaPlaybackRate' &&
typeof value === 'number'
) {
(node as HTMLMediaElement).playbackRate = value;
} else if (name === 'rr_mediaMuted' && typeof value === 'boolean') {
(node as HTMLMediaElement).muted = value;
} else if (name === 'rr_mediaLoop' && typeof value === 'boolean') {
(node as HTMLMediaElement).loop = value;
} else if (name === 'rr_mediaVolume' && typeof value === 'number') {
(node as HTMLMediaElement).volume = value;
}
}

Expand Down
10 changes: 8 additions & 2 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
KeepIframeSrcFn,
ICanvas,
serializedElementNodeWithId,
type mediaAttributes,
} from './types';
import {
Mirror,
Expand Down Expand Up @@ -788,10 +789,15 @@ function serializeElementNode(
}
// media elements
if (tagName === 'audio' || tagName === 'video') {
attributes.rr_mediaState = (n as HTMLMediaElement).paused
const mediaAttributes = attributes as mediaAttributes;
mediaAttributes.rr_mediaState = (n as HTMLMediaElement).paused
? 'paused'
: 'played';
attributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime;
mediaAttributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime;
mediaAttributes.rr_mediaPlaybackRate = (n as HTMLMediaElement).playbackRate;
mediaAttributes.rr_mediaMuted = (n as HTMLMediaElement).muted;
mediaAttributes.rr_mediaLoop = (n as HTMLMediaElement).loop;
mediaAttributes.rr_mediaVolume = (n as HTMLMediaElement).volume;
}
// Scroll
if (!newlyAddedElement) {
Expand Down
21 changes: 21 additions & 0 deletions packages/rrweb-snapshot/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,27 @@ export type tagMap = {
[key: string]: string;
};

export type mediaAttributes = {
rr_mediaState: 'played' | 'paused';
rr_mediaCurrentTime: number;
/**
* for backwards compatibility this is optional but should always be set
*/
rr_mediaPlaybackRate?: number;
/**
* for backwards compatibility this is optional but should always be set
*/
rr_mediaMuted?: boolean;
/**
* for backwards compatibility this is optional but should always be set
*/
rr_mediaLoop?: boolean;
/**
* for backwards compatibility this is optional but should always be set
*/
rr_mediaVolume?: number;
};

// @deprecated
export interface INode extends Node {
__sn: serializedNodeWithId;
Expand Down
3 changes: 2 additions & 1 deletion packages/rrweb/src/record/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1042,7 +1042,7 @@ function initMediaInteractionObserver({
) {
return;
}
const { currentTime, volume, muted, playbackRate } =
const { currentTime, volume, muted, playbackRate, loop } =
target as HTMLMediaElement;
mediaInteractionCb({
type,
Expand All @@ -1051,6 +1051,7 @@ function initMediaInteractionObserver({
volume,
muted,
playbackRate,
loop,
});
}),
sampling.media || 500,
Expand Down
75 changes: 44 additions & 31 deletions packages/rrweb/src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import {
ReplayerEvents,
Handler,
Emitter,
MediaInteractions,
metaEvent,
mutationData,
scrollData,
Expand Down Expand Up @@ -81,6 +80,7 @@ import getInjectStyleRules from './styles/inject-style';
import './styles/style.css';
import canvasMutation from './canvas';
import { deserializeArg } from './canvas/deserialize-args';
import { MediaManager } from './media';

const SKIP_TIME_INTERVAL = 5 * 1000;

Expand Down Expand Up @@ -142,6 +142,9 @@ export class Replayer {
// Used to track StyleSheetObjects adopted on multiple document hosts.
private styleMirror: StyleSheetMirror = new StyleSheetMirror();

// Used to track video & audio elements, and keep them in sync with general playback.
private mediaManager: MediaManager;

private firstFullSnapshot: eventWithTime | true | null = null;

private newDocumentQueue: addedNodeMutation[] = [];
Expand Down Expand Up @@ -324,6 +327,7 @@ export class Replayer {
this.firstFullSnapshot = null;
this.mirror.reset();
this.styleMirror.reset();
this.mediaManager.reset();
});

const timer = new Timer([], {
Expand Down Expand Up @@ -366,6 +370,13 @@ export class Replayer {
speed: state,
});
});
this.mediaManager = new MediaManager({
warn: this.warn.bind(this),
service: this.service,
speedService: this.speedService,
emitter: this.emitter,
getCurrentTime: this.getCurrentTime.bind(this),
});

// rebuild first full snapshot as the poster of the player
// maybe we can cache it for performance optimization
Expand Down Expand Up @@ -464,10 +475,16 @@ export class Replayer {
};
}

/**
* Get the actual time offset the player is at now compared to the first event.
*/
public getCurrentTime(): number {
return this.timer.timeOffset + this.getTimeOffset();
}

/**
* Get the time offset the player is at now compared to the first event, but without regard for the timer.
*/
public getTimeOffset(): number {
const { baselineTime, events } = this.service.state.context;
return baselineTime - events[0].timestamp;
Expand Down Expand Up @@ -527,6 +544,9 @@ export class Replayer {
*/
public destroy() {
this.pause();
this.mirror.reset();
this.styleMirror.reset();
this.mediaManager.reset();
this.config.root.removeChild(this.wrapper);
this.emitter.emit(ReplayerEvents.Destroy);
}
Expand Down Expand Up @@ -667,9 +687,10 @@ export class Replayer {
// Timer (requestAnimationFrame) can be faster than setTimeout(..., 1)
this.firstFullSnapshot = true;
}
this.mediaManager.reset();
this.styleMirror.reset();
this.rebuildFullSnapshot(event, isSync);
this.iframe.contentWindow?.scrollTo(event.data.initialOffset);
this.styleMirror.reset();
};
break;
case EventType.IncrementalSnapshot:
Expand Down Expand Up @@ -778,6 +799,14 @@ export class Replayer {
const collected: AppendedIframe[] = [];
const afterAppend = (builtNode: Node, id: number) => {
this.collectIframeAndAttachDocument(collected, builtNode);
if (this.mediaManager.isSupportedMediaElement(builtNode)) {
const { events } = this.service.state.context;
this.mediaManager.addMediaElements(
builtNode,
event.timestamp - events[0].timestamp,
this.mirror,
);
}
for (const plugin of this.config.plugins || []) {
if (plugin.onBuild)
plugin.onBuild(builtNode, {
Expand Down Expand Up @@ -1261,35 +1290,14 @@ export class Replayer {
return this.debugNodeNotFound(d, d.id);
}
const mediaEl = target as HTMLMediaElement | RRMediaElement;
try {
if (d.currentTime !== undefined) {
mediaEl.currentTime = d.currentTime;
}
if (d.volume !== undefined) {
mediaEl.volume = d.volume;
}
if (d.muted !== undefined) {
mediaEl.muted = d.muted;
}
if (d.type === MediaInteractions.Pause) {
mediaEl.pause();
}
if (d.type === MediaInteractions.Play) {
// remove listener for 'canplay' event because play() is async and returns a promise
// i.e. media will evntualy start to play when data is loaded
// 'canplay' event fires even when currentTime attribute changes which may lead to
// unexpeted behavior
void mediaEl.play();
}
if (d.type === MediaInteractions.RateChange) {
mediaEl.playbackRate = d.playbackRate;
}
} catch (error) {
this.warn(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions
`Failed to replay media interactions: ${error.message || error}`,
);
}
const { events } = this.service.state.context;

this.mediaManager.mediaMutation({
target: mediaEl,
timeOffset: e.timestamp - events[0].timestamp,
mutation: d,
});

break;
}
case IncrementalSource.StyleSheetRule:
Expand Down Expand Up @@ -1366,6 +1374,11 @@ export class Replayer {
}
}

/**
* Apply the mutation to the virtual dom or the real dom.
* @param d - The mutation data.
* @param isSync - Whether the mutation should be applied synchronously (while fast-forwarding).
*/
private applyMutation(d: mutationData, isSync: boolean) {
// Only apply virtual dom optimization if the fast-forward process has node mutation. Because the cost of creating a virtual dom tree and executing the diff algorithm is usually higher than directly applying other kind of events.
if (this.config.useVirtualDom && !this.usingVirtualDom && isSync) {
Expand Down
Loading
Loading