Skip to content

feat(packages): add youtube media with html and react components - #1853

Open
luwes wants to merge 6 commits into
mainfrom
feat/youtube-media
Open

feat(packages): add youtube media with html and react components#1853
luwes wants to merge 6 commits into
mainfrom
feat/youtube-media

Conversation

@luwes

@luwes luwes commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Refs #1434

Summary

Adds a YouTube media host so the player can drive YouTube embeds the same way it drives Vimeo ones. Ports the playback logic from youtube-video-element into the v10 media-host architecture, mirroring the existing Vimeo media structure across core, html, and react.

Changes

  • YouTubeMedia core host: attach/detach lifecycle, state mapped from iframe API events, played-range tracking, caption tracklist syncing, and loop-on-ended (the API has no single-video loop)
  • Source parsing supports watch / youtu.be / embed / shorts / live URLs, raw 11-char ids, playlists (list=), t=2m51s start times, and the -nocookie host
  • <youtube-video> custom element (@videojs/html/media/youtube-video) and <YouTubeVideo> React component (@videojs/react/media/youtube-video)
  • Sandbox preset with HTML and React templates
Implementation details
  • YouTube ships no npm SDK, so the iframe API is loaded via the existing loadScript utility with minimal local typings — no new dependency
  • The iframe API pushes no timeupdate/progress/seeking events, so a 50ms poll derives them (same approach as the original element)
  • Player creation is async (script load); an attach id guards against detach-during-load races
  • src changes reuse the player via cueVideoById/loadVideoById (or the playlist variants) instead of rebuilding the iframe; any post-load state transition completes the load since onReady only fires once

Testing

pnpm -F @videojs/core test src/dom/media/youtube — 32 unit tests covering URL parsing, embed URL building, and YouTubeMedia behavior against a mocked YT.Player. Manual: pnpm dev → sandbox → “YouTube Video” preset (HTML and React).


Note

Medium Risk
Large new async embed integration with external script loading and lifecycle edge cases, but it mirrors proven Vimeo patterns and has broad unit test coverage.

Overview
Adds YouTube embed playback to the v10 media-host stack (alongside Vimeo): a new YouTubeMedia core adapter loads the iframe API, maps player state to standard media events, polls for time/progress updates, syncs captions, and handles playlists, start times, and nocookie URLs.

Ships <youtube-video> (HTML) and <YouTubeVideo> (React) wired through the same iframe attach pattern as Vimeo, plus a sandbox “YouTube Video” preset with HTML/React demo pages.

Vimeo detach() now resolves the load promise so play() does not hang if the component unmounts before load completes (YouTube uses the same guard).

Reviewed by Cursor Bugbot for commit 7e5803e. Bugbot is set up for automated code reviews on this repo. Configure here.

@netlify

netlify Bot commented Jul 20, 2026

Copy link
Copy Markdown

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit 7e5803e
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/6a5ea4948483310008a6132a
😎 Deploy Preview https://deploy-preview-1853--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Jul 20, 2026 10:44pm

Request Review

Comment thread packages/core/src/dom/media/youtube/index.ts
Comment thread packages/core/src/dom/media/youtube/index.ts
Comment thread packages/core/src/dom/media/youtube/index.ts
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

🎨 @videojs/html

Path Base initial PR initial Diff % Lazy
/media/youtube-video 6.41 kB 🆕
Presets (7)
Entry Initial Lazy
/video (default) 51.00 kB 54.24 kB
/video (default + hls) 190.25 kB 54.24 kB
/video (minimal) 51.02 kB 54.24 kB
/video (minimal + hls) 190.21 kB 54.24 kB
/audio (default) 44.26 kB 54.24 kB
/audio (minimal) 41.47 kB 54.24 kB
/background 4.20 kB
Media (11)
Entry Initial
/media/background-video 1.14 kB
/media/container 1.71 kB
/media/dash-video 214.79 kB
/media/hlsjs-video 141.52 kB
/media/mux-audio 164.00 kB
/media/mux-video 163.70 kB
/media/native-hls-video 9.05 kB
/media/simple-hls-audio-only 17.19 kB
/media/simple-hls-video 18.69 kB
/media/vimeo-video 12.36 kB
/media/youtube-video 6.41 kB
Players (5)
Entry Initial
/video/player 8.22 kB
/audio/player 5.38 kB
/background/player 3.92 kB
/live-video/player 7.63 kB
/live-audio/player 5.39 kB
Skins (30)
Entry Type Initial Lazy
/video/minimal-skin.css css 5.70 kB
/video/skin.css css 5.65 kB
/video/minimal-skin js 51.02 kB 54.24 kB
/video/minimal-skin.tailwind js 51.64 kB 54.24 kB
/video/skin js 50.96 kB 54.24 kB
/video/skin.tailwind js 51.77 kB 54.24 kB
/audio/minimal-skin.css css 3.97 kB
/audio/skin.css css 3.86 kB
/audio/minimal-skin js 41.45 kB 54.24 kB
/audio/minimal-skin.tailwind js 42.04 kB 54.24 kB
/audio/skin js 44.27 kB 54.24 kB
/audio/skin.tailwind js 44.82 kB 54.24 kB
/background/skin.css css 133 B
/background/skin js 1.14 kB
/live-video/minimal-skin.css css 5.70 kB
/live-video/skin.css css 5.65 kB
/live-video/minimal-skin js 47.89 kB 54.24 kB
/live-video/minimal-skin.tailwind js 48.45 kB 54.24 kB
/live-video/skin js 49.48 kB 54.24 kB
/live-video/skin.tailwind js 50.11 kB 54.24 kB
/live-audio/minimal-skin.css css 3.97 kB
/live-audio/skin.css css 3.86 kB
/live-audio/minimal-skin js 33.86 kB 54.24 kB
/live-audio/minimal-skin.tailwind js 33.22 kB 54.24 kB
/live-audio/skin js 36.79 kB 54.24 kB
/live-audio/skin.tailwind js 36.29 kB 54.24 kB
/global.css css 183 B
/shared.css css 153 B
/tailwind.css css 161 B
/skin-element js 1.51 kB
UI Components (39)
Entry Initial
/ui/airplay-button 2.54 kB
/ui/alert-dialog 2.90 kB
/ui/alert-dialog-close 2.36 kB
/ui/alert-dialog-description 2.31 kB
/ui/alert-dialog-title 2.25 kB
/ui/audio-track-radio-group 3.03 kB
/ui/buffering-indicator 2.62 kB
/ui/captions-button 2.66 kB
/ui/captions-radio-group 3.03 kB
/ui/cast-button 2.57 kB
/ui/compounds 3.19 kB
/ui/controls 2.96 kB
/ui/error-dialog 2.97 kB
/ui/fullscreen-button 2.57 kB
/ui/hotkey 2.47 kB
/ui/menu 3.00 kB
/ui/mute-button 2.56 kB
/ui/pip-button 2.55 kB
/ui/play-button 2.61 kB
/ui/playback-rate-button 2.63 kB
/ui/playback-rate-radio-group 2.86 kB
/ui/popover 3.13 kB
/ui/poster 2.49 kB
/ui/quality-radio-group 3.00 kB
/ui/seek-button 2.56 kB
/ui/seek-indicator 2.74 kB
/ui/seek-indicator-value 385 B
/ui/slider 2.99 kB
/ui/status-announcer 2.50 kB
/ui/status-indicator 2.64 kB
/ui/status-indicator-value 450 B
/ui/thumbnail 2.56 kB
/ui/time 2.98 kB
/ui/time-slider 3.00 kB
/ui/tooltip 2.86 kB
/ui/volume-indicator 2.57 kB
/ui/volume-indicator-fill 441 B
/ui/volume-indicator-value 514 B
/ui/volume-slider 2.99 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react

Path Base initial PR initial Diff % Lazy
/media/youtube-video 4.44 kB 🆕
Presets (7)
Entry Initial Lazy
/video (default) 40.56 kB 54.24 kB
/video (default + hls) 178.73 kB 54.24 kB
/video (minimal) 40.94 kB 54.24 kB
/video (minimal + hls) 179.07 kB 54.24 kB
/audio (default) 33.76 kB 54.24 kB
/audio (minimal) 33.80 kB 54.24 kB
/background 579 B
Media (10)
Entry Initial
/media/background-video 403 B
/media/dash-video 212.98 kB
/media/hlsjs-video 139.79 kB
/media/mux-audio 162.38 kB
/media/mux-video 162.41 kB
/media/native-hls-video 7.26 kB
/media/simple-hls-audio-only 15.39 kB
/media/simple-hls-video 16.96 kB
/media/vimeo-video 10.45 kB
/media/youtube-video 4.44 kB
Skins (27)
Entry Type Initial Lazy
/tailwind.css css 161 B
/video/minimal-skin.css css 5.55 kB
/video/skin.css css 5.52 kB
/video/minimal-skin js 40.88 kB 54.24 kB
/video/minimal-skin.tailwind js 46.97 kB 54.24 kB
/video/skin js 40.47 kB 54.24 kB
/video/skin.tailwind js 46.63 kB 54.24 kB
/audio/minimal-skin.css css 3.79 kB
/audio/skin.css css 3.68 kB
/audio/minimal-skin js 33.80 kB 54.24 kB
/audio/minimal-skin.tailwind js 35.90 kB 54.24 kB
/audio/skin js 33.68 kB 54.24 kB
/audio/skin.tailwind js 37.92 kB 54.24 kB
/background/skin.css css 90 B
/background/skin js 272 B
/live-video/minimal-skin.css css 5.55 kB
/live-video/skin.css css 5.52 kB
/live-video/minimal-skin js 35.43 kB 54.24 kB
/live-video/minimal-skin.tailwind js 41.46 kB 54.24 kB
/live-video/skin js 35.44 kB 54.24 kB
/live-video/skin.tailwind js 41.55 kB 54.24 kB
/live-audio/minimal-skin.css css 3.79 kB
/live-audio/skin.css css 3.68 kB
/live-audio/minimal-skin js 24.25 kB 54.24 kB
/live-audio/minimal-skin.tailwind js 27.42 kB 54.24 kB
/live-audio/skin js 24.31 kB 54.24 kB
/live-audio/skin.tailwind js 27.45 kB 54.24 kB
UI Components (33)
Entry Initial
/ui/airplay-button 2.45 kB
/ui/alert-dialog 2.60 kB
/ui/audio-track 2.32 kB
/ui/buffering-indicator 2.51 kB
/ui/captions-button 2.55 kB
/ui/captions-radio-group 2.29 kB
/ui/cast-button 2.46 kB
/ui/controls 2.53 kB
/ui/error-dialog 2.58 kB
/ui/fullscreen-button 2.42 kB
/ui/gesture 2.31 kB
/ui/hotkey 2.32 kB
/ui/live-button 2.40 kB
/ui/menu 2.56 kB
/ui/mute-button 2.38 kB
/ui/pip-button 2.50 kB
/ui/play-button 2.49 kB
/ui/playback-rate 2.45 kB
/ui/playback-rate-button 2.47 kB
/ui/popover 3.02 kB
/ui/poster 2.43 kB
/ui/quality 2.26 kB
/ui/seek-button 2.40 kB
/ui/seek-indicator 2.58 kB
/ui/slider 2.70 kB
/ui/status-announcer 2.30 kB
/ui/status-indicator 2.34 kB
/ui/thumbnail 2.46 kB
/ui/time 2.23 kB
/ui/time-slider 2.54 kB
/ui/tooltip 2.97 kB
/ui/volume-indicator 2.33 kB
/ui/volume-slider 2.52 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core

Path Base initial PR initial Diff % Lazy
/dom/media/youtube 3.87 kB 🆕
Entries (69)
Entry Initial Lazy
. 10.39 kB
/dom 17.70 kB
/dom/media/custom-media-element 2.09 kB
/dom/media/dash 208.97 kB
/dom/media/google-cast 4.03 kB
/dom/media/hls-js 135.87 kB
/dom/media/media-host 1.25 kB
/dom/media/media-played-ranges 576 B
/dom/media/mux 151.37 kB
/dom/media/native-hls 3.07 kB
/dom/media/simple-hls 16.48 kB
/dom/media/simple-hls-audio-only 14.93 kB
/dom/media/vimeo 9.89 kB
/dom/media/youtube 3.87 kB
/media/predicate 563 B
/i18n 2.66 kB 54.24 kB
/i18n/locales/all 30.58 kB
/i18n/locales/ar 1.21 kB
/i18n/locales/az 1.08 kB
/i18n/locales/bg 1.25 kB
/i18n/locales/bn 1.27 kB
/i18n/locales/bs 1014 B
/i18n/locales/ca 1.05 kB
/i18n/locales/cs 1.04 kB
/i18n/locales/cy 1.00 kB
/i18n/locales/da 996 B
/i18n/locales/de 1.08 kB
/i18n/locales/el 1.45 kB
/i18n/locales/en 621 B
/i18n/locales/es 1010 B
/i18n/locales/et 1.05 kB
/i18n/locales/eu 1.01 kB
/i18n/locales/fa 1.20 kB
/i18n/locales/fi 1.03 kB
/i18n/locales/fr 1.07 kB
/i18n/locales/gd 1.10 kB
/i18n/locales/gl 1014 B
/i18n/locales/he 1.12 kB
/i18n/locales/hi 1.28 kB
/i18n/locales/hr 1.02 kB
/i18n/locales/hu 1.09 kB
/i18n/locales/it 1023 B
/i18n/locales/ja 1.17 kB
/i18n/locales/ko 1.11 kB
/i18n/locales/lv 1.08 kB
/i18n/locales/mr 1.28 kB
/i18n/locales/nb 1000 B
/i18n/locales/ne 1.28 kB
/i18n/locales/nl 1013 B
/i18n/locales/nn 996 B
/i18n/locales/oc 1.05 kB
/i18n/locales/pl 1.12 kB
/i18n/locales/pt 1.01 kB
/i18n/locales/pt-BR 1.01 kB
/i18n/locales/pt-PT 1009 B
/i18n/locales/ro 1.05 kB
/i18n/locales/ru 1.33 kB
/i18n/locales/sk 1.10 kB
/i18n/locales/sl 1.02 kB
/i18n/locales/sr 1.03 kB
/i18n/locales/sv 1022 B
/i18n/locales/te 1.31 kB
/i18n/locales/th 1.29 kB
/i18n/locales/tr 1.07 kB
/i18n/locales/uk 1.36 kB
/i18n/locales/vi 1.08 kB
/i18n/locales/zh 1.01 kB
/i18n/locales/zh-CN 1.01 kB
/i18n/locales/zh-TW 1.01 kB
🏷️ @videojs/element — no changes
Entries (2)
Entry Initial
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Initial
. 1.39 kB
/html 696 B
/react 360 B
🔧 @videojs/utils — no changes
Entries (11)
Entry Initial
/array 104 B
/dom 2.74 kB
/events 319 B
/function 327 B
/object 275 B
/predicate 265 B
/percent 281 B
/string 231 B
/style 190 B
/time 629 B
/number 158 B
📦 @videojs/spf — no changes
Entries (4)
Entry Initial
. 4.45 kB
/dom 6.32 kB
/hls 15.40 kB
/background-video 12.85 kB

ℹ️ How to interpret

JS sizes are initial static graph totals (minified + brotli). Lazy dynamic chunks are shown separately when present.

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current initial sizes.

Comment thread packages/core/src/dom/media/youtube/index.ts
Comment thread packages/core/src/dom/media/youtube/index.ts
// Unblock callers awaiting load so play()/fullscreen don't hang.
this.#loadComplete.resolve();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale attach error unblocks load

Medium Severity

The #createPlayer failure path sets #error, emits error, and resolves #loadComplete without checking #attachId / #target. A failed API load from a detached attach can mark a newer attach as failed and unblock its play() before the new player is ready.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 23a842a. Configure here.

Comment thread packages/core/src/dom/media/youtube/index.ts
frameborder="0"
width="100%"
height="100%"
referrerpolicy="${escapeHtml(attrs.referrerpolicy ?? '')}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing YouTube referrerPolicy default

Medium Severity

The HTML and React YouTube iframes omit a usable default referrerpolicy. On pages with strict Referrer-Policy (or other no-referrer contexts), YouTube serves Error 153 (embedder.identity.missing.referrer) and the embed fails unless callers set config.referrerPolicy themselves.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 23a842a. Configure here.

this.#player = player;
this.#bindPlayerEvents(player);
this.#setupTextTracks(player);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Detach race leaves load unguarded

High Severity

#createPlayer checks #attachId only on the success path after loadYouTubeApi. The catch path, and the onReady / onError callbacks, still mutate state after detach() (or a newer attach()). That can mark a fresh attach as loaded, resolve its #loadComplete early, restart polling, or set #playerReady before the new player is ready so a later cueVideoById runs too soon.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ba0d777. Configure here.

const EMBED_BASE_NOCOOKIE = 'https://www.youtube-nocookie.com/embed';
const VIDEO_MATCH_SRC =
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=|shorts\/|live\/))((?:\w|-){11})/;
const PLAYLIST_MATCH_SRC = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/.*?[?&]list=)([\w-]+)/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Playlist regex misfires on youtu.be

Medium Severity

PLAYLIST_MATCH_SRC treats the youtu.be/ alternative as enough to capture the next path segment as listId, without requiring a list= param. Every short link therefore gets a spurious listId (usually the video id), and non-11-character youtu.be/... paths are misclassified as kind: 'playlist' instead of failing to parse.

Suggested change
const PLAYLIST_MATCH_SRC = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/.*?[?&]list=)([\w-]+)/;
const PLAYLIST_MATCH_SRC = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/).*?[?&]list=([\w-]+)/;
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ba0d777. Configure here.

async requestFullscreen() {
await this.#target?.requestFullscreen?.();
this.#isFullscreen = true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fullscreen flag set without success

Low Severity

requestFullscreen always sets #isFullscreen to true after awaiting the optional call. If #target is null or requestFullscreen is missing, nothing enters fullscreen but the host still reports that it did, so UI that trusts isFullscreen can get stuck in the wrong state.

Suggested change
}
async requestFullscreen() {
if (!this.#target?.requestFullscreen) return;
await this.#target.requestFullscreen();
this.#isFullscreen = true;
}
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ba0d777. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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 using default effort and found 1 potential issue.

There are 7 total unresolved issues (including 6 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e523d13. Configure here.

this.#player = player;
this.#bindPlayerEvents(player);
this.#setupTextTracks(player);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale ready callbacks after detach

High Severity

#attachId only guards the pre-Player creation path. onReady, onError, and bound state listeners never check it, so a callback from a destroyed player can still run after detach() / re-attach() (common with React Strict Mode). That can flip #loaded / #playerReady, resolve the new session’s #loadComplete early, start a leaked poll timer, and make the real onReady no-op so play() never proceeds.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e523d13. Configure here.

'dom/media/simple-hls-audio-only/index': './src/dom/media/simple-hls-audio-only/index.ts',
'dom/media/simple-hls/index': './src/dom/media/simple-hls/index.ts',
'dom/media/vimeo/index': './src/dom/media/vimeo/index.ts',
'dom/media/youtube/index': './src/dom/media/youtube/index.ts',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

super nit but it might be nicer if these files were name differently so it's a little easier to discover them, rather than index.ts, which strikes me as a barrel file.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also, a wider change rather than related directly to this PR...

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.

2 participants