Skip to content

refactor(packages): improve webkit presentation API handling and button availability#1362

Closed
mihar-22 wants to merge 14 commits into
mainfrom
refactor/webkit-presentation-api
Closed

refactor(packages): improve webkit presentation API handling and button availability#1362
mihar-22 wants to merge 14 commits into
mainfrom
refactor/webkit-presentation-api

Conversation

@mihar-22
Copy link
Copy Markdown
Member

@mihar-22 mihar-22 commented Apr 18, 2026

Summary

Refactor WebKit fullscreen and picture-in-picture handling to use the modern presentation mode API (webkitSetPresentationMode) instead of deprecated methods. Replace the available boolean on buttons with disabled and hidden states that follow standard ARIA patterns — aria-disabled for non-interactive controls and HTML hidden for unsupported features.

Changes

  • Use webkitSetPresentationMode('fullscreen') instead of webkitEnterFullscreen / webkitExitFullscreen for iOS Safari
  • Convert fullscreen/PiP features from async/await to sync promise chaining to preserve user-activation tokens on iOS Safari
  • Add strict WebKit type guards (isWebKitVideoElement, etc.) replacing loose optional-field interfaces
  • Move capability predicates (isMediaPauseCapable, etc.) to runtime-agnostic core/media/predicate.ts
  • Add symbol-based identification to media host classes with resolveHTMLMediaElement helpers
  • Breaking: replace available: boolean + data-available with disabled and hidden states on cast, fullscreen, and PiP buttons
  • aria-disabled is now set from button state (disabled by prop or unavailability), not just props
  • HTML hidden attribute applied when a feature is unsupported (e.g., no fullscreen API)
  • React buttons return null when state.hidden is true
  • Skins: replace old has-data-[availability]:not-data-[available]:hidden Tailwind classes with data-[disabled] styling (both default and minimal skins)
  • Skins CSS uses &[data-disabled] instead of &[disabled]
  • Sync PiP state on play event for iOS Safari background-to-foreground transitions
  • Correct PiP feature event target to listen on the video element for webkitpresentationmodechanged
  • Chain requestPictureInPicture after exitFullscreen so PiP activates once fullscreen exits
Implementation details

The async-to-sync change preserves the user activation token that iOS Safari requires for fullscreen requests. With await, the microtask boundary caused Safari to reject requests silently.

The disabled / hidden split follows the design doc in internal/design/ui/disabled-hidden.md: aria-disabled keeps the button visible and discoverable (screen readers still announce it) while hidden removes it entirely for unsupported features. This is more semantically correct than the single available boolean.

The PiP feature now correctly listens on the video element for webkitpresentationmodechanged and chains requestPictureInPicture after exitFullscreen when transitioning from fullscreen. It also syncs on play to handle iOS Safari returning from background while in PiP mode.

Testing

  1. pnpm -F @videojs/core test — unit tests for button cores and feature state
  2. pnpm -F @videojs/react test — React button integration
  3. Manual: verify fullscreen/PiP buttons show aria-disabled when unavailable and hidden when unsupported
  4. Manual: iOS Safari — verify PiP state updates correctly after backgrounding/foregrounding
  5. Visual: confirm disabled buttons render with reduced opacity/grayscale in both default and minimal skins

Note

Medium Risk
Touches core presentation (fullscreen/PiP/cast) flows and changes button rendering/attributes across HTML, React, skins, and tests; behavior differs per browser and now propagates errors instead of swallowing them.

Overview
Refactors fullscreen and picture-in-picture support detection and transitions to use WebKit’s presentation mode API (via new presentation/webkit.ts) and new resolveHTML* helpers/symbol-based host guards, updating cast/fullscreen/PiP features and tests to match.

Updates Cast/Fullscreen/PiP buttons to model disabled vs hidden explicitly: aria-disabled now reflects both prop-based disabling and feature unavailability, unsupported features set native hidden (and React buttons return null via isSupported), and styling shifts from [disabled]/data-availability hiding to [data-disabled] with docs/e2e/tests adjusted accordingly.

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

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 18, 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 19, 2026 0:08am

Request Review

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 18, 2026

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit b4cdc2d
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/69e41cf1e432be0008f71929
😎 Deploy Preview https://deploy-preview-1362--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 18, 2026

📦 Bundle Size Report

🎨 @videojs/html

Path Base PR Diff %
/video/minimal-skin 25.95 kB 26.50 kB +563 B +2.1% 🔺
/video/minimal-skin.tailwind 26.17 kB 26.71 kB +559 B +2.1% 🔺
/video/player 6.98 kB 7.49 kB +516 B +7.2% 🔺
/video/skin 28.50 kB 29.05 kB +559 B +1.9% 🔺
/video/skin.tailwind 28.56 kB 29.10 kB +556 B +1.9% 🔺
/audio/minimal-skin 23.95 kB 24.42 kB +487 B +2.0% 🔺
/audio/minimal-skin.tailwind 24.13 kB 24.62 kB +502 B +2.0% 🔺
/audio/skin 26.35 kB 26.86 kB +516 B +1.9% 🔺
/audio/skin.tailwind 26.51 kB 27.01 kB +512 B +1.9% 🔺
/ui/buffering-indicator 2.24 kB 1.90 kB -357 B -15.5% 🔽
/ui/captions-button 2.55 kB 2.11 kB -444 B -17.0% 🔽
/ui/cast-button 2.49 kB 2.15 kB -340 B -13.4% 🔽
/ui/compounds 3.89 kB 2.84 kB -1.05 kB -26.9% 🔽
/ui/controls 2.19 kB 1.77 kB -429 B -19.2% 🔽
/ui/error-dialog 2.90 kB 2.48 kB -430 B -14.5% 🔽
/ui/fullscreen-button 2.54 kB 2.11 kB -436 B -16.8% 🔽
/ui/hotkey 2.53 kB 2.72 kB +197 B +7.6% 🔺
/ui/mute-button 2.51 kB 2.13 kB -388 B -15.1% 🔽
/ui/pip-button 2.52 kB 2.10 kB -428 B -16.6% 🔽
/ui/play-button 2.55 kB 2.13 kB -439 B -16.8% 🔽
/ui/playback-rate-button 2.54 kB 2.13 kB -429 B -16.5% 🔽
/ui/poster 2.05 kB 1.66 kB -395 B -18.8% 🔽
/ui/seek-button 2.54 kB 2.14 kB -409 B -15.7% 🔽
/ui/thumbnail 2.59 kB 2.27 kB -332 B -12.5% 🔽
/ui/time 2.41 kB 1.93 kB -495 B -20.1% 🔽
/ui/time-slider 3.84 kB 3.17 kB -685 B -17.4% 🔽
/ui/volume-slider 3.22 kB 3.55 kB +332 B +10.1% 🔴
/video (default) 28.45 kB 29.06 kB +622 B +2.1% 🔺
/video (minimal) 25.98 kB 26.52 kB +553 B +2.1% 🔺
/audio (default) 26.36 kB 26.86 kB +507 B +1.9% 🔺
/audio (minimal) 23.95 kB 24.43 kB +493 B +2.0% 🔺
Presets (7)
Entry Size
/video (default) 29.06 kB
/video (default + hls) 160.67 kB
/video (minimal) 26.52 kB
/video (minimal + hls) 158.19 kB
/audio (default) 26.86 kB
/audio (minimal) 24.43 kB
/background 4.15 kB
Media (8)
Entry Size
/media/background-video 1.04 kB
/media/container 1.73 kB
/media/dash-video 236.47 kB
/media/hls-video 133.56 kB
/media/mux-audio 159.39 kB
/media/mux-video 159.40 kB
/media/native-hls-video 3.47 kB
/media/simple-hls-video 15.74 kB
Players (3)
Entry Size
/video/player 7.49 kB
/audio/player 5.07 kB
/background/player 3.86 kB
Skins (17)
Entry Type Size
/video/minimal-skin.css css 3.49 kB
/video/skin.css css 3.51 kB
/video/minimal-skin js 26.50 kB
/video/minimal-skin.tailwind js 26.71 kB
/video/skin js 29.05 kB
/video/skin.tailwind js 29.10 kB
/audio/minimal-skin.css css 2.52 kB
/audio/skin.css css 2.49 kB
/audio/minimal-skin js 24.42 kB
/audio/minimal-skin.tailwind js 24.62 kB
/audio/skin js 26.86 kB
/audio/skin.tailwind js 27.01 kB
/background/skin.css css 117 B
/background/skin js 1.14 kB
/base.css css 157 B
/shared.css css 88 B
/skin-element js 1.35 kB
UI Components (25)
Entry Size
/ui/alert-dialog 713 B
/ui/alert-dialog-close 323 B
/ui/alert-dialog-description 279 B
/ui/alert-dialog-title 285 B
/ui/buffering-indicator 1.90 kB
/ui/captions-button 2.11 kB
/ui/cast-button 2.15 kB
/ui/compounds 2.84 kB
/ui/controls 1.77 kB
/ui/error-dialog 2.48 kB
/ui/fullscreen-button 2.11 kB
/ui/hotkey 2.72 kB
/ui/mute-button 2.13 kB
/ui/pip-button 2.10 kB
/ui/play-button 2.13 kB
/ui/playback-rate-button 2.13 kB
/ui/popover 1.49 kB
/ui/poster 1.66 kB
/ui/seek-button 2.14 kB
/ui/slider 1.09 kB
/ui/thumbnail 2.27 kB
/ui/time 1.93 kB
/ui/time-slider 3.17 kB
/ui/tooltip 1.64 kB
/ui/volume-slider 3.55 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react

Path Base PR Diff %
/video/minimal-skin 20.80 kB 21.40 kB +613 B +2.9% 🔺
/video/minimal-skin.tailwind 24.29 kB 24.90 kB +618 B +2.5% 🔺
/video/skin 23.17 kB 23.80 kB +641 B +2.7% 🔺
/video/skin.tailwind 24.41 kB 25.02 kB +629 B +2.5% 🔺
/audio/minimal-skin 17.30 kB 17.87 kB +575 B +3.2% 🔺
/audio/minimal-skin.tailwind 19.79 kB 20.34 kB +564 B +2.8% 🔺
/audio/skin 18.77 kB 19.34 kB +586 B +3.0% 🔺
/audio/skin.tailwind 19.80 kB 20.31 kB +525 B +2.6% 🔺
/ui/buffering-indicator 1.28 kB 1.34 kB +58 B +4.4% 🔺
/ui/captions-button 1.94 kB 2.01 kB +76 B +3.8% 🔺
/ui/cast-button 1.96 kB 2.03 kB +78 B +3.9% 🔺
/ui/controls 1.33 kB 1.41 kB +82 B +6.0% 🔺
/ui/error-dialog 1.77 kB 1.93 kB +163 B +9.0% 🔺
/ui/fullscreen-button 1.94 kB 2.07 kB +136 B +6.9% 🔺
/ui/mute-button 1.96 kB 2.03 kB +70 B +3.5% 🔺
/ui/pip-button 1.94 kB 2.06 kB +126 B +6.4% 🔺
/ui/play-button 1.92 kB 2.03 kB +106 B +5.4% 🔺
/ui/playback-rate-button 1.97 kB 2.03 kB +54 B +2.7% 🔺
/ui/poster 1.21 kB 1.33 kB +127 B +10.3% 🔴
/ui/seek-button 1.96 kB 2.03 kB +64 B +3.2% 🔺
/ui/slider 2.63 kB 2.53 kB -110 B -4.1% 🔽
/ui/thumbnail 1.54 kB 1.70 kB +167 B +10.6% 🔴
/ui/time 2.06 kB 1.52 kB -549 B -26.1% 🔽
/ui/time-slider 2.19 kB 2.29 kB +100 B +4.5% 🔺
/ui/volume-slider 2.26 kB 2.30 kB +45 B +1.9% 🔺
/video (default) 23.28 kB 23.88 kB +613 B +2.6% 🔺
/video (minimal) 20.90 kB 21.46 kB +583 B +2.7% 🔺
/audio (default) 18.85 kB 19.42 kB +587 B +3.0% 🔺
/audio (minimal) 17.38 kB 17.92 kB +551 B +3.1% 🔺
Presets (7)
Entry Size
/video (default) 23.88 kB
/video (default + hls) 154.38 kB
/video (minimal) 21.46 kB
/video (minimal + hls) 152.09 kB
/audio (default) 19.42 kB
/audio (minimal) 17.92 kB
/background 755 B
Media (7)
Entry Size
/media/background-video 575 B
/media/dash-video 235.18 kB
/media/hls-video 132.13 kB
/media/mux-audio 158.12 kB
/media/mux-video 158.17 kB
/media/native-hls-video 2.03 kB
/media/simple-hls-video 14.40 kB
Skins (14)
Entry Type Size
/video/minimal-skin.css css 3.42 kB
/video/skin.css css 3.44 kB
/video/minimal-skin js 21.40 kB
/video/minimal-skin.tailwind js 24.90 kB
/video/skin js 23.80 kB
/video/skin.tailwind js 25.02 kB
/audio/minimal-skin.css css 2.42 kB
/audio/skin.css css 2.38 kB
/audio/minimal-skin js 17.87 kB
/audio/minimal-skin.tailwind js 20.34 kB
/audio/skin js 19.34 kB
/audio/skin.tailwind js 20.31 kB
/background/skin.css css 90 B
/background/skin js 272 B
UI Components (20)
Entry Size
/ui/alert-dialog 1.06 kB
/ui/buffering-indicator 1.34 kB
/ui/captions-button 2.01 kB
/ui/cast-button 2.03 kB
/ui/controls 1.41 kB
/ui/error-dialog 1.93 kB
/ui/fullscreen-button 2.07 kB
/ui/mute-button 2.03 kB
/ui/pip-button 2.06 kB
/ui/play-button 2.03 kB
/ui/playback-rate-button 2.03 kB
/ui/popover 1.86 kB
/ui/poster 1.33 kB
/ui/seek-button 2.03 kB
/ui/slider 2.53 kB
/ui/thumbnail 1.70 kB
/ui/time 1.52 kB
/ui/time-slider 2.29 kB
/ui/tooltip 2.26 kB
/ui/volume-slider 2.30 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core

Path Base PR Diff %
/dom 11.62 kB 12.16 kB +548 B +4.6% 🔺
Entries (9)
Entry Size
. 4.95 kB
/dom 12.16 kB
/dom/media/castable 4.05 kB
/dom/media/custom-media-element 1.90 kB
/dom/media/dash 234.24 kB
/dom/media/hls 131.50 kB
/dom/media/mux 157.53 kB
/dom/media/native-hls 1.30 kB
/dom/media/simple-hls 13.69 kB
🏷️ @videojs/element — no changes
Entries (2)
Entry Size
. 999 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.30 kB
/playback-engine 13.17 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.

…ature

- Add missing `available` field to test helpers for FullscreenButtonState and PiPButtonState
- Pass `media` argument to `exitFullscreen()` in cast feature (signature changed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…st button

Aligns cast button with fullscreen and pip buttons — adds derived
`available` boolean state and `data-available` data attribute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread packages/core/src/dom/presentation/fullscreen.ts Outdated
Comment thread packages/core/src/dom/store/features/pip.ts
Comment thread packages/core/src/dom/store/features/pip.ts
Comment thread packages/skins/src/default/tailwind/components/button.ts Outdated
@mihar-22 mihar-22 marked this pull request as draft April 18, 2026 08:17
@mihar-22 mihar-22 removed the request for review from luwes April 18, 2026 08:17
…nsition

Listen for webkitpresentationmodechanged on the video element instead of
the media proxy, and chain requestPictureInPicture after exitFullscreen
so PiP activates once fullscreen exits. Also removes a stray console.log
from the fullscreen request path.
Covers the rationale for using aria-disabled over HTML disabled, HTML
hidden for unsupported features, and separate data-disabled/data-hidden
styling hooks across cast, fullscreen, and pip buttons.
…idden`

Feature buttons now expose `disabled` (non-interactive) and `hidden`
(unsupported) instead of a single `available` boolean. Unsupported
buttons get the HTML hidden attribute; disabled buttons stay visible
with aria-disabled and data-disabled for styling. Aligns with WAI-ARIA
APG toolbar guidance on keeping disabled controls focusable.
Listen for the `play` event so pip state updates when playback starts
in picture-in-picture mode (e.g., after returning from background on
iOS Safari).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace `has-data-[availability]:not-data-[available]:hidden` with
`data-[disabled]` styling classes to match the new disabled/hidden
button state model. Hidden buttons use the native HTML hidden attribute;
disabled buttons get reduced opacity and grayscale via data-disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…button docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@mihar-22 mihar-22 marked this pull request as ready for review April 18, 2026 22:42
PiP is unsupported on WebKit so the button receives the `hidden`
attribute and is removed from the DOM. Only assert `data-availability`
when the pip button is visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread packages/core/src/dom/presentation/pip.ts Outdated
Comment thread packages/core/src/dom/presentation/pip.ts
Comment thread packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts Outdated
- Check standard `requestPictureInPicture` before webkit fallback in
  `isPictureInPictureEnabled`, matching `requestPictureInPicture` order.
- Guard `exitPictureInPicture` webkit path with presentation mode check
  to avoid accidentally exiting fullscreen.
- Update fullscreen button test to expect error propagation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 1 potential issue.

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 b4cdc2d. Configure here.

Comment thread packages/core/src/core/media/predicate.ts
Comment on lines +43 to +88
export function isHTMLMediaElementHost(value: unknown): value is HTMLMediaElementHost<HTMLMediaElement, any> {
return isObject(value) && MEDIA_ELEMENT_HOST_SYMBOL in value;
}

export function isMediaVolumeCapable(value: unknown): value is MediaVolumeCapability {
return isObject(value) && 'volume' in value && 'muted' in value;
export function isHTMLVideoElementHost(value: unknown): value is HTMLVideoElementHost {
return isObject(value) && VIDEO_ELEMENT_HOST_SYMBOL in value;
}

export function isMediaPlaybackRateCapable(value: unknown): value is MediaPlaybackRateCapability {
return isObject(value) && 'playbackRate' in value;
export function isHTMLAudioElementHost(value: unknown): value is HTMLAudioElementHost {
return isObject(value) && AUDIO_ELEMENT_HOST_SYMBOL in value;
}

export function isMediaBufferCapable(value: unknown): value is MediaBufferCapability {
return isObject(value) && 'buffered' in value && 'seekable' in value;
export function isHlsMedia(value: unknown): value is HlsMedia {
return isObject(value) && HLS_MEDIA_SYMBOL in value;
}

export function isMediaErrorCapable(value: unknown): value is MediaErrorCapability {
return isObject(value) && 'error' in value;
export function isDashMedia(value: unknown): value is DashMedia {
return isObject(value) && DASH_MEDIA_SYMBOL in value;
}

export function isMediaTextTrackCapable(value: unknown): value is MediaTextTrackCapability {
return isObject(value) && 'textTracks' in value;
}
// -- Resolve helpers --

export interface RemotePlaybackLike extends EventTarget {
readonly state: string;
prompt(): Promise<void>;
watchAvailability(callback: (available: boolean) => void): Promise<number>;
cancelWatchAvailability(id?: number): Promise<void>;
export function resolveMediaRemote(media: Media): MediaRemotePlaybackCapability['remote'] | null {
if (isMediaRemotePlaybackCapable(media)) {
return media.remote;
}

return null;
}

export interface MediaRemotePlaybackCapability {
readonly remote: RemotePlaybackLike;
export function resolveHTMLMediaElement(media: Media): HTMLMediaElement | null {
if (media instanceof HTMLMediaElement) return media;
if (isHTMLMediaElementHost(media)) return media.target;
return null;
}

export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemotePlaybackCapability {
return isObject(value) && 'remote' in value && isObject((value as Record<string, unknown>).remote);
export function resolveHTMLVideoElement(media: Media): HTMLVideoElement | null {
if (media instanceof HTMLVideoElement) return media;
if (isHTMLVideoElementHost(media)) return media.target;
return null;
}

export function isQuerySelectorAllCapable<T extends string>(
value: unknown
): value is {
querySelectorAll: (selectors: T) => NodeListOf<HTMLElementTagNameMap[Extract<T, keyof HTMLElementTagNameMap>]>;
} {
return (
isObject(value) && 'querySelectorAll' in value && isFunction((value as Record<string, unknown>).querySelectorAll)
);
export function resolveHTMLAudioElement(media: Media): HTMLAudioElement | null {
if (media instanceof HTMLAudioElement) return media;
if (isHTMLAudioElementHost(media)) return media.target;
return null;
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.

This feels like it's going against the uniform media API design. We shouldn't have to do this. I might need more context.

Copy link
Copy Markdown
Member Author

@mihar-22 mihar-22 Apr 19, 2026

Choose a reason for hiding this comment

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

No problem, happy to discuss more! I get where that sense is coming from, valid. I'll do my best to lay my thoughts out below.

As you know, we have an interesting and unique challenge of wanting to support native browser media out of the box (e.g., <video>) and also have a flexible enough Media interface that anyone can implement easily. The "easy" part comes from the last wave of changes we made to untangle assumptions about what subset of DOM-like APIs we support, and how any "Media" can satisfy all, or some, of the complete interface through "capabilities."

Based on our definition of "Media", we can't assume how it's implemented. At the end of the day, how someone satisfies the interface is up to the them. This means we don't actually know contractually if it's a HTMLMediaElement that's been directly given to the store, something of the shape { target: HTMLMediaElement }, rendering inside an <iframe>, rendering to a <canvas>, or however they've decided to build it out depending on the platform, environment, or tech they want to support. As another example, moq.dev is built on WebTransport, WebCodecs, and WebAudio. Another cool library that someone might build upon is Mediabunny. React Native won't have a DOM.

Basically this all means, we need a way to resolve DOM media elements if they're needed in our store DOM features to support certain APIs like fullscreen and pip out of the box. Normally, we would move that logic into the HTMLVideoElementHost but because we want to support <video> itself for spec-compliance, we simply can't. This would be true for external store feature authors too, they might need access to these specific elements when available.

The media type guards like isHlsMedia and isDashMedia are for lower-level access to media/engine APIs without bundling the media themselves via instanceof checks or some other direct reference (keeping it treeshakeable). Important to remember the store is a base/generic Media interface, it needs to be narrowed for deeper access. You can imagine ourselves or someone else externally building out a store feature that might need it for something like analytics, logging, debugging, testing, etc. It could even be an odd case where we need a small workaround in a store feature for a specific media. This happens all the time.

In summary, the resolvers and media type guards don't invalidate the uniform Media API design. They're just there to support the flexible contracts we've set up.


Something I haven't tackled in this PR is that Media authors should be able to provide their own fullscreen and pip interfaces separately from the DOM. This means notify the store of availability, handle request/exit, dispatch events, and whatever else is needed. This can ultimately be a separate conversation in another PR. Thought it was worth noting here.

Copy link
Copy Markdown
Member Author

@mihar-22 mihar-22 Apr 20, 2026

Choose a reason for hiding this comment

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

I was thinking about this today a bit more, wanted to illustrate the problem and a potential way we could decide to move forward now, or in the future to strength our Media API design.

So the problem I was describing above is because as of today we're expecting:

In HTML via fallback:

<media-container>
  <video />
</media-container>

Which will essentially do the following in JS:

store.attach(video); // video = <video>

This is what leads to the awkwardness and expectations in our store features. Technically it's also why we have to design our Media API a certain way.

Basically it can be the <video> element or it could be a target (e.g., HTMLVideoElementHost).


There's a world where the default expectation could be:

import { HTMLVideoElementHost } from '@videojs/html';
const media = new HTMLVideoElementHost();
media.attach(video); // video = <video>
store.attach(media);

This is no different to playback tech in VJS 8

This means we now control the Media API completely, not partially by the DOM. But it also means in order to do this via HTML we need a custom element that attaches native media so host class is treeshaken out:

<media-container>
  <html-video> <!-- performs the code above -->
    <video />
  </html-video>
</media-container>

A bit ugly if someone wants to use native media elements, but it does mean we can move native fullscreen/pip handling out of store features and into media layer (where it belongs imo). Why I like this:

  1. Media API becomes solely responsible for media-related behaviour - it's not snuck into store features or living in potentially multiple places. It also means the store features are further disconnected from the DOM.
  2. Maybe document fullscreen and pip handling could be composed in via classes or mixins so others can take advantage of it too. Clearer boundary there too between what is specifically native media handling vs. document.
  3. Consistent with our other media in terms of how context is used for discovery and attaching. Technically anyone could pass in a <video> compliant element in and it would work.

Anyway, all for food for thought :) I just wanted to make sure I illustrated some of the challenges we have. It all kind of hinges on how we strongly we believe passing in <video> with no adapters should work and more importantly why.

Copy link
Copy Markdown
Member

@heff heff Apr 24, 2026

Choose a reason for hiding this comment

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

There's some fundamental misalignment happening here that is hard to nail down and we should talk IRL, but I think it might boil down to the following. And I can't fully tell if we're disagreed on something or if we didn't implement something as planned.

Basically this all means, we need a way to resolve DOM media elements if they're needed in our store DOM features to support certain APIs like fullscreen and pip out of the box. Normally, we would move that logic into the HTMLVideoElementHost but because we want to support <video> itself for spec-compliance, we simply can't. This would be true for external store feature authors too, they might need access to these specific elements when available.

Specifically "need a way to resolve DOM media elements if they're needed in our store"

If the store needs access to the internal DOM media element then we're not doing the Media API well. Nothing should have to reach through the Media API contract and grab the internal media. We might do that for debugging, but any normal feature operation that needs to reach through actually needs an extension of the Media API.

This is no different to playback tech in VJS 8

I intentionally moved away from techs when building Media Chrome because it was completely redundant. We had a <video> API (vjs player) wrapping a <video> API (tech) wrapping a <video>API (<video>). We definitely haven't missed techs in that codebase. It does add needed intentionally in extending the media API. But I would rather get more aggressive in extending the API than going back to Techs (i.e. HTMLVideoElementHost). We should be intentional, naturally, because it's an API devs will work with directly. But at the same time we shouldn't see it as heavy thing we can't touch and have to work around. We're in control of our ecosystem.

@mihar-22 This is where I think I'm feeling some hesitancy from you, at least from the suggestion of requiring a <html-video> wrapper for <video>.

but it does mean we can move native fullscreen/pip handling out of store features and into media layer

This is where I get confused where the misalignment is happening, because I'd agree with this and assumed this was already the case. When the store triggers PiP and <hls-video> is being used, does it call requestPictureInPicture on <hls-video> or does it reach deeper and call it directly on the internal <video>. If it's the former, that's intended, if it's the latter it goes against the Media API contract.

So help me understand if you can see where the misalignment is happening more clearly.

@mihar-22 mihar-22 closed this Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants