New PeerTube Video Element Integration PR#214
Conversation
|
@younes200 is attempting to deploy a commit to the Mux Team on Vercel. A member of the Team first needs to authorize it. |
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
Add check to skip setting muted if value is undefined
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: CSS selector targets wrong element in demo page
- Fixed all three references: changed title from twitch-video to peertube-video, updated CSS selector from twitch-video to peertube-video, and changed h1 heading from twitch-video to peertube-video.
- ✅ Fixed: Buffered progress tracks playback position not buffer
- Removed the incorrect assignment of #progress = data.position from the playbackStatusUpdate handler, as PeerTube does not provide buffer information in this event (unlike Vimeo which has a separate progress event).
- ✅ Fixed: Wrong PeerTube event name for resolution updates
- Changed the event listener from resolutionChange to resolutionUpdate to match the correct PeerTube embed API event name.
Or push these changes by commenting:
@cursor push 3f1662b705
Preview (3f1662b705)
diff --git a/packages/peertube-video-element/index.html b/packages/peertube-video-element/index.html
new file mode 100644
--- /dev/null
+++ b/packages/peertube-video-element/index.html
@@ -1,0 +1,48 @@
+<!doctype html>
+<html>
+ <head>
+ <title><peertube-video></title>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css" />
+ <style>
+ body {
+ text-align: center;
+ }
+ media-controller,
+ peertube-video {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: #000;
+ }
+ </style>
+ <script type="module" src="./peertube-video-element.js"></script>
+ <script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome/+esm"></script>
+ </head>
+ <body>
+ <h1><peertube-video></h1>
+ <br />
+
+ <peertube-video id="t1" muted controls src="https://peertube.tv/videos/watch/52a10666-3a18-4e73-93da-e8d3c12c305a"></peertube-video>
+
+ <br />
+
+ <br />
+
+ <h2>With <a href="https://github.com/muxinc/media-chrome" target="_blank">Media Chrome</a></h2>
+
+ <media-controller>
+ <peertube-video id="t2" src="https://peertube.tv/videos/watch/52a10666-3a18-4e73-93da-e8d3c12c305a" slot="media"></peertube-video>
+ <media-control-bar>
+ <media-play-button></media-play-button>
+ <media-seek-backward-button seek-offset="15"></media-seek-backward-button>
+ <media-seek-forward-button seek-offset="15"></media-seek-forward-button>
+ <media-mute-button></media-mute-button>
+ <media-volume-range></media-volume-range>
+ <media-time-range></media-time-range>
+ <media-time-display show-duration remaining></media-time-display>
+ <media-fullscreen-button></media-fullscreen-button>
+ </media-control-bar>
+ </media-controller>
+ </body>
+</html>
diff --git a/packages/peertube-video-element/peertube-video-element.js b/packages/peertube-video-element/peertube-video-element.js
new file mode 100644
--- /dev/null
+++ b/packages/peertube-video-element/peertube-video-element.js
@@ -1,0 +1,573 @@
+// https://docs.joinpeertube.org/api/embed-player
+import { MediaPlayedRangesMixin } from 'media-played-ranges-mixin';
+import { PeerTubePlayer } from '@peertube/embed-api';
+const MATCH_SRC = /peertube\.(?:tv|io|online|site|ch|app|dev)\/(?:videos\/(?:embed|watch)\/)?([a-z0-9-]+)/i;
+
+function getTemplateHTML(attrs, props = {}) {
+ const iframeAttrs = {
+ src: serializeIframeUrl(attrs, props),
+ frameborder: 0,
+ width: '100%',
+ height: '100%',
+ allow: 'accelerometer; fullscreen; autoplay; encrypted-media; gyroscope; picture-in-picture',
+ };
+
+ if (props.config) {
+ iframeAttrs['data-config'] = JSON.stringify(props.config);
+ }
+
+ return /*html*/`
+ <style>
+ :host {
+ display: inline-block;
+ min-width: 300px;
+ min-height: 150px;
+ position: relative;
+ }
+ iframe {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+ :host(:not([controls])) {
+ pointer-events: none;
+ }
+ </style>
+ <iframe${serializeAttributes(iframeAttrs)}></iframe>
+ `;
+}
+
+function serializeIframeUrl(attrs, props) {
+ if (!attrs.src) return;
+
+ const matches = attrs.src.match(MATCH_SRC);
+ if (!matches) return '';
+
+ const videoId = matches[1];
+ const url = new URL(attrs.src);
+ const params = {
+ api: 1, // Required for API to work
+ autoplay: attrs.autoplay,
+ controls: attrs.controls === '' ? null : 0,
+ loop: attrs.loop,
+ muted: attrs.muted,
+ playsinline: attrs.playsinline,
+ preload: attrs.preload ?? 'metadata',
+ ...props.config,
+ };
+
+ // Extract instance URL from src
+ const instanceUrl = url.origin;
+ return `${instanceUrl}/videos/embed/${videoId}?${serialize(params)}`;
+}
+
+class PeerTubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement ?? class {}) {
+ static getTemplateHTML = getTemplateHTML;
+ static shadowRootOptions = { mode: 'open' };
+ static observedAttributes = [
+ 'autoplay',
+ 'controls',
+ 'loop',
+ 'muted',
+ 'playsinline',
+ 'preload',
+ 'src',
+ ];
+
+ loadComplete = new PublicPromise();
+ #loadRequested;
+ #hasLoaded;
+ #isInit;
+ #currentTime = 0;
+ #duration = NaN;
+ #muted = false;
+ #paused = !this.autoplay;
+ #playbackRate = 1;
+ #progress = 0;
+ #readyState = 0;
+ #seeking = false;
+ #volume = 1;
+ #videoWidth = NaN;
+ #videoHeight = NaN;
+ #config = null;
+ #api = null;
+
+ constructor() {
+ super();
+ this.#upgradeProperty('config');
+ }
+
+ get api() {
+ return this.#api;
+ }
+
+ set api(value) {
+ this.#api = value;
+ }
+
+ requestFullscreen() {
+ return this.#api?.requestFullscreen?.();
+ }
+
+ exitFullscreen() {
+ return this.#api?.exitFullscreen?.();
+ }
+
+ get config() {
+ return this.#config;
+ }
+
+ set config(value) {
+ this.#config = value;
+ }
+
+ async load() {
+ if (this.#loadRequested) return;
+
+ const isFirstLoad = !this.#hasLoaded;
+
+ if (this.#hasLoaded) this.loadComplete = new PublicPromise();
+ this.#hasLoaded = true;
+
+ // Wait 1 tick to allow other attributes to be set.
+ await (this.#loadRequested = Promise.resolve());
+ this.#loadRequested = null;
+
+ this.#currentTime = 0;
+ this.#duration = NaN;
+ this.#muted = false;
+ this.#paused = !this.autoplay;
+ this.#playbackRate = 1;
+ this.#progress = 0;
+ this.#readyState = 0;
+ this.#seeking = false;
+ this.#volume = 1;
+ this.#videoWidth = NaN;
+ this.#videoHeight = NaN;
+ this.dispatchEvent(new Event('emptied'));
+
+ let oldApi = this.#api;
+ this.#api = null;
+
+ if (!this.src) {
+ return;
+ }
+
+ this.dispatchEvent(new Event('loadstart'));
+
+ const onLoaded = async () => {
+ this.#readyState = 1; // HTMLMediaElement.HAVE_METADATA
+ this.dispatchEvent(new Event('loadedmetadata'));
+
+ if (this.#api) {
+ this.#muted = await this.#api.getMuted();
+ this.#volume = await this.#api.getVolume();
+ this.dispatchEvent(new Event('volumechange'));
+
+ this.#duration = await this.#api.getDuration();
+ this.dispatchEvent(new Event('durationchange'));
+ }
+
+ this.dispatchEvent(new Event('loadcomplete'));
+ this.loadComplete.resolve();
+ };
+
+ if (this.#isInit) {
+ this.#api = oldApi;
+ await onLoaded();
+ await this.loadComplete;
+ return;
+ }
+
+ this.#isInit = true;
+
+ let iframe = this.shadowRoot?.querySelector('iframe');
+
+ if (isFirstLoad && iframe) {
+ this.#config = JSON.parse(iframe.getAttribute('data-config') || '{}');
+ }
+
+ if (!this.shadowRoot) {
+ this.attachShadow({ mode: 'open' });
+ this.shadowRoot.innerHTML = getTemplateHTML(namedNodeMapToObject(this.attributes), this);
+ iframe = this.shadowRoot.querySelector('iframe');
+ }
+
+ this.#api = new PeerTubePlayer(iframe);
+ await this.#api.ready;
+
+ const textTracksVideo = document.createElement('video');
+ this.textTracks = textTracksVideo.textTracks;
+
+ this.#api.on('play', () => {
+ if (!this.#paused) return;
+ this.#paused = false;
+ this.dispatchEvent(new Event('play'));
+ });
+
+ this.#api.on('playing', () => {
+ this.#readyState = 3; // HTMLMediaElement.HAVE_FUTURE_DATA
+ this.#paused = false;
+ this.dispatchEvent(new Event('playing'));
+ });
+
+ this.#api.on('seeking', () => {
+ this.#seeking = true;
+ this.onSeeking();
+ this.dispatchEvent(new Event('seeking'));
+ });
+
+ this.#api.on('seeked', async () => {
+ this.#seeking = false;
+ this.#currentTime = await this.#api.getCurrentTime().catch(() => this.#currentTime);
+ this.dispatchEvent(new Event('seeked'));
+ });
+
+ this.#api.on('pause', () => {
+ this.#paused = true;
+ this.dispatchEvent(new Event('pause'));
+ });
+
+ this.#api.on('ended', () => {
+ this.#paused = true;
+ this.dispatchEvent(new Event('ended'));
+ });
+
+ this.#api.on('ratechange', ({ playbackRate }) => {
+ this.#playbackRate = playbackRate;
+ this.dispatchEvent(new Event('ratechange'));
+ });
+
+ this.#api.on('volumechange', async ({ volume }) => {
+ this.#volume = volume;
+ if (this.#api) {
+ this.#muted = await this.#api.getMuted();
+ }
+ this.dispatchEvent(new Event('volumechange'));
+ });
+
+ this.#api.on('durationchange', ({ duration }) => {
+ this.#duration = duration;
+ this.dispatchEvent(new Event('durationchange'));
+ });
+
+ this.#api.on('timeupdate', ({ seconds }) => {
+ const currentTime = Math.round(seconds * 100) / 100;
+ this.#currentTime = currentTime;
+ this.dispatchEvent(new Event('timeupdate'));
+ });
+
+ // PeerTube's playbackStatusUpdate provides position (current time), not buffer
+ // Note: PeerTube doesn't provide buffer information in playbackStatusUpdate,
+ // so we don't update #progress here (unlike Vimeo which has a separate 'progress' event)
+ this.#api.on('playbackStatusUpdate', (data) => {
+ this.#currentTime = data.position ?? this.#currentTime;
+ if (data.duration !== undefined) {
+ this.#duration = data.duration;
+ }
+ this.dispatchEvent(new Event('timeupdate'));
+ });
+
+ this.#api.on('resolutionUpdate', ({ videoWidth, videoHeight }) => {
+ this.#videoWidth = videoWidth;
+ this.#videoHeight = videoHeight;
+ this.dispatchEvent(new Event('resize'));
+ });
+
+ await onLoaded();
+ await this.loadComplete;
+ }
+
+ async attributeChangedCallback(attrName, oldValue, newValue) {
+ if (oldValue === newValue) return;
+
+ // This is required to come before the await for resolving loadComplete.
+ switch (attrName) {
+ case 'autoplay':
+ case 'controls':
+ case 'src': {
+ this.load();
+ return;
+ }
+ }
+
+ await this.loadComplete;
+
+ switch (attrName) {
+ case 'loop': {
+ this.#api.setLoop(this.loop);
+ break;
+ }
+ }
+ }
+
+ async play() {
+ this.#paused = false;
+ this.dispatchEvent(new Event('play'));
+
+ await this.loadComplete;
+
+ try {
+ await this.#api?.play();
+ } catch (error) {
+ this.#paused = true;
+ this.dispatchEvent(new Event('pause'));
+ throw error;
+ }
+ }
+
+ async pause() {
+ await this.loadComplete;
+ return this.#api?.pause();
+ }
+
+ get ended() {
+ return this.#currentTime >= this.#duration;
+ }
+
+ get seeking() {
+ return this.#seeking;
+ }
+
+ get readyState() {
+ return this.#readyState;
+ }
+
+ get videoWidth() {
+ return this.#videoWidth;
+ }
+
+ get videoHeight() {
+ return this.#videoHeight;
+ }
+
+ get src() {
+ return this.getAttribute('src');
+ }
+
+ set src(val) {
+ if (this.src == val) return;
+ this.setAttribute('src', `${val}`);
+ }
+
+ get paused() {
+ return this.#paused;
+ }
+
+ get duration() {
+ return this.#duration;
+ }
+
+ get autoplay() {
+ return this.hasAttribute('autoplay');
+ }
+
+ set autoplay(val) {
+ if (this.autoplay == val) return;
+ this.toggleAttribute('autoplay', Boolean(val));
+ }
+
+ get buffered() {
+ if (this.#progress > 0) {
+ return createTimeRanges(0, this.#progress);
+ }
+ return createTimeRanges();
+ }
+
+ get controls() {
+ return this.hasAttribute('controls');
+ }
+
+ set controls(val) {
+ if (this.controls == val) return;
+ this.toggleAttribute('controls', Boolean(val));
+ }
+
+ get currentTime() {
+ return this.#currentTime;
+ }
+
+ set currentTime(val) {
+ if (this.currentTime == val) return;
+ this.#currentTime = val;
+ this.loadComplete.then(() => {
+ this.#api?.seek(val).catch(() => {});
+ });
+ }
+
+ get defaultMuted() {
+ return this.hasAttribute('muted');
+ }
+
+ set defaultMuted(val) {
+ if (this.defaultMuted == val) return;
+ this.toggleAttribute('muted', Boolean(val));
+ }
+
+ get loop() {
+ return this.hasAttribute('loop');
+ }
+
+ set loop(val) {
+ if (this.loop == val) return;
+ this.toggleAttribute('loop', Boolean(val));
+ }
+
+ get muted() {
+ return this.#muted;
+ }
+
+ set muted(val) {
+ if (this.muted == val) return;
+ this.#muted = val;
+ this.loadComplete.then(() => {
+ this.#api?.setMuted(val).catch(() => {});
+ });
+ }
+
+ get playbackRate() {
+ return this.#playbackRate;
+ }
+
+ set playbackRate(val) {
+ if (this.playbackRate == val) return;
+ this.#playbackRate = val;
+ this.loadComplete.then(() => {
+ this.#api?.setPlaybackRate(val).catch(() => {});
+ });
+ }
+
+ get playsInline() {
+ return this.hasAttribute('playsinline');
+ }
+
+ set playsInline(val) {
+ if (this.playsInline == val) return;
+ this.toggleAttribute('playsinline', Boolean(val));
+ }
+
+ get volume() {
+ return this.#volume;
+ }
+
+ set volume(val) {
+ if (this.volume == val) return;
+ this.#volume = val;
+ this.loadComplete.then(() => {
+ this.#api?.setVolume(val).catch(() => {});
+ });
+ }
+
+ // This is a pattern to update property values that are set before
+ // the custom element is upgraded.
+ // https://web.dev/custom-elements-best-practices/#make-properties-lazy
+ #upgradeProperty(prop) {
+ if (Object.prototype.hasOwnProperty.call(this, prop)) {
+ const value = this[prop];
+ // Delete the set property from this instance.
+ delete this[prop];
+ // Set the value again via the (prototype) setter on this class.
+ this[prop] = value;
+ }
+ }
+}
+
+function serializeAttributes(attrs) {
+ let html = '';
+ for (const key in attrs) {
+ const value = attrs[key];
+ if (value === '') html += ` ${escapeHtml(key)}`;
+ else html += ` ${escapeHtml(key)}="${escapeHtml(`${value}`)}"`;
+ }
+ return html;
+}
+
+function escapeHtml(str) {
+ return str
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(/`/g, '`');
+}
+
+function serialize(props) {
+ return String(new URLSearchParams(boolToBinary(props)));
+}
+
+function boolToBinary(props) {
+ let p = {};
+ for (let key in props) {
+ let val = props[key];
+ if (val === true || val === '') p[key] = 1;
+ else if (val === false) p[key] = 0;
+ else if (val != null) p[key] = val;
+ }
+ return p;
+}
+
+function namedNodeMapToObject(namedNodeMap) {
+ let obj = {};
+ for (let attr of namedNodeMap) {
+ obj[attr.name] = attr.value;
+ }
+ return obj;
+}
+
+/**
+ * A utility to create Promises with convenient public resolve and reject methods.
+ * @return {Promise}
+ */
+class PublicPromise extends Promise {
+ constructor(executor = () => {}) {
+ let res, rej;
+ super((resolve, reject) => {
+ executor(resolve, reject);
+ res = resolve;
+ rej = reject;
+ });
+ this.resolve = res;
+ this.reject = rej;
+ }
+}
+
+/**
+ * Creates a fake `TimeRanges` object.
+ *
+ * A TimeRanges object. This object is normalized, which means that ranges are
+ * ordered, don't overlap, aren't empty, and don't touch (adjacent ranges are
+ * folded into one bigger range).
+ *
+ * @param {(Number|Array)} Start of a single range or an array of ranges
+ * @param {Number} End of a single range
+ * @return {Array}
+ */
+function createTimeRanges(start, end) {
+ if (Array.isArray(start)) {
+ return createTimeRangesObj(start);
+ } else if (start == null || end == null || (start === 0 && end === 0)) {
+ return createTimeRangesObj([[0, 0]]);
+ }
+ return createTimeRangesObj([[start, end]]);
+}
+
+function createTimeRangesObj(ranges) {
+ Object.defineProperties(ranges, {
+ start: {
+ value: (i) => ranges[i][0],
+ },
+ end: {
+ value: (i) => ranges[i][1],
+ },
+ });
+ return ranges;
+}
+
+if (globalThis.customElements && !globalThis.customElements.get('peertube-video')) {
+ globalThis.customElements.define('peertube-video', PeerTubeVideoElement);
+}
+
+export default PeerTubeVideoElement;| <script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome/+esm"></script> | ||
| </head> | ||
| <body> | ||
| <h1><twitch-video></h1> |
There was a problem hiding this comment.
CSS selector targets wrong element in demo page
Medium Severity
The index.html was copied from the Twitch element but not updated. The <title> (line 4), CSS selector (line 12), and <h1> (line 23) all reference twitch-video instead of peertube-video. Most critically, the CSS selector twitch-video means the <peertube-video> elements won't receive proper styling — no block display, no 100% width, no 16/9 aspect ratio, and no black background.
Additional Locations (1)
|
|
||
| // Update progress (buffered) | ||
| this.#progress = data.position; | ||
| this.dispatchEvent(new Event("progress")); |
There was a problem hiding this comment.
Buffered progress tracks playback position not buffer
Medium Severity
#progress is set to data.position (the current playback position), making the buffered getter always return the same range as currentTime. The buffered property is meant to represent how much media data has been pre-loaded ahead of the playback position, not the playback position itself. This causes Media Chrome's progress/time-range UI to display incorrect buffer information.
| if (data.width) this.#videoWidth = data.width; | ||
| if (data.height) this.#videoHeight = data.height; | ||
| this.dispatchEvent(new Event("resize")); | ||
| }); |
There was a problem hiding this comment.
Wrong PeerTube event name for resolution updates
Low Severity
The PeerTube embed API uses the event name resolutionUpdate, not resolutionChange. This listener will never fire, so #videoWidth and #videoHeight will remain NaN for the lifetime of the element, and the resize event will never be dispatched.



Hi,
I am want to share this PR introduces a integration for the PeerTube Video Element.
Please review the changes and let me know if there are any adjustments needed before we proceed with the merge.
Thank you
Note
Medium Risk
Adds a new embedded-player integration that dynamically loads a third-party SDK and proxies playback APIs/events, which could introduce runtime/embed regressions but is largely isolated to a new package.
Overview
Adds a new
peertube-video-elementworkspace package that registers a<peertube-video>custom element backed by a PeerTube embed iframe, including URL parsing/serialization of PeerTube watch links and a Player-SDK bridge that maps coreHTMLMediaElement-style properties/events (play,pause,currentTime,volume, etc.).Includes package metadata/exports, TypeScript declarations, and a simple
index.htmldemo/README docs; the rootREADME.mdis updated to list the new element, andpackage-lock.jsonis updated to include the new workspace link (plus lockfile metadata tweaks).Written by Cursor Bugbot for commit 4e361d3. This will update automatically on new commits. Configure here.