Skip to content

feat(packages): add chromecast support via remote playback API#1348

Merged
luwes merged 16 commits into
mainfrom
feat/1043-cast-support
Apr 17, 2026
Merged

feat(packages): add chromecast support via remote playback API#1348
luwes merged 16 commits into
mainfrom
feat/1043-cast-support

Conversation

@luwes
Copy link
Copy Markdown
Collaborator

@luwes luwes commented Apr 15, 2026

Refs #1043
Closes #1355
Closes #1356
Closes #1357
Closes #1358
Closes #1359
Closes #1360

Test url: https://v10-sandbox-git-feat-1043-cast-support-mux.vercel.app/?platform=html&styling=css&preset=mux-video&skin=default&source=hls-1

Summary

Add Chromecast support using a RemotePlayback polyfill backed by the Cast Application Framework (CAF) sender SDK. The implementation follows the same feature pattern as fullscreen and picture-in-picture — a CastableMediaMixin, a castFeature store slice, and a CastButton UI component across HTML and React packages.

Sub-issues

Changes

  • CastableMediaMixin wraps any media host with a RemotePlayback-shaped API that delegates to CAF sender sessions
  • castFeature exposes castState, castAvailability, requestCast, exitCast, and toggleCast as reactive state
  • Controls stay visible while a cast session is connected
  • CastButton component (core, HTML custom element, React) with enter/exit icon toggling via data-cast-state
  • Cast enter/exit icons in default and minimal themes
  • Skin and tailwind presets updated for all video skins
Implementation details
  • RemotePlayback polyfill mirrors the browser API surface (state, prompt(), watchAvailability(), cancelWatchAvailability()) so the feature layer doesn't need to know whether it's using native remote playback or the CAF polyfill
  • CastableMediaMixin loads the CAF sender SDK on-demand and manages the CastContext/CastSession lifecycle
  • The controlsFeature listens for connect/disconnect events on the remote object to recompute visibility
  • @types/chrome and @types/chromecast-caf-sender added as dev dependencies for type-safe CAF SDK usage

Testing

  • Unit tests for CastButtonCore state derivation
  • Unit test for controls visibility during cast
  • Manual: load sandbox, click cast button on a Chromecast-capable network

Made with Cursor


Note

High Risk
High risk because it introduces a new remote playback/casting layer that intercepts core media operations (play/pause/seek/volume/rate/load) and adds network fetches for HLS probing, affecting playback behavior across video presets.

Overview
Adds Chromecast/remote playback support by introducing a RemotePlayback-shaped polyfill backed by Google Cast CAF, plus a CastableMediaMixin that routes media operations to a remote session while casting.

Exposes casting as a first-class player capability via new MediaCastState/castFeature + selectCast, updates controlsFeature to keep controls visible while connecting/connected, and wires a new CastButton across core (CastButtonCore), HTML (media-cast-button), and React, including icon/skin integration.

Also updates media host plumbing (e.g., HTMLMediaElementHost attach/detach/query helpers, HTMLVideoElementHost.poster), makes Mux presets cast-capable, adds CAF/chrome type deps and TS config, and includes unit tests + docs for the new cast feature.

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

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 15, 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 17, 2026 9:50pm

Request Review

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 15, 2026

Deploy Preview for vjs10-site ready!

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

📦 Bundle Size Report

🎨 @videojs/html

Path Base PR Diff %
/video/minimal-skin 25.37 kB 25.96 kB +604 B +2.3% 🔺
/video/minimal-skin.tailwind 25.56 kB 26.17 kB +619 B +2.4% 🔺
/video/skin 27.84 kB 28.47 kB +650 B +2.3% 🔺
/video/skin.tailwind 27.96 kB 28.54 kB +595 B +2.1% 🔺
/audio/minimal-skin 23.50 kB 23.95 kB +464 B +1.9% 🔺
/audio/minimal-skin.tailwind 23.68 kB 24.12 kB +457 B +1.9% 🔺
/audio/skin 25.92 kB 26.36 kB +447 B +1.7% 🔺
/audio/skin.tailwind 26.03 kB 26.50 kB +473 B +1.8% 🔺
/ui/cast-button 2.58 kB 🆕
/media/mux-audio 155.91 kB 159.50 kB +3.60 kB +2.3% 🔺
/media/mux-video 155.89 kB 159.58 kB +3.69 kB +2.4% 🔺
/video (default) 27.82 kB 28.45 kB +651 B +2.3% 🔺
/video (default + hls) 159.89 kB 160.50 kB +624 B +0.4% 🔺
/video (minimal) 25.40 kB 25.95 kB +560 B +2.2% 🔺
/video (minimal + hls) 157.52 kB 158.07 kB +563 B +0.3% 🔺
/audio (default) 25.91 kB 26.36 kB +464 B +1.7% 🔺
/audio (minimal) 23.50 kB 23.94 kB +452 B +1.9% 🔺
Presets (7)
Entry Size
/video (default) 28.45 kB
/video (default + hls) 160.50 kB
/video (minimal) 25.95 kB
/video (minimal + hls) 158.07 kB
/audio (default) 26.36 kB
/audio (minimal) 23.94 kB
/background 4.15 kB
Media (8)
Entry Size
/media/background-video 1.04 kB
/media/container 1.73 kB
/media/dash-video 236.43 kB
/media/hls-video 133.51 kB
/media/mux-audio 159.50 kB
/media/mux-video 159.58 kB
/media/native-hls-video 3.50 kB
/media/simple-hls-video 15.78 kB
Players (3)
Entry Size
/video/player 6.98 kB
/audio/player 5.06 kB
/background/player 3.86 kB
Skins (17)
Entry Type Size
/video/minimal-skin.css css 3.50 kB
/video/skin.css css 3.52 kB
/video/minimal-skin js 25.96 kB
/video/minimal-skin.tailwind js 26.17 kB
/video/skin js 28.47 kB
/video/skin.tailwind js 28.54 kB
/audio/minimal-skin.css css 2.53 kB
/audio/skin.css css 2.50 kB
/audio/minimal-skin js 23.95 kB
/audio/minimal-skin.tailwind js 24.12 kB
/audio/skin js 26.36 kB
/audio/skin.tailwind js 26.50 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 983 B
/ui/alert-dialog-close 429 B
/ui/alert-dialog-description 398 B
/ui/alert-dialog-title 353 B
/ui/buffering-indicator 2.23 kB
/ui/captions-button 2.53 kB
/ui/cast-button 2.58 kB
/ui/compounds 3.94 kB
/ui/controls 2.20 kB
/ui/error-dialog 2.96 kB
/ui/fullscreen-button 2.49 kB
/ui/hotkey 2.60 kB
/ui/mute-button 2.56 kB
/ui/pip-button 2.53 kB
/ui/play-button 2.58 kB
/ui/playback-rate-button 2.55 kB
/ui/popover 1.81 kB
/ui/poster 2.07 kB
/ui/seek-button 2.56 kB
/ui/slider 1.46 kB
/ui/thumbnail 2.60 kB
/ui/time 2.38 kB
/ui/time-slider 3.88 kB
/ui/tooltip 2.02 kB
/ui/volume-slider 3.23 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react

Path Base PR Diff %
/media/mux-audio 154.62 kB 158.16 kB +3.54 kB +2.3% 🔺
/media/mux-video 154.60 kB 158.04 kB +3.44 kB +2.2% 🔺
/video/minimal-skin 20.25 kB 20.79 kB +549 B +2.6% 🔺
/video/minimal-skin.tailwind 23.72 kB 24.31 kB +609 B +2.5% 🔺
/video/skin 22.53 kB 23.16 kB +642 B +2.8% 🔺
/video/skin.tailwind 23.77 kB 24.40 kB +642 B +2.6% 🔺
/ui/cast-button 1.94 kB 🆕
/video (default) 22.62 kB 23.26 kB +658 B +2.8% 🔺
/video (default + hls) 153.58 kB 154.32 kB +760 B +0.5% 🔺
/video (minimal) 20.32 kB 20.87 kB +564 B +2.7% 🔺
/video (minimal + hls) 151.20 kB 152.00 kB +821 B +0.5% 🔺
Presets (7)
Entry Size
/video (default) 23.26 kB
/video (default + hls) 154.32 kB
/video (minimal) 20.87 kB
/video (minimal + hls) 152.00 kB
/audio (default) 18.86 kB
/audio (minimal) 17.38 kB
/background 755 B
Media (7)
Entry Size
/media/background-video 575 B
/media/dash-video 234.89 kB
/media/hls-video 132.03 kB
/media/mux-audio 158.16 kB
/media/mux-video 158.04 kB
/media/native-hls-video 2.06 kB
/media/simple-hls-video 14.42 kB
Skins (14)
Entry Type Size
/video/minimal-skin.css css 3.43 kB
/video/skin.css css 3.45 kB
/video/minimal-skin js 20.79 kB
/video/minimal-skin.tailwind js 24.31 kB
/video/skin js 23.16 kB
/video/skin.tailwind js 24.40 kB
/audio/minimal-skin.css css 2.43 kB
/audio/skin.css css 2.39 kB
/audio/minimal-skin js 17.30 kB
/audio/minimal-skin.tailwind js 19.78 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
UI Components (20)
Entry Size
/ui/alert-dialog 1.15 kB
/ui/buffering-indicator 1.24 kB
/ui/captions-button 1.93 kB
/ui/cast-button 1.94 kB
/ui/controls 1.30 kB
/ui/error-dialog 1.78 kB
/ui/fullscreen-button 1.92 kB
/ui/mute-button 1.96 kB
/ui/pip-button 1.92 kB
/ui/play-button 1.93 kB
/ui/playback-rate-button 1.97 kB
/ui/popover 1.92 kB
/ui/poster 1.16 kB
/ui/seek-button 1.95 kB
/ui/slider 2.63 kB
/ui/thumbnail 1.54 kB
/ui/time 2.06 kB
/ui/time-slider 2.22 kB
/ui/tooltip 2.20 kB
/ui/volume-slider 2.28 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core

Path Base PR Diff %
/dom 11.31 kB 11.61 kB +302 B +2.6% 🔺
/dom/media/castable 4.05 kB 🆕
/dom/media/mux 153.99 kB 157.47 kB +3.48 kB +2.3% 🔺
Entries (9)
Entry Size
. 4.89 kB
/dom 11.61 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.37 kB
/dom/media/mux 157.47 kB
/dom/media/native-hls 1.32 kB
/dom/media/simple-hls 13.73 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.

Comment on lines +134 to +144
// Recompute visibility when cast state changes.
if (isMediaRemotePlaybackCapable(media)) {
const onCastChange = () => {
const { userActive } = get();
set({ controlsVisible: computeVisible(userActive) });
};

listen(media.remote, 'connect', onCastChange, { signal });
listen(media.remote, 'disconnect', onCastChange, { signal });
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

maybe we should override the controlsVisible state in the cast feature? wasn't 100% sure.

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.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: exitCast calls requestCast instead of disconnecting session
    • Added exitCast function that calls castContext().endCurrentSession(true) and updated exitCast and toggleCast methods to use it instead of requestCast.
  • ✅ Fixed: HLS Content-Type check fails with header parameters
    • Updated isHls function to normalize content-type by splitting on semicolon, trimming, and comparing case-insensitively to handle parameters and case variations.

Create PR

Or push these changes by commenting:

@cursor push 66cfdf3f5d
Preview (66cfdf3f5d)
diff --git a/packages/core/src/dom/media/castable/utils.ts b/packages/core/src/dom/media/castable/utils.ts
--- a/packages/core/src/dom/media/castable/utils.ts
+++ b/packages/core/src/dom/media/castable/utils.ts
@@ -126,7 +126,10 @@
     const response = await fetch(url, { method: 'HEAD' });
     const contentType = response.headers.get('Content-Type');
 
-    return HLS_RESPONSE_HEADERS.some((header) => contentType === header);
+    if (!contentType) return false;
+
+    const normalizedContentType = contentType.toLowerCase().split(';')[0]!.trim();
+    return HLS_RESPONSE_HEADERS.some((header) => normalizedContentType === header.toLowerCase());
   } catch (err) {
     console.error('Error while trying to get the Content-Type of the manifest', err);
     return false;

diff --git a/packages/core/src/dom/presentation/cast.ts b/packages/core/src/dom/presentation/cast.ts
--- a/packages/core/src/dom/presentation/cast.ts
+++ b/packages/core/src/dom/presentation/cast.ts
@@ -25,3 +25,17 @@
   }
   return remote.prompt();
 }
+
+export async function exitCast(media: EventTarget) {
+  const remote = resolveRemote(media);
+  if (!remote) {
+    throw new DOMException('Remote playback not supported', 'NotSupportedError');
+  }
+
+  const castCtx = (globalThis as any).cast?.framework?.CastContext?.getInstance();
+  if (!castCtx) {
+    throw new DOMException('Cast context not available', 'NotSupportedError');
+  }
+
+  return castCtx.endCurrentSession(true);
+}

diff --git a/packages/core/src/dom/store/features/cast.ts b/packages/core/src/dom/store/features/cast.ts
--- a/packages/core/src/dom/store/features/cast.ts
+++ b/packages/core/src/dom/store/features/cast.ts
@@ -3,7 +3,7 @@
 import type { CastState, MediaCastState } from '../../../core/media/state';
 import { definePlayerFeature } from '../../feature';
 import { isMediaRemotePlaybackCapable } from '../../media/predicate';
-import { isCastConnected, requestCast } from '../../presentation/cast';
+import { exitCast, isCastConnected, requestCast } from '../../presentation/cast';
 import { exitFullscreen, isFullscreenElement } from '../../presentation/fullscreen';
 
 export const castFeature = definePlayerFeature({
@@ -24,14 +24,14 @@
 
     async exitCast() {
       const { media } = target();
-      return requestCast(media);
+      return exitCast(media);
     },
 
     async toggleCast() {
       const { media, container } = target();
 
       if (isCastConnected(media)) {
-        return requestCast(media);
+        return exitCast(media);
       }
 
       if (isFullscreenElement(container, media)) {

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Comment thread packages/core/src/dom/store/features/cast.ts Outdated
Comment thread packages/core/src/dom/media/castable/utils.ts Outdated
Comment thread packages/core/src/dom/media/castable/remote-playback.ts Outdated
Comment thread packages/core/src/dom/media/castable/remote-playback.ts Outdated
Comment thread packages/core/src/dom/presentation/cast.ts
Comment thread packages/core/src/dom/media/castable/index.ts
Comment thread packages/core/src/dom/media/castable/index.ts
Comment thread packages/core/src/dom/media/castable/utils.ts Outdated
Comment thread packages/core/src/dom/media/castable/google-cast-provider.ts
luwes added 2 commits April 17, 2026 12:56
The `remote` getter treated `null` the same as "never initialized",
so accessing `.remote` after `destroy()` would lazily create a new
GoogleCastProvider (leaking listeners on providerInstances). Track a
destroyed flag so the getter returns undefined post-destroy.

Made-with: Cursor
Comment thread packages/core/src/dom/media/castable/google-cast-provider.ts
CAF's RemotePlayerController does not deduplicate handlers. Attaching
listeners before requestSession and returning early on cancel (or
calling requestCastSession again to stop casting) left stale listeners
in place, causing duplicate timeupdate/volumechange/play/pause events.

Make attach/detach idempotent via a #listenersAttached flag and route
every call site through the private helpers. Inline the listener
helpers into the provider, then compact surrounding code: simplify
onCastStateChanged, PLAYER_STATE_CHANGED, load()'s HLS/subtitle blocks,
and #updateRemoteTextTrack; dedupe the disableRemotePlayback guard in
RemotePlayback; drop the brittle globalThis casts in utils.

Made-with: Cursor
Comment thread packages/core/src/dom/store/features/controls.ts
Comment thread packages/core/src/core/ui/cast-button/cast-button-core.ts
The recent change typed CastableMediaBase.remote as our concrete
RemotePlayback class, which broke the mixin's superclass constraint:
HTMLMediaElementHost.remote is the DOM RemotePlayback (structurally
different due to private fields on our class) and is optional because
target may be null. The constraint failure cascaded into MuxDataMedia,
MediaHost, and React/html wrappers.

Use the existing structural RemotePlaybackLike interface in the
constraint and widen the mixin output to accept either our class or a
RemotePlaybackLike fallback returned via super.remote.

Made-with: Cursor
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 3999ef2. Configure here.

Comment thread packages/core/src/dom/media/castable/google-cast-provider.ts
@luwes luwes self-assigned this Apr 17, 2026
@luwes luwes merged commit 31a005e into main Apr 17, 2026
23 of 24 checks passed
@luwes luwes deleted the feat/1043-cast-support branch April 17, 2026 21:51
@luwes luwes mentioned this pull request Apr 17, 2026
@github-actions github-actions Bot mentioned this pull request Apr 17, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment