Refactoring rrweb-player (#26)
* enable drag and drop in controller * setup svelte v3 workflow and entry point * add ts eslint config and do compatibility fallbacks in API * rewrite replayer in svelte v3 * fix css import * fix fullscreen API
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "google",
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 8,
|
"ecmaVersion": 2019,
|
||||||
"sourceType": "module"
|
"sourceType": "module"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -10,5 +11,15 @@
|
|||||||
"object-curly-spacing": "off",
|
"object-curly-spacing": "off",
|
||||||
"indent": "off"
|
"indent": "off"
|
||||||
},
|
},
|
||||||
"plugins": ["html"]
|
"env": {
|
||||||
|
"es6": true,
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"plugins": ["svelte3", "@typescript-eslint"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.svelte"],
|
||||||
|
"processor": "svelte3/svelte3"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
public/bundle.*
|
public/bundle.*
|
||||||
|
public/build
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
@@ -9,3 +10,5 @@ temp
|
|||||||
|
|
||||||
dist
|
dist
|
||||||
lib
|
lib
|
||||||
|
|
||||||
|
*.log
|
||||||
|
|||||||
39
package.json
39
package.json
@@ -2,29 +2,36 @@
|
|||||||
"name": "rrweb-player",
|
"name": "rrweb-player",
|
||||||
"version": "0.4.6",
|
"version": "0.4.6",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^5.10.0",
|
"@rollup/plugin-commonjs": "^11.0.0",
|
||||||
|
"@rollup/plugin-node-resolve": "^7.0.0",
|
||||||
|
"@rollup/plugin-typescript": "^4.0.0",
|
||||||
|
"@tsconfig/svelte": "^1.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-config-google": "^0.11.0",
|
||||||
"eslint-plugin-html": "^4.0.6",
|
"eslint-plugin-svelte3": "^2.7.3",
|
||||||
"npm-run-all": "^4.1.3",
|
"postcss-easy-import": "^3.0.0",
|
||||||
"rollup": "^0.66.2",
|
"rollup": "^1.20.0",
|
||||||
"rollup-plugin-commonjs": "^9.1.8",
|
"rollup-plugin-livereload": "^1.0.0",
|
||||||
"rollup-plugin-node-resolve": "^3.4.0",
|
"rollup-plugin-svelte": "^5.0.3",
|
||||||
"rollup-plugin-postcss": "^1.6.3",
|
"rollup-plugin-terser": "^5.1.2",
|
||||||
"rollup-plugin-svelte": "^4.5.0",
|
"sirv-cli": "^0.4.4",
|
||||||
"rollup-plugin-terser": "^3.0.0",
|
"svelte": "^3.2.0",
|
||||||
"sirv-cli": "^0.2.2",
|
"svelte-check": "^0.1.0",
|
||||||
"svelte": "^2.16.0"
|
"svelte-preprocess": "^4.0.0",
|
||||||
|
"tslib": "^2.0.0",
|
||||||
|
"typescript": "^3.9.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"rrweb": "^0.7.32"
|
"rrweb": "^0.9.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rollup -c",
|
"build": "rollup -c",
|
||||||
"prepublishOnly": "npm run build",
|
"dev": "rollup -c -w",
|
||||||
"autobuild": "rollup -c -w",
|
"prepublishOnly": "yarn build",
|
||||||
"dev": "run-p start:dev autobuild",
|
|
||||||
"start": "sirv public",
|
"start": "sirv public",
|
||||||
"start:dev": "sirv public --dev"
|
"validate": "svelte-check"
|
||||||
},
|
},
|
||||||
"description": "rrweb's replayer UI",
|
"description": "rrweb's replayer UI",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
|
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
|
||||||
<title>dev panel</title>
|
<title>Svelte app</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="./global.css" />
|
<link rel="stylesheet" href="/global.css" />
|
||||||
<link rel="stylesheet" href="./bundle.css" />
|
<link rel="stylesheet" href="/bundle.css" />
|
||||||
|
|
||||||
|
<script src="/bundle.js"></script>
|
||||||
|
<script src="./events.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body></body>
|
||||||
<script src="./bundle.js"></script>
|
|
||||||
<script src="./events.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const component = new rrwebPlayer({
|
const component = new rrwebPlayer({
|
||||||
@@ -21,11 +22,10 @@
|
|||||||
events,
|
events,
|
||||||
skipInactive: true,
|
skipInactive: true,
|
||||||
showDebug: false,
|
showDebug: false,
|
||||||
autoPlay: false,
|
|
||||||
showWarning: false,
|
showWarning: false,
|
||||||
|
autoPlay: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
component.addEventListener('finish', () => console.log('finish'));
|
component.addEventListener('finish', () => console.log('finish'));
|
||||||
</script>
|
</script>
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,35 +1,43 @@
|
|||||||
import svelte from 'rollup-plugin-svelte';
|
import svelte from 'rollup-plugin-svelte';
|
||||||
import resolve from 'rollup-plugin-node-resolve';
|
import resolve from '@rollup/plugin-node-resolve';
|
||||||
import commonjs from 'rollup-plugin-commonjs';
|
import commonjs from '@rollup/plugin-commonjs';
|
||||||
|
import livereload from 'rollup-plugin-livereload';
|
||||||
import { terser } from 'rollup-plugin-terser';
|
import { terser } from 'rollup-plugin-terser';
|
||||||
import postcss from 'rollup-plugin-postcss';
|
import sveltePreprocess from 'svelte-preprocess';
|
||||||
|
import typescript from '@rollup/plugin-typescript';
|
||||||
import pkg from './package.json';
|
import pkg from './package.json';
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
const production = !process.env.ROLLUP_WATCH;
|
const production = !process.env.ROLLUP_WATCH;
|
||||||
|
|
||||||
export default [
|
const entries = (production
|
||||||
|
? [
|
||||||
{ file: pkg.module, format: 'es' },
|
{ file: pkg.module, format: 'es' },
|
||||||
{ file: pkg.main, format: 'cjs' },
|
{ file: pkg.main, format: 'cjs' },
|
||||||
{ file: pkg.unpkg, format: 'iife', name: 'rrwebPlayer' },
|
{ file: pkg.unpkg, format: 'iife', name: 'rrwebPlayer' },
|
||||||
{ file: 'public/bundle.js', format: 'iife', name: 'rrwebPlayer' },
|
]
|
||||||
].map(output => ({
|
: []
|
||||||
input: 'src/Player.html',
|
).concat([{ file: 'public/bundle.js', format: 'iife', name: 'rrwebPlayer' }]);
|
||||||
|
|
||||||
|
export default entries.map((output) => ({
|
||||||
|
input: 'src/main.ts',
|
||||||
output,
|
output,
|
||||||
plugins: [
|
plugins: [
|
||||||
svelte({
|
svelte({
|
||||||
cascade: false,
|
|
||||||
// opt in to v3 behaviour today
|
|
||||||
skipIntroByDefault: true,
|
|
||||||
nestedTransitions: true,
|
|
||||||
|
|
||||||
// enable run-time checks when not in production
|
// enable run-time checks when not in production
|
||||||
dev: !production,
|
dev: !production,
|
||||||
// we'll extract any component CSS out into
|
// we'll extract any component CSS out into
|
||||||
// a separate file — better for performance
|
// a separate file — better for performance
|
||||||
css: css => {
|
css: (css) => {
|
||||||
css.write('dist/style.css');
|
css.write('dist/style.css');
|
||||||
css.write('public/bundle.css');
|
css.write('public/bundle.css');
|
||||||
},
|
},
|
||||||
|
preprocess: sveltePreprocess({
|
||||||
|
postcss: {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
plugins: [require('postcss-easy-import')],
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// If you have external dependencies installed from
|
// If you have external dependencies installed from
|
||||||
@@ -37,13 +45,45 @@ export default [
|
|||||||
// some cases you'll need additional configuration —
|
// some cases you'll need additional configuration —
|
||||||
// consult the documentation for details:
|
// consult the documentation for details:
|
||||||
// https://github.com/rollup/rollup-plugin-commonjs
|
// https://github.com/rollup/rollup-plugin-commonjs
|
||||||
resolve(),
|
resolve({
|
||||||
|
browser: true,
|
||||||
|
dedupe: ['svelte'],
|
||||||
|
}),
|
||||||
commonjs(),
|
commonjs(),
|
||||||
|
|
||||||
postcss(),
|
typescript({ sourceMap: !production }),
|
||||||
|
|
||||||
|
// 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
|
// If we're building for production (npm run build
|
||||||
// instead of npm run dev), minify
|
// instead of npm run dev), minify
|
||||||
production && terser(),
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
{#if showController}
|
|
||||||
<div class="rr-controller">
|
|
||||||
<div class="rr-timeline">
|
|
||||||
<span class="rr-timeline__time">{formatTime(currentTime)}</span>
|
|
||||||
<div
|
|
||||||
class="rr-progress"
|
|
||||||
class:disabled="isSkipping"
|
|
||||||
ref:progress on:click="handleProgressClick(event)"
|
|
||||||
>
|
|
||||||
<div class="rr-progress__step" ref:step style="width: {percentage}"></div>
|
|
||||||
{#each getCustomEvents as event,i (i)}
|
|
||||||
<div
|
|
||||||
title="{event.name}"
|
|
||||||
style="width: 10px;height: 5px;position: absolute;top: 2px;transform: translate(-50%, -50%);background: {event.background};left: {event.position};"
|
|
||||||
></div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="rr-progress__handler"
|
|
||||||
ref:handler
|
|
||||||
style="left: {percentage}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="rr-timeline__time">{formatTime(meta.totalTime)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="rr-controller__btns">
|
|
||||||
<button on:click="toggle()">
|
|
||||||
{#if isPlaying}
|
|
||||||
<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"
|
|
||||||
></path>
|
|
||||||
</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"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#each speedOption as s}
|
|
||||||
<button
|
|
||||||
class:active="s === speed && !isSkipping"
|
|
||||||
on:click="setSpeed(s)"
|
|
||||||
disabled="{isSkipping}"
|
|
||||||
>
|
|
||||||
{s}x
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<Switch id="skip" bind:checked="skipInactive" disabled="{isSkipping}" label="skip inactive" />
|
|
||||||
<button on:click="fire('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"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { EventType } from 'rrweb';
|
|
||||||
import { formatTime } from './utils.js';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
Switch: './components/Switch.html',
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
currentTime: 0,
|
|
||||||
isPlaying: false,
|
|
||||||
isSkipping: false,
|
|
||||||
skipInactive: true,
|
|
||||||
speed: 1,
|
|
||||||
speedOption: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
meta({ replayer }) {
|
|
||||||
return replayer.getMetaData();
|
|
||||||
},
|
|
||||||
percentage({ currentTime, meta }) {
|
|
||||||
const percent = Math.min(1, currentTime / meta.totalTime);
|
|
||||||
return `${100 * percent}%`;
|
|
||||||
},
|
|
||||||
getCustomEvents({tags, replayer}) {
|
|
||||||
const { events } = replayer;
|
|
||||||
const totalEvents = events.length;
|
|
||||||
const start = events[0].timestamp;
|
|
||||||
const end = events[totalEvents - 1].timestamp;
|
|
||||||
const customEvents = [];
|
|
||||||
|
|
||||||
// calculate tag position.
|
|
||||||
const position = (startTime, endTime, tagTime) => {
|
|
||||||
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.
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
helpers: {
|
|
||||||
formatTime,
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
loopTimer() {
|
|
||||||
const self = this;
|
|
||||||
|
|
||||||
function update() {
|
|
||||||
const { meta, isPlaying, replayer } = self.get();
|
|
||||||
if (!isPlaying) {
|
|
||||||
self.timer = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime =
|
|
||||||
replayer.timer.timeOffset + replayer.getTimeOffset();
|
|
||||||
self.set({
|
|
||||||
currentTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentTime < meta.totalTime) {
|
|
||||||
requestAnimationFrame(update);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.timer = requestAnimationFrame(update);
|
|
||||||
},
|
|
||||||
play() {
|
|
||||||
const { replayer, currentTime } = this.get();
|
|
||||||
|
|
||||||
if (currentTime > 0) {
|
|
||||||
replayer.resume(currentTime);
|
|
||||||
} else {
|
|
||||||
this.set({ isPlaying: true });
|
|
||||||
replayer.play(currentTime);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pause() {
|
|
||||||
const { replayer } = this.get();
|
|
||||||
replayer.pause();
|
|
||||||
},
|
|
||||||
toggle() {
|
|
||||||
const { isPlaying } = this.get();
|
|
||||||
if (isPlaying) {
|
|
||||||
this.pause();
|
|
||||||
} else {
|
|
||||||
this.play();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setSpeed(speed) {
|
|
||||||
const { replayer, currentTime, isPlaying } = this.get();
|
|
||||||
// freeze before set speed, and resume if is playing before freeze
|
|
||||||
replayer.pause();
|
|
||||||
replayer.setConfig({ speed });
|
|
||||||
this.set({ speed });
|
|
||||||
if (isPlaying) {
|
|
||||||
replayer.resume(currentTime);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleProgressClick(event) {
|
|
||||||
const { meta, replayer, isPlaying, isSkipping } = this.get();
|
|
||||||
if (isSkipping) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const progressRect = this.refs.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;
|
|
||||||
this.set({ currentTime: timeOffset });
|
|
||||||
replayer.play(timeOffset);
|
|
||||||
if (!isPlaying) {
|
|
||||||
replayer.pause();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onupdate({ changed, current, previous }) {
|
|
||||||
if (current.replayer && !previous) {
|
|
||||||
window.replayer = current.replayer;
|
|
||||||
setTimeout(() => {
|
|
||||||
this.set({ isPlaying: true });
|
|
||||||
}, 0);
|
|
||||||
current.replayer.play(0);
|
|
||||||
if (!current.autoPlay) {
|
|
||||||
let firstFullSnapshotRebuilded = false;
|
|
||||||
current.replayer.on('fullsnapshot-rebuilded', () => {
|
|
||||||
if (!firstFullSnapshotRebuilded) {
|
|
||||||
firstFullSnapshotRebuilded = true;
|
|
||||||
current.replayer.pause();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
current.replayer.on('pause', () => {
|
|
||||||
this.set({ isPlaying: false });
|
|
||||||
});
|
|
||||||
current.replayer.on('resume', () => {
|
|
||||||
this.set({ isPlaying: true });
|
|
||||||
});
|
|
||||||
current.replayer.on('finish', () => {
|
|
||||||
this.timer = null;
|
|
||||||
this.set({ isPlaying: false, currentTime: 0 });
|
|
||||||
});
|
|
||||||
current.replayer.on('skip-start', payload => {
|
|
||||||
payload.isSkipping = true;
|
|
||||||
this.set(payload);
|
|
||||||
});
|
|
||||||
current.replayer.on('skip-end', payload => {
|
|
||||||
payload.isSkipping = false;
|
|
||||||
this.set(payload);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (changed.isPlaying) {
|
|
||||||
if (current.isPlaying && !this.timer) {
|
|
||||||
this.loopTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed.skipInactive) {
|
|
||||||
current.replayer.setConfig({ skipInactive: current.skipInactive });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ondestroy() {
|
|
||||||
const { isPlaying } = this.get();
|
|
||||||
if (isPlaying) {
|
|
||||||
this.pause();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</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 {
|
|
||||||
padding: 0 20px;
|
|
||||||
color: #11103e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rr-progress {
|
|
||||||
width: 100%;
|
|
||||||
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>
|
|
||||||
403
src/Controller.svelte
Normal file
403
src/Controller.svelte
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
<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;
|
||||||
|
let timer: number | null = null;
|
||||||
|
let playerState: 'playing' | 'paused' | 'live';
|
||||||
|
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}%`;
|
||||||
|
}
|
||||||
|
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.timer.timeOffset + replayer.getTimeOffset();
|
||||||
|
|
||||||
|
if (currentTime < meta.totalTime) {
|
||||||
|
timer = requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopTimer = () => {
|
||||||
|
if (timer) {
|
||||||
|
cancelAnimationFrame(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
switch (playerState) {
|
||||||
|
case 'playing':
|
||||||
|
replayer.pause();
|
||||||
|
break;
|
||||||
|
case 'paused':
|
||||||
|
if (finished) {
|
||||||
|
replayer.play();
|
||||||
|
finished = false;
|
||||||
|
} else {
|
||||||
|
replayer.play(currentTime);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
currentTime = timeOffset;
|
||||||
|
const isPlaying = playerState === 'playing';
|
||||||
|
replayer.pause();
|
||||||
|
replayer.play(timeOffset);
|
||||||
|
if (!isPlaying) {
|
||||||
|
replayer.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSpeed = (newSpeed: number) => {
|
||||||
|
let needFreeze = playerState === 'playing';
|
||||||
|
speed = newSpeed;
|
||||||
|
if (needFreeze) {
|
||||||
|
replayer.pause();
|
||||||
|
}
|
||||||
|
replayer.setConfig({ speed });
|
||||||
|
if (needFreeze) {
|
||||||
|
replayer.play(currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ['playing', 'skipping'].includes(playerState)}
|
||||||
|
<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}
|
||||||
192
src/Player.html
192
src/Player.html
@@ -1,192 +0,0 @@
|
|||||||
<div class="rr-player" ref:player style="{playerStyle}">
|
|
||||||
<div class="rr-player__frame" ref:frame { style }></div>
|
|
||||||
{#if replayer}
|
|
||||||
<Controller
|
|
||||||
{
|
|
||||||
replayer
|
|
||||||
}
|
|
||||||
{showController}
|
|
||||||
{autoPlay}
|
|
||||||
{skipInactive}
|
|
||||||
{tags}
|
|
||||||
{speedOption}
|
|
||||||
{speed}
|
|
||||||
on:fullscreen="fullscreen()"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { Replayer, unpack } from 'rrweb';
|
|
||||||
import 'rrweb/dist/rrweb.min.css';
|
|
||||||
import {
|
|
||||||
inlineCss,
|
|
||||||
openFullscreen,
|
|
||||||
exitFullscreen,
|
|
||||||
isFullscreen,
|
|
||||||
onFullscreenChange,
|
|
||||||
typeOf,
|
|
||||||
} from './utils.js';
|
|
||||||
|
|
||||||
const controllerHeight = 80;
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
Controller: './Controller.html',
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showController: true,
|
|
||||||
width: 1024,
|
|
||||||
height: 576,
|
|
||||||
events: [],
|
|
||||||
autoPlay: true,
|
|
||||||
replayer: null,
|
|
||||||
triggerFocus: true,
|
|
||||||
tags: {},
|
|
||||||
skipInactive: true,
|
|
||||||
speedOption: [1, 2, 3],
|
|
||||||
speed: 1,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
style({ width, height }) {
|
|
||||||
return inlineCss({
|
|
||||||
width: `${width}px`,
|
|
||||||
height: `${height}px`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
playerStyle({ width, height, showController }) {
|
|
||||||
return inlineCss({
|
|
||||||
width: `${width}px`,
|
|
||||||
height: `${height + (showController ? controllerHeight : 0)}px`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
updateScale(el, frameDimension) {
|
|
||||||
const { width, height } = this.get();
|
|
||||||
const widthScale = width / frameDimension.width;
|
|
||||||
const heightScale = height / frameDimension.height;
|
|
||||||
el.style.transform =
|
|
||||||
`scale(${Math.min(widthScale, heightScale)})` +
|
|
||||||
'translate(-50%, -50%)';
|
|
||||||
},
|
|
||||||
fullscreen() {
|
|
||||||
if (this.refs.player) {
|
|
||||||
isFullscreen() ? exitFullscreen() : openFullscreen(this.refs.player);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addEventListener(event, handler) {
|
|
||||||
const { replayer } = this.get();
|
|
||||||
replayer.on(event, handler);
|
|
||||||
},
|
|
||||||
addEvent(event) {
|
|
||||||
replayer.addEvent(event);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
oncreate() {
|
|
||||||
const { events, triggerFocus, showWarning, showDebug, speedOption } = this.get();
|
|
||||||
let { skipInactive, speed } = this.get();
|
|
||||||
skipInactive = skipInactive === undefined ? true : !!skipInactive;
|
|
||||||
speed = speed === undefined ? 1 : speed;
|
|
||||||
// 类型检查
|
|
||||||
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()}]
|
|
||||||
...
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
const replayer = new Replayer(events, {
|
|
||||||
speed: speed === undefined ? 1 : speed,
|
|
||||||
root: this.refs.frame,
|
|
||||||
skipInactive,
|
|
||||||
showWarning: showWarning === undefined ? true : !!showWarning,
|
|
||||||
showDebug: showDebug === undefined ? true : !!showDebug,
|
|
||||||
triggerFocus,
|
|
||||||
unpackFn: unpack,
|
|
||||||
});
|
|
||||||
replayer.on('resize', (dimension) =>
|
|
||||||
this.updateScale(replayer.wrapper, dimension),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.set({
|
|
||||||
replayer,
|
|
||||||
skipInactive,
|
|
||||||
speed,
|
|
||||||
});
|
|
||||||
this.fullscreenListener = onFullscreenChange(() => {
|
|
||||||
if (isFullscreen()) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const { width, height } = this.get();
|
|
||||||
// store the original dimension which do not need to be reactive
|
|
||||||
this._width = width;
|
|
||||||
this._height = height;
|
|
||||||
const dimension = {
|
|
||||||
width: this.refs.player.offsetWidth,
|
|
||||||
height: this.refs.player.offsetHeight - controllerHeight,
|
|
||||||
};
|
|
||||||
this.set(dimension);
|
|
||||||
this.updateScale(replayer.wrapper, {
|
|
||||||
width: replayer.iframe.offsetWidth,
|
|
||||||
height: replayer.iframe.offsetHeight,
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
} else {
|
|
||||||
this.set({
|
|
||||||
width: this._width,
|
|
||||||
height: this._height,
|
|
||||||
});
|
|
||||||
this.updateScale(replayer.wrapper, {
|
|
||||||
width: replayer.iframe.offsetWidth,
|
|
||||||
height: replayer.iframe.offsetHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
ondestroy() {
|
|
||||||
if (this.fullscreenListener) {
|
|
||||||
this.fullscreenListener();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.replayer-wrapper) {
|
|
||||||
float: left;
|
|
||||||
clear: both;
|
|
||||||
transform-origin: top left;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.replayer-wrapper > iframe) {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
178
src/Player.svelte
Normal file
178
src/Player.svelte
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<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 triggerFocus: boolean = true;
|
||||||
|
export let speedOption: number[] = [1, 2, 4, 8];
|
||||||
|
export let showController: boolean = true;
|
||||||
|
export let showWarning: boolean = true;
|
||||||
|
export let showDebug: boolean = true;
|
||||||
|
export let tags: Record<string, string> = {};
|
||||||
|
|
||||||
|
const controllerHeight = 80;
|
||||||
|
let speed = 1;
|
||||||
|
let player: HTMLElement;
|
||||||
|
let frame: HTMLElement;
|
||||||
|
let replayer: Replayer;
|
||||||
|
let fullscreenListener: undefined | (() => void);
|
||||||
|
let _width: number = width;
|
||||||
|
let _height: number = height;
|
||||||
|
|
||||||
|
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)})` + 'translate(-50%, -50%)';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullscreen = () => {
|
||||||
|
if (player) {
|
||||||
|
isFullscreen() ? exitFullscreen() : openFullscreen(player);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addEventListener = (event: string, handler: () => unknown) => {
|
||||||
|
replayer.on(event, handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addEvent = (event: eventWithTime) => {
|
||||||
|
replayer.addEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
skipInactive,
|
||||||
|
showWarning,
|
||||||
|
showDebug,
|
||||||
|
triggerFocus,
|
||||||
|
unpackFn: unpack,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.replayer-wrapper) {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
transform-origin: top left;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.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
|
||||||
|
{replayer}
|
||||||
|
{showController}
|
||||||
|
{autoPlay}
|
||||||
|
{speedOption}
|
||||||
|
{skipInactive}
|
||||||
|
{tags}
|
||||||
|
on:fullscreen={() => fullscreen()} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
<div class="switch" class:disabled="disabled">
|
<script lang="ts">
|
||||||
<input
|
export let disabled: boolean;
|
||||||
type="checkbox"
|
export let checked: boolean;
|
||||||
id="{id}"
|
export let id: string;
|
||||||
bind:checked="checked"
|
export let label: string;
|
||||||
disabled="{disabled}"
|
</script>
|
||||||
/>
|
|
||||||
<label for="{id}"></label> <span class="label">{label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.switch {
|
.switch {
|
||||||
@@ -74,3 +71,9 @@
|
|||||||
left: 1.1em;
|
left: 1.1em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<div class="switch" class:disabled>
|
||||||
|
<input type="checkbox" {id} bind:checked {disabled} />
|
||||||
|
<label for={id} />
|
||||||
|
<span class="label">{label}</span>
|
||||||
|
</div>
|
||||||
22
src/main.ts
Normal file
22
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;
|
||||||
@@ -1,26 +1,43 @@
|
|||||||
export function inlineCss(cssObj) {
|
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 = '';
|
let style = '';
|
||||||
Object.keys(cssObj).forEach(key => {
|
Object.keys(cssObj).forEach((key) => {
|
||||||
style += `${key}: ${cssObj[key]};`;
|
style += `${key}: ${cssObj[key]};`;
|
||||||
});
|
});
|
||||||
return style;
|
return style;
|
||||||
}
|
}
|
||||||
|
|
||||||
function padZero(num, len = 2) {
|
function padZero(num: number, len = 2): string {
|
||||||
|
let str = String(num);
|
||||||
const threshold = Math.pow(10, len - 1);
|
const threshold = Math.pow(10, len - 1);
|
||||||
if (num < threshold) {
|
if (num < threshold) {
|
||||||
num = String(num);
|
while (String(threshold).length > str.length) {
|
||||||
while (String(threshold).length > num.length) {
|
str = '0' + num;
|
||||||
num = '0' + num;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return num;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECOND = 1000;
|
const SECOND = 1000;
|
||||||
const MINUTE = 60 * SECOND;
|
const MINUTE = 60 * SECOND;
|
||||||
const HOUR = 60 * MINUTE;
|
const HOUR = 60 * MINUTE;
|
||||||
export function formatTime(ms) {
|
export function formatTime(ms: number): string {
|
||||||
if (ms <= 0) {
|
if (ms <= 0) {
|
||||||
return '00:00';
|
return '00:00';
|
||||||
}
|
}
|
||||||
@@ -35,7 +52,7 @@ export function formatTime(ms) {
|
|||||||
return `${padZero(minute)}:${padZero(second)}`;
|
return `${padZero(minute)}:${padZero(second)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openFullscreen(el) {
|
export function openFullscreen(el: HTMLElement): Promise<void> {
|
||||||
if (el.requestFullscreen) {
|
if (el.requestFullscreen) {
|
||||||
return el.requestFullscreen();
|
return el.requestFullscreen();
|
||||||
} else if (el.mozRequestFullScreen) {
|
} else if (el.mozRequestFullScreen) {
|
||||||
@@ -50,7 +67,7 @@ export function openFullscreen(el) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exitFullscreen() {
|
export function exitFullscreen(): Promise<void> {
|
||||||
if (document.exitFullscreen) {
|
if (document.exitFullscreen) {
|
||||||
return document.exitFullscreen();
|
return document.exitFullscreen();
|
||||||
} else if (document.mozExitFullscreen) {
|
} else if (document.mozExitFullscreen) {
|
||||||
@@ -65,7 +82,7 @@ export function exitFullscreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isFullscreen() {
|
export function isFullscreen(): boolean {
|
||||||
return (
|
return (
|
||||||
document.fullscreen ||
|
document.fullscreen ||
|
||||||
document.webkitIsFullScreen ||
|
document.webkitIsFullScreen ||
|
||||||
@@ -74,7 +91,7 @@ export function isFullscreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onFullscreenChange(handler) {
|
export function onFullscreenChange(handler: () => unknown): () => void {
|
||||||
document.addEventListener('fullscreenchange', handler);
|
document.addEventListener('fullscreenchange', handler);
|
||||||
document.addEventListener('webkitfullscreenchange', handler);
|
document.addEventListener('webkitfullscreenchange', handler);
|
||||||
document.addEventListener('mozfullscreenchange', handler);
|
document.addEventListener('mozfullscreenchange', handler);
|
||||||
@@ -88,7 +105,19 @@ export function onFullscreenChange(handler) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function typeOf(obj) {
|
export function typeOf(
|
||||||
|
obj: unknown,
|
||||||
|
):
|
||||||
|
| 'boolean'
|
||||||
|
| 'number'
|
||||||
|
| 'string'
|
||||||
|
| 'function'
|
||||||
|
| 'array'
|
||||||
|
| 'date'
|
||||||
|
| 'regExp'
|
||||||
|
| 'undefined'
|
||||||
|
| 'null'
|
||||||
|
| 'object' {
|
||||||
const toString = Object.prototype.toString;
|
const toString = Object.prototype.toString;
|
||||||
const map = {
|
const map = {
|
||||||
'[object Boolean]': 'boolean',
|
'[object Boolean]': 'boolean',
|
||||||
@@ -100,7 +129,7 @@ export function typeOf(obj) {
|
|||||||
'[object RegExp]': 'regExp',
|
'[object RegExp]': 'regExp',
|
||||||
'[object Undefined]': 'undefined',
|
'[object Undefined]': 'undefined',
|
||||||
'[object Null]': 'null',
|
'[object Null]': 'null',
|
||||||
'[object Object]': 'object'
|
'[object Object]': 'object',
|
||||||
};
|
};
|
||||||
return map[toString.call(obj)];
|
return map[toString.call(obj)];
|
||||||
}
|
}
|
||||||
5
tsconfig.json
Normal file
5
tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules/*", "__sapper__/*", "public/*"],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user