From 31a005eeef4cee496c15f6f6be0129ef0006a5a8 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Fri, 17 Apr 2026 14:51:16 -0700 Subject: [PATCH] feat(packages): add chromecast support via remote playback API (#1348) --- packages/core/package.json | 2 + packages/core/src/core/index.ts | 2 + packages/core/src/core/media/state.ts | 19 + .../core/ui/cast-button/cast-button-core.ts | 94 ++++ .../ui/cast-button/cast-button-data-attrs.ts | 7 + .../tests/cast-button-core.test.ts | 136 +++++ .../media/castable/google-cast-augments.d.ts | 26 + .../media/castable/google-cast-provider.ts | 498 ++++++++++++++++++ packages/core/src/dom/media/castable/index.ts | 226 ++++++++ .../src/dom/media/castable/remote-playback.ts | 74 +++ packages/core/src/dom/media/castable/types.ts | 51 ++ packages/core/src/dom/media/castable/utils.ts | 184 +++++++ .../tests/custom-media-element.test.ts | 44 +- packages/core/src/dom/media/hls/index.ts | 4 +- packages/core/src/dom/media/media-host.ts | 156 ++++-- packages/core/src/dom/media/mux/index.ts | 5 +- packages/core/src/dom/media/predicate.ts | 15 + packages/core/src/dom/media/types.ts | 2 + packages/core/src/dom/media/video-host.ts | 24 +- packages/core/src/dom/presentation/cast.ts | 27 + packages/core/src/dom/presentation/index.ts | 1 + packages/core/src/dom/store/features/cast.ts | 55 ++ .../core/src/dom/store/features/controls.ts | 17 +- .../src/dom/store/features/feature.parts.ts | 2 + packages/core/src/dom/store/features/index.ts | 1 + .../core/src/dom/store/features/presets.ts | 2 + .../dom/store/features/tests/controls.test.ts | 66 +++ packages/core/src/dom/store/selectors.ts | 3 + packages/core/src/dom/tsconfig.json | 1 + packages/html/src/define/ui/cast-button.ts | 10 + .../src/define/video/minimal-skin.tailwind.ts | 5 + .../html/src/define/video/minimal-skin.ts | 6 + packages/html/src/define/video/minimal-ui.ts | 2 + .../html/src/define/video/skin.tailwind.ts | 5 + packages/html/src/define/video/skin.ts | 6 + packages/html/src/define/video/ui.ts | 2 + packages/html/src/index.ts | 1 + .../src/ui/cast-button/cast-button-element.ts | 18 + .../icons/src/assets/default/cast-enter.svg | 6 + .../icons/src/assets/default/cast-exit.svg | 7 + .../icons/src/assets/minimal/cast-enter.svg | 6 + .../icons/src/assets/minimal/cast-exit.svg | 7 + packages/react/src/index.ts | 1 + .../presets/video/minimal-skin.tailwind.tsx | 15 + .../react/src/presets/video/minimal-skin.tsx | 15 + .../react/src/presets/video/skin.tailwind.tsx | 15 + packages/react/src/presets/video/skin.tsx | 15 + .../react/src/ui/cast-button/cast-button.tsx | 23 + packages/react/src/ui/cast-button/index.ts | 1 + .../skins/src/shared/css/video/icon-state.css | 6 + .../skins/src/shared/tailwind/icon-state.ts | 6 + pnpm-lock.yaml | 42 +- .../content/docs/reference/feature-cast.mdx | 52 ++ site/src/docs.config.ts | 1 + 54 files changed, 1928 insertions(+), 89 deletions(-) create mode 100644 packages/core/src/core/ui/cast-button/cast-button-core.ts create mode 100644 packages/core/src/core/ui/cast-button/cast-button-data-attrs.ts create mode 100644 packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts create mode 100644 packages/core/src/dom/media/castable/google-cast-augments.d.ts create mode 100644 packages/core/src/dom/media/castable/google-cast-provider.ts create mode 100644 packages/core/src/dom/media/castable/index.ts create mode 100644 packages/core/src/dom/media/castable/remote-playback.ts create mode 100644 packages/core/src/dom/media/castable/types.ts create mode 100644 packages/core/src/dom/media/castable/utils.ts create mode 100644 packages/core/src/dom/presentation/cast.ts create mode 100644 packages/core/src/dom/store/features/cast.ts create mode 100644 packages/html/src/define/ui/cast-button.ts create mode 100644 packages/html/src/ui/cast-button/cast-button-element.ts create mode 100644 packages/icons/src/assets/default/cast-enter.svg create mode 100644 packages/icons/src/assets/default/cast-exit.svg create mode 100644 packages/icons/src/assets/minimal/cast-enter.svg create mode 100644 packages/icons/src/assets/minimal/cast-exit.svg create mode 100644 packages/react/src/ui/cast-button/cast-button.tsx create mode 100644 packages/react/src/ui/cast-button/index.ts create mode 100644 site/src/content/docs/reference/feature-cast.mdx diff --git a/packages/core/package.json b/packages/core/package.json index 48a8853b..16300bd9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -56,6 +56,8 @@ "mux-embed": "^5.17.10" }, "devDependencies": { + "@types/chrome": "^0.1.40", + "@types/chromecast-caf-sender": "^1.0.11", "jsdom": "^26.1.0", "tsdown": "^0.21.4", "typescript": "^6.0.2", diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 7ba45be9..7243824c 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -6,6 +6,8 @@ export * from './ui/buffering-indicator/buffering-indicator-core'; export * from './ui/buffering-indicator/buffering-indicator-data-attrs'; export * from './ui/captions-button/captions-button-core'; export * from './ui/captions-button/captions-button-data-attrs'; +export * from './ui/cast-button/cast-button-core'; +export * from './ui/cast-button/cast-button-data-attrs'; export * from './ui/controls/controls-core'; export * from './ui/controls/controls-data-attrs'; export * from './ui/error-dialog/error-dialog-core'; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index f01be4af..77db678f 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -274,6 +274,25 @@ export interface MediaErrorState { dismissError(): void; } +export type CastState = 'disconnected' | 'connecting' | 'connected'; + +export interface MediaCastState { + /** + * Current cast connection state. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback/state + */ + castState: CastState; + /** + * Whether casting can be requested on this platform. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback + */ + castAvailability: MediaFeatureAvailability; + /** Toggle cast connection. */ + toggleCast(): Promise; +} + export interface MediaPictureInPictureState { /** * Whether picture-in-picture mode is currently active. diff --git a/packages/core/src/core/ui/cast-button/cast-button-core.ts b/packages/core/src/core/ui/cast-button/cast-button-core.ts new file mode 100644 index 00000000..3910ee0d --- /dev/null +++ b/packages/core/src/core/ui/cast-button/cast-button-core.ts @@ -0,0 +1,94 @@ +import { createState } from '@videojs/store'; +import { defaults } from '@videojs/utils/object'; +import { isFunction } from '@videojs/utils/predicate'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { CastState, MediaCastState } from '../../media/state'; +import type { MediaFeatureAvailability } from '../../media/types'; +import type { ButtonState } from '../types'; + +export interface CastButtonProps { + /** Custom label for the button. */ + label?: string | ((state: CastButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export interface CastButtonState extends ButtonState { + castState: CastState; + availability: MediaFeatureAvailability; +} + +export class CastButtonCore { + static readonly defaultProps: NonNullableObject = { + label: '', + disabled: false, + }; + + readonly state = createState({ + castState: 'disconnected', + availability: 'unavailable', + label: '', + }); + + #props = { ...CastButtonCore.defaultProps }; + #media: MediaCastState | null = null; + + constructor(props?: CastButtonProps) { + if (props) this.setProps(props); + } + + setProps(props: CastButtonProps): void { + this.#props = defaults(props, CastButtonCore.defaultProps); + } + + getLabel(state: CastButtonState): string { + const { label } = this.#props; + + if (isFunction(label)) { + const customLabel = label(state); + if (customLabel) return customLabel; + } else if (label) { + return label; + } + + if (state.castState === 'connected') return 'Stop casting'; + if (state.castState === 'connecting') return 'Connecting'; + return 'Start casting'; + } + + getAttrs(state: CastButtonState) { + return { + 'aria-label': this.getLabel(state), + 'aria-disabled': this.#props.disabled ? 'true' : undefined, + }; + } + + setMedia(media: MediaCastState): void { + this.#media = media; + } + + getState(): CastButtonState { + const media = this.#media!; + this.state.patch({ castState: media.castState, availability: media.castAvailability }); + this.state.patch({ label: this.getLabel(this.state.current) }); + + return this.state.current; + } + + async toggle(media: MediaCastState): Promise { + if (this.#props.disabled) return; + if (media.castAvailability !== 'available') return; + + try { + await media.toggleCast(); + } catch { + // Cast requests can fail (user cancelled, permissions, etc.) + } + } +} + +export namespace CastButtonCore { + export type Props = CastButtonProps; + export type State = CastButtonState; +} diff --git a/packages/core/src/core/ui/cast-button/cast-button-data-attrs.ts b/packages/core/src/core/ui/cast-button/cast-button-data-attrs.ts new file mode 100644 index 00000000..039ca5eb --- /dev/null +++ b/packages/core/src/core/ui/cast-button/cast-button-data-attrs.ts @@ -0,0 +1,7 @@ +import type { StateAttrMap } from '../types'; +import type { CastButtonState } from './cast-button-core'; + +export const CastButtonDataAttrs = { + castState: 'data-cast-state', + availability: 'data-availability', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts b/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts new file mode 100644 index 00000000..b6e24b21 --- /dev/null +++ b/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MediaCastState } from '../../../media/state'; +import type { CastButtonState } from '../cast-button-core'; +import { CastButtonCore } from '../cast-button-core'; + +function createMediaState(overrides: Partial = {}): MediaCastState { + return { + castState: 'disconnected', + castAvailability: 'available', + toggleCast: vi.fn(async () => {}), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): CastButtonState { + return { + castState: 'disconnected', + availability: 'available', + label: '', + ...overrides, + }; +} + +describe('CastButtonCore', () => { + describe('getState', () => { + it('projects castState and availability', () => { + const core = new CastButtonCore(); + const media = createMediaState({ castState: 'connected' }); + core.setMedia(media); + const state = core.getState(); + + expect(state.castState).toBe('connected'); + expect(state.availability).toBe('available'); + }); + + it('reflects unsupported availability', () => { + const core = new CastButtonCore(); + core.setMedia(createMediaState({ castAvailability: 'unsupported' })); + const state = core.getState(); + + expect(state.availability).toBe('unsupported'); + }); + + it('reflects connecting state', () => { + const core = new CastButtonCore(); + core.setMedia(createMediaState({ castState: 'connecting' })); + const state = core.getState(); + + expect(state.castState).toBe('connecting'); + }); + }); + + describe('getLabel', () => { + it('returns Start casting when disconnected', () => { + const core = new CastButtonCore(); + expect(core.getLabel(createState({ castState: 'disconnected' }))).toBe('Start casting'); + }); + + it('returns Stop casting when connected', () => { + const core = new CastButtonCore(); + expect(core.getLabel(createState({ castState: 'connected' }))).toBe('Stop casting'); + }); + + it('returns Connecting when connecting', () => { + const core = new CastButtonCore(); + expect(core.getLabel(createState({ castState: 'connecting' }))).toBe('Connecting'); + }); + + it('returns custom string label', () => { + const core = new CastButtonCore({ label: 'Cast' }); + expect(core.getLabel(createState())).toBe('Cast'); + }); + + it('returns custom function label', () => { + const core = new CastButtonCore({ + label: (state) => (state.castState === 'connected' ? 'Disconnect' : 'Connect'), + }); + expect(core.getLabel(createState({ castState: 'connected' }))).toBe('Disconnect'); + }); + }); + + describe('getAttrs', () => { + it('returns aria-label', () => { + const core = new CastButtonCore(); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-label']).toBe('Start casting'); + }); + + it('sets aria-disabled when disabled', () => { + const core = new CastButtonCore({ disabled: true }); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBe('true'); + }); + }); + + describe('toggle', () => { + it('calls toggleCast when disconnected', async () => { + const core = new CastButtonCore(); + const media = createMediaState({ castState: 'disconnected' }); + await core.toggle(media); + expect(media.toggleCast).toHaveBeenCalled(); + }); + + it('calls toggleCast when connected', async () => { + const core = new CastButtonCore(); + const media = createMediaState({ castState: 'connected' }); + await core.toggle(media); + expect(media.toggleCast).toHaveBeenCalled(); + }); + + it('does nothing when disabled', async () => { + const core = new CastButtonCore({ disabled: true }); + const media = createMediaState(); + await core.toggle(media); + expect(media.toggleCast).not.toHaveBeenCalled(); + }); + + it('does nothing when unsupported', async () => { + const core = new CastButtonCore(); + const media = createMediaState({ castAvailability: 'unsupported' }); + await core.toggle(media); + expect(media.toggleCast).not.toHaveBeenCalled(); + }); + + it('catches cast errors silently', async () => { + const core = new CastButtonCore(); + const media = createMediaState({ + toggleCast: vi.fn(async () => { + throw new Error('user cancelled'); + }), + }); + await expect(core.toggle(media)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/dom/media/castable/google-cast-augments.d.ts b/packages/core/src/dom/media/castable/google-cast-augments.d.ts new file mode 100644 index 00000000..c99914a6 --- /dev/null +++ b/packages/core/src/dom/media/castable/google-cast-augments.d.ts @@ -0,0 +1,26 @@ +/** + * Augments `@types/chromecast-caf-sender` with HLS segment format + * properties added after the community types were last updated. + */ +declare namespace chrome.cast.media { + enum HlsSegmentFormat { + AAC = 'aac', + AC3 = 'ac3', + E_AC3 = 'e_ac3', + FMP4 = 'fmp4', + MP3 = 'mp3', + TS = 'ts', + TS_AAC = 'ts_aac', + } + + enum HlsVideoSegmentFormat { + FMP4 = 'fmp4', + MPEG2_TS = 'mpeg2_ts', + TS = 'ts', + } + + interface MediaInfo { + hlsSegmentFormat?: chrome.cast.media.HlsSegmentFormat; + hlsVideoSegmentFormat?: chrome.cast.media.HlsVideoSegmentFormat; + } +} diff --git a/packages/core/src/dom/media/castable/google-cast-provider.ts b/packages/core/src/dom/media/castable/google-cast-provider.ts new file mode 100644 index 00000000..9790764c --- /dev/null +++ b/packages/core/src/dom/media/castable/google-cast-provider.ts @@ -0,0 +1,498 @@ +import type { RemotePlaybackState } from './remote-playback'; +import type { CastableMediaElement } from './types'; +import { + castContext, + currentMedia, + currentSession, + editTracksInfo, + getMediaStatus, + getPlaylistSegmentFormat, + IterableWeakSet, + isHls, + NotSupportedError, + onCastApiAvailable, + setCastOptions, + setPlaybackRate, +} from './utils'; + +type RemotePlayerListener = (event?: cast.framework.RemotePlayerChangedEvent) => void; + +export type GoogleCastProviderHooks = { + setState: (next: RemotePlaybackState) => void; + setAvailable: (available: boolean) => void; +}; + +export type LocalPlayer = { + duration: () => number; + currentTime: () => number; + paused: () => boolean; + muted: () => boolean; + pause: () => void; +}; + +const providerInstances = new IterableWeakSet(); +const castElementRef = new WeakSet(); + +let cf: typeof cast.framework | undefined; + +onCastApiAvailable(() => { + if (!globalThis.chrome?.cast?.isAvailable) { + console.debug('chrome.cast.isAvailable', globalThis.chrome?.cast?.isAvailable); + return; + } + + if (!cf) { + cf = cast.framework; + + castContext()!.addEventListener(cf.CastContextEventType.CAST_STATE_CHANGED, () => { + providerInstances.forEach((p) => p.onCastStateChanged()); + }); + + castContext()!.addEventListener(cf.CastContextEventType.SESSION_STATE_CHANGED, () => { + providerInstances.forEach((p) => p.onSessionStateChanged()); + }); + + providerInstances.forEach((p) => p.init()); + } +}); + +export class GoogleCastProvider { + readonly media: CastableMediaElement; + seeking = false; + + #hooks: Partial = {}; + #isInit = false; + #local: LocalPlayer; + #remote!: cast.framework.RemotePlayer; + #remoteListeners!: Record; + #listenersAttached = false; + #playbackRate = 1; + #localPaused = false; + #onTextTrackChange = () => this.#updateRemoteTextTrack(); + #onMediaUpdate = () => this.#checkPlaybackRate(); + + constructor(media: CastableMediaElement, local: LocalPlayer) { + this.media = media; + this.#local = local; + providerInstances.add(this); + this.init(); + } + + bindHooks(hooks: Partial) { + Object.assign(this.#hooks, hooks); + } + + get isCasting() { + return castElementRef.has(this.media); + } + + set isCasting(value: boolean) { + if (value) castElementRef.add(this.media); + else castElementRef.delete(this.media); + } + + hasDevicesAvailable() { + const state = castContext()?.getCastState(); + return !!state && state !== cast.framework.CastState.NO_DEVICES_AVAILABLE; + } + + async requestCastSession() { + if (!this.#isCastApiAvailable()) { + throw new NotSupportedError('The RemotePlayback API is disabled on this platform.'); + } + + const willDisconnect = this.isCasting; + this.isCasting = true; + + this.#applyCastOptions(); + this.#attachRemoteListeners(); + + try { + await castContext()!.requestSession(); + } catch (err) { + if (!willDisconnect) { + this.isCasting = false; + } + + if (err === 'cancel') { + return; + } + + throw new Error(err as string); + } + + this.#localPaused = this.#local.paused(); + this.#local.pause(); + this.muted = this.#local.muted(); + + try { + await this.load(); + } catch (err) { + console.error(err); + } + } + + async load() { + const mediaInfo = new chrome.cast.media.MediaInfo(this.media.castSrc, this.media.castContentType ?? ''); + mediaInfo.customData = (this.media.castCustomData as object) ?? null; + + const subtitles = [...this.media.querySelectorAll('track')].filter( + (el) => el.src && (el.kind === 'subtitles' || el.kind === 'captions') + ); + + const { Track, TrackType, TextTrackType } = chrome.cast.media; + const activeTrackIds: number[] = []; + + if (subtitles.length) { + mediaInfo.tracks = subtitles.map((el, i) => { + const trackId = i + 1; + if (!activeTrackIds.length && el.track.mode === 'showing') activeTrackIds.push(trackId); + + const track = new Track(trackId, TrackType.TEXT); + track.trackContentId = el.src; + track.trackContentType = 'text/vtt'; + track.subtype = el.kind === 'captions' ? TextTrackType.CAPTIONS : TextTrackType.SUBTITLES; + track.name = el.label; + track.language = el.srclang; + return track; + }); + } + + mediaInfo.streamType = + this.media.castStreamType === 'live' ? chrome.cast.media.StreamType.LIVE : chrome.cast.media.StreamType.BUFFERED; + + mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); + mediaInfo.metadata.title = this.media.title; + mediaInfo.metadata.images = [new chrome.cast.Image(this.media.poster)]; + + if (await isHls(this.media.castSrc)) { + mediaInfo.contentType ||= 'application/x-mpegURL'; + + const fmt = (await getPlaylistSegmentFormat(this.media.castSrc)) ?? ''; + const { HlsSegmentFormat: HS, HlsVideoSegmentFormat: HVS } = chrome.cast.media; + + if (fmt.includes('m4s') || fmt.includes('mp4')) { + mediaInfo.hlsSegmentFormat = HS.FMP4; + mediaInfo.hlsVideoSegmentFormat = HVS.FMP4; + } else if (fmt.includes('ts')) { + mediaInfo.hlsSegmentFormat = HS.TS; + mediaInfo.hlsVideoSegmentFormat = HVS.TS; + } + } + + const request = new chrome.cast.media.LoadRequest(mediaInfo); + request.currentTime = this.#local.currentTime(); + request.autoplay = !this.#localPaused; + request.activeTrackIds = activeTrackIds; + + await currentSession()?.loadMedia(request); + + this.media.dispatchEvent(new Event('volumechange')); + } + + // -- Native media surface (called by the mixin while isCasting is true) -- + + async play() { + // When a casted media ends, the media is unloaded and the player state is IDLE. + if (!this.#remote.isMediaLoaded) { + this.#localPaused = false; + await this.load(); + return; + } + if (this.paused) { + this.#remote.controller?.playOrPause(); + return new Promise((resolve) => { + this.media.addEventListener('play', () => resolve(), { once: true }); + }); + } + } + + pause() { + if (!this.paused) { + this.#remote.controller?.playOrPause(); + } + } + + // isPaused is not true when the media has ended so add the ended check. + get paused() { + return this.#remote.isPaused || this.ended; + } + + get ended() { + return ( + this.#remote.playerState === chrome.cast.media.PlayerState.IDLE && + currentMedia()?.idleReason === chrome.cast.media.IdleReason.FINISHED + ); + } + + get muted() { + return this.#remote.isMuted; + } + + set muted(value: boolean) { + if (value !== this.#remote.isMuted) { + this.#remote.controller?.muteOrUnmute(); + } + } + + get volume() { + return this.#remote.volumeLevel ?? 1; + } + + set volume(value: number) { + this.#remote.volumeLevel = +value; + this.#remote.controller?.setVolumeLevel(); + } + + get playbackRate() { + return currentMedia()?.playbackRate ?? 1; + } + + set playbackRate(value: number) { + setPlaybackRate(value); + } + + get duration() { + if (this.#remote?.isMediaLoaded) return this.#remote.duration ?? NaN; + return this.#local.duration(); + } + + get currentTime() { + if (this.#remote?.isMediaLoaded) return this.#remote.currentTime ?? 0; + return this.#local.currentTime(); + } + + set currentTime(value: number) { + this.#remote.currentTime = value; + this.#notifySeeking(); + this.#remote.controller?.seek(); + } + + get readyState() { + switch (this.#remote.playerState) { + case chrome.cast.media.PlayerState.IDLE: + return 0; + case chrome.cast.media.PlayerState.BUFFERING: + return 2; + default: + return 3; + } + } + + init() { + if (!cf || this.#isInit) return; + this.#isInit = true; + + this.#applyCastOptions(); + + this.media.textTracks.addEventListener('change', this.#onTextTrackChange); + + this.onCastStateChanged(); + + this.#remote = new cf.RemotePlayer(); + new cf.RemotePlayerController(this.#remote); + + this.#remoteListeners = { + [cf.RemotePlayerEventType.IS_CONNECTED_CHANGED]: (event?: cast.framework.RemotePlayerChangedEvent) => { + const value = event?.value; + if (value === true) { + this.#hooks.setState?.('connected'); + } else { + this.#disconnect(); + this.#hooks.setState?.('disconnected'); + } + }, + [cf.RemotePlayerEventType.DURATION_CHANGED]: () => { + this.media.dispatchEvent(new Event('durationchange')); + }, + [cf.RemotePlayerEventType.VOLUME_LEVEL_CHANGED]: () => { + this.media.dispatchEvent(new Event('volumechange')); + }, + [cf.RemotePlayerEventType.IS_MUTED_CHANGED]: () => { + this.media.dispatchEvent(new Event('volumechange')); + }, + [cf.RemotePlayerEventType.CURRENT_TIME_CHANGED]: () => { + if (!this.isCasting || !this.#remote.isMediaLoaded) return; + this.#notifySeeked(); + this.media.dispatchEvent(new Event('timeupdate')); + }, + [cf.RemotePlayerEventType.VIDEO_INFO_CHANGED]: () => { + this.media.dispatchEvent(new Event('resize')); + }, + [cf.RemotePlayerEventType.IS_PAUSED_CHANGED]: () => { + this.media.dispatchEvent(new Event(this.isCasting && this.#remote.isPaused ? 'pause' : 'play')); + }, + [cf.RemotePlayerEventType.PLAYER_STATE_CHANGED]: () => { + const PS = chrome.cast.media.PlayerState; + const state = this.isCasting ? this.#remote.playerState : undefined; + + if (state !== PS.BUFFERING) this.#notifySeeked(); + if (state === PS.PAUSED) return; + + if (state === PS.IDLE) { + const finished = currentMedia()?.idleReason === chrome.cast.media.IdleReason.FINISHED; + this.media.dispatchEvent(new Event(finished ? 'ended' : 'emptied')); + return; + } + + if (state === PS.PLAYING) this.media.dispatchEvent(new Event('playing')); + else if (state === PS.BUFFERING) this.media.dispatchEvent(new Event('waiting')); + }, + [cf.RemotePlayerEventType.IS_MEDIA_LOADED_CHANGED]: async () => { + if (!this.isCasting || !this.#remote.isMediaLoaded) return; + + await Promise.resolve(); + this.#onRemoteMediaLoaded(); + }, + }; + } + + onCastStateChanged() { + if (!this.#isInit) return; + const CS = cast.framework.CastState; + const state = castContext()!.getCastState(); + + if (this.isCasting && state === CS.CONNECTING) { + this.#hooks.setState?.('connecting'); + } + + this.#hooks.setAvailable?.(!!state && state !== CS.NO_DEVICES_AVAILABLE); + } + + async onSessionStateChanged() { + if (!this.#isInit) return; + const { SESSION_RESUMED } = cf!.SessionState; + if (castContext()!.getSessionState() === SESSION_RESUMED) { + if (this.media.castSrc === currentMedia()?.media?.contentId) { + castElementRef.add(this.media); + + this.#attachRemoteListeners(); + + try { + await getMediaStatus(new chrome.cast.media.GetStatusRequest()); + } catch (error) { + console.error(error); + } + + this.#remoteListeners[cf!.RemotePlayerEventType.IS_PAUSED_CHANGED]!(); + this.#remoteListeners[cf!.RemotePlayerEventType.PLAYER_STATE_CHANGED]!(); + this.media.dispatchEvent(new Event('ratechange')); + + // TODO: sync remote enabled text track state to local text tracks + } + } + } + + destroy() { + providerInstances.delete(this); + currentMedia()?.removeUpdateListener(this.#onMediaUpdate); + this.media.textTracks?.removeEventListener('change', this.#onTextTrackChange); + this.#detachRemoteListeners(); + this.isCasting = false; + this.#isInit = false; + } + + #isCastApiAvailable() { + return Boolean(globalThis.chrome?.cast?.isAvailable); + } + + #applyCastOptions() { + setCastOptions(this.media.castOptions); + } + + // CAF's RemotePlayerController does not deduplicate handlers, so we guard + // attach/detach with a flag to prevent duplicate event dispatches across + // cancel/retry and stop-casting flows. + #attachRemoteListeners() { + if (this.#listenersAttached) return; + const controller = this.#remote?.controller; + if (!controller) return; + + for (const [type, handler] of Object.entries(this.#remoteListeners)) { + controller.addEventListener(type as cast.framework.RemotePlayerEventType, handler); + } + this.#listenersAttached = true; + } + + #detachRemoteListeners() { + if (!this.#listenersAttached) return; + const controller = this.#remote?.controller; + + if (controller) { + for (const [type, handler] of Object.entries(this.#remoteListeners)) { + controller.removeEventListener(type as cast.framework.RemotePlayerEventType, handler); + } + } + this.#listenersAttached = false; + } + + #disconnect() { + if (!this.isCasting) return; + + currentMedia()?.removeUpdateListener(this.#onMediaUpdate); + this.#detachRemoteListeners(); + this.seeking = false; + this.#playbackRate = 1; + this.isCasting = false; + + this.media.muted = this.#remote.isMuted; + const saved = this.#remote.savedPlayerState; + if (saved) { + this.media.currentTime = saved.currentTime; + if (saved.isPaused === false) this.media.play(); + } + } + + #notifySeeking() { + this.seeking = true; + this.media.dispatchEvent(new Event('seeking')); + } + + #notifySeeked() { + if (!this.seeking) return; + this.seeking = false; + this.media.dispatchEvent(new Event('seeked')); + } + + #onRemoteMediaLoaded() { + this.#playbackRate = currentMedia()?.playbackRate ?? 1; + currentMedia()?.addUpdateListener(this.#onMediaUpdate); + this.#updateRemoteTextTrack(); + } + + #checkPlaybackRate() { + const rate = currentMedia()?.playbackRate ?? 1; + if (rate !== this.#playbackRate) { + this.#playbackRate = rate; + this.media.dispatchEvent(new Event('ratechange')); + } + } + + async #updateRemoteTextTrack() { + if (!this.isCasting) return; + + const localSubs = [...this.media.textTracks].filter(({ kind }) => kind === 'subtitles' || kind === 'captions'); + + const matched = (this.#remote.mediaInfo?.tracks ?? []) + .filter(({ type }) => type === chrome.cast.media.TrackType.TEXT) + .flatMap(({ language, name, trackId }) => { + const local = localSubs.find((l) => l.language === language && l.label === name); + return local?.mode ? [{ mode: local.mode, trackId }] : []; + }); + + const hidden = new Set(matched.filter((m) => m.mode !== 'showing').map((m) => m.trackId)); + const showing = matched.find((m) => m.mode === 'showing')?.trackId; + + const active = currentSession()?.getSessionObj().media[0]?.activeTrackIds ?? []; + const next = new Set(active.filter((id) => !hidden.has(id))); + if (showing) next.add(showing); + + if (next.size === active.length && active.every((id) => next.has(id))) return; + + try { + await editTracksInfo(new chrome.cast.media.EditTracksInfoRequest([...next])); + } catch (error) { + console.error(error); + } + } +} diff --git a/packages/core/src/dom/media/castable/index.ts b/packages/core/src/dom/media/castable/index.ts new file mode 100644 index 00000000..e3ccf130 --- /dev/null +++ b/packages/core/src/dom/media/castable/index.ts @@ -0,0 +1,226 @@ +import type { MixinReturn } from '@videojs/utils/types'; +import type { RemotePlaybackLike } from '../predicate'; +import { GoogleCastProvider } from './google-cast-provider'; +import { RemotePlayback } from './remote-playback'; +import type { CastableMediaProps, CastableMediaSuperclass } from './types'; +import { getDefaultCastOptions, loadCastFramework, requiresCastFramework } from './utils'; + +export type { CastableMediaElement } from './types'; + +export const CastableMediaMixin = ( + superclass: Base +): MixinReturn => { + class CastableMedia extends superclass { + #castOptions = getDefaultCastOptions(); + #castCustomData: Record | null | undefined; + #castSrc: string | undefined; + #castContentType: string | undefined; + #castStreamType: string | undefined; + #castReceiver: string | undefined; + #remote: RemotePlayback | null | undefined; + #provider: GoogleCastProvider | null | undefined; + #destroyed = false; + + get remote(): RemotePlayback | RemotePlaybackLike | undefined { + if (this.#remote) return this.#remote; + if (this.#destroyed) return super.remote; + + if (requiresCastFramework()) { + if (!this.disableRemotePlayback) { + loadCastFramework(); + } + + this.#provider = new GoogleCastProvider(this, { + duration: () => super.duration, + currentTime: () => super.currentTime, + paused: () => super.paused, + muted: () => super.muted, + pause: () => super.pause(), + }); + return (this.#remote = new RemotePlayback(this.#provider)); + } + + return super.remote; + } + + attach(target: HTMLMediaElement) { + super.attach(target); + + if (requiresCastFramework() && !this.disableRemotePlayback) { + loadCastFramework(); + } + } + + detach() { + super.detach(); + } + + destroy() { + this.#provider?.destroy(); + this.#provider = null; + this.#remote = null; + this.#destroyed = true; + super.destroy(); + } + + async load() { + if (!this.#provider?.isCasting) return super.load(); + return this.#provider.load(); + } + + play() { + if (this.#provider?.isCasting) { + return this.#provider.play(); + } + return super.play(); + } + + pause() { + if (this.#provider?.isCasting) { + this.#provider.pause(); + return; + } + super.pause(); + } + + get castOptions() { + return this.#castOptions; + } + + get castReceiver() { + return this.#castReceiver; + } + + set castReceiver(val: string | undefined) { + if (this.#castReceiver === val) return; + this.#castReceiver = val; + + if (val) { + this.#castOptions.receiverApplicationId = val; + } + } + + get castSrc() { + return this.#castSrc ?? this.querySelector('source')?.src ?? this.src ?? this.currentSrc; + } + + set castSrc(val: string) { + if (this.#castSrc === val) return; + this.#castSrc = val; + + if (this.#provider?.isCasting) this.load(); + } + + get castContentType() { + return this.#castContentType; + } + + set castContentType(val: string | undefined) { + this.#castContentType = val; + } + + get castStreamType() { + return this.#castStreamType ?? this.streamType; + } + + set castStreamType(val: string | undefined) { + if (this.#castStreamType === val) return; + this.#castStreamType = val; + + if (this.#provider?.isCasting) this.load(); + } + + get castCustomData() { + return this.#castCustomData; + } + + set castCustomData(val: Record | null | undefined) { + const valType = typeof val; + if (!['object', 'undefined'].includes(valType)) { + console.error(`castCustomData must be nullish or an object but value was of type ${valType}`); + return; + } + + this.#castCustomData = val; + } + + get seeking() { + if (this.#provider?.isCasting) return this.#provider.seeking; + return super.seeking; + } + + get readyState() { + if (this.#provider?.isCasting) return this.#provider.readyState; + return super.readyState; + } + + get paused() { + if (this.#provider?.isCasting) return this.#provider.paused; + return super.paused; + } + + get ended() { + if (this.#provider?.isCasting) return this.#provider.ended; + return super.ended; + } + + get muted() { + if (this.#provider?.isCasting) return this.#provider.muted; + return super.muted; + } + + set muted(val: boolean) { + if (this.#provider?.isCasting) { + this.#provider.muted = val; + return; + } + super.muted = val; + } + + get volume() { + if (this.#provider?.isCasting) return this.#provider.volume; + return super.volume; + } + + set volume(val: number) { + if (this.#provider?.isCasting) { + this.#provider.volume = val; + return; + } + super.volume = val; + } + + get playbackRate() { + if (this.#provider?.isCasting) return this.#provider.playbackRate; + return super.playbackRate; + } + + set playbackRate(val: number) { + if (this.#provider?.isCasting) { + this.#provider.playbackRate = val; + return; + } + super.playbackRate = val; + } + + get duration() { + if (this.#provider?.isCasting) return this.#provider.duration; + return super.duration; + } + + get currentTime() { + if (this.#provider?.isCasting) return this.#provider.currentTime; + return super.currentTime; + } + + set currentTime(val: number) { + if (this.#provider?.isCasting) { + this.#provider.currentTime = val; + return; + } + super.currentTime = val; + } + } + + return CastableMedia as unknown as MixinReturn; +}; diff --git a/packages/core/src/dom/media/castable/remote-playback.ts b/packages/core/src/dom/media/castable/remote-playback.ts new file mode 100644 index 00000000..06cc0ad8 --- /dev/null +++ b/packages/core/src/dom/media/castable/remote-playback.ts @@ -0,0 +1,74 @@ +import type { GoogleCastProvider } from './google-cast-provider'; +import { InvalidStateError, NotFoundError } from './utils'; + +export type RemotePlaybackState = 'disconnected' | 'connecting' | 'connected'; + +type AvailabilityCallback = (available: boolean) => void; + +let callbackIdCount = 0; + +export class RemotePlayback extends EventTarget { + #provider: GoogleCastProvider; + #state: RemotePlaybackState = 'disconnected'; + #available = false; + #callbacks = new Map(); + + constructor(provider: GoogleCastProvider) { + super(); + this.#provider = provider; + provider.bindHooks({ + setState: (next) => this.#setState(next), + setAvailable: (available) => this.#setAvailable(available), + }); + } + + get state() { + return this.#state; + } + + async watchAvailability(callback: AvailabilityCallback) { + this.#assertEnabled(); + const id = ++callbackIdCount; + this.#callbacks.set(id, callback); + queueMicrotask(() => callback(this.#provider.hasDevicesAvailable())); + return id; + } + + async cancelWatchAvailability(callbackId?: number) { + this.#assertEnabled(); + + if (callbackId === undefined) { + this.#callbacks.clear(); + return; + } + + if (!this.#callbacks.delete(callbackId)) { + throw new NotFoundError(`Callback not found for id ${callbackId}.`); + } + } + + async prompt() { + this.#assertEnabled(); + await this.#provider.requestCastSession(); + } + + #assertEnabled() { + if (this.#provider.media.disableRemotePlayback) { + throw new InvalidStateError('disableRemotePlayback attribute is present.'); + } + } + + #setState(next: RemotePlaybackState) { + if (this.#state === next) return; + this.#state = next; + if (next === 'connecting') this.dispatchEvent(new Event('connecting')); + else if (next === 'connected') this.dispatchEvent(new Event('connect')); + else this.dispatchEvent(new Event('disconnect')); + } + + #setAvailable(available: boolean) { + if (this.#available === available) return; + this.#available = available; + for (const callback of this.#callbacks.values()) callback(available); + } +} diff --git a/packages/core/src/dom/media/castable/types.ts b/packages/core/src/dom/media/castable/types.ts new file mode 100644 index 00000000..9de8c744 --- /dev/null +++ b/packages/core/src/dom/media/castable/types.ts @@ -0,0 +1,51 @@ +import type { RemotePlaybackLike } from '../predicate'; +import type { RemotePlayback } from './remote-playback'; +import type { CastOptions } from './utils'; + +export interface CastableMediaBase extends EventTarget { + readonly target: HTMLMediaElement | null; + readonly remote: RemotePlaybackLike | undefined; + title: string; + poster: string; + src: string; + currentSrc: string; + currentTime: number; + duration: number; + muted: boolean; + paused: boolean; + ended: boolean; + loop: boolean; + seeking: boolean; + readyState: number; + volume: number; + playbackRate: number; + streamType?: string; + textTracks: TextTrackList; + disableRemotePlayback: boolean; + load(): void | Promise; + play(): void | Promise; + pause(): void; + attach(target: HTMLMediaElement): void; + detach(): void; + destroy(): void; + querySelectorAll(selectors: K): Iterable; + querySelector(selectors: K): HTMLElementTagNameMap[K] | null; +} + +export interface CastableMediaProps { + readonly remote: RemotePlayback | RemotePlaybackLike | undefined; + readonly castOptions: CastOptions; + castReceiver: string | undefined; + castSrc: string; + castContentType: string | undefined; + castStreamType: string | undefined; + castCustomData: Record | null | undefined; + poster: string; + title: string; +} + +export type CastableMediaElement = CastableMediaBase & CastableMediaProps; + +export interface CastableMediaSuperclass { + new (...args: any[]): CastableMediaBase; +} diff --git a/packages/core/src/dom/media/castable/utils.ts b/packages/core/src/dom/media/castable/utils.ts new file mode 100644 index 00000000..44ac5e6e --- /dev/null +++ b/packages/core/src/dom/media/castable/utils.ts @@ -0,0 +1,184 @@ +export class InvalidStateError extends Error {} +export class NotSupportedError extends Error {} +export class NotFoundError extends Error {} + +const HLS_RESPONSE_HEADERS = ['application/x-mpegURL', 'application/vnd.apple.mpegurl', 'audio/mpegurl']; + +export class IterableWeakSet { + readonly #refs = new Set>(); + readonly #seen = new WeakMap>(); + + add(value: T): this { + if (this.#seen.has(value)) return this; + const ref = new WeakRef(value); + this.#seen.set(value, ref); + this.#refs.add(ref); + return this; + } + + delete(value: T): boolean { + const ref = this.#seen.get(value); + if (!ref) return false; + this.#seen.delete(value); + return this.#refs.delete(ref); + } + + forEach(fn: (value: T) => void): void { + for (const ref of this.#refs) { + const value = ref.deref(); + if (value) fn(value); + else this.#refs.delete(ref); + } + } +} + +export function onCastApiAvailable(callback: () => void) { + const whenDefined = () => customElements.whenDefined('google-cast-button').then(callback); + + if (!globalThis.chrome?.cast?.isAvailable) { + (globalThis as { __onGCastApiAvailable?: () => void }).__onGCastApiAvailable = whenDefined; + } else if (typeof cast === 'undefined' || !cast.framework) { + whenDefined(); + } else { + callback(); + } +} + +export function requiresCastFramework() { + // todo: exclude for Android>=56 which supports the Remote Playback API natively. + return Boolean(globalThis.chrome); +} + +export function loadCastFramework() { + const sdkUrl = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'; + if (globalThis.chrome?.cast || document.querySelector(`script[src="${sdkUrl}"]`)) return; + + const script = document.createElement('script'); + script.src = sdkUrl; + document.head.append(script); +} + +export function castContext() { + return typeof cast === 'undefined' ? undefined : cast.framework?.CastContext.getInstance(); +} + +export function currentSession() { + return castContext()?.getCurrentSession(); +} + +export function currentMedia() { + return currentSession()?.getSessionObj().media[0] ?? undefined; +} + +export function editTracksInfo(request: chrome.cast.media.EditTracksInfoRequest) { + return new Promise((resolve, reject) => { + currentMedia()!.editTracksInfo(request, resolve, reject); + }); +} + +export function getMediaStatus(request: chrome.cast.media.GetStatusRequest) { + return new Promise((resolve, reject) => { + currentMedia()!.getStatus(request, resolve, reject); + }); +} + +const MEDIA_NAMESPACE = 'urn:x-cast:com.google.cast.media'; +let requestId = 0; + +export function setPlaybackRate(rate: number) { + const media = currentMedia(); + return currentSession()!.sendMessage(MEDIA_NAMESPACE, { + type: 'SET_PLAYBACK_RATE', + playbackRate: rate, + mediaSessionId: media?.mediaSessionId, + requestId: ++requestId, + }); +} + +export type CastOptions = cast.framework.CastOptions; + +export function setCastOptions(options: Partial) { + castContext()!.setOptions({ + ...getDefaultCastOptions(), + ...options, + }); +} + +export function getDefaultCastOptions(): CastOptions { + return { + receiverApplicationId: 'CC1AD845', + autoJoinPolicy: + globalThis.chrome?.cast?.AutoJoinPolicy?.ORIGIN_SCOPED ?? ('origin_scoped' as chrome.cast.AutoJoinPolicy), + androidReceiverCompatible: false, + language: 'en-US', + resumeSavedSession: true, + }; +} + +function getFormat(segment: string | undefined) { + if (!segment) return undefined; + + const regex = /\.([a-zA-Z0-9]+)(?:\?.*)?$/; + const match = segment.match(regex); + return match ? match[1] : null; +} + +function parsePlaylistUrls(playlistContent: string) { + const lines = playlistContent.split('\n'); + const urls: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); + + if (line.startsWith('#EXT-X-STREAM-INF')) { + const nextLine = lines[i + 1] ? lines[i + 1]!.trim() : ''; + if (nextLine && !nextLine.startsWith('#')) { + urls.push(nextLine); + } + } + } + + return urls; +} + +function parseSegment(playlistContent: string) { + const lines = playlistContent.split('\n'); + return lines.find((line) => !line.trim().startsWith('#') && line.trim() !== ''); +} + +export async function isHls(url: string) { + if (!url) return false; + if (/\.m3u8?(\?.*)?$/i.test(url)) return true; + if (url.startsWith('blob:')) return false; + + try { + const response = await fetch(url, { method: 'HEAD' }); + const contentType = response.headers.get('Content-Type'); + 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; + } +} + +export async function getPlaylistSegmentFormat(url: string) { + try { + const mainManifestContent = await (await fetch(url)).text(); + let availableChunksContent = mainManifestContent; + + const playlists = parsePlaylistUrls(mainManifestContent); + if (playlists.length > 0) { + const chosenPlaylistUrl = new URL(playlists[0]!, url).toString(); + availableChunksContent = await (await fetch(chosenPlaylistUrl)).text(); + } + + const segment = parseSegment(availableChunksContent); + const format = getFormat(segment); + return format; + } catch (err) { + console.error('Error while trying to parse the manifest playlist', err); + return undefined; + } +} diff --git a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts index 791b12bb..2b148c57 100644 --- a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts +++ b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts @@ -393,11 +393,11 @@ describe('CustomMediaElement', () => { const el = create(defineVideoElement()); const target = el.target!; - el.setAttribute('poster', 'https://example.com/poster.jpg'); - expect(target.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + el.setAttribute('preload', 'metadata'); + expect(target.getAttribute('preload')).toBe('metadata'); - el.removeAttribute('poster'); - expect(target.hasAttribute('poster')).toBe(false); + el.removeAttribute('preload'); + expect(target.hasAttribute('preload')).toBe(false); }); it('updates forwarded attribute value when changed', () => { @@ -454,22 +454,22 @@ describe('CustomMediaElement', () => { it('string property getter returns null when attribute is absent', () => { const el = create(defineVideoElement()); - expect(el.poster).toBeNull(); expect(el.preload).toBeNull(); + expect(el.controlsList).toBeNull(); }); it('string property setter sets the attribute and forwards to target', () => { const el = create(defineVideoElement()); - el.poster = 'https://example.com/poster.jpg'; - expect(el.getAttribute('poster')).toBe('https://example.com/poster.jpg'); - expect(el.target!.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + el.preload = 'metadata'; + expect(el.getAttribute('preload')).toBe('metadata'); + expect(el.target!.getAttribute('preload')).toBe('metadata'); }); it('removing attribute resets string property getter to null', () => { const el = create(defineVideoElement()); - el.poster = 'https://example.com/poster.jpg'; - el.removeAttribute('poster'); - expect(el.poster).toBeNull(); + el.preload = 'metadata'; + el.removeAttribute('preload'); + expect(el.preload).toBeNull(); }); it('property accessors work for all non-MediaHost video attributes', () => { @@ -487,10 +487,6 @@ describe('CustomMediaElement', () => { expect(el.playsInline).toBe(true); expect(el.hasAttribute('playsinline')).toBe(true); - el.poster = 'poster.jpg'; - expect(el.poster).toBe('poster.jpg'); - expect(el.getAttribute('poster')).toBe('poster.jpg'); - el.preload = 'metadata'; expect(el.preload).toBe('metadata'); expect(el.getAttribute('preload')).toBe('metadata'); @@ -519,6 +515,24 @@ describe('CustomMediaElement', () => { }); }); + describe('MediaHost-backed string properties', () => { + it('poster property reads through to the target', () => { + const el = create(defineVideoElement()); + expect(el.poster).toBe(''); + + el.setAttribute('poster', 'https://example.com/poster.jpg'); + expect(el.poster).toBe('https://example.com/poster.jpg'); + expect(el.target!.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + }); + + it('poster setter forwards through MediaHost to the target', () => { + const el = create(defineVideoElement()); + el.poster = 'https://example.com/poster.jpg'; + expect(el.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + expect(el.target!.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + }); + }); + describe('setter attributes route through MediaHost property', () => { it('sets muted property via attribute', () => { const el = create(defineVideoElement()); diff --git a/packages/core/src/dom/media/hls/index.ts b/packages/core/src/dom/media/hls/index.ts index 67a28b9e..dfea2438 100644 --- a/packages/core/src/dom/media/hls/index.ts +++ b/packages/core/src/dom/media/hls/index.ts @@ -100,13 +100,13 @@ export class HlsMedia extends HTMLVideoElementHost { } attach(target: HTMLVideoElement) { - super.attach?.(target); + super.attach(target); this.#delegate?.attach(target); } detach() { this.#delegate?.detach(); - super.detach?.(); + super.detach(); } destroy() { diff --git a/packages/core/src/dom/media/media-host.ts b/packages/core/src/dom/media/media-host.ts index 6d4d7115..06dbc0d7 100644 --- a/packages/core/src/dom/media/media-host.ts +++ b/packages/core/src/dom/media/media-host.ts @@ -21,6 +21,88 @@ export class HTMLMediaElementHost(selectors: K): NodeListOf | never[]; + querySelectorAll(selectors: string): NodeListOf | never[]; + querySelectorAll(selectors: string): NodeListOf | never[] { + return this.target?.querySelectorAll(selectors) ?? []; + } + + querySelector(selectors: K): HTMLElementTagNameMap[K] | null; + querySelector(selectors: string): E | null; + querySelector(selectors: string): Element | null { + return this.target?.querySelector(selectors) ?? null; + } + + addEventListener( + type: K, + listener: (event: Events[K]) => void, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | ((event: never) => void) | null, + options?: boolean | AddEventListenerOptions + ): void { + if (!this.#types.has(type)) { + this.#types.add(type); + this.target?.addEventListener(type, this.#forwardEvent); + } + super.addEventListener(type, listener as EventListener, options); + } + + removeEventListener( + type: K, + listener: (event: Events[K]) => void, + options?: boolean | EventListenerOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | ((event: never) => void) | null, + options?: boolean | EventListenerOptions + ): void { + super.removeEventListener(type, listener as EventListener, options); + } + + #forwardEvent = (event: Event) => { + this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event)); + }; + + // -- Metadata -- + + get title() { + return this.target?.title ?? ''; + } + + set title(value: string) { + if (this.target) this.target.title = value; + } + // -- Playback -- get paused() { @@ -31,6 +113,14 @@ export class HTMLMediaElementHost( - type: K, - listener: (event: Events[K]) => void, - options?: boolean | AddEventListenerOptions - ): void; - addEventListener( - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | AddEventListenerOptions - ): void; - addEventListener( - type: string, - listener: EventListenerOrEventListenerObject | ((event: never) => void) | null, - options?: boolean | AddEventListenerOptions - ): void { - if (!this.#types.has(type)) { - this.#types.add(type); - this.target?.addEventListener(type, this.#forwardEvent); - } - super.addEventListener(type, listener as EventListener, options); - } - - removeEventListener( - type: K, - listener: (event: Events[K]) => void, - options?: boolean | EventListenerOptions - ): void; - removeEventListener( - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | EventListenerOptions - ): void; - removeEventListener( - type: string, - listener: EventListenerOrEventListenerObject | ((event: never) => void) | null, - options?: boolean | EventListenerOptions - ): void { - super.removeEventListener(type, listener as EventListener, options); - } - - #forwardEvent = (event: Event) => { - this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event)); - }; } diff --git a/packages/core/src/dom/media/mux/index.ts b/packages/core/src/dom/media/mux/index.ts index e7e424eb..e4243242 100644 --- a/packages/core/src/dom/media/mux/index.ts +++ b/packages/core/src/dom/media/mux/index.ts @@ -1,13 +1,14 @@ +import { CastableMediaMixin } from '../castable'; import { HlsMedia } from '../hls'; import { MuxDataMediaMixin } from './mux-data'; -export class MuxVideoMedia extends MuxDataMediaMixin(HlsMedia) { +export class MuxVideoMedia extends MuxDataMediaMixin(CastableMediaMixin(HlsMedia)) { static PLAYER_SOFTWARE_NAME = 'mux-video'; } // TODO: HlsMedia extends HTMLVideoElementHost, we should extend // HTMLAudioElementHost instead but this would require a HlsMediaMixin, // keep it simple for now. -export class MuxAudioMedia extends MuxDataMediaMixin(HlsMedia) { +export class MuxAudioMedia extends MuxDataMediaMixin(CastableMediaMixin(HlsMedia)) { static PLAYER_SOFTWARE_NAME = 'mux-audio'; } diff --git a/packages/core/src/dom/media/predicate.ts b/packages/core/src/dom/media/predicate.ts index b23bcae9..8f2ea8bc 100644 --- a/packages/core/src/dom/media/predicate.ts +++ b/packages/core/src/dom/media/predicate.ts @@ -55,6 +55,21 @@ export function isMediaTextTrackCapable(value: unknown): value is MediaTextTrack return isObject(value) && 'textTracks' in value; } +export interface RemotePlaybackLike extends EventTarget { + readonly state: string; + prompt(): Promise; + watchAvailability(callback: (available: boolean) => void): Promise; + cancelWatchAvailability(id?: number): Promise; +} + +export interface MediaRemotePlaybackCapability { + readonly remote: RemotePlaybackLike; +} + +export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemotePlaybackCapability { + return isObject(value) && 'remote' in value && isObject((value as Record).remote); +} + export function isQuerySelectorAllCapable( value: unknown ): value is { diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index e884cf5f..1e9f9b91 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -1,6 +1,7 @@ import type { AnySlice, Slice, Store, UnionSliceState } from '@videojs/store'; import type { MediaBufferState, + MediaCastState, MediaControlsState, MediaErrorState, MediaFullscreenState, @@ -46,6 +47,7 @@ export type VideoFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, + PlayerFeature, PlayerFeature, PlayerFeature, PlayerFeature, diff --git a/packages/core/src/dom/media/video-host.ts b/packages/core/src/dom/media/video-host.ts index 3eb4b469..8a70aa74 100644 --- a/packages/core/src/dom/media/video-host.ts +++ b/packages/core/src/dom/media/video-host.ts @@ -3,6 +3,22 @@ import type { WebKitPresentationMode, WebKitVideoElement } from '../presentation import { HTMLMediaElementHost } from './media-host'; export class HTMLVideoElementHost extends HTMLMediaElementHost implements Video { + get poster() { + return this.target?.poster ?? ''; + } + + set poster(value: string) { + if (this.target) this.target.poster = value; + } + + get webkitDisplayingFullscreen() { + return (this.target as unknown as WebKitVideoElement | null)?.webkitDisplayingFullscreen ?? false; + } + + get webkitPresentationMode() { + return (this.target as unknown as WebKitVideoElement | null)?.webkitPresentationMode ?? 'inline'; + } + requestPictureInPicture() { return this.target?.requestPictureInPicture() ?? Promise.reject(); } @@ -22,12 +38,4 @@ export class HTMLVideoElementHost extends HTMLMediaElementHost ({ + castState: 'disconnected', + castAvailability: 'unavailable', + + async toggleCast() { + const { media, container } = target(); + + if (isCastConnected(media)) { + return requestCast(media); + } + + if (isFullscreenElement(container, media)) { + await exitFullscreen(); + } + + return requestCast(media); + }, + }), + + attach({ target, signal, set }) { + const { media } = target; + + if (!isMediaRemotePlaybackCapable(media)) return; + + const syncState = () => set({ castState: media.remote.state as CastState }); + + syncState(); + + listen(media.remote, 'connect', syncState, { signal }); + listen(media.remote, 'connecting', syncState, { signal }); + listen(media.remote, 'disconnect', syncState, { signal }); + + media.remote + .watchAvailability((available: boolean) => { + set({ castAvailability: available ? 'available' : 'unavailable' }); + }) + .catch(() => { + set({ castAvailability: 'unsupported' }); + }); + + signal.addEventListener('abort', () => { + media.remote?.cancelWatchAvailability().catch(() => {}); + }); + }, +}); diff --git a/packages/core/src/dom/store/features/controls.ts b/packages/core/src/dom/store/features/controls.ts index fc1481b1..a35231ab 100644 --- a/packages/core/src/dom/store/features/controls.ts +++ b/packages/core/src/dom/store/features/controls.ts @@ -4,7 +4,8 @@ import { isNull } from '@videojs/utils/predicate'; import type { MediaControlsState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; import { findGestureCoordinator } from '../../gesture/coordinator'; -import { isMediaPauseCapable } from '../../media/predicate'; +import { isMediaPauseCapable, isMediaRemotePlaybackCapable } from '../../media/predicate'; +import { isCastConnected, isCastConnecting } from '../../presentation/cast'; const IDLE_DELAY = 2000; const TAP_THRESHOLD = 250; @@ -33,7 +34,7 @@ export const controlsFeature = definePlayerFeature({ } const computeVisible = (userActive: boolean): boolean => { - return userActive || media.paused; + return userActive || media.paused || isCastConnected(media) || isCastConnecting(media); }; // Idle timer @@ -130,6 +131,18 @@ export const controlsFeature = definePlayerFeature({ listen(media, 'pause', onPlaybackChange, { signal }); listen(media, 'ended', onPlaybackChange, { signal }); + // 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, 'connecting', onCastChange, { signal }); + listen(media.remote, 'disconnect', onCastChange, { signal }); + } + // Clean up timer on signal abort. signal.addEventListener('abort', clearIdle, { once: true }); diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index e847e77f..c2e75818 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -1,4 +1,5 @@ import { bufferFeature } from './buffer'; +import { castFeature } from './cast'; import { controlsFeature } from './controls'; import { fullscreenFeature } from './fullscreen'; import { pipFeature } from './pip'; @@ -14,6 +15,7 @@ export { audioFeatures, backgroundFeatures, videoFeatures } from './presets'; // Short aliases export { bufferFeature as buffer, + castFeature as cast, controlsFeature as controls, fullscreenFeature as fullscreen, pipFeature as pip, diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 2bbb08f7..15d73506 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -1,4 +1,5 @@ export * from './buffer'; +export * from './cast'; export * from './controls'; export * from './error'; export * as features from './feature.parts'; diff --git a/packages/core/src/dom/store/features/presets.ts b/packages/core/src/dom/store/features/presets.ts index c1e110d2..6451270d 100644 --- a/packages/core/src/dom/store/features/presets.ts +++ b/packages/core/src/dom/store/features/presets.ts @@ -1,5 +1,6 @@ import type { AudioFeatures, BackgroundFeatures, VideoFeatures } from '../../media/types'; import { bufferFeature } from './buffer'; +import { castFeature } from './cast'; import { controlsFeature } from './controls'; import { errorFeature } from './error'; import { fullscreenFeature } from './fullscreen'; @@ -20,6 +21,7 @@ export const videoFeatures: VideoFeatures = [ bufferFeature, fullscreenFeature, pipFeature, + castFeature, controlsFeature, textTrackFeature, errorFeature, diff --git a/packages/core/src/dom/store/features/tests/controls.test.ts b/packages/core/src/dom/store/features/tests/controls.test.ts index 568c0f19..927c1203 100644 --- a/packages/core/src/dom/store/features/tests/controls.test.ts +++ b/packages/core/src/dom/store/features/tests/controls.test.ts @@ -354,6 +354,58 @@ describe('controlsFeature', () => { }); }); + describe('cast interaction', () => { + it('keeps controlsVisible true when casting and user goes inactive', () => { + const { video, remote } = createCastableVideo({ paused: false }); + const { store } = createPlayerStore(video); + + remote.state = 'connected'; + remote.dispatchEvent(new Event('connect')); + flush(); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(true); + }); + + it('hides controls after cast disconnects and user is inactive', () => { + const { video, remote } = createCastableVideo({ paused: false }); + const { store } = createPlayerStore(video); + + remote.state = 'connected'; + remote.dispatchEvent(new Event('connect')); + flush(); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.controlsVisible).toBe(true); + + remote.state = 'disconnected'; + remote.dispatchEvent(new Event('disconnect')); + flush(); + + expect(store.state.controlsVisible).toBe(false); + }); + + it('keeps controlsVisible true on mouseleave while casting', () => { + const { video, remote } = createCastableVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + remote.state = 'connected'; + remote.dispatchEvent(new Event('connect')); + flush(); + + container!.dispatchEvent(new Event('mouseleave')); + flush(); + + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(true); + }); + }); + describe('null container', () => { it('does not track activity without container', () => { const video = createMockVideo({ paused: false }); @@ -440,6 +492,20 @@ function createPointerEvent(type: string, init?: { pointerType?: string }): Even return event; } +function createMockRemote(): EventTarget & { state: string; prompt: () => Promise } { + const target = new EventTarget() as EventTarget & { state: string; prompt: () => Promise }; + target.state = 'disconnected'; + target.prompt = () => Promise.resolve(); + return target; +} + +function createCastableVideo(overrides: Parameters[0] = {}) { + const video = createMockVideo(overrides); + const remote = createMockRemote(); + Object.defineProperty(video, 'remote', { value: remote, configurable: true }); + return { video, remote }; +} + function createPlayerStore(video?: HTMLVideoElement, container?: HTMLElement | null) { const store = createStore()(controlsFeature); diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 040a7672..f0afb020 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -1,6 +1,7 @@ import { createSelector } from '@videojs/store'; import { bufferFeature } from './features/buffer'; +import { castFeature } from './features/cast'; import { controlsFeature } from './features/controls'; import { errorFeature } from './features/error'; import { fullscreenFeature } from './features/fullscreen'; @@ -14,6 +15,8 @@ import { volumeFeature } from './features/volume'; /** Select the buffer state (buffered ranges, percent buffered). */ export const selectBuffer = createSelector(bufferFeature); +/** Select the cast state (cast connection state, availability). */ +export const selectCast = createSelector(castFeature); /** Select the controls state (controls visible, user-active). */ export const selectControls = createSelector(controlsFeature); /** Select the error state (error, dismissed, dismissError). */ diff --git a/packages/core/src/dom/tsconfig.json b/packages/core/src/dom/tsconfig.json index e3330927..2214d7bd 100644 --- a/packages/core/src/dom/tsconfig.json +++ b/packages/core/src/dom/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "composite": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["chromecast-caf-sender"], "declarationDir": "../../types/dom", "useDefineForClassFields": false }, diff --git a/packages/html/src/define/ui/cast-button.ts b/packages/html/src/define/ui/cast-button.ts new file mode 100644 index 00000000..0056370b --- /dev/null +++ b/packages/html/src/define/ui/cast-button.ts @@ -0,0 +1,10 @@ +import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(CastButtonElement); + +declare global { + interface HTMLElementTagNameMap { + [CastButtonElement.tagName]: CastButtonElement; + } +} diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index f05a0775..70a8a75d 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -138,6 +138,11 @@ function getTemplateHTML() { ${renderIcon('captions-on', { class: cn(icon, iconState.captions.on) })} + + ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} + ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index 45469a22..8fb7e059 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -120,6 +120,12 @@ function getTemplateHTML() { Toggle captions + + ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} + ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index 83d0c618..306d4094 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -4,6 +4,7 @@ import { MediaContainerElement } from '../../media/container-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; +import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; @@ -35,6 +36,7 @@ defineTime(); // Standalone elements. safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); +safeDefine(CastButtonElement); safeDefine(FullscreenButtonElement); safeDefine(MuteButtonElement); safeDefine(PiPButtonElement); diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index f88fc345..63dc732e 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -133,6 +133,11 @@ function getTemplateHTML() { ${renderIcon('captions-on', { class: cn(icon, iconState.captions.on) })} + + ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} + ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index a914f199..e186f423 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -114,6 +114,12 @@ function getTemplateHTML() { + + ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} + ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index 70c7f1ed..2f19f939 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -4,6 +4,7 @@ import { MediaContainerElement } from '../../media/container-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; +import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element'; import { GestureElement } from '../../ui/gesture/gesture-element'; import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; @@ -37,6 +38,7 @@ defineTime(); // Standalone elements. safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); +safeDefine(CastButtonElement); safeDefine(FullscreenButtonElement); safeDefine(GestureElement); safeDefine(HotkeyElement); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 94d1e664..fac64205 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -22,6 +22,7 @@ export { AlertDialogTitleElement } from './ui/alert-dialog/alert-dialog-title-el export { type AlertDialogContextValue, alertDialogContext } from './ui/alert-dialog/context'; export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-indicator-element'; export { CaptionsButtonElement } from './ui/captions-button/captions-button-element'; +export { CastButtonElement } from './ui/cast-button/cast-button-element'; export { ContextPartElement, type PartContextValue } from './ui/context-part-element'; export { ControlsElement } from './ui/controls/controls-element'; export { ControlsGroupElement } from './ui/controls/controls-group-element'; diff --git a/packages/html/src/ui/cast-button/cast-button-element.ts b/packages/html/src/ui/cast-button/cast-button-element.ts new file mode 100644 index 00000000..c731b065 --- /dev/null +++ b/packages/html/src/ui/cast-button/cast-button-element.ts @@ -0,0 +1,18 @@ +import { CastButtonCore, CastButtonDataAttrs, type MediaCastState } from '@videojs/core'; +import { selectCast } from '@videojs/core/dom'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaButtonElement } from '../media-button-element'; + +export class CastButtonElement extends MediaButtonElement { + static readonly tagName = 'media-cast-button'; + + protected readonly core = new CastButtonCore(); + protected readonly stateAttrMap = CastButtonDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectCast); + + protected activate(state: MediaCastState): void { + this.core.toggle(state); + } +} diff --git a/packages/icons/src/assets/default/cast-enter.svg b/packages/icons/src/assets/default/cast-enter.svg new file mode 100644 index 00000000..9f8d3416 --- /dev/null +++ b/packages/icons/src/assets/default/cast-enter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/icons/src/assets/default/cast-exit.svg b/packages/icons/src/assets/default/cast-exit.svg new file mode 100644 index 00000000..64749aaf --- /dev/null +++ b/packages/icons/src/assets/default/cast-exit.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/icons/src/assets/minimal/cast-enter.svg b/packages/icons/src/assets/minimal/cast-enter.svg new file mode 100644 index 00000000..56ec8792 --- /dev/null +++ b/packages/icons/src/assets/minimal/cast-enter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/icons/src/assets/minimal/cast-exit.svg b/packages/icons/src/assets/minimal/cast-exit.svg new file mode 100644 index 00000000..ad2900a6 --- /dev/null +++ b/packages/icons/src/assets/minimal/cast-exit.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9f4ccb1e..f75ce4c6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -33,6 +33,7 @@ export { export { AlertDialog, type AlertDialogContextValue, useAlertDialogContext } from './ui/alert-dialog'; export { BufferingIndicator, type BufferingIndicatorProps } from './ui/buffering-indicator/buffering-indicator'; export { CaptionsButton, type CaptionsButtonProps } from './ui/captions-button/captions-button'; +export { CastButton, type CastButtonProps } from './ui/cast-button/cast-button'; export { Controls } from './ui/controls'; export type { ControlsGroupProps } from './ui/controls/controls-group'; export type { ControlsRootProps } from './ui/controls/controls-root'; diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 3c7f978e..001554eb 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -1,6 +1,8 @@ import { CaptionsOffIcon, CaptionsOnIcon, + CastEnterIcon, + CastExitIcon, FullscreenEnterIcon, FullscreenExitIcon, PauseIcon, @@ -41,6 +43,7 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { Container, usePlayer } from '@/player/context'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; +import { CastButton } from '@/ui/cast-button'; import { Controls } from '@/ui/controls'; import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; @@ -268,6 +271,18 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + , CastButtonCore.Props {} + +/** A button that toggles casting to a remote device. */ +export const CastButton = createMediaButton({ + displayName: 'CastButton', + core: CastButtonCore, + stateAttrMap: CastButtonDataAttrs, + selector: selectCast, + action: (core, state) => core.toggle(state), +}); + +export namespace CastButton { + export type Props = CastButtonProps; + export type State = CastButtonCore.State; +} diff --git a/packages/react/src/ui/cast-button/index.ts b/packages/react/src/ui/cast-button/index.ts new file mode 100644 index 00000000..9c8c98aa --- /dev/null +++ b/packages/react/src/ui/cast-button/index.ts @@ -0,0 +1 @@ +export * from './cast-button'; diff --git a/packages/skins/src/shared/css/video/icon-state.css b/packages/skins/src/shared/css/video/icon-state.css index def6fe93..672367e5 100644 --- a/packages/skins/src/shared/css/video/icon-state.css +++ b/packages/skins/src/shared/css/video/icon-state.css @@ -18,6 +18,8 @@ .media-button--fullscreen .media-icon--fullscreen-exit, .media-button--pip .media-icon--pip-enter, .media-button--pip .media-icon--pip-exit, +.media-button--cast .media-icon--cast-enter, +.media-button--cast .media-icon--cast-exit, .media-button--captions .media-icon--captions-off, .media-button--captions .media-icon--captions-on { display: none; @@ -46,6 +48,10 @@ .media-button--pip:not([data-pip]) .media-icon--pip-enter, /* Picture-in-Picture: active → exit */ .media-button--pip[data-pip] .media-icon--pip-exit, +/* Cast: not connected → enter */ +.media-button--cast:not([data-cast-state="connected"]) .media-icon--cast-enter, +/* Cast: connected → exit */ +.media-button--cast[data-cast-state="connected"] .media-icon--cast-exit, /* Captions: not active → captions off */ .media-button--captions:not([data-active]) .media-icon--captions-off, /* Captions: active → captions on */ diff --git a/packages/skins/src/shared/tailwind/icon-state.ts b/packages/skins/src/shared/tailwind/icon-state.ts index 23873d8a..5e31bfde 100644 --- a/packages/skins/src/shared/tailwind/icon-state.ts +++ b/packages/skins/src/shared/tailwind/icon-state.ts @@ -33,4 +33,10 @@ export const iconState = { off: 'hidden opacity-0 group-not-data-pip:block group-not-data-pip:opacity-100', on: 'hidden opacity-0 group-data-pip:block group-data-pip:opacity-100', }, + cast: { + button: 'group', + enter: + 'hidden opacity-0 group-not-data-[cast-state=connected]:block group-not-data-[cast-state=connected]:opacity-100', + exit: 'hidden opacity-0 group-data-[cast-state=connected]:block group-data-[cast-state=connected]:opacity-100', + }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ae47bff..3bc023ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,12 @@ importers: specifier: ^5.17.10 version: 5.17.10 devDependencies: + '@types/chrome': + specifier: ^0.1.40 + version: 0.1.40 + '@types/chromecast-caf-sender': + specifier: ^1.0.11 + version: 1.0.11 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -3287,6 +3293,12 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chrome@0.1.40': + resolution: {integrity: sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==} + + '@types/chromecast-caf-sender@1.0.11': + resolution: {integrity: sha512-Pv3xvNYtxD/cTM/tKfuZRlLasvpxAm+CFni0GJd6Cp8XgiZS9g9tMZkR1uymsi5fIFv057SZKKAWVFFgy7fJtw==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -3302,9 +3314,18 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -10848,6 +10869,15 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/chrome@0.1.40': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + + '@types/chromecast-caf-sender@1.0.11': + dependencies: + '@types/chrome': 0.1.40 + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.15 @@ -10864,11 +10894,19 @@ snapshots: '@types/estree@1.0.8': {} + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 '@types/node': 22.19.15 + '@types/har-format@1.2.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -11080,7 +11118,7 @@ snapshots: '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) playwright: 1.59.1 tinyrainbow: 3.1.0 - vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@27.4.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@26.1.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - bufferutil - msw @@ -11251,7 +11289,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@27.4.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.1.0)(happy-dom@18.0.1)(jsdom@26.1.0)(vite@8.0.0(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils@4.1.0': dependencies: diff --git a/site/src/content/docs/reference/feature-cast.mdx b/site/src/content/docs/reference/feature-cast.mdx new file mode 100644 index 00000000..4726c4a6 --- /dev/null +++ b/site/src/content/docs/reference/feature-cast.mdx @@ -0,0 +1,52 @@ +--- +title: Cast +description: Cast state and actions for the player store +--- + +import FeatureReference from "@/components/docs/api-reference/FeatureReference.astro"; +import DocsLink from "@/components/docs/DocsLink.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; + +Controls casting to remote playback devices (e.g. Chromecast). Exits fullscreen before initiating a cast session. + + + +### Selector + + +Pass `selectCast` to `usePlayer` to subscribe to cast state. Returns `undefined` if the cast feature is not configured. + + + +Pass `selectCast` to `PlayerController` to subscribe to cast state. Returns `undefined` if the cast feature is not configured. + + + +```tsx title="CastButton.tsx" +import { selectCast, usePlayer } from '@videojs/react'; + +function CastButton() { + const cast = usePlayer(selectCast); + if (!cast || cast.castAvailability !== 'available') return null; + + return ( + + ); +} +``` + + + +```ts title="cast-button.ts" +import { createPlayer, MediaElement, selectCast } from '@videojs/html'; +import { videoFeatures } from '@videojs/html/video'; + +const { PlayerController, context } = createPlayer({ features: videoFeatures }); + +class CastButton extends MediaElement { + readonly #cast = new PlayerController(this, context, selectCast); +} +``` + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index 7d417424..e9b697b0 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -124,6 +124,7 @@ export const sidebar: Sidebar = [ contents: [ { slug: 'reference/create-selector' }, { slug: 'reference/feature-buffer' }, + { slug: 'reference/feature-cast' }, { slug: 'reference/feature-controls' }, { slug: 'reference/feature-error' }, { slug: 'reference/feature-fullscreen' },