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..826189a --- /dev/null +++ b/packages/pillarbox-debug-panel/README.md @@ -0,0 +1,192 @@ +# 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. This method avoids the need to bundle the +debug panel in your application build, ensuring that the debug panel is only loaded when needed. 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 omit the graph component 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: { + mimeType: { + componentClass: "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..68c4e18 --- /dev/null +++ b/packages/pillarbox-debug-panel/scss/pillarbox-debug-panel.scss @@ -0,0 +1,43 @@ +.pbw-debug-panel { + position: absolute; + bottom: 6em; + left: 2.1em; + width: 30%; + padding: 0.8em; + color: #fff; + 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..58243fe --- /dev/null +++ b/packages/pillarbox-debug-panel/src/graph-component.js @@ -0,0 +1,146 @@ +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'`); + throw new Error(`'${tag}' is not supported for GraphComponent`); + } + + return super.createEl( + tag, + videojs.obj.merge({ + className: this.buildCSSClass() + }, 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 canvas = this.el(); + const ctx = canvas.getContext('2d'); + + canvas.width = this.width(); + canvas.height = this.height(); + + const maxValue = Math.max(...this.data); + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const barWidth = canvas.width / this.options().maxDataPoints; + + this.data.forEach((point, index) => { + const x = index * barWidth; + const y = canvas.height - (point / maxValue) * canvas.height; // Scale dynamically based on maxValue + const barHeight = (point / maxValue) * canvas.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`; + } + + /** + * Get the `GraphComponent`s DOM element + * + * @return {HTMLCanvasElement} + * The DOM element for this `Component`. + */ + el() { + return this.el_; + } +} + +/** + * @type {GraphComponentOptions & Object} + */ +GraphComponent.prototype.options_ = { + fillStyle: 'rgba(11,83,148)', + strokeStyle: 'rgb(50,50,50)', + maxDataPoints: 30 +}; + +videojs.registerComponent('GraphComponent', GraphComponent); + +export default GraphComponent; + +/** + * @typedef {Object} GraphComponentOptions + * @property {string} [fillStyle] - The fill color style in any valid CSS color format (e.g., 'rgba(11,83,148)'). + * @property {string} [strokeStyle] - The stroke color style in any valid CSS color format (e.g., 'rgb(50,50,50)'). + * @property {number} [maxDataPoints] - The maximum number of data points to display. + */ 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..1c2d178 --- /dev/null +++ b/packages/pillarbox-debug-panel/src/metric-component.js @@ -0,0 +1,50 @@ +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. + * @param {Function} [options.valueFormatter] - A function to format the metric value before display. Defaults to the identity function. + * @param {Function} options.valueExtractor - A function to extract the metric value from the player instance. + * + */ + 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..5fcc566 --- /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}: ${this.localize(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..e3a61b5 --- /dev/null +++ b/packages/pillarbox-debug-panel/src/pillarbox-debug-panel.js @@ -0,0 +1,255 @@ +import videojs from 'video.js'; +import { version } from '../package.json'; +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 => child.update && child.update()); + } + + createEl(tag, props, attributes) { + return super.createEl( + tag, + videojs.obj.merge({ + className: this.buildCSSClass() + }, props), + attributes + ); + } + + buildCSSClass() { + return `${super.buildCSSClass()} pbw-debug-panel`; + } + + static get VERSION() { + return version; + } +} + +/** + * 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_ = { + children: [ + { + name: 'bufferMetricComponent', + id: 'bufferMetricComponent', + componentClass: '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: 'bandwidthMetricComponent', + id: 'bandwidthMetricComponent', + componentClass: 'MetricComponent', + metricLabel: { label: 'Bandwidth' }, + valueExtractor: (player) => extractVhsStats(player, 'bandwidth'), + valueFormatter: (value) => value ? `${(value / 1e6).toFixed(2)} Mbps` : 'N/A' + }, + { + name: 'bitrateMetricComponent', + id: 'bitrateMetricComponent', + componentClass: '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: 'mediaDurationMetricComponent', + id: 'mediaDurationMetricComponent', + componentClass: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Media Duration' }, + valueExtractor: (player) => player.duration().toFixed(2) + 's' + }, + { + name: 'positionMetricComponent', + id: 'positionMetricComponent', + componentClass: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Position' }, + valueExtractor: (player) => player.currentTime().toFixed(2) + 's' + }, + { + name: 'codecsMetricComponent', + id: 'codecsMetricComponent', + componentClass: '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: 'framerateMetricComponent', + id: 'framerateMetricComponent', + componentClass: '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: 'totalFramesMetricComponent', + id: 'totalFramesMetricComponent', + componentClass: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Total frames' }, + valueExtractor: (player) => { + const quality = player.getVideoPlaybackQuality(); + + return quality.totalVideoFrames ?? 'N/A'; + } + }, + { + name: 'droppedFramesMetricComponent', + id: 'droppedFramesMetricComponent', + componentClass: 'MetricComponent', + graphComponent: false, + metricLabel: { label: 'Dropped frames' }, + valueExtractor: (player) => { + const quality = player.getVideoPlaybackQuality(); + + return quality.droppedVideoFrames ?? 'N/A'; + } + }, + { + name: 'resolutionMetricComponent', + id: 'resolutionMetricComponent', + componentClass: '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: 'timestampMetricComponent', + id: 'timestampMetricComponent', + componentClass: '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 @@