From 98ce4f234c92c1eef64c1458bf574b1fbf065312 Mon Sep 17 00:00:00 2001 From: Josep Boix Requesens Date: Fri, 9 Aug 2024 18:11:38 +0200 Subject: [PATCH] feat(pillarbox-debug-panel): debug panel component for playback metrics Introduces a debug panel that provides real-time playback metrics for enhanced monitoring and debugging of the video player. This panel can be added to the player interface only when necessary, allowing developers to keep track of key playback statistics during video streaming. To integrate the debug panel into the player: ```javascript import videojs from 'video.js'; import '@srgssr/pillarbox-debug-panel'; const player = videojs('my-player', { pillarboxDebugPanel: true }); ``` Changes: - Implemented a debug panel displaying metrics such as buffer size, media duration, playback position, bandwidth, bitrate, codecs, framerate, total frames, dropped frames, resolution, and timestamp. - Added a canvas element to the panel for visualizing the evolution of buffer health, bitrate, and bandwidth. - Used VHS API for data retrieval when supported, and native APIs for browsers utilizing native video playback (such as HLS in Safari). --- index.html | 1 + package-lock.json | 116 +++++++++- package.json | 3 +- packages/pillarbox-debug-panel/.babelrc | 3 + packages/pillarbox-debug-panel/.releaserc | 111 +++++++++ packages/pillarbox-debug-panel/LICENSE | 21 ++ packages/pillarbox-debug-panel/README.md | 189 +++++++++++++++ packages/pillarbox-debug-panel/index.html | 57 +++++ packages/pillarbox-debug-panel/package.json | 49 ++++ .../scss/pillarbox-debug-panel.scss | 44 ++++ .../src/graph-component.js | 116 ++++++++++ .../src/metric-component.js | 47 ++++ .../pillarbox-debug-panel/src/metric-label.js | 59 +++++ .../src/pillarbox-debug-panel.js | 219 ++++++++++++++++++ .../test/pillarbox-debug-panel.spec.js | 58 +++++ packages/pillarbox-debug-panel/vite.config.js | 8 + .../pillarbox-debug-panel/vite.config.lib.js | 30 +++ .../pillarbox-debug-panel/vite.config.umd.js | 38 +++ scripts/template/index.html.hbs | 1 + scripts/template/vite.config.lib.js.hbs | 2 +- 20 files changed, 1168 insertions(+), 4 deletions(-) create mode 100644 packages/pillarbox-debug-panel/.babelrc create mode 100644 packages/pillarbox-debug-panel/.releaserc create mode 100644 packages/pillarbox-debug-panel/LICENSE create mode 100644 packages/pillarbox-debug-panel/README.md create mode 100644 packages/pillarbox-debug-panel/index.html create mode 100644 packages/pillarbox-debug-panel/package.json create mode 100644 packages/pillarbox-debug-panel/scss/pillarbox-debug-panel.scss create mode 100644 packages/pillarbox-debug-panel/src/graph-component.js create mode 100644 packages/pillarbox-debug-panel/src/metric-component.js create mode 100644 packages/pillarbox-debug-panel/src/metric-label.js create mode 100644 packages/pillarbox-debug-panel/src/pillarbox-debug-panel.js create mode 100644 packages/pillarbox-debug-panel/test/pillarbox-debug-panel.spec.js create mode 100644 packages/pillarbox-debug-panel/vite.config.js create mode 100644 packages/pillarbox-debug-panel/vite.config.lib.js create mode 100644 packages/pillarbox-debug-panel/vite.config.umd.js diff --git a/index.html b/index.html index 03638c5..bf7d125 100644 --- a/index.html +++ b/index.html @@ -73,6 +73,7 @@

Pillarbox Extensions

diff --git a/package-lock.json b/package-lock.json index 8df6195..e1bc897 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@commitlint/config-conventional": "^19.1.0", "@parcel/transformer-sass": "^2.12.0", "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-terser": "^0.4.4", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^12.0.0", "@semantic-release/git": "^10.0.1", @@ -2706,6 +2707,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -5118,6 +5129,28 @@ } } }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", @@ -5869,6 +5902,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@srgssr/pillarbox-debug-panel": { + "resolved": "packages/pillarbox-debug-panel", + "link": true + }, "node_modules/@srgssr/pillarbox-playlist": { "resolved": "packages/pillarbox-playlist", "link": true @@ -7313,6 +7350,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -18316,6 +18359,15 @@ "integrity": "sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==", "dev": true }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -19852,6 +19904,15 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -20089,6 +20150,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/smob": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", + "dev": true + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -20276,6 +20343,16 @@ "urix": "^0.1.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/source-map-url": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", @@ -22053,6 +22130,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/terser": { + "version": "5.31.5", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.5.tgz", + "integrity": "sha512-YPmas0L0rE1UyLL/llTWA0SiDOqIcAQYLeUj7cJYzXHlRTAnMSg9pPe4VJ5PlKvTrPQsdVFuiRiwyeNlYgwh2Q==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -23603,9 +23704,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/pillarbox-debug-panel": { + "name": "@srgssr/pillarbox-debug-panel", + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "@srgssr/pillarbox-web": "^1.12.1" + }, + "peerDependencies": { + "video.js": "^8.0.0" + } + }, "packages/pillarbox-playlist": { "name": "@srgssr/pillarbox-playlist", - "version": "1.2.0", + "version": "2.0.0", "license": "MIT", "devDependencies": { "@srgssr/pillarbox-web": "^1.12.2" @@ -23616,7 +23728,7 @@ }, "packages/skip-button": { "name": "@srgssr/skip-button", - "version": "1.0.1", + "version": "1.0.3", "license": "MIT", "peerDependencies": { "@srgssr/pillarbox-web": "^1.12.2" diff --git a/package.json b/package.json index 57936e4..d43f796 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "create": "plop --plopfile scripts/create.js", "eslint": "eslint {packages/**/{src,test}/**/*.{js,jsx},scripts/*.{js,jsx}}", "github:page": "npm run github:page --ws && vite build && node scripts/prepare-deployment.js", - "start": " vite --port 4200 --open", + "start": " vite --host --port 4200 --open", "outdated": "npm outdated", "prepare": "husky", "stylelint": "stylelint **/*.{css,scss} --allow-empty-input", @@ -36,6 +36,7 @@ "@commitlint/config-conventional": "^19.1.0", "@parcel/transformer-sass": "^2.12.0", "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-terser": "^0.4.4", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^12.0.0", "@semantic-release/git": "^10.0.1", diff --git a/packages/pillarbox-debug-panel/.babelrc b/packages/pillarbox-debug-panel/.babelrc new file mode 100644 index 0000000..ac08da0 --- /dev/null +++ b/packages/pillarbox-debug-panel/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../babel.config.json" +} diff --git a/packages/pillarbox-debug-panel/.releaserc b/packages/pillarbox-debug-panel/.releaserc new file mode 100644 index 0000000..5ccbf51 --- /dev/null +++ b/packages/pillarbox-debug-panel/.releaserc @@ -0,0 +1,111 @@ +{ + "branches": ["main"], + "extends": "semantic-release-monorepo", + "plugins": [ + "@semantic-release/commit-analyzer", + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "parserOpts": { + "noteKeywords": [ + "BREAKING CHANGE", + "BREAKING CHANGES", + "BREAKING" + ] + }, + "presetConfig": { + "types": [ + { + "type": "breaking", + "section": "Breaking Changes ❗", + "hidden": false + }, + { + "type": "feat", + "section": "New Features πŸš€", + "hidden": false + }, + { + "type": "fix", + "section": "Enhancements and Bug Fixes πŸ›", + "hidden": false + }, + { + "type": "docs", + "section": "Docs πŸ“–", + "hidden": false + }, + { + "type": "style", + "section": "Styles 🎨", + "hidden": false + }, + { + "type": "refactor", + "section": "Refactor πŸ”©", + "hidden": false + }, + { + "type": "perf", + "section": "Performances ⚑️", + "hidden": false + }, + { + "type": "test", + "section": "Tests βœ…", + "hidden": false + }, + { + "type": "ci", + "section": "CI πŸ”", + "hidden": false + }, + { + "type": "chore", + "section": "Chore 🧹", + "hidden": false + } + ] + }, + "writerOpts": { + "groupBy": "type", + "commitGroupsSort": [ + "breaking", + "feat", + "fix" + ] + } + } + ], + [ + "@semantic-release/changelog", + { + "changelogFile": "CHANGELOG.md" + } + ], + "@semantic-release/npm", + [ + "@semantic-release/git", + { + "assets": [ + "package.json", + "package-lock.json", + "CHANGELOG.md" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + [ + "@semantic-release/github", + { + "assets": [ + { + "path": "dist/**/*" + } + ] + } + ] + ] +} + diff --git a/packages/pillarbox-debug-panel/LICENSE b/packages/pillarbox-debug-panel/LICENSE new file mode 100644 index 0000000..d9ef7ca --- /dev/null +++ b/packages/pillarbox-debug-panel/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 SRG SSR + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/pillarbox-debug-panel/README.md b/packages/pillarbox-debug-panel/README.md new file mode 100644 index 0000000..ca33c28 --- /dev/null +++ b/packages/pillarbox-debug-panel/README.md @@ -0,0 +1,189 @@ +# Pillarbox Web: Pillarbox Debug Panel + +The Pillarbox Debug Panel is a component for the video.js player that displays a minimal +debugging information. It provides real-time metrics and detailed playback information, helping +developers monitor and analyze video performance. + +## Requirements + +To use this component, you need the following installed on your system: + +- Node.js + +## Quick Start + +To get started with this component, install it through the following command: + +```bash +npm install --save video.js @srgssr/pillarbox-debug-panel +``` + +Once the player is installed you can activate the component as follows: + +```javascript +import videojs from 'video.js'; +import '@srgssr/pillarbox-debug-panel'; + +const player = videojs('my-player', { PillarboxDebugPanel: true }); +``` + +To apply the default styling, add the following line to your CSS file: + +```css +@import "@srgssr/pillarbox-debug-panel/dist/pillarbox-debug-panel.min.css"; +``` + +### Loading the Debug Panel Dynamically + +You can also load the debug panel dynamically using a CDN, see the example below: + +```javascript +async function addDebugPanel(player) { + // Add the required styles directly to the document's stylesheet + const styleSheet = new CSSStyleSheet(); + const cssText = await fetch('https://cdn.jsdelivr.net/npm/@srgssr/pillarbox-debug-panel/dist/pillarbox-debug-panel.min.css') + .then(response => response.text()); + styleSheet.replaceSync(cssText); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet]; + + // Dynamically import the Pillarbox Debug Panel script as a module + await import('https://cdn.jsdelivr.net/npm/@srgssr/pillarbox-debug-panel/dist/pillarbox-debug-panel.umd.cjs'); + + // Add the Pillarbox Debug Panel to the player + player.addChild('PillarboxDebugPanel'); +} +``` + +> [!IMPORTANT] +> For this script to function properly, the `videojs` object must be available in the global scope. +> **If `videojs` is not in the global scope, this method will not work.** + +## API Documentation + +## PillarboxDebugPanel + +The `PillarboxDebugPanel` is a high-level component that serves as a container for +multiple `MetricComponent` instances. It listens to video player events and updates each +metric component dynamically as the video plays, ensuring that the displayed metrics are always +current. + +Here’s a schematic representation of the relationship between the components: + +``` +PillarboxDebugPanel + β”œβ”€β”€ MetricComponent + β”‚ β”œβ”€β”€ MetricLabel + β”‚ └── GraphComponent (optional) + β”œβ”€β”€ MetricComponent + β”‚ β”œβ”€β”€ MetricLabel + β”‚ └── GraphComponent (optional) + └── ... +``` + +### MetricComponent + +The `MetricComponent` is responsible for rendering an individual metric. It can display both a +label (`MetricLabel`) and an optional graph (`GraphComponent`) that dynamically visualizes the +metric's data over time. + +| Option | Type | Default | Description | +|------------------|----------|-------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| `valueExtractor` | Function | Required | A function that extracts the metric value from the player. | +| `valueFormatter` | Function | `(value) => value` | A function that formats the extracted value before displaying it. | +| `children` | Array | `['metricLabel', 'graphComponent']` | The child components to be rendered within the `MetricComponent`. This usually includes `MetricLabel` and optionally `GraphComponent`. | + +You can disable the graph as such: + +```javascript +player.pillarboxDebugPanel.addChild( + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'My Metric' }, + valueExtractor: (player) => 'Some value' + } +); +``` + +### MetricLabel + +The `MetricLabel` is a simple component responsible for displaying a text label for a specific +metric. It shows the metric name along with its current value. + +| Option | Type | Default | Description | +|---------|--------|----------|-------------------------------------------------------------------------------| +| `label` | String | Required | The label text for the metric. This is displayed along with the metric value. | + +### GraphComponent + +The `GraphComponent` is responsible for rendering a dynamic bar graph that visualizes the metric’s +data over time. This component is optional and is used within the `MetricComponent` when a graphical +representation of the metric is required. + +| Option | Type | Default | Description | +|-----------------|--------|---------------------|------------------------------------------------------------------------------------------------------------| +| `fillStyle` | String | `'rgba(11,83,148)'` | The color of the bars in the graph. | +| `strokeStyle` | String | `'rgb(50,50,50)'` | The color of the stroke around the line in the graph. | +| `maxDataPoints` | Number | `30` | The maximum number of data points to display on the graph. Older points are removed as new ones are added. | + +### Adding custom metrics + +You can add additional metrics programmatically to the debug panel by adding a child or modifying +the default children array. As an example, let's display the mime type fo the currently playing +source: + +```javascript +import videojs from 'video.js'; +import '@srgssr/pillarbox-debug-panel'; + +const player = videojs('my-player', { pillarboxDebugPanel: true }); +player.pillarboxDebugPanel.addChild( + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Mime Type' }, + valueExtractor: (player) => player.currentSource().type + } +); +``` + +## Contributing + +For detailed contribution guidelines, refer to our [Contributing guide][contributing-guide]. +Please adhere to the specified guidelines. + +### Setting up a development server + +Start the development server: + +```bash +npm run start +``` + +This will start the server on `http://localhost:4200`. Open this URL in your browser to view the +demo page. + +The video player (`player`) and the Pillarbox library (`pillarbox`) are exposed on the `window` +object, making it easy to access and manipulate from the browser's developer console for debugging. + +#### Available URL parameters + +The demo page supports several URL parameters that modify the behavior of the video player: + +- `debug`: Set this to enable debugging mode. +- `ilHost`: Specifies the host for the data provider. +- `language`: Sets the language for the player interface. +- `urn`: Specifies the URN of the video to load. Default is `urn:rts:video:14683290`. + +You can combine parameters in the URL like so: + +```plaintext +http://localhost:4200/?language=fr&urn=urn:rts:video:14318206 +``` + +## Licensing + +This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for more +details. + +[contributing-guide]: https://github.com/SRGSSR/pillarbox-web-suite/blob/main/docs/README.md#contributing diff --git a/packages/pillarbox-debug-panel/index.html b/packages/pillarbox-debug-panel/index.html new file mode 100644 index 0000000..032f92a --- /dev/null +++ b/packages/pillarbox-debug-panel/index.html @@ -0,0 +1,57 @@ + + + + + + + Pillarbox-Debug-Panel Demo + + + + + + + diff --git a/packages/pillarbox-debug-panel/package.json b/packages/pillarbox-debug-panel/package.json new file mode 100644 index 0000000..4af602d --- /dev/null +++ b/packages/pillarbox-debug-panel/package.json @@ -0,0 +1,49 @@ +{ + "name": "@srgssr/pillarbox-debug-panel", + "version": "0.0.1", + "license": "MIT", + "author": "SRG SSR", + "repository": { + "type": "git", + "url": "git+https://github.com/SRGSSR/pillarbox-web-suite.git" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/SRGSSR/pillarbox-web-suite/tree/main/packages/pillarbox-debug-panel#readme", + "type": "module", + "main": "dist/pillarbox-debug-panel.cjs", + "module": "dist/pillarbox-debug-panel.js", + "style": "./dist/pillarbox-debug-panel.min.css", + "exports": { + ".": { + "import": "./dist/pillarbox-debug-panel.js", + "require": "./dist/pillarbox-debug-panel.cjs" + }, + "./*": "./*" + }, + "files": [ + "dist/*", + "scss/*" + ], + "keywords": [ + "video.js", + "player" + ], + "scripts": { + "build": "npm run build:lib && npm run build:umd && npm run build:css", + "build:css": "sass ./scss/pillarbox-debug-panel.scss:dist/pillarbox-debug-panel.min.css --style compressed --source-map --load-path node_modules", + "build:lib": "vite build --config vite.config.lib.js", + "build:umd": "vite build --config vite.config.umd.js", + "github:page": "vite build", + "release:ci": "semantic-release", + "start": " vite --port 4200 --open", + "test": "vitest run --silent --coverage --coverage.reporter text" + }, + "peerDependencies": { + "video.js": "^8.0.0" + }, + "devDependencies": { + "@srgssr/pillarbox-web": "^1.12.1" + } +} diff --git a/packages/pillarbox-debug-panel/scss/pillarbox-debug-panel.scss b/packages/pillarbox-debug-panel/scss/pillarbox-debug-panel.scss new file mode 100644 index 0000000..521fbdd --- /dev/null +++ b/packages/pillarbox-debug-panel/scss/pillarbox-debug-panel.scss @@ -0,0 +1,44 @@ +.pbw-debug-panel { + position: absolute; + bottom: 6em; + left: 2.1em; + width: 30%; + padding: 0.8em; + color: white; + font-size: 0.8em; + background-color: rgb(0 0 0 / 70%); + transition: bottom .5s; + + + > * { + margin-bottom: 0.2em; + } +} + +.vjs-layout-tiny, .vjs-layout-x-small, .vjs-layout-small, .vjs-layout-medium { + .pbw-debug-panel { + width: calc(100% - 4.2em); + } +} + +.vjs-layout-large .pbw-debug-panel { + width: 40% +} + +.vjs-playing.vjs-user-inactive .pbw-debug-panel { + bottom: 1em; +} + +.metric-component { + display: flex; + gap: 0.5em; + justify-content: space-between; + font-weight: 800; + white-space: nowrap; +} + +.graph-component { + width: 60%; + height: 1em; + image-rendering: pixelated; +} diff --git a/packages/pillarbox-debug-panel/src/graph-component.js b/packages/pillarbox-debug-panel/src/graph-component.js new file mode 100644 index 0000000..113d603 --- /dev/null +++ b/packages/pillarbox-debug-panel/src/graph-component.js @@ -0,0 +1,116 @@ +import videojs from 'video.js'; + +/** + * @ignore + * @type {typeof import('video.js/dist/types/component').default} + */ +const Component = videojs.getComponent('Component'); + +const log = videojs.log.createLogger('graph-component'); + +/** + * A video.js component that plots a dynamic line graph. + * + * This component is used to visualize data over time on a Video.js player by plotting + * a line graph within a canvas element. It supports dynamic updates, allowing new data points + * to be added. + */ +class GraphComponent extends Component { + /** + * Creates an instance of a GraphComponent. + * + * @param {import('video.js/dist/types/player.js').default} player - The Video.js player instance. + * @param {Object} [options={}] - Configuration options for the GraphComponent. + * @param {number} [options.maxDataPoints=30] - The maximum number of data points to display on the graph. + * Older data points are removed as new ones are added. + * @param {string} [options.fillStyle='rgba(11,83,148)'] - The color of the bars on the graph. + * @param {string} [options.strokeStyle='rgb(50,50,50)'] - The color of the stroke around the line on the graph. + */ + constructor(player, options = {}) { + super(player, options); + this.data = []; + } + + /** + * Constructs the DOM element that will be used to render the graph. + * It ensures the element is a canvas and sets up the necessary properties. + * + * @param {string} [tag='canvas'] - The HTML tag name for the element. Must be 'canvas'. + * @param {Object} [props={}] - Additional properties to set on the element. + * @param {Object} [attributes={}] - Additional attributes to set on the element. + * @returns {HTMLCanvasElement} The created canvas element. + */ + createEl(tag = 'canvas', props = {}, attributes = {}) { + if (tag !== 'canvas') { + log.error(`Creating a GraphComponent with an HTML element of ${tag} is not supported; the element must be a 'canvas'`); + } + + props = Object.assign({ className: this.buildCSSClass(), }, props); + + return videojs.dom.createEl(tag, props, attributes); + } + + /** + * Updates the graph with a new data point. + * + * This method adds a new data point to the graph. If the maximum number of + * data points is exceeded, the oldest point is removed. After updating the + * data, the graph is redrawn. + * + * @param {number} value - The new data point to add to the graph. + */ + update(value) { + if (this.data.length >= this.options_.maxDataPoints) { + this.data.shift(); + } + + this.data.push(value); + this.drawGraph(); + } + + /** + * Draws the graph on the canvas element. + * + * This method iterates over the stored data points and renders them on the + * canvas as a series of bars. Each bar represents a data point, and the + * height of the bar is scaled relative to the maximum value in the dataset. + */ + drawGraph() { + const ctx = this.el_.getContext('2d'); + const maxValue = Math.max(...this.data); + + ctx.clearRect(0, 0, this.el_.width, this.el_.height); + + const barWidth = this.el_.width / this.options_.maxDataPoints; + + this.data.forEach((point, index) => { + const x = index * barWidth; + const y = this.el_.height - (point / maxValue) * this.el_.height; // Scale dynamically based on maxValue + const barHeight = (point / maxValue) * this.el_.height; + + ctx.beginPath(); + ctx.rect(x, y, barWidth, barHeight); + ctx.closePath(); + + ctx.fillStyle = this.options_.fillStyle; + ctx.fill(); + + ctx.strokeStyle = this.options_.strokeStyle; + ctx.stroke(); + }); + } + + buildCSSClass() { + return `${super.buildCSSClass()} graph-component`; + } +} + +GraphComponent.prototype.options_ = { + fillStyle: 'rgba(11,83,148)', + strokeStyle: 'rgb(50,50,50)', + maxDataPoints: 30 +}; + +videojs.registerComponent('GraphComponent', GraphComponent); + +export default GraphComponent; diff --git a/packages/pillarbox-debug-panel/src/metric-component.js b/packages/pillarbox-debug-panel/src/metric-component.js new file mode 100644 index 0000000..46ce77c --- /dev/null +++ b/packages/pillarbox-debug-panel/src/metric-component.js @@ -0,0 +1,47 @@ +import videojs from 'video.js'; +import './metric-label.js'; +import './graph-component.js'; + +/** + * @ignore + * @type {typeof import('video.js/dist/types/component').default} + */ +const Component = videojs.getComponent('Component'); + +/** + * Represents a PlottedMetricsComponent for displaying individual metrics with a graph. + */ +class MetricComponent extends Component { + /** + * Creates an instance of a PlottedMetricsComponent. + * + * @param {import('video.js/dist/types/player.js').default} player The player instance. + * @param {Object} options Configuration options for the component. + */ + constructor(player, options) { + super(player, options); + } + + /** + * Update the value of the metric. + */ + update() { + const value = this.options_.valueExtractor(this.player()); + + this.metricLabel?.update(this.options_.valueFormatter(value)); + this.graphComponent?.update(value); + } +} + +MetricComponent.prototype.options_ = { + className: 'metric-component', + valueFormatter: (value) => value, + children: [ + 'metricLabel', + 'graphComponent' + ] +}; + +videojs.registerComponent('MetricComponent', MetricComponent); + +export default MetricComponent; diff --git a/packages/pillarbox-debug-panel/src/metric-label.js b/packages/pillarbox-debug-panel/src/metric-label.js new file mode 100644 index 0000000..b577e79 --- /dev/null +++ b/packages/pillarbox-debug-panel/src/metric-label.js @@ -0,0 +1,59 @@ +import videojs from 'video.js'; + +/** + * @ignore + * @type {typeof import('video.js/dist/types/component').default} + */ +const Component = videojs.getComponent('Component'); + +/** + * Represents a MetricLabel component for displaying individual metrics. + * + * The MetricLabel is a simple component used to display a text label for a specific metric. + * It shows the metric's name and its current value. + */ +class MetricLabel extends Component { + /** + * Creates an instance of a MetricLabel. + * + * @param {import('video.js/dist/types/player.js').default} player - The Video.js player instance. + * @param {Object} options - Configuration options for the MetricLabel. + * @param {string} options.label - The label text for the metric. This text is displayed along with the metric value. + */ + constructor(player, options) { + super(player, options); + } + + /** + * Constructs the DOM element that will represent the MetricLabel in the player UI. + * + * @param {string} [tag='div'] - The HTML tag name for the element. + * @param {Object} [props={}] - Additional properties to set on the element. + * @param {Object} [attributes={}] - Additional attributes to set on the element. + * + * @return {Element} The created DOM element. + */ + createEl(tag = 'div', props = {}, attributes = {}) { + props = Object.assign({ className: this.buildCSSClass() }, props); + + return videojs.dom.createEl(tag, props, attributes); + } + + /** + * Updates the value of the metric displayed by the MetricLabel. + * + * @param {string|number} value - The current value of the metric to display. + */ + update(value) { + videojs.dom.textContent(this.el_, `${this.options_.label}: ${value}`); + } + + buildCSSClass() { + return `${super.buildCSSClass()} metric-label`; + } +} + +videojs.registerComponent('MetricLabel', MetricLabel); + +export default MetricLabel; + diff --git a/packages/pillarbox-debug-panel/src/pillarbox-debug-panel.js b/packages/pillarbox-debug-panel/src/pillarbox-debug-panel.js new file mode 100644 index 0000000..9d8a938 --- /dev/null +++ b/packages/pillarbox-debug-panel/src/pillarbox-debug-panel.js @@ -0,0 +1,219 @@ +import videojs from 'video.js'; +import './metric-component.js'; + +/** + * @ignore + * @type {typeof import('video.js/dist/types/component').default} + */ +const Component = videojs.getComponent('Component'); + +/** + * Represents a PillarboxDebugPanel component for the Video.js player. + * + * The PillarboxDebugPanel is a high-level component that serves as a container + * for multiple MetricComponent instances. Each MetricComponent is responsible + * for displaying a specific video playback metric, optionally with a graphical + * representation (using a GraphComponent). + */ +class PillarboxDebugPanel extends Component { + update_ = () => this.update(); + + /** + * Creates an instance of a PillarboxDebugPanel. + * + * @param {import('video.js/dist/types/player.js').default} player - The Video.js player instance. + * @param {Object} options - Configuration options for the PillarboxDebugPanel. + */ + constructor(player, options) { + super(player, options); + + this.player().one('loadeddata', this.update_); + this.player().on('timeupdate', this.update_); + } + + dispose() { + this.player().off('loadeddata', this.update_); + this.player().off('timeupdate', this.update_); + } + + /** + * Updates all MetricComponent instances within this panel. + */ + update() { + this.children().forEach(child => { + if (child.update) { + child.update(); + } + }); + } +} + +/** + * Helper function to extract an attribute from the VHS tech. + * + * @param {import('video.js/dist/types/player.js').default} player - The Video.js player instance. + * @param {string} attribute - The VHS attribute to extract. + * + * @returns {string|undefined} The value of the VHS attribute, or undefined if not available. + */ +function extractVhsAttribute(player, attribute) { + const tech = player.tech({}); + + if (!tech?.vhs) return undefined; + + return tech.vhs.playlists.media().attributes[attribute]; +} + +/** + * Helper function to extract a statistic from the VHS tech. + * + * @param {import('video.js/dist/types/player.js').default} player - The Video.js player instance. + * @param {string} stat - The VHS statistic to extract. + * + * @returns {number|undefined} The value of the VHS statistic, or undefined if not available. + */ +function extractVhsStats(player, stat) { + const tech = player.tech({}); + + if (!tech?.vhs) return undefined; + + return tech.vhs.stats[stat]; +} + +/** + * Helper function to get the currently selected video track. + * + * @param {import('video.js/dist/types/player.js').default} player - The Video.js player instance. + * + * @returns {Object|undefined} The currently selected video track, or undefined if none is selected. + */ +function currentVideoTrack(player) { + return Array.from(player.videoTracks()).find(track => track.selected); +} + +/** + * Helper function to get the currently enabled audio track. + * + * @param {import('video.js/dist/types/player.js').default} player - The Video.js player instance. + * + * @returns {Object|undefined} The currently enabled audio track, or undefined if none is enabled. + */ +function currentAudioTrack(player) { + return Array.from(player.audioTracks()).find(track => track.enabled); +} + +PillarboxDebugPanel.prototype.options_ = { + className: 'pbw-debug-panel', + children: [ + { + name: 'MetricComponent', + metricLabel: { label: 'Buffer' }, + graphComponent: { fillStyle: 'rgb(151,32,48)' }, + valueExtractor: (player) => { + const buffered = player.buffered(); + const currentTime = player.currentTime(); + + return Math.max( + 0, + (buffered.length ? buffered.end(buffered.length - 1) - currentTime + : 0) + ).toFixed(2); + }, + valueFormatter: (value) => value ? `${value} s` : 'N/A' + }, + { + name: 'MetricComponent', + metricLabel: { label: 'Bandwidth' }, + valueExtractor: (player) => extractVhsStats(player, 'bandwidth'), + valueFormatter: (value) => value ? `${(value / 1e6).toFixed(2)} Mbps` : 'N/A' + }, + { + name: 'MetricComponent', + metricLabel: { label: 'Bitrate' }, + graphComponent: { fillStyle: 'rgb(0,128,128)' }, + valueExtractor: (player) => + extractVhsAttribute(player, 'BANDWIDTH') ?? + currentVideoTrack(player)?.configuration?.bitrate, + valueFormatter: (value) => value ? `${(value / 1e6).toFixed(2)} Mbps` : 'N/A', + }, + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Media Duration' }, + valueExtractor: (player) => player.duration().toFixed(2) + 's' + }, + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Position' }, + valueExtractor: (player) => player.currentTime().toFixed(2) + 's' + }, + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Codecs' }, + valueExtractor: (player) => { + const codec = extractVhsAttribute(player, 'CODECS'); + + if (codec) return codec; + + const videoCodec = currentVideoTrack(player)?.configuration?.codec; + const audioCodec = currentAudioTrack(player)?.configuration?.codec; + + return [videoCodec, audioCodec].filter(Boolean).join(',') || 'N/A'; + } + }, + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Framerate' }, + valueExtractor: (player) => { + const frameRate = extractVhsAttribute(player, 'FRAME-RATE') ?? + currentVideoTrack(player)?.configuration?.framerate; + + return frameRate ? `${frameRate} fps` : 'N/A'; + } + }, + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Total frames' }, + valueExtractor: (player) => { + const quality = player.getVideoPlaybackQuality(); + + return quality.totalVideoFrames ?? 'N/A'; + } + }, + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Dropped frames' }, + valueExtractor: (player) => { + const quality = player.getVideoPlaybackQuality(); + + return quality.droppedVideoFrames ?? 'N/A'; + } + }, + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Resolution' }, + valueExtractor: (player) => { + const resolution = extractVhsAttribute(player, 'RESOLUTION') ?? + currentVideoTrack(player)?.configuration; + + return resolution ? `${resolution.width}x${resolution.height}` : 'N/A'; + } + }, + { + name: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Timestamp' }, + valueExtractor: () => new Date().toLocaleTimeString() + } + ] +}; + +videojs.registerComponent('PillarboxDebugPanel', PillarboxDebugPanel); + +export default PillarboxDebugPanel; diff --git a/packages/pillarbox-debug-panel/test/pillarbox-debug-panel.spec.js b/packages/pillarbox-debug-panel/test/pillarbox-debug-panel.spec.js new file mode 100644 index 0000000..09a0d03 --- /dev/null +++ b/packages/pillarbox-debug-panel/test/pillarbox-debug-panel.spec.js @@ -0,0 +1,58 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import videojs from 'video.js'; +import PillarboxDebugPanel from '../src/pillarbox-debug-panel.js'; + +window.HTMLMediaElement.prototype.load = () => { +}; + +HTMLCanvasElement.prototype.getContext = () => ({ + clearRect: vi.fn(), + beginPath: vi.fn(), + rect: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + stroke: vi.fn() +}); + +describe('PillarboxDebugPanel', () => { + let player, videoElement; + + beforeAll(() => { + document.body.innerHTML = ''; + videoElement = document.querySelector('#test-video'); + }); + + beforeEach(async() => { + player = videojs(videoElement, { + pillarboxDebugPanel: true + }); + await new Promise((resolve) => player.ready(() => resolve())); + }); + + afterEach(() => { + vi.resetAllMocks(); + player.dispose(); + }); + + it('should be registered and attached to the player', () => { + expect(videojs.getComponent('PillarboxDebugPanel')).toBe(PillarboxDebugPanel); + expect(player.pillarboxDebugPanel).toBeDefined(); + }); + + it('should update the UI when "loadeddata" event is triggered', () => { + const metrics = player.pillarboxDebugPanel.children(); + + player.trigger('loadeddata'); + expect(metrics[0].metricLabel.el().textContent).toMatch(/^Buffer:/); + expect(metrics[metrics.length - 1].metricLabel.el().textContent).toMatch(/^Timestamp:/); + }); + + it('should update the UI when "timeupdate" event is triggered', () => { + const metrics = player.pillarboxDebugPanel.children(); + + player.trigger('timeupdate'); + + expect(metrics[0].metricLabel.el().textContent).toMatch(/^Buffer:/); + expect(metrics[metrics.length - 1].metricLabel.el().textContent).toMatch(/^Timestamp:/); + }); +}); diff --git a/packages/pillarbox-debug-panel/vite.config.js b/packages/pillarbox-debug-panel/vite.config.js new file mode 100644 index 0000000..18699ea --- /dev/null +++ b/packages/pillarbox-debug-panel/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: './', + test: { + environment: 'jsdom' + } +}); diff --git a/packages/pillarbox-debug-panel/vite.config.lib.js b/packages/pillarbox-debug-panel/vite.config.lib.js new file mode 100644 index 0000000..795df05 --- /dev/null +++ b/packages/pillarbox-debug-panel/vite.config.lib.js @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite'; +import babel from '@rollup/plugin-babel'; + +/** + * Vite's configuration for the lib build. + * + * Outputs: + * - 'dist/pillarbox-debug-panel.js': ESModule version with sourcemaps. + * - 'dist/pillarbox-debug-panel.cjs': CommonJS version with sourcemaps. + */ +export default defineConfig({ + esbuild: false, + build: { + sourcemap: true, + lib: { + formats: ['es', 'cjs'], + name: 'PillarboxDebugPanel', + entry: 'src/pillarbox-debug-panel.js' + }, + rollupOptions: { + external: ['video.js'], + plugins: [ + babel({ + babelHelpers: 'bundled', + exclude: 'node_modules/**' + }) + ] + } + } +}); diff --git a/packages/pillarbox-debug-panel/vite.config.umd.js b/packages/pillarbox-debug-panel/vite.config.umd.js new file mode 100644 index 0000000..60d9adf --- /dev/null +++ b/packages/pillarbox-debug-panel/vite.config.umd.js @@ -0,0 +1,38 @@ +import { defineConfig } from 'vite'; +import babel from '@rollup/plugin-babel'; +import terser from '@rollup/plugin-terser'; + +/** + * Vite's configuration for the lib build. + * + * Outputs: + * - 'dist/pillarbox-debug-panel.js': ESModule version with sourcemaps. + * - 'dist/pillarbox-debug-panel.cjs': CommonJS version with sourcemaps. + */ +export default defineConfig({ + esbuild: false, + build: { + emptyOutDir: false, + sourcemap: true, + lib: { + formats: ['umd'], + name: 'PillarboxDebugPanel', + entry: 'src/pillarbox-debug-panel.js' + }, + rollupOptions: { + external: ['video.js'], + output: { + globals: { + 'video.js': 'videojs' + } + }, + plugins: [ + babel({ + babelHelpers: 'bundled', + exclude: 'node_modules/**' + }), + terser() + ] + } + } +}); diff --git a/scripts/template/index.html.hbs b/scripts/template/index.html.hbs index 2f79583..65d049d 100644 --- a/scripts/template/index.html.hbs +++ b/scripts/template/index.html.hbs @@ -23,6 +23,7 @@