moved rrweb-player into packages/rrweb-player

This commit is contained in:
Mark-fenng
2026-04-01 12:00:00 +08:00
parent ec423f29d0
commit 2ef99ed976
16 changed files with 0 additions and 1524 deletions

View File

@@ -0,0 +1,433 @@
<script lang="ts">
import { EventType } from 'rrweb';
import type { Replayer } from 'rrweb';
import type { playerMetaData } from 'rrweb/typings/types';
import type {
PlayerMachineState,
SpeedMachineState,
} from 'rrweb/typings/replay/machine';
import {
onMount,
onDestroy,
createEventDispatcher,
afterUpdate,
} from 'svelte';
import { formatTime } from './utils';
import Switch from './components/Switch.svelte';
const dispatch = createEventDispatcher();
export let replayer: Replayer;
export let showController: boolean;
export let autoPlay: boolean;
export let skipInactive: boolean;
export let speedOption: number[];
export let speed = speedOption.length ? speedOption[0] : 1;
export let tags: Record<string, string> = {};
let currentTime = 0;
$: {
dispatch('ui-update-current-time', { payload: currentTime });
}
let timer: number | null = null;
let playerState: 'playing' | 'paused' | 'live';
$: {
dispatch('ui-update-player-state', { payload: playerState });
}
let speedState: 'normal' | 'skipping';
let progress: HTMLElement;
let step: HTMLElement;
let finished: boolean;
let meta: playerMetaData;
$: meta = replayer.getMetaData();
let percentage: string;
$: {
const percent = Math.min(1, currentTime / meta.totalTime);
percentage = `${100 * percent}%`;
dispatch('ui-update-progress', { payload: percent });
}
type CustomEvent = {
name: string;
background: string;
position: string;
};
let customEvents: CustomEvent[];
$: customEvents = (() => {
const { context } = replayer.service.state;
const totalEvents = context.events.length;
const start = context.events[0].timestamp;
const end = context.events[totalEvents - 1].timestamp;
const customEvents: CustomEvent[] = [];
// calculate tag position.
const position = (startTime: number, endTime: number, tagTime: number) => {
const sessionDuration = endTime - startTime;
const eventDuration = endTime - tagTime;
const eventPosition = 100 - (eventDuration / sessionDuration) * 100;
return eventPosition.toFixed(2);
};
// loop through all the events and find out custom event.
context.events.forEach((event) => {
/**
* we are only interested in custom event and calculate it's position
* to place it in player's timeline.
*/
if (event.type === EventType.Custom) {
const customEvent = {
name: event.data.tag,
background: tags[event.data.tag] || 'rgb(73, 80, 246)',
position: `${position(start, end, event.timestamp)}%`,
};
customEvents.push(customEvent);
}
});
return customEvents;
})();
const loopTimer = () => {
stopTimer();
function update() {
currentTime = replayer.getCurrentTime();
if (currentTime < meta.totalTime) {
timer = requestAnimationFrame(update);
}
}
timer = requestAnimationFrame(update);
};
const stopTimer = () => {
if (timer) {
cancelAnimationFrame(timer);
timer = null;
}
};
export const toggle = () => {
switch (playerState) {
case 'playing':
pause();
break;
case 'paused':
play();
break;
default:
break;
}
};
export const play = () => {
if (playerState !== 'paused') {
return;
}
if (finished) {
replayer.play();
finished = false;
} else {
replayer.play(currentTime);
}
};
export const pause = () => {
if (playerState !== 'playing') {
return;
}
replayer.pause();
};
export const goto = (timeOffset: number, play?: boolean) => {
currentTime = timeOffset;
const resumePlaying =
typeof play === 'boolean' ? play : playerState === 'playing';
if (resumePlaying) {
replayer.play(timeOffset);
} else {
replayer.pause(timeOffset);
}
};
const handleProgressClick = (event: MouseEvent) => {
if (speedState === 'skipping') {
return;
}
const progressRect = progress.getBoundingClientRect();
const x = event.clientX - progressRect.left;
let percent = x / progressRect.width;
if (percent < 0) {
percent = 0;
} else if (percent > 1) {
percent = 1;
}
const timeOffset = meta.totalTime * percent;
goto(timeOffset);
};
export const setSpeed = (newSpeed: number) => {
let needFreeze = playerState === 'playing';
speed = newSpeed;
if (needFreeze) {
replayer.pause();
}
replayer.setConfig({ speed });
if (needFreeze) {
replayer.play(currentTime);
}
};
export const toggleSkipInactive = () => {
skipInactive = !skipInactive;
};
onMount(() => {
playerState = replayer.service.state.value as typeof playerState;
speedState = replayer.speedService.state.value as typeof speedState;
replayer.on(
'state-change',
(states: { player?: PlayerMachineState; speed?: SpeedMachineState }) => {
const { player, speed } = states;
if (player?.value && playerState !== player.value) {
playerState = player.value as typeof playerState;
switch (playerState) {
case 'playing':
loopTimer();
break;
case 'paused':
stopTimer();
break;
default:
break;
}
}
if (speed?.value && speedState !== speed.value) {
speedState = speed.value as typeof speedState;
}
},
);
replayer.on('finish', () => {
finished = true;
});
if (autoPlay) {
replayer.play();
}
});
afterUpdate(() => {
if (skipInactive !== replayer.config.skipInactive) {
replayer.setConfig({ skipInactive });
}
});
onDestroy(() => {
replayer.pause();
stopTimer();
});
</script>
<style>
.rr-controller {
width: 100%;
height: 80px;
background: #fff;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
border-radius: 0 0 5px 5px;
}
.rr-timeline {
width: 80%;
display: flex;
align-items: center;
}
.rr-timeline__time {
display: inline-block;
width: 100px;
text-align: center;
color: #11103e;
}
.rr-progress {
flex: 1;
height: 12px;
background: #eee;
position: relative;
border-radius: 3px;
cursor: pointer;
box-sizing: border-box;
border-top: solid 4px #fff;
border-bottom: solid 4px #fff;
}
.rr-progress.disabled {
cursor: not-allowed;
}
.rr-progress__step {
height: 100%;
position: absolute;
left: 0;
top: 0;
background: #e0e1fe;
}
.rr-progress__handler {
width: 20px;
height: 20px;
border-radius: 10px;
position: absolute;
top: 2px;
transform: translate(-50%, -50%);
background: rgb(73, 80, 246);
}
.rr-controller__btns {
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.rr-controller__btns button {
width: 32px;
height: 32px;
display: flex;
padding: 0;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 50%;
cursor: pointer;
}
.rr-controller__btns button:active {
background: #e0e1fe;
}
.rr-controller__btns button.active {
color: #fff;
background: rgb(73, 80, 246);
}
.rr-controller__btns button:disabled {
cursor: not-allowed;
}
</style>
{#if showController}
<div class="rr-controller">
<div class="rr-timeline">
<span class="rr-timeline__time">{formatTime(currentTime)}</span>
<div
class="rr-progress"
class:disabled={speedState === 'skipping'}
bind:this={progress}
on:click={(event) => handleProgressClick(event)}>
<div
class="rr-progress__step"
bind:this={step}
style="width: {percentage}" />
{#each customEvents as event}
<div
title={event.name}
style="width: 10px;height: 5px;position: absolute;top:
2px;transform: translate(-50%, -50%);background: {event.background};left:
{event.position};" />
{/each}
<div class="rr-progress__handler" style="left: {percentage}" />
</div>
<span class="rr-timeline__time">{formatTime(meta.totalTime)}</span>
</div>
<div class="rr-controller__btns">
<button on:click={toggle}>
{#if playerState === 'playing'}
<svg
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="16"
height="16">
<path
d="M682.65984 128q53.00224 0 90.50112 37.49888t37.49888 90.50112l0
512q0 53.00224-37.49888 90.50112t-90.50112
37.49888-90.50112-37.49888-37.49888-90.50112l0-512q0-53.00224
37.49888-90.50112t90.50112-37.49888zM341.34016 128q53.00224 0
90.50112 37.49888t37.49888 90.50112l0 512q0 53.00224-37.49888
90.50112t-90.50112
37.49888-90.50112-37.49888-37.49888-90.50112l0-512q0-53.00224
37.49888-90.50112t90.50112-37.49888zM341.34016 213.34016q-17.67424
0-30.16704 12.4928t-12.4928 30.16704l0 512q0 17.67424 12.4928
30.16704t30.16704 12.4928 30.16704-12.4928
12.4928-30.16704l0-512q0-17.67424-12.4928-30.16704t-30.16704-12.4928zM682.65984
213.34016q-17.67424 0-30.16704 12.4928t-12.4928 30.16704l0 512q0
17.67424 12.4928 30.16704t30.16704 12.4928 30.16704-12.4928
12.4928-30.16704l0-512q0-17.67424-12.4928-30.16704t-30.16704-12.4928z" />
</svg>
{:else}
<svg
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="16"
height="16">
<path
d="M170.65984 896l0-768 640 384zM644.66944
512l-388.66944-233.32864 0 466.65728z" />
</svg>
{/if}
</button>
{#each speedOption as s}
<button
class:active={s === speed && speedState !== 'skipping'}
on:click={() => setSpeed(s)}
disabled={speedState === 'skipping'}>
{s}x
</button>
{/each}
<Switch
id="skip"
bind:checked={skipInactive}
disabled={speedState === 'skipping'}
label="skip inactive" />
<button on:click={() => dispatch('fullscreen')}>
<svg
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="16"
height="16">
<defs>
<style type="text/css">
</style>
</defs>
<path
d="M916 380c-26.4 0-48-21.6-48-48L868 223.2 613.6 477.6c-18.4
18.4-48.8 18.4-68 0-18.4-18.4-18.4-48.8 0-68L800 156 692 156c-26.4
0-48-21.6-48-48 0-26.4 21.6-48 48-48l224 0c26.4 0 48 21.6 48 48l0
224C964 358.4 942.4 380 916 380zM231.2 860l108.8 0c26.4 0 48 21.6 48
48s-21.6 48-48 48l-224 0c-26.4 0-48-21.6-48-48l0-224c0-26.4 21.6-48
48-48 26.4 0 48 21.6 48 48L164 792l253.6-253.6c18.4-18.4 48.8-18.4
68 0 18.4 18.4 18.4 48.8 0 68L231.2 860z"
p-id="1286" />
</svg>
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Replayer, unpack } from 'rrweb';
import type { eventWithTime } from 'rrweb/typings/types';
import {
inlineCss,
openFullscreen,
exitFullscreen,
isFullscreen,
onFullscreenChange,
typeOf,
} from './utils';
import Controller from './Controller.svelte';
export let width: number = 1024;
export let height: number = 576;
export let events: eventWithTime[] = [];
export let skipInactive: boolean = true;
export let autoPlay: boolean = true;
export let speedOption: number[] = [1, 2, 4, 8];
export let speed: number = 1;
export let showController: boolean = true;
export let tags: Record<string, string> = {};
let replayer: Replayer;
export const getMirror = () => replayer.getMirror();
const controllerHeight = 80;
let player: HTMLElement;
let frame: HTMLElement;
let fullscreenListener: undefined | (() => void);
let _width: number = width;
let _height: number = height;
let controller: {
toggle: () => void;
setSpeed: (speed: number) => void;
toggleSkipInactive: () => void;
} & Controller;
let style: string;
$: style = inlineCss({
width: `${width}px`,
height: `${height}px`,
});
let playerStyle: string;
$: playerStyle = inlineCss({
width: `${width}px`,
height: `${height + (showController ? controllerHeight : 0)}px`,
});
const updateScale = (
el: HTMLElement,
frameDimension: { width: number; height: number },
) => {
const widthScale = width / frameDimension.width;
const heightScale = height / frameDimension.height;
el.style.transform =
`scale(${Math.min(widthScale, heightScale, 1)})` +
'translate(-50%, -50%)';
};
export const triggerResize = () => {
updateScale(replayer.wrapper, {
width: replayer.iframe.offsetWidth,
height: replayer.iframe.offsetHeight,
});
};
export const toggleFullscreen = () => {
if (player) {
isFullscreen() ? exitFullscreen() : openFullscreen(player);
}
};
export const addEventListener = (
event: string,
handler: (detail: unknown) => unknown,
) => {
replayer.on(event, handler);
switch (event) {
case 'ui-update-current-time':
case 'ui-update-progress':
case 'ui-update-player-state':
controller.$on(event, ({ detail }) => handler(detail));
default:
break;
}
};
export const addEvent = (event: eventWithTime) => {
replayer.addEvent(event);
};
export const getMetaData = () => replayer.getMetaData();
export const getReplayer = () => replayer;
// by pass controller methods as public API
export const toggle = () => {
controller.toggle();
};
export const setSpeed = (speed: number) => {
controller.setSpeed(speed);
};
export const toggleSkipInactive = () => {
controller.toggleSkipInactive();
};
export const play = () => {
controller.play();
};
export const pause = () => {
controller.pause();
};
export const goto = (timeOffset: number, play?: boolean) => {
controller.goto(timeOffset, play);
};
onMount(() => {
// runtime type check
if (speedOption !== undefined && typeOf(speedOption) !== 'array') {
throw new Error('speedOption must be array');
}
speedOption.forEach((item) => {
if (typeOf(item) !== 'number') {
throw new Error('item of speedOption must be number');
}
});
if (speedOption.indexOf(speed) < 0) {
throw new Error(`speed must be one of speedOption,
current config:
{
...
speed: ${speed},
speedOption: [${speedOption.toString()}]
...
}
`);
}
replayer = new Replayer(events, {
speed,
root: frame,
unpackFn: unpack,
...$$props,
});
replayer.on('resize', (dimension) => {
updateScale(
replayer.wrapper,
dimension as { width: number; height: number },
);
});
fullscreenListener = onFullscreenChange(() => {
if (isFullscreen()) {
setTimeout(() => {
_width = width;
_height = height;
width = player.offsetWidth;
height = player.offsetHeight;
updateScale(replayer.wrapper, {
width: replayer.iframe.offsetWidth,
height: replayer.iframe.offsetHeight,
});
}, 0);
} else {
width = _width;
height = _height;
updateScale(replayer.wrapper, {
width: replayer.iframe.offsetWidth,
height: replayer.iframe.offsetHeight,
});
}
});
});
onDestroy(() => {
fullscreenListener && fullscreenListener();
});
</script>
<style global>
@import 'rrweb/dist/rrweb.min.css';
.rr-player {
position: relative;
background: white;
float: left;
border-radius: 5px;
box-shadow: 0 24px 48px rgba(17, 16, 62, 0.12);
}
.rr-player__frame {
overflow: hidden;
}
.replayer-wrapper {
float: left;
clear: both;
transform-origin: top left;
left: 50%;
top: 50%;
}
.replayer-wrapper > iframe {
border: none;
}
</style>
<div class="rr-player" bind:this={player} style={playerStyle}>
<div class="rr-player__frame" bind:this={frame} {style} />
{#if replayer}
<Controller
bind:this={controller}
{replayer}
{showController}
{autoPlay}
{speedOption}
{skipInactive}
{tags}
on:fullscreen={() => toggleFullscreen()} />
{/if}
</div>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
export let disabled: boolean;
export let checked: boolean;
export let id: string;
export let label: string;
</script>
<style>
.switch {
height: 1em;
display: flex;
align-items: center;
}
.switch.disabled {
opacity: 0.5;
}
.label {
margin: 0 8px;
}
.switch input[type='checkbox'] {
position: absolute;
opacity: 0;
}
.switch label {
width: 2em;
height: 1em;
position: relative;
cursor: pointer;
display: block;
}
.switch.disabled label {
cursor: not-allowed;
}
.switch label:before {
content: '';
position: absolute;
width: 2em;
height: 1em;
left: 0.1em;
transition: background 0.1s ease;
background: rgba(73, 80, 246, 0.5);
border-radius: 50px;
}
.switch label:after {
content: '';
position: absolute;
width: 1em;
height: 1em;
border-radius: 50px;
left: 0;
transition: all 0.2s ease;
box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.3);
background: #fcfff4;
animation: switch-off 0.2s ease-out;
z-index: 2;
}
.switch input[type='checkbox']:checked + label:before {
background: rgb(73, 80, 246);
}
.switch input[type='checkbox']:checked + label:after {
animation: switch-on 0.2s ease-out;
left: 1.1em;
}
</style>
<div class="switch" class:disabled>
<input type="checkbox" {id} bind:checked {disabled} />
<label for={id} />
<span class="label">{label}</span>
</div>

View File

@@ -0,0 +1,22 @@
import type { eventWithTime } from 'rrweb/typings/types';
import _Player from './Player.svelte';
type PlayerProps = {
events: eventWithTime[];
};
class Player extends _Player {
constructor(options: {
target: Element;
props: PlayerProps;
// for compatibility
data?: PlayerProps;
}) {
super({
target: options.target,
props: options.data || options.props,
});
}
}
export default Player;

View File

@@ -0,0 +1,135 @@
declare global {
interface Document {
mozExitFullscreen: Document['exitFullscreen'];
webkitExitFullscreen: Document['exitFullscreen'];
msExitFullscreen: Document['exitFullscreen'];
webkitIsFullScreen: Document['fullscreen'];
mozFullScreen: Document['fullscreen'];
msFullscreenElement: Document['fullscreen'];
}
interface HTMLElement {
mozRequestFullScreen: Element['requestFullscreen'];
webkitRequestFullscreen: Element['requestFullscreen'];
msRequestFullscreen: Element['requestFullscreen'];
}
}
export function inlineCss(cssObj: Record<string, string>): string {
let style = '';
Object.keys(cssObj).forEach((key) => {
style += `${key}: ${cssObj[key]};`;
});
return style;
}
function padZero(num: number, len = 2): string {
let str = String(num);
const threshold = Math.pow(10, len - 1);
if (num < threshold) {
while (String(threshold).length > str.length) {
str = '0' + num;
}
}
return str;
}
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
export function formatTime(ms: number): string {
if (ms <= 0) {
return '00:00';
}
const hour = Math.floor(ms / HOUR);
ms = ms % HOUR;
const minute = Math.floor(ms / MINUTE);
ms = ms % MINUTE;
const second = Math.floor(ms / SECOND);
if (hour) {
return `${padZero(hour)}:${padZero(minute)}:${padZero(second)}`;
}
return `${padZero(minute)}:${padZero(second)}`;
}
export function openFullscreen(el: HTMLElement): Promise<void> {
if (el.requestFullscreen) {
return el.requestFullscreen();
} else if (el.mozRequestFullScreen) {
/* Firefox */
return el.mozRequestFullScreen();
} else if (el.webkitRequestFullscreen) {
/* Chrome, Safari and Opera */
return el.webkitRequestFullscreen();
} else if (el.msRequestFullscreen) {
/* IE/Edge */
return el.msRequestFullscreen();
}
}
export function exitFullscreen(): Promise<void> {
if (document.exitFullscreen) {
return document.exitFullscreen();
} else if (document.mozExitFullscreen) {
/* Firefox */
return document.mozExitFullscreen();
} else if (document.webkitExitFullscreen) {
/* Chrome, Safari and Opera */
return document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
/* IE/Edge */
return document.msExitFullscreen();
}
}
export function isFullscreen(): boolean {
return (
document.fullscreen ||
document.webkitIsFullScreen ||
document.mozFullScreen ||
document.msFullscreenElement
);
}
export function onFullscreenChange(handler: () => unknown): () => void {
document.addEventListener('fullscreenchange', handler);
document.addEventListener('webkitfullscreenchange', handler);
document.addEventListener('mozfullscreenchange', handler);
document.addEventListener('MSFullscreenChange', handler);
return () => {
document.removeEventListener('fullscreenchange', handler);
document.removeEventListener('webkitfullscreenchange', handler);
document.removeEventListener('mozfullscreenchange', handler);
document.removeEventListener('MSFullscreenChange', handler);
};
}
export function typeOf(
obj: unknown,
):
| 'boolean'
| 'number'
| 'string'
| 'function'
| 'array'
| 'date'
| 'regExp'
| 'undefined'
| 'null'
| 'object' {
const toString = Object.prototype.toString;
const map = {
'[object Boolean]': 'boolean',
'[object Number]': 'number',
'[object String]': 'string',
'[object Function]': 'function',
'[object Array]': 'array',
'[object Date]': 'date',
'[object RegExp]': 'regExp',
'[object Undefined]': 'undefined',
'[object Null]': 'null',
'[object Object]': 'object',
};
return map[toString.call(obj)];
}