Merge branch 'rrweb-player' into monorepo
This commit is contained in:
25
packages/rrweb-player/.eslintrc.json
Normal file
25
packages/rrweb-player/.eslintrc.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2019,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"require-jsdoc": "off",
|
||||
"arrow-parens": "off",
|
||||
"object-curly-spacing": "off",
|
||||
"indent": "off"
|
||||
},
|
||||
"env": {
|
||||
"es6": true,
|
||||
"browser": true
|
||||
},
|
||||
"plugins": ["svelte3", "@typescript-eslint"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.svelte"],
|
||||
"processor": "svelte3/svelte3"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
packages/rrweb-player/.gitignore
vendored
Normal file
14
packages/rrweb-player/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
public/bundle.*
|
||||
public/build
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
.vscode
|
||||
temp
|
||||
|
||||
dist
|
||||
lib
|
||||
|
||||
*.log
|
||||
5
packages/rrweb-player/.release-it.json
Normal file
5
packages/rrweb-player/.release-it.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"non-interactive": false,
|
||||
"buildCommand": "npm run build",
|
||||
"requireCleanWorkingDir": false
|
||||
}
|
||||
72
packages/rrweb-player/README.md
Normal file
72
packages/rrweb-player/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
*Psst — looking for a shareable component template? Go here --> [sveltejs/component-template](https://github.com/sveltejs/component-template)*
|
||||
|
||||
*Looking for a Vue.js version? Go here --> [@preflight-hq/rrweb-player-vue](https://github.com/Preflight-HQ/rrweb-player-vue)*
|
||||
|
||||
---
|
||||
|
||||
# svelte app
|
||||
|
||||
This is a project template for [Svelte](https://svelte.technology) apps. It lives at https://github.com/sveltejs/template.
|
||||
|
||||
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit):
|
||||
|
||||
```bash
|
||||
npm install -g degit # you only need to do this once
|
||||
|
||||
degit sveltejs/template svelte-app
|
||||
cd svelte-app
|
||||
```
|
||||
|
||||
*Note that you will need to have [Node.js](https://nodejs.org) installed.*
|
||||
|
||||
|
||||
## Get started
|
||||
|
||||
Install the dependencies...
|
||||
|
||||
```bash
|
||||
cd svelte-app
|
||||
npm install
|
||||
```
|
||||
|
||||
...then start [Rollup](https://rollupjs.org):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Navigate to [localhost:5000](http://localhost:5000). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes.
|
||||
|
||||
|
||||
## Deploying to the web
|
||||
|
||||
### With [now](https://zeit.co/now)
|
||||
|
||||
Install `now` if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install -g now
|
||||
```
|
||||
|
||||
Then, from within your project folder:
|
||||
|
||||
```bash
|
||||
now
|
||||
```
|
||||
|
||||
As an alternative, use the [Now desktop client](https://zeit.co/download) and simply drag the unzipped project folder to the taskbar icon.
|
||||
|
||||
### With [surge](https://surge.sh/)
|
||||
|
||||
Install `surge` if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install -g surge
|
||||
```
|
||||
|
||||
Then, from within your project folder:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
surge public
|
||||
```
|
||||
60
packages/rrweb-player/package.json
Normal file
60
packages/rrweb-player/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "rrweb-player",
|
||||
"version": "0.7.4",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^11.0.0",
|
||||
"@rollup/plugin-node-resolve": "^7.0.0",
|
||||
"@rollup/plugin-typescript": "^4.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^3.7.0",
|
||||
"@typescript-eslint/parser": "^3.7.0",
|
||||
"eslint": "^7.5.0",
|
||||
"eslint-config-google": "^0.11.0",
|
||||
"eslint-plugin-svelte3": "^2.7.3",
|
||||
"postcss-easy-import": "^3.0.0",
|
||||
"rollup": "^2.45.2",
|
||||
"rollup-plugin-css-only": "^3.1.0",
|
||||
"rollup-plugin-livereload": "^2.0.0",
|
||||
"rollup-plugin-svelte": "^7.1.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"sirv-cli": "^0.4.4",
|
||||
"svelte": "^3.2.0",
|
||||
"svelte-check": "^1.4.0",
|
||||
"svelte-preprocess": "^4.0.0",
|
||||
"tslib": "^2.0.0",
|
||||
"typescript": "^3.9.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tsconfig/svelte": "^1.0.0",
|
||||
"rrweb": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -c -w",
|
||||
"prepublishOnly": "yarn build",
|
||||
"start": "sirv public",
|
||||
"validate": "svelte-check"
|
||||
},
|
||||
"description": "rrweb's replayer UI",
|
||||
"main": "lib/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"unpkg": "dist/index.js",
|
||||
"files": [
|
||||
"lib",
|
||||
"dist",
|
||||
"typings"
|
||||
],
|
||||
"typings": "typings/index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/rrweb-io/rrweb-player.git"
|
||||
},
|
||||
"keywords": [
|
||||
"rrweb"
|
||||
],
|
||||
"author": "yanzhen@smartx.com",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/rrweb-io/rrweb-player/issues"
|
||||
},
|
||||
"homepage": "https://github.com/rrweb-io/rrweb-player#readme"
|
||||
}
|
||||
3
packages/rrweb-player/public/events.js
Normal file
3
packages/rrweb-player/public/events.js
Normal file
File diff suppressed because one or more lines are too long
12
packages/rrweb-player/public/global.css
Normal file
12
packages/rrweb-player/public/global.css
Normal file
@@ -0,0 +1,12 @@
|
||||
html,
|
||||
body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
36
packages/rrweb-player/public/index.html
Normal file
36
packages/rrweb-player/public/index.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
|
||||
<title>Svelte app</title>
|
||||
|
||||
<link rel="stylesheet" href="/global.css" />
|
||||
<link rel="stylesheet" href="/bundle.css" />
|
||||
|
||||
<script src="/bundle.js"></script>
|
||||
<script src="./events.js"></script>
|
||||
</head>
|
||||
|
||||
<body></body>
|
||||
<script>
|
||||
// eslint-disable-next-line
|
||||
const component = new rrwebPlayer({
|
||||
target: document.body,
|
||||
data: {
|
||||
events,
|
||||
skipInactive: true,
|
||||
showDebug: false,
|
||||
showWarning: false,
|
||||
autoPlay: true,
|
||||
mouseTail: {
|
||||
strokeStyle: 'yellow',
|
||||
},
|
||||
},
|
||||
});
|
||||
window.$c = component;
|
||||
component.addEventListener('finish', () => console.log('finish'));
|
||||
// component.addEventListener('ui-update-progress', console.log);
|
||||
</script>
|
||||
</html>
|
||||
110
packages/rrweb-player/rollup.config.js
Normal file
110
packages/rrweb-player/rollup.config.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import svelte from 'rollup-plugin-svelte';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import livereload from 'rollup-plugin-livereload';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
import sveltePreprocess from 'svelte-preprocess';
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import pkg from './package.json';
|
||||
import css from 'rollup-plugin-css-only';
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const production = !process.env.ROLLUP_WATCH;
|
||||
|
||||
const entries = (production
|
||||
? [
|
||||
{ file: pkg.module, format: 'es', css: false },
|
||||
{ file: pkg.main, format: 'cjs', css: false },
|
||||
{
|
||||
file: pkg.unpkg,
|
||||
format: 'iife',
|
||||
name: 'rrwebPlayer',
|
||||
css: 'style.css',
|
||||
},
|
||||
]
|
||||
: []
|
||||
).concat([
|
||||
{
|
||||
file: 'public/bundle.js',
|
||||
format: 'iife',
|
||||
name: 'rrwebPlayer',
|
||||
css: 'bundle.css',
|
||||
},
|
||||
]);
|
||||
|
||||
export default entries.map((output) => ({
|
||||
input: 'src/main.ts',
|
||||
output: {
|
||||
file: output.file,
|
||||
format: output.format,
|
||||
name: output.name,
|
||||
sourcemap: true,
|
||||
exports: 'auto',
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
compilerOptions: {
|
||||
// enable run-time checks when not in production
|
||||
dev: !production,
|
||||
},
|
||||
preprocess: sveltePreprocess({
|
||||
postcss: {
|
||||
// eslint-disable-next-line no-undef
|
||||
plugins: [require('postcss-easy-import')],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
// npm, you'll most likely need these plugins. In
|
||||
// some cases you'll need additional configuration —
|
||||
// consult the documentation for details:
|
||||
// https://github.com/rollup/rollup-plugin-commonjs
|
||||
resolve({
|
||||
browser: true,
|
||||
dedupe: ['svelte'],
|
||||
}),
|
||||
commonjs(),
|
||||
|
||||
typescript(),
|
||||
|
||||
css({
|
||||
// we'll extract any component CSS out into
|
||||
// a separate file — better for performance
|
||||
output: output.css,
|
||||
}),
|
||||
|
||||
// In dev mode, call `npm run start` once
|
||||
// the bundle has been generated
|
||||
!production && serve(),
|
||||
|
||||
// Watch the `public` directory and refresh the
|
||||
// browser on changes when not in production
|
||||
!production && livereload('public'),
|
||||
|
||||
// If we're building for production (npm run build
|
||||
// instead of npm run dev), minify
|
||||
production && terser(),
|
||||
],
|
||||
watch: {
|
||||
clearScreen: false,
|
||||
},
|
||||
}));
|
||||
|
||||
function serve() {
|
||||
let started = false;
|
||||
|
||||
return {
|
||||
writeBundle() {
|
||||
if (!started) {
|
||||
started = true;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires, no-undef
|
||||
require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
shell: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
433
packages/rrweb-player/src/Controller.svelte
Normal file
433
packages/rrweb-player/src/Controller.svelte
Normal 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}
|
||||
222
packages/rrweb-player/src/Player.svelte
Normal file
222
packages/rrweb-player/src/Player.svelte
Normal 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>
|
||||
79
packages/rrweb-player/src/components/Switch.svelte
Normal file
79
packages/rrweb-player/src/components/Switch.svelte
Normal 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>
|
||||
22
packages/rrweb-player/src/main.ts
Normal file
22
packages/rrweb-player/src/main.ts
Normal 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;
|
||||
135
packages/rrweb-player/src/utils.ts
Normal file
135
packages/rrweb-player/src/utils.ts
Normal 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)];
|
||||
}
|
||||
5
packages/rrweb-player/tsconfig.json
Normal file
5
packages/rrweb-player/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules/*", "__sapper__/*", "public/*"],
|
||||
}
|
||||
36
packages/rrweb-player/typings/index.d.ts
vendored
Normal file
36
packages/rrweb-player/typings/index.d.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
import { eventWithTime, playerConfig } from 'rrweb/typings/types';
|
||||
import { Replayer, mirror } from 'rrweb';
|
||||
import { SvelteComponent } from 'svelte';
|
||||
|
||||
export type RRwebPlayerOptions = {
|
||||
target: HTMLElement;
|
||||
props: {
|
||||
events: eventWithTime[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
autoPlay?: boolean;
|
||||
speed?: number;
|
||||
speedOption?: number[];
|
||||
showController?: boolean;
|
||||
tags?: Record<string, string>;
|
||||
} & Partial<playerConfig>;
|
||||
};
|
||||
|
||||
export default class rrwebPlayer extends SvelteComponent {
|
||||
constructor(options: RRwebPlayerOptions);
|
||||
|
||||
addEventListener(event: string, handler: (params: any) => unknown): void;
|
||||
|
||||
addEvent(event: eventWithTime): void;
|
||||
getMetaData: Replayer['getMetaData'];
|
||||
getReplayer: () => Replayer;
|
||||
getMirror: () => typeof mirror;
|
||||
|
||||
toggle: () => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
toggleSkipInactive: () => void;
|
||||
triggerResize: () => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
goto: (timeOffset: number, play?: boolean) => void;
|
||||
}
|
||||
Reference in New Issue
Block a user