Skip to content

refactor(packages)!: normalize fullscreen and picture-in-picture host APIs#1468

Closed
luwes wants to merge 3 commits intomainfrom
refactor/host-presentation-api
Closed

refactor(packages)!: normalize fullscreen and picture-in-picture host APIs#1468
luwes wants to merge 3 commits intomainfrom
refactor/host-presentation-api

Conversation

@luwes
Copy link
Copy Markdown
Collaborator

@luwes luwes commented Apr 24, 2026

Summary

Normalize the video host's fullscreen and picture-in-picture APIs around
standard methods (requestFullscreen, exitFullscreen,
requestPictureInPicture, exitPictureInPicture) plus isFullscreen and
isPictureInPicture getters. WebKit-specific quirks (webkit* events and
methods) are now centralized in HTMLVideoElementHost so features and
presentation helpers can stay platform-agnostic.

Changes

  • Host API — Generic methods/getters on HTMLVideoElementHost. WebKit
    events (webkitpresentationmodechanged, webkitfullscreenchange) are
    normalized into bubbling fullscreenchange and
    enter/leavepictureinpicture events.
  • Promise correctnessrequestPictureInPicture and
    exitPictureInPicture now resolve only after the corresponding event
    fires (WebKit's setPresentationMode is fire-and-forget).
  • iOS regression fix — Restored preference for webkitEnterFullscreen
    over the standard API on <video>, where the latter silently fails
    (originally fixed in a05e8f2).
  • toMediaHost helper — New helper that wraps raw HTMLVideoElement /
    HTMLAudioElement instances into their host classes. Lives at
    @videojs/core/dom/media/host (a dedicated subpath) so the host classes
    stay out of the main @videojs/core/dom barrel — only @videojs/html
    and @videojs/react import from it.
  • Controls visibility — Controls stay visible while in
    picture-in-picture, mirroring the existing paused / remote-playback
    behavior.
Breaking changes
  • PlayerTarget.media is now typed as Video instead of Media.
  • presentation/fullscreen.ts and presentation/pip.ts no longer accept
    WebKit-specific overloads — call media.requestFullscreen() etc.
    directly.
  • exitFullscreen no longer takes a container argument; it picks the
    correct exit path from media.isFullscreen.

Testing

  • pnpm typecheck
  • pnpm -F @videojs/core test — 1066 tests
  • pnpm -F @videojs/html test — 101 tests
  • pnpm -F @videojs/react test — 200 tests
  • New tests cover toMediaHost, host PiP/fullscreen behavior, WebKit event
    normalization, and PiP-aware controls visibility.

Made with Cursor


Note

Medium Risk
Medium risk due to breaking type/API changes around PlayerTarget.media and fullscreen/PiP handling, plus new wrapping lifecycle that could affect attach/detach behavior across consumers and platforms (notably iOS WebKit).

Overview
Normalizes fullscreen and picture-in-picture APIs across the DOM player stack by extending media capability types with isFullscreen/exitFullscreen and isPictureInPicture/exitPictureInPicture, and moving WebKit-specific behavior into HTMLVideoElementHost (including event normalization and promise timing).

Introduces toMediaHost under @videojs/core/dom/media/host to wrap raw <video>/<audio> elements into host classes and provide a release() hook; @videojs/html and @videojs/react now wrap media before store.attach() and clean up the wrapper on detach.

Updates player features to rely on host capabilities (fullscreen/PiP/controls visibility) and trims presentation helpers (pip.ts now only exposes enablement; fullscreen.ts delegates exit to media when element-level fullscreen is active). Extensive tests are added/updated to use host-based media and to cover WebKit fullscreen/PiP edge cases and controls staying visible during PiP.

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

… APIs

BREAKING CHANGE: PlayerTarget.media is now typed as Video. Presentation
helpers (presentation/fullscreen.ts, presentation/pip.ts) no longer
accept WebKit-specific overloads — call media.requestFullscreen() etc.
directly.

- Add isFullscreen and isPictureInPicture getters to HTMLVideoElementHost
- Centralize WebKit event normalization in the host: webkitfullscreenchange
  and webkitpresentationmodechanged are mapped to bubbling fullscreenchange
  and enter/leavepictureinpicture
- Prefer webkitEnterFullscreen on iOS Safari (regression fix from a05e8f2)
- Resolve requestPictureInPicture/exitPictureInPicture promises when the
  corresponding event fires (WebKit setPresentationMode is fire-and-forget)
- Add toMediaHost helper that wraps raw HTMLVideoElement / HTMLAudioElement
  so store.attach always sees the full host API
- Wire toMediaHost through @videojs/html provider-mixin and @videojs/react
  create-player
- Keep controls visible while in picture-in-picture

Made-with: Cursor
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 24, 2026

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

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Apr 24, 2026 11:43pm

Request Review

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 24, 2026

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit ca36017
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/69ec0017e48aa50007596db6
😎 Deploy Preview https://deploy-preview-1468--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 24, 2026

📦 Bundle Size Report

🎨 @videojs/html

Path Base PR Diff %
/video/minimal-skin 26.26 kB 26.94 kB +701 B +2.6% 🔺
/video/minimal-skin.tailwind 26.48 kB 27.20 kB +737 B +2.7% 🔺
/video/player 7.04 kB 7.77 kB +741 B +10.3% 🔴
/video/skin 28.76 kB 29.47 kB +732 B +2.5% 🔺
/video/skin.tailwind 28.88 kB 29.63 kB +766 B +2.6% 🔺
/audio/minimal-skin 24.20 kB 24.94 kB +760 B +3.1% 🔺
/audio/minimal-skin.tailwind 24.40 kB 25.14 kB +751 B +3.0% 🔺
/audio/player 5.12 kB 6.15 kB +1.04 kB +20.3% 🔴
/audio/skin 26.64 kB 27.34 kB +717 B +2.6% 🔺
/audio/skin.tailwind 26.78 kB 27.53 kB +770 B +2.8% 🔺
/background/player 3.86 kB 4.99 kB +1.14 kB +29.5% 🔴
/live-video/minimal-skin 26.04 kB 26.72 kB +701 B +2.6% 🔺
/live-video/minimal-skin.tailwind 26.07 kB 26.79 kB +742 B +2.8% 🔺
/live-video/skin 28.45 kB 29.18 kB +751 B +2.6% 🔺
/live-video/skin.tailwind 28.52 kB 29.24 kB +741 B +2.5% 🔺
/live-audio/minimal-skin 23.99 kB 24.76 kB +787 B +3.2% 🔺
/live-audio/minimal-skin.tailwind 23.99 kB 24.70 kB +730 B +3.0% 🔺
/live-audio/skin 26.35 kB 27.10 kB +761 B +2.8% 🔺
/live-audio/skin.tailwind 26.42 kB 27.14 kB +740 B +2.7% 🔺
/media/dash-video 236.54 kB 236.88 kB +343 B +0.1% 🔺
/media/hls-video 134.01 kB 134.35 kB +344 B +0.3% 🔺
/media/mux-audio 160.06 kB 160.54 kB +491 B +0.3% 🔺
/media/native-hls-video 3.77 kB 4.22 kB +459 B +11.9% 🔴
/media/simple-hls-video 15.80 kB 16.21 kB +422 B +2.6% 🔺
/video (default) 28.72 kB 29.46 kB +754 B +2.6% 🔺
/video (minimal) 26.25 kB 26.98 kB +753 B +2.8% 🔺
/audio (default) 26.64 kB 27.34 kB +719 B +2.6% 🔺
/audio (minimal) 24.21 kB 24.95 kB +760 B +3.1% 🔺
/background 4.16 kB 5.29 kB +1.13 kB +27.1% 🔴
Presets (7)
Entry Size
/video (default) 29.46 kB
/video (default + hls) 161.34 kB
/video (minimal) 26.98 kB
/video (minimal + hls) 159.13 kB
/audio (default) 27.34 kB
/audio (minimal) 24.95 kB
/background 5.29 kB
Media (8)
Entry Size
/media/background-video 1.04 kB
/media/container 1.72 kB
/media/dash-video 236.88 kB
/media/hls-video 134.35 kB
/media/mux-audio 160.54 kB
/media/mux-video 160.32 kB
/media/native-hls-video 4.22 kB
/media/simple-hls-video 16.21 kB
Players (3)
Entry Size
/video/player 7.77 kB
/audio/player 6.15 kB
/background/player 4.99 kB
Skins (29)
Entry Type Size
/video/minimal-skin.css css 3.50 kB
/video/skin.css css 3.53 kB
/video/minimal-skin js 26.94 kB
/video/minimal-skin.tailwind js 27.20 kB
/video/skin js 29.47 kB
/video/skin.tailwind js 29.63 kB
/audio/minimal-skin.css css 2.54 kB
/audio/skin.css css 2.50 kB
/audio/minimal-skin js 24.94 kB
/audio/minimal-skin.tailwind js 25.14 kB
/audio/skin js 27.34 kB
/audio/skin.tailwind js 27.53 kB
/background/skin.css css 117 B
/background/skin js 1.14 kB
/live-video/minimal-skin.css css 3.50 kB
/live-video/skin.css css 3.53 kB
/live-video/minimal-skin js 26.72 kB
/live-video/minimal-skin.tailwind js 26.79 kB
/live-video/skin js 29.18 kB
/live-video/skin.tailwind js 29.24 kB
/live-audio/minimal-skin.css css 2.54 kB
/live-audio/skin.css css 2.50 kB
/live-audio/minimal-skin js 24.76 kB
/live-audio/minimal-skin.tailwind js 24.70 kB
/live-audio/skin js 27.10 kB
/live-audio/skin.tailwind js 27.14 kB
/base.css css 157 B
/shared.css css 88 B
/skin-element js 1.36 kB
UI Components (25)
Entry Size
/ui/alert-dialog 662 B
/ui/alert-dialog-close 363 B
/ui/alert-dialog-description 344 B
/ui/alert-dialog-title 292 B
/ui/buffering-indicator 1.89 kB
/ui/captions-button 2.02 kB
/ui/cast-button 2.00 kB
/ui/compounds 2.92 kB
/ui/controls 1.94 kB
/ui/error-dialog 2.41 kB
/ui/fullscreen-button 2.00 kB
/ui/hotkey 2.57 kB
/ui/mute-button 2.02 kB
/ui/pip-button 2.00 kB
/ui/play-button 2.02 kB
/ui/playback-rate-button 2.01 kB
/ui/popover 1.50 kB
/ui/poster 1.72 kB
/ui/seek-button 2.01 kB
/ui/slider 1.10 kB
/ui/thumbnail 2.31 kB
/ui/time 1.87 kB
/ui/time-slider 2.96 kB
/ui/tooltip 1.66 kB
/ui/volume-slider 3.46 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react

Path Base PR Diff %
/media/dash-video 235.04 kB 235.61 kB +580 B +0.2% 🔺
/media/hls-video 132.49 kB 132.90 kB +419 B +0.3% 🔺
/media/mux-audio 158.70 kB 159.06 kB +363 B +0.2% 🔺
/media/mux-video 158.57 kB 158.99 kB +429 B +0.3% 🔺
/media/native-hls-video 2.26 kB 2.72 kB +468 B +20.2% 🔴
/media/simple-hls-video 14.36 kB 14.83 kB +485 B +3.3% 🔺
Presets (7)
Entry Size
/video (default) 23.27 kB
/video (default + hls) 154.91 kB
/video (minimal) 20.90 kB
/video (minimal + hls) 152.57 kB
/audio (default) 18.85 kB
/audio (minimal) 17.38 kB
/background 755 B
Media (7)
Entry Size
/media/background-video 575 B
/media/dash-video 235.61 kB
/media/hls-video 132.90 kB
/media/mux-audio 159.06 kB
/media/mux-video 158.99 kB
/media/native-hls-video 2.72 kB
/media/simple-hls-video 14.83 kB
Skins (26)
Entry Type Size
/video/minimal-skin.css css 3.44 kB
/video/skin.css css 3.46 kB
/video/minimal-skin js 20.80 kB
/video/minimal-skin.tailwind js 24.33 kB
/video/skin js 23.17 kB
/video/skin.tailwind js 24.45 kB
/audio/minimal-skin.css css 2.44 kB
/audio/skin.css css 2.39 kB
/audio/minimal-skin js 17.30 kB
/audio/minimal-skin.tailwind js 19.79 kB
/audio/skin js 18.76 kB
/audio/skin.tailwind js 19.78 kB
/background/skin.css css 90 B
/background/skin js 272 B
/live-video/minimal-skin.css css 3.44 kB
/live-video/skin.css css 3.46 kB
/live-video/minimal-skin js 17.53 kB
/live-video/minimal-skin.tailwind js 20.95 kB
/live-video/skin js 19.91 kB
/live-video/skin.tailwind js 21.11 kB
/live-audio/minimal-skin.css css 2.44 kB
/live-audio/skin.css css 2.39 kB
/live-audio/minimal-skin js 15.52 kB
/live-audio/minimal-skin.tailwind js 17.80 kB
/live-audio/skin js 17.01 kB
/live-audio/skin.tailwind js 17.93 kB
UI Components (20)
Entry Size
/ui/alert-dialog 1.07 kB
/ui/buffering-indicator 1.73 kB
/ui/captions-button 1.98 kB
/ui/cast-button 1.95 kB
/ui/controls 1.72 kB
/ui/error-dialog 2.19 kB
/ui/fullscreen-button 1.97 kB
/ui/mute-button 1.96 kB
/ui/pip-button 1.97 kB
/ui/play-button 1.97 kB
/ui/playback-rate-button 1.96 kB
/ui/popover 1.81 kB
/ui/poster 1.58 kB
/ui/seek-button 2.06 kB
/ui/slider 3.07 kB
/ui/thumbnail 2.00 kB
/ui/time 1.79 kB
/ui/time-slider 2.79 kB
/ui/tooltip 2.18 kB
/ui/volume-slider 2.27 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core

Path Base PR Diff %
/dom/media/dash 234.13 kB 234.78 kB +668 B +0.3% 🔺
/dom/media/hls 131.85 kB 132.38 kB +536 B +0.4% 🔺
/dom/media/host 1.29 kB 🆕
/dom/media/mux 158.10 kB 158.59 kB +508 B +0.3% 🔺
/dom/media/native-hls 1.61 kB 2.10 kB +499 B +30.3% 🔴
/dom/media/simple-hls 13.73 kB 14.21 kB +485 B +3.4% 🔺
Entries (10)
Entry Size
. 4.96 kB
/dom 11.66 kB
/dom/media/custom-media-element 1.90 kB
/dom/media/dash 234.78 kB
/dom/media/google-cast 4.07 kB
/dom/media/hls 132.38 kB
/dom/media/host 1.29 kB
/dom/media/mux 158.59 kB
/dom/media/native-hls 2.10 kB
/dom/media/simple-hls 14.21 kB
🏷️ @videojs/element — no changes
Entries (2)
Entry Size
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Size
. 1.39 kB
/html 695 B
/react 360 B
🔧 @videojs/utils — no changes
Entries (10)
Entry Size
/array 104 B
/dom 1.92 kB
/events 319 B
/function 327 B
/object 275 B
/predicate 265 B
/string 148 B
/style 190 B
/time 478 B
/number 158 B
📦 @videojs/spf — no changes
Entries (3)
Entry Size
. 40 B
/dom 13.33 kB
/playback-engine 13.24 kB

ℹ️ How to interpret

All sizes are standalone totals (minified + brotli).

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

Run pnpm size locally to check current sizes.

Comment thread packages/core/src/dom/presentation/pip.ts
Isolates HTMLVideoElementHost and HTMLAudioElementHost from the main
@videojs/core/dom barrel so consumers that don't wrap raw <video> /
<audio> elements no longer pay the cost of shipping the host classes.
Only @videojs/html and @videojs/react import from the new subpath.

Made-with: Cursor
this.#wasPictureInPicture = isPip;
this.dispatchEvent(new Event(isPip ? 'enterpictureinpicture' : 'leavepictureinpicture'));
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicate PiP events from WebKit normalization and base forwarding

Medium Severity

The host's #handlePresentationChange synthesizes enterpictureinpicture/leavepictureinpicture events from WebKit's webkitpresentationmodechanged. Simultaneously, the base class addEventListener override auto-registers a #forwardEvent listener on the video target for these same event types whenever a feature subscribes (e.g., listen(media, 'enterpictureinpicture', ...)). On Safari, where user-initiated PiP (via browser controls) can fire both webkitpresentationmodechanged and native enterpictureinpicture, listeners on the host receive duplicate events. The #wasPictureInPicture guard only deduplicates synthetic events, not forwarded native ones.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0fb7c58. Configure here.

const target = (media as EventTarget & { target?: unknown }).target;
return target instanceof HTMLMediaElement ? target : media;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Safari PWA PiP availability exclusion silently dropped

Low Severity

isPictureInPictureEnabled previously returned false for Safari Progressive Web Apps (standalone display mode) because the standard PiP API doesn't work reliably in that context. The new code removes the isPWA check entirely: on Safari, it now always falls through to the webkitSetPresentationMode capability check, which returns true. If WebKit's presentation mode API also doesn't work in Safari PWAs, the player would show a non-functional PiP button.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0fb7c58. Configure here.

Update hls, dash, simple-hls, and native-hls to import HTMLVideoElementHost
from '../host' instead of '../video-host', so the host classes have a
single canonical import point. tsdown's unbundle mode resolves the
re-export so consumers don't pay any runtime cost.

Made-with: Cursor
@luwes
Copy link
Copy Markdown
Collaborator Author

luwes commented Apr 24, 2026

this doesn't work very well. the wrench is the native video element in html, if we convert that to our media host API with normalizations it has to be done in the provider mixin and that increases bundle size for others.

@luwes luwes closed this Apr 24, 2026
Copy link
Copy Markdown

@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 2 potential issues.

There are 4 total unresolved issues (including 2 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 ca36017. Configure here.

* @label Media
* @param media - A {@link Media}-shaped value (e.g. an existing host); returned unchanged.
*/
export function toMediaHost(media: Media): ToMediaHostResult<Video>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Type overload unsafely widens Media to Video

Medium Severity

The toMediaHost(media: Media): ToMediaHostResult<Video> overload promises the result contains a Video, but at runtime non-element inputs (like HTMLAudioElementHost, which implements Audio but not Video) pass through unchanged via the fallback path. Downstream code (e.g., PlayerTarget.media: Video) then accesses isFullscreen, isPictureInPicture, exitPictureInPicture() etc. on an object that lacks them, leading to undefined property access at runtime.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ca36017. Configure here.

return;
}

throw new DOMException('Picture-in-Picture not supported', 'NotSupportedError');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WebKit PiP promises hang if mode change fails

Low Severity

requestPictureInPicture and exitPictureInPicture use onEvent to await enterpictureinpicture / leavepictureinpicture after calling webkitSetPresentationMode. If the mode change fails silently (e.g., system blocks PiP, or the browser is in an unsupported context like Safari PWA), the webkitpresentationmodechanged event never fires, the synthetic event is never dispatched, and the returned promise hangs indefinitely with no timeout or abort mechanism.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ca36017. Configure here.

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