Skip to content

New PeerTube Video Element Integration PR#214

Open
younes200 wants to merge 3 commits intomuxinc:mainfrom
celluloid-camp:main
Open

New PeerTube Video Element Integration PR#214
younes200 wants to merge 3 commits intomuxinc:mainfrom
celluloid-camp:main

Conversation

@younes200
Copy link

@younes200 younes200 commented Feb 26, 2026

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-element workspace 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 core HTMLMediaElement-style properties/events (play, pause, currentTime, volume, etc.).

Includes package metadata/exports, TypeScript declarations, and a simple index.html demo/README docs; the root README.md is updated to list the new element, and package-lock.json is 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.

@vercel
Copy link

vercel bot commented Feb 26, 2026

@younes200 is attempting to deploy a commit to the Mux Team on Vercel.

A member of the Team first needs to authorize it.

@snyk-io
Copy link

snyk-io bot commented Feb 26, 2026

Snyk checks have passed. No issues have been found so far.

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@younes200 younes200 changed the title Add peertube video element package with custom video player New PeerTube Video Element Integration PR Feb 26, 2026
Add check to skip setting muted if value is undefined
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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>&lt;peertube-video&gt;</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>&lt;peertube-video&gt;</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, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&apos;')
+    .replace(/`/g, '&#x60;');
+}
+
+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;
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome/+esm"></script>
</head>
<body>
<h1>&lt;twitch-video&gt;</h1>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Fix in Cursor Fix in Web


// Update progress (buffered)
this.#progress = data.position;
this.dispatchEvent(new Event("progress"));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

if (data.width) this.#videoWidth = data.width;
if (data.height) this.#videoHeight = data.height;
this.dispatchEvent(new Event("resize"));
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant