diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 77db678f..d55558da 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -274,23 +274,23 @@ export interface MediaErrorState { dismissError(): void; } -export type CastState = 'disconnected' | 'connecting' | 'connected'; +export type RemotePlaybackConnectionState = 'disconnected' | 'connecting' | 'connected'; -export interface MediaCastState { +export interface MediaRemotePlaybackState { /** - * Current cast connection state. + * Current remote playback connection state. * * @see https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback/state */ - castState: CastState; + remotePlaybackState: RemotePlaybackConnectionState; /** - * Whether casting can be requested on this platform. + * Whether remote playback can be requested on this platform. * * @see https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback */ - castAvailability: MediaFeatureAvailability; - /** Toggle cast connection. */ - toggleCast(): Promise; + remotePlaybackAvailability: MediaFeatureAvailability; + /** Toggle the remote playback connection. */ + toggleRemotePlayback(): Promise; } export interface MediaPictureInPictureState { diff --git a/packages/core/src/core/media/types.ts b/packages/core/src/core/media/types.ts index f2aff270..5ec61c11 100644 --- a/packages/core/src/core/media/types.ts +++ b/packages/core/src/core/media/types.ts @@ -14,6 +14,13 @@ export interface EventTargetLike; + watchAvailability(callback: (available: boolean) => void): Promise; + cancelWatchAvailability(id?: number): Promise; +} + export function TypedEventTarget() { return EventTarget as unknown as { new (): EventTargetLike }; } @@ -174,6 +181,10 @@ export interface MediaPictureInPictureCapability { requestPictureInPicture(): Promise; } +export interface MediaRemotePlaybackCapability { + readonly remote: RemotePlaybackLike; +} + interface MediaEvents extends MediaPlaybackEvents {} export interface Media extends MediaPlaybackCapability, EventTargetLike { 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 index 3910ee0d..f39d019d 100644 --- a/packages/core/src/core/ui/cast-button/cast-button-core.ts +++ b/packages/core/src/core/ui/cast-button/cast-button-core.ts @@ -3,7 +3,7 @@ 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 { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../media/state'; import type { MediaFeatureAvailability } from '../../media/types'; import type { ButtonState } from '../types'; @@ -15,7 +15,7 @@ export interface CastButtonProps { } export interface CastButtonState extends ButtonState { - castState: CastState; + castState: RemotePlaybackConnectionState; availability: MediaFeatureAvailability; } @@ -32,7 +32,7 @@ export class CastButtonCore { }); #props = { ...CastButtonCore.defaultProps }; - #media: MediaCastState | null = null; + #media: MediaRemotePlaybackState | null = null; constructor(props?: CastButtonProps) { if (props) this.setProps(props); @@ -64,24 +64,29 @@ export class CastButtonCore { }; } - setMedia(media: MediaCastState): void { + setMedia(media: MediaRemotePlaybackState): void { this.#media = media; } getState(): CastButtonState { const media = this.#media!; - this.state.patch({ castState: media.castState, availability: media.castAvailability }); + const castSupported = !!(globalThis as any).chrome; + + this.state.patch({ + castState: media.remotePlaybackState, + availability: castSupported ? media.remotePlaybackAvailability : 'unsupported', + }); this.state.patch({ label: this.getLabel(this.state.current) }); return this.state.current; } - async toggle(media: MediaCastState): Promise { + async toggle(media: MediaRemotePlaybackState): Promise { if (this.#props.disabled) return; - if (media.castAvailability !== 'available') return; + if (media.remotePlaybackAvailability !== 'available') return; try { - await media.toggleCast(); + await media.toggleRemotePlayback(); } catch { // Cast requests can fail (user cancelled, permissions, etc.) } 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 index b6e24b21..fc0b9781 100644 --- 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 @@ -1,14 +1,26 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MediaCastState } from '../../../media/state'; +import type { MediaRemotePlaybackState } from '../../../media/state'; import type { CastButtonState } from '../cast-button-core'; import { CastButtonCore } from '../cast-button-core'; -function createMediaState(overrides: Partial = {}): MediaCastState { +// CastButtonCore reports `availability: 'unsupported'` outside Chromium +// (detected via `globalThis.chrome`). Tests run in jsdom which lacks that +// global, so stub it for every test and let individual tests override. +function stubChrome(present: boolean) { + const key = 'chrome'; + if (present) { + (globalThis as unknown as Record)[key] = {}; + } else { + delete (globalThis as unknown as Record)[key]; + } +} + +function createMediaState(overrides: Partial = {}): MediaRemotePlaybackState { return { - castState: 'disconnected', - castAvailability: 'available', - toggleCast: vi.fn(async () => {}), + remotePlaybackState: 'disconnected', + remotePlaybackAvailability: 'available', + toggleRemotePlayback: vi.fn(async () => {}), ...overrides, }; } @@ -23,10 +35,13 @@ function createState(overrides: Partial = {}): CastButtonState } describe('CastButtonCore', () => { + beforeEach(() => stubChrome(true)); + afterEach(() => stubChrome(false)); + describe('getState', () => { it('projects castState and availability', () => { const core = new CastButtonCore(); - const media = createMediaState({ castState: 'connected' }); + const media = createMediaState({ remotePlaybackState: 'connected' }); core.setMedia(media); const state = core.getState(); @@ -36,7 +51,7 @@ describe('CastButtonCore', () => { it('reflects unsupported availability', () => { const core = new CastButtonCore(); - core.setMedia(createMediaState({ castAvailability: 'unsupported' })); + core.setMedia(createMediaState({ remotePlaybackAvailability: 'unsupported' })); const state = core.getState(); expect(state.availability).toBe('unsupported'); @@ -44,11 +59,20 @@ describe('CastButtonCore', () => { it('reflects connecting state', () => { const core = new CastButtonCore(); - core.setMedia(createMediaState({ castState: 'connecting' })); + core.setMedia(createMediaState({ remotePlaybackState: 'connecting' })); const state = core.getState(); expect(state.castState).toBe('connecting'); }); + + it('reports unsupported outside Chromium', () => { + stubChrome(false); + const core = new CastButtonCore(); + core.setMedia(createMediaState({ remotePlaybackAvailability: 'available' })); + const state = core.getState(); + + expect(state.availability).toBe('unsupported'); + }); }); describe('getLabel', () => { @@ -95,38 +119,38 @@ describe('CastButtonCore', () => { }); describe('toggle', () => { - it('calls toggleCast when disconnected', async () => { + it('calls toggleRemotePlayback when disconnected', async () => { const core = new CastButtonCore(); - const media = createMediaState({ castState: 'disconnected' }); + const media = createMediaState({ remotePlaybackState: 'disconnected' }); await core.toggle(media); - expect(media.toggleCast).toHaveBeenCalled(); + expect(media.toggleRemotePlayback).toHaveBeenCalled(); }); - it('calls toggleCast when connected', async () => { + it('calls toggleRemotePlayback when connected', async () => { const core = new CastButtonCore(); - const media = createMediaState({ castState: 'connected' }); + const media = createMediaState({ remotePlaybackState: 'connected' }); await core.toggle(media); - expect(media.toggleCast).toHaveBeenCalled(); + expect(media.toggleRemotePlayback).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(); + expect(media.toggleRemotePlayback).not.toHaveBeenCalled(); }); it('does nothing when unsupported', async () => { const core = new CastButtonCore(); - const media = createMediaState({ castAvailability: 'unsupported' }); + const media = createMediaState({ remotePlaybackAvailability: 'unsupported' }); await core.toggle(media); - expect(media.toggleCast).not.toHaveBeenCalled(); + expect(media.toggleRemotePlayback).not.toHaveBeenCalled(); }); it('catches cast errors silently', async () => { const core = new CastButtonCore(); const media = createMediaState({ - toggleCast: vi.fn(async () => { + toggleRemotePlayback: vi.fn(async () => { throw new Error('user cancelled'); }), }); diff --git a/packages/core/src/dom/media/castable/google-cast-augments.d.ts b/packages/core/src/dom/media/google-cast/google-cast-augments.d.ts similarity index 100% rename from packages/core/src/dom/media/castable/google-cast-augments.d.ts rename to packages/core/src/dom/media/google-cast/google-cast-augments.d.ts diff --git a/packages/core/src/dom/media/castable/google-cast-provider.ts b/packages/core/src/dom/media/google-cast/google-cast-provider.ts similarity index 95% rename from packages/core/src/dom/media/castable/google-cast-provider.ts rename to packages/core/src/dom/media/google-cast/google-cast-provider.ts index 9790764c..c0089776 100644 --- a/packages/core/src/dom/media/castable/google-cast-provider.ts +++ b/packages/core/src/dom/media/google-cast/google-cast-provider.ts @@ -1,5 +1,5 @@ import type { RemotePlaybackState } from './remote-playback'; -import type { CastableMediaElement } from './types'; +import type { GoogleCastMediaElement } from './types'; import { castContext, currentMedia, @@ -31,7 +31,7 @@ export type LocalPlayer = { }; const providerInstances = new IterableWeakSet(); -const castElementRef = new WeakSet(); +const castElementRef = new WeakSet(); let cf: typeof cast.framework | undefined; @@ -57,7 +57,7 @@ onCastApiAvailable(() => { }); export class GoogleCastProvider { - readonly media: CastableMediaElement; + readonly media: GoogleCastMediaElement; seeking = false; #hooks: Partial = {}; @@ -71,7 +71,7 @@ export class GoogleCastProvider { #onTextTrackChange = () => this.#updateRemoteTextTrack(); #onMediaUpdate = () => this.#checkPlaybackRate(); - constructor(media: CastableMediaElement, local: LocalPlayer) { + constructor(media: GoogleCastMediaElement, local: LocalPlayer) { this.media = media; this.#local = local; providerInstances.add(this); @@ -133,6 +133,11 @@ export class GoogleCastProvider { } async load() { + if (!this.media.castSrc) { + // TODO: handle unloading the media? + return; + } + const mediaInfo = new chrome.cast.media.MediaInfo(this.media.castSrc, this.media.castContentType ?? ''); mediaInfo.customData = (this.media.castCustomData as object) ?? null; @@ -214,7 +219,12 @@ export class GoogleCastProvider { } // isPaused is not true when the media has ended so add the ended check. + // Fall back to the local element before remote media has loaded — e.g. + // while the cast picker is open, `isCasting` is already true but the + // RemotePlayer's `isPaused` still reports its default `false`, which + // would otherwise leak through as `media.paused === false`. get paused() { + if (!this.#remote.isMediaLoaded) return this.#local.paused(); return this.#remote.isPaused || this.ended; } diff --git a/packages/core/src/dom/media/castable/index.ts b/packages/core/src/dom/media/google-cast/index.ts similarity index 92% rename from packages/core/src/dom/media/castable/index.ts rename to packages/core/src/dom/media/google-cast/index.ts index 92c15cd1..7eef099e 100644 --- a/packages/core/src/dom/media/castable/index.ts +++ b/packages/core/src/dom/media/google-cast/index.ts @@ -1,16 +1,16 @@ import type { MixinReturn } from '@videojs/utils/types'; -import type { RemotePlaybackLike } from '../predicate'; +import type { RemotePlaybackLike } from '../../../core/media/types'; import { GoogleCastProvider } from './google-cast-provider'; import { RemotePlayback } from './remote-playback'; -import type { CastableMedia, CastableMediaHostConstructor } from './types'; +import type { GoogleCastMedia, GoogleCastMediaHostConstructor } from './types'; import { getDefaultCastOptions, loadCastFramework, requiresCastFramework } from './utils'; -export type { CastableMediaElement } from './types'; +export type { GoogleCastMediaElement } from './types'; -export const CastableMediaMixin = ( +export const GoogleCastMixin = ( superclass: Base -): MixinReturn => { - class CastableMedia extends superclass { +): MixinReturn => { + class GoogleCastMedia extends superclass { #castOptions = getDefaultCastOptions(); #castCustomData: Record | null | undefined; #castSrc: string | undefined; @@ -222,5 +222,5 @@ export const CastableMediaMixin = ( } } - return CastableMedia as unknown as MixinReturn; + return GoogleCastMedia as unknown as MixinReturn; }; diff --git a/packages/core/src/dom/media/castable/remote-playback.ts b/packages/core/src/dom/media/google-cast/remote-playback.ts similarity index 100% rename from packages/core/src/dom/media/castable/remote-playback.ts rename to packages/core/src/dom/media/google-cast/remote-playback.ts diff --git a/packages/core/src/dom/media/castable/types.ts b/packages/core/src/dom/media/google-cast/types.ts similarity index 72% rename from packages/core/src/dom/media/castable/types.ts rename to packages/core/src/dom/media/google-cast/types.ts index 4247357e..80821e01 100644 --- a/packages/core/src/dom/media/castable/types.ts +++ b/packages/core/src/dom/media/google-cast/types.ts @@ -1,22 +1,24 @@ -import type { RemotePlaybackLike } from '../predicate'; +import type { RemotePlaybackLike } from '../../../core/media/types'; import type { RemotePlayback } from './remote-playback'; import type { CastOptions } from './utils'; -export interface CastableMediaProps { +export interface GoogleCastMediaProps { + castSrc: string | undefined; castReceiver: string | undefined; castContentType: string | undefined; castStreamType: string | undefined; castCustomData: Record | null | undefined; } -export const castableMediaDefaultProps: CastableMediaProps = { +export const googleCastMediaDefaultProps: GoogleCastMediaProps = { + castSrc: undefined, castReceiver: undefined, castContentType: undefined, castStreamType: undefined, castCustomData: undefined, }; -export interface CastableMediaHost extends EventTarget { +export interface GoogleCastMediaHost extends EventTarget { readonly target: HTMLMediaElement | null; readonly remote: RemotePlaybackLike | undefined; title: string; @@ -46,16 +48,15 @@ export interface CastableMediaHost extends EventTarget { querySelector(selectors: K): HTMLElementTagNameMap[K] | null; } -export interface CastableMedia extends CastableMediaProps { +export interface GoogleCastMedia extends GoogleCastMediaProps { readonly remote: RemotePlayback | RemotePlaybackLike | undefined; readonly castOptions: CastOptions; - castSrc: string; poster: string; title: string; } -export type CastableMediaElement = CastableMediaHost & CastableMedia; +export type GoogleCastMediaElement = GoogleCastMediaHost & GoogleCastMedia; -export interface CastableMediaHostConstructor { - new (...args: any[]): CastableMediaHost; +export interface GoogleCastMediaHostConstructor { + new (...args: any[]): GoogleCastMediaHost; } diff --git a/packages/core/src/dom/media/castable/utils.ts b/packages/core/src/dom/media/google-cast/utils.ts similarity index 100% rename from packages/core/src/dom/media/castable/utils.ts rename to packages/core/src/dom/media/google-cast/utils.ts diff --git a/packages/core/src/dom/media/mux/index.ts b/packages/core/src/dom/media/mux/index.ts index 0fbdf8a1..7100fcc7 100644 --- a/packages/core/src/dom/media/mux/index.ts +++ b/packages/core/src/dom/media/mux/index.ts @@ -1,28 +1,25 @@ -import { CastableMediaMixin } from '../castable'; -import { type CastableMediaProps, castableMediaDefaultProps } from '../castable/types'; +import { GoogleCastMixin } from '../google-cast'; +import { type GoogleCastMediaProps, googleCastMediaDefaultProps } from '../google-cast/types'; import { HlsMedia, type HlsMediaProps, hlsMediaDefaultProps } from '../hls'; import { MuxDataMediaMixin, type MuxDataMediaProps, muxDataMediaDefaultProps } from './mux-data'; -export type { CastableMediaProps, HlsMediaProps, MuxDataMediaProps }; +export type { GoogleCastMediaProps, HlsMediaProps, MuxDataMediaProps }; -export interface MuxMediaProps extends HlsMediaProps, CastableMediaProps, MuxDataMediaProps { - castSrc: string | undefined; -} +export interface MuxMediaProps extends HlsMediaProps, GoogleCastMediaProps, MuxDataMediaProps {} export const muxMediaDefaultProps: MuxMediaProps = { ...hlsMediaDefaultProps, - ...castableMediaDefaultProps, + ...googleCastMediaDefaultProps, ...muxDataMediaDefaultProps, - castSrc: undefined, }; -export class MuxVideoMedia extends MuxDataMediaMixin(CastableMediaMixin(HlsMedia)) implements MuxMediaProps { +export class MuxVideoMedia extends MuxDataMediaMixin(GoogleCastMixin(HlsMedia)) implements MuxMediaProps { 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(CastableMediaMixin(HlsMedia)) implements MuxMediaProps { +export class MuxAudioMedia extends MuxDataMediaMixin(GoogleCastMixin(HlsMedia)) implements MuxMediaProps { 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 8f2ea8bc..5faba7a3 100644 --- a/packages/core/src/dom/media/predicate.ts +++ b/packages/core/src/dom/media/predicate.ts @@ -5,6 +5,7 @@ import type { MediaErrorCapability, MediaPauseCapability, MediaPlaybackRateCapability, + MediaRemotePlaybackCapability, MediaSeekCapability, MediaSourceCapability, MediaTextTrackCapability, @@ -55,17 +56,6 @@ 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); } diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index 1e9f9b91..f052b7a8 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -1,13 +1,13 @@ import type { AnySlice, Slice, Store, UnionSliceState } from '@videojs/store'; import type { MediaBufferState, - MediaCastState, MediaControlsState, MediaErrorState, MediaFullscreenState, MediaPictureInPictureState, MediaPlaybackRateState, MediaPlaybackState, + MediaRemotePlaybackState, MediaSourceState, MediaTextTrackState, MediaTimeState, @@ -47,7 +47,7 @@ export type VideoFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, - PlayerFeature, + PlayerFeature, PlayerFeature, PlayerFeature, PlayerFeature, diff --git a/packages/core/src/dom/presentation/index.ts b/packages/core/src/dom/presentation/index.ts index 51ed7892..8fc3d9c7 100644 --- a/packages/core/src/dom/presentation/index.ts +++ b/packages/core/src/dom/presentation/index.ts @@ -1,4 +1,4 @@ -export * from './cast'; export * from './fullscreen'; export * from './pip'; +export * from './remote-playback'; export * from './types'; diff --git a/packages/core/src/dom/presentation/cast.ts b/packages/core/src/dom/presentation/remote-playback.ts similarity index 71% rename from packages/core/src/dom/presentation/cast.ts rename to packages/core/src/dom/presentation/remote-playback.ts index b81f2c0a..56d226e4 100644 --- a/packages/core/src/dom/presentation/cast.ts +++ b/packages/core/src/dom/presentation/remote-playback.ts @@ -1,6 +1,6 @@ import { isObject } from '@videojs/utils/predicate'; -import type { MediaRemotePlaybackCapability } from '../media/predicate'; +import type { MediaRemotePlaybackCapability } from '../../core/media/types'; function resolveRemote(media: EventTarget): MediaRemotePlaybackCapability['remote'] | undefined { const target = media as EventTarget & { remote?: unknown }; @@ -10,15 +10,15 @@ function resolveRemote(media: EventTarget): MediaRemotePlaybackCapability['remot return undefined; } -export function isCastConnected(media: EventTarget) { +export function isRemotePlaybackConnected(media: EventTarget) { return resolveRemote(media)?.state === 'connected'; } -export function isCastConnecting(media: EventTarget) { +export function isRemotePlaybackConnecting(media: EventTarget) { return resolveRemote(media)?.state === 'connecting'; } -export async function requestCast(media: EventTarget) { +export async function requestRemotePlayback(media: EventTarget) { const remote = resolveRemote(media); if (!remote) { throw new DOMException('Remote playback not supported', 'NotSupportedError'); diff --git a/packages/core/src/dom/store/features/controls.ts b/packages/core/src/dom/store/features/controls.ts index 854b5ca5..3712c482 100644 --- a/packages/core/src/dom/store/features/controls.ts +++ b/packages/core/src/dom/store/features/controls.ts @@ -5,7 +5,7 @@ import type { MediaControlsState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; import { findGestureCoordinator } from '../../gesture/coordinator'; import { isMediaPauseCapable, isMediaRemotePlaybackCapable } from '../../media/predicate'; -import { isCastConnected, isCastConnecting } from '../../presentation/cast'; +import { isRemotePlaybackConnected, isRemotePlaybackConnecting } from '../../presentation/remote-playback'; const IDLE_DELAY = 2000; const TAP_THRESHOLD = 250; @@ -34,7 +34,7 @@ export const controlsFeature = definePlayerFeature({ } const computeVisible = (userActive: boolean): boolean => { - return userActive || media.paused || isCastConnected(media) || isCastConnecting(media); + return userActive || media.paused || isRemotePlaybackConnected(media) || isRemotePlaybackConnecting(media); }; // Idle timer diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index c2e75818..82c05e5b 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -1,10 +1,10 @@ import { bufferFeature } from './buffer'; -import { castFeature } from './cast'; import { controlsFeature } from './controls'; import { fullscreenFeature } from './fullscreen'; import { pipFeature } from './pip'; import { playbackFeature } from './playback'; import { playbackRateFeature } from './playback-rate'; +import { remotePlaybackFeature } from './remote-playback'; import { sourceFeature } from './source'; import { textTrackFeature } from './text-track'; import { timeFeature } from './time'; @@ -15,12 +15,12 @@ export { audioFeatures, backgroundFeatures, videoFeatures } from './presets'; // Short aliases export { bufferFeature as buffer, - castFeature as cast, controlsFeature as controls, fullscreenFeature as fullscreen, pipFeature as pip, playbackFeature as playback, playbackRateFeature as playbackRate, + remotePlaybackFeature as remotePlayback, sourceFeature as source, textTrackFeature as textTrack, timeFeature as time, diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 15d73506..91b189a4 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -1,5 +1,4 @@ export * from './buffer'; -export * from './cast'; export * from './controls'; export * from './error'; export * as features from './feature.parts'; @@ -8,6 +7,7 @@ export * from './pip'; export * from './playback'; export * from './playback-rate'; export * from './presets'; +export * from './remote-playback'; export * from './source'; export * from './text-track'; export * from './time'; diff --git a/packages/core/src/dom/store/features/presets.ts b/packages/core/src/dom/store/features/presets.ts index 6451270d..cb8a9c4c 100644 --- a/packages/core/src/dom/store/features/presets.ts +++ b/packages/core/src/dom/store/features/presets.ts @@ -1,12 +1,12 @@ 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'; import { pipFeature } from './pip'; import { playbackFeature } from './playback'; import { playbackRateFeature } from './playback-rate'; +import { remotePlaybackFeature } from './remote-playback'; import { sourceFeature } from './source'; import { textTrackFeature } from './text-track'; import { timeFeature } from './time'; @@ -21,7 +21,7 @@ export const videoFeatures: VideoFeatures = [ bufferFeature, fullscreenFeature, pipFeature, - castFeature, + remotePlaybackFeature, controlsFeature, textTrackFeature, errorFeature, diff --git a/packages/core/src/dom/store/features/cast.ts b/packages/core/src/dom/store/features/remote-playback.ts similarity index 53% rename from packages/core/src/dom/store/features/cast.ts rename to packages/core/src/dom/store/features/remote-playback.ts index 5bd540e1..8a4a27eb 100644 --- a/packages/core/src/dom/store/features/cast.ts +++ b/packages/core/src/dom/store/features/remote-playback.ts @@ -1,29 +1,29 @@ import { listen } from '@videojs/utils/dom'; -import type { CastState, MediaCastState } from '../../../core/media/state'; +import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; import { isMediaRemotePlaybackCapable } from '../../media/predicate'; -import { isCastConnected, requestCast } from '../../presentation/cast'; import { exitFullscreen, isFullscreenElement } from '../../presentation/fullscreen'; +import { isRemotePlaybackConnected, requestRemotePlayback } from '../../presentation/remote-playback'; -export const castFeature = definePlayerFeature({ - name: 'cast', - state: ({ target }): MediaCastState => ({ - castState: 'disconnected', - castAvailability: 'unavailable', +export const remotePlaybackFeature = definePlayerFeature({ + name: 'remotePlayback', + state: ({ target }): MediaRemotePlaybackState => ({ + remotePlaybackState: 'disconnected', + remotePlaybackAvailability: 'unavailable', - async toggleCast() { + async toggleRemotePlayback() { const { media, container } = target(); - if (isCastConnected(media)) { - return requestCast(media); + if (isRemotePlaybackConnected(media)) { + return requestRemotePlayback(media); } if (isFullscreenElement(container, media)) { await exitFullscreen(); } - return requestCast(media); + return requestRemotePlayback(media); }, }), @@ -32,7 +32,7 @@ export const castFeature = definePlayerFeature({ if (!isMediaRemotePlaybackCapable(media)) return; - const syncState = () => set({ castState: media.remote.state as CastState }); + const syncState = () => set({ remotePlaybackState: media.remote.state as RemotePlaybackConnectionState }); syncState(); @@ -42,10 +42,10 @@ export const castFeature = definePlayerFeature({ media.remote .watchAvailability((available: boolean) => { - set({ castAvailability: available ? 'available' : 'unavailable' }); + set({ remotePlaybackAvailability: available ? 'available' : 'unavailable' }); }) .catch(() => { - set({ castAvailability: 'unsupported' }); + set({ remotePlaybackAvailability: 'unsupported' }); }); signal.addEventListener('abort', () => { 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 927c1203..d0525f3e 100644 --- a/packages/core/src/dom/store/features/tests/controls.test.ts +++ b/packages/core/src/dom/store/features/tests/controls.test.ts @@ -356,7 +356,7 @@ describe('controlsFeature', () => { describe('cast interaction', () => { it('keeps controlsVisible true when casting and user goes inactive', () => { - const { video, remote } = createCastableVideo({ paused: false }); + const { video, remote } = createGoogleCastVideo({ paused: false }); const { store } = createPlayerStore(video); remote.state = 'connected'; @@ -371,7 +371,7 @@ describe('controlsFeature', () => { }); it('hides controls after cast disconnects and user is inactive', () => { - const { video, remote } = createCastableVideo({ paused: false }); + const { video, remote } = createGoogleCastVideo({ paused: false }); const { store } = createPlayerStore(video); remote.state = 'connected'; @@ -391,7 +391,7 @@ describe('controlsFeature', () => { }); it('keeps controlsVisible true on mouseleave while casting', () => { - const { video, remote } = createCastableVideo({ paused: false }); + const { video, remote } = createGoogleCastVideo({ paused: false }); const { store, container } = createPlayerStore(video); remote.state = 'connected'; @@ -499,7 +499,7 @@ function createMockRemote(): EventTarget & { state: string; prompt: () => Promis return target; } -function createCastableVideo(overrides: Parameters[0] = {}) { +function createGoogleCastVideo(overrides: Parameters[0] = {}) { const video = createMockVideo(overrides); const remote = createMockRemote(); Object.defineProperty(video, 'remote', { value: remote, configurable: true }); diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index f0afb020..384a1d8c 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -1,13 +1,13 @@ 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'; import { pipFeature } from './features/pip'; import { playbackFeature } from './features/playback'; import { playbackRateFeature } from './features/playback-rate'; +import { remotePlaybackFeature } from './features/remote-playback'; import { sourceFeature } from './features/source'; import { textTrackFeature } from './features/text-track'; import { timeFeature } from './features/time'; @@ -15,8 +15,6 @@ 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). */ @@ -29,6 +27,8 @@ export const selectPiP = createSelector(pipFeature); export const selectPlayback = createSelector(playbackFeature); /** Select the playback rate state (playbackRate, playbackRates, setPlaybackRate). */ export const selectPlaybackRate = createSelector(playbackRateFeature); +/** Select the remote playback state (remote playback connection state, availability). */ +export const selectRemotePlayback = createSelector(remotePlaybackFeature); /** Select the source state (src, type). */ export const selectSource = createSelector(sourceFeature); /** Select the text track state (chapters cues, thumbnail cues). */ diff --git a/packages/html/src/ui/cast-button/cast-button-element.ts b/packages/html/src/ui/cast-button/cast-button-element.ts index c731b065..58911ebb 100644 --- a/packages/html/src/ui/cast-button/cast-button-element.ts +++ b/packages/html/src/ui/cast-button/cast-button-element.ts @@ -1,5 +1,5 @@ -import { CastButtonCore, CastButtonDataAttrs, type MediaCastState } from '@videojs/core'; -import { selectCast } from '@videojs/core/dom'; +import { CastButtonCore, CastButtonDataAttrs, type MediaRemotePlaybackState } from '@videojs/core'; +import { selectRemotePlayback } from '@videojs/core/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; @@ -10,9 +10,9 @@ export class CastButtonElement extends MediaButtonElement { protected readonly core = new CastButtonCore(); protected readonly stateAttrMap = CastButtonDataAttrs; - protected readonly mediaState = new PlayerController(this, playerContext, selectCast); + protected readonly mediaState = new PlayerController(this, playerContext, selectRemotePlayback); - protected activate(state: MediaCastState): void { + protected activate(state: MediaRemotePlaybackState): void { this.core.toggle(state); } } diff --git a/packages/react/src/ui/cast-button/cast-button.tsx b/packages/react/src/ui/cast-button/cast-button.tsx index 3847b294..58951f03 100644 --- a/packages/react/src/ui/cast-button/cast-button.tsx +++ b/packages/react/src/ui/cast-button/cast-button.tsx @@ -1,7 +1,7 @@ 'use client'; import { CastButtonCore, CastButtonDataAttrs } from '@videojs/core'; -import { selectCast } from '@videojs/core/dom'; +import { selectRemotePlayback } from '@videojs/core/dom'; import type { UIComponentProps } from '../../utils/types'; import { createMediaButton } from '../create-media-button'; @@ -13,7 +13,7 @@ export const CastButton = createMediaButton({ displayName: 'CastButton', core: CastButtonCore, stateAttrMap: CastButtonDataAttrs, - selector: selectCast, + selector: selectRemotePlayback, action: (core, state) => core.toggle(state), }); diff --git a/packages/skins/src/default/css/components/button.css b/packages/skins/src/default/css/components/button.css index 06cfd69b..4407254b 100644 --- a/packages/skins/src/default/css/components/button.css +++ b/packages/skins/src/default/css/components/button.css @@ -38,7 +38,8 @@ cursor: not-allowed; } - &[data-availability="unavailable"] { + &[data-availability="unavailable"], + &[data-availability="unsupported"] { display: none; } } diff --git a/packages/skins/src/default/tailwind/components/button.ts b/packages/skins/src/default/tailwind/components/button.ts index 813d6978..2786c008 100644 --- a/packages/skins/src/default/tailwind/components/button.ts +++ b/packages/skins/src/default/tailwind/components/button.ts @@ -9,7 +9,8 @@ export const button = { 'active:scale-[0.98]', 'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale', 'focus-visible:outline-current focus-visible:outline-offset-2', - 'data-[availability=unavailable]:hidden' + 'data-[availability=unavailable]:hidden', + 'data-[availability=unsupported]:hidden' ), primary: 'bg-white text-black font-medium text-shadow-none', subtle: cn( diff --git a/packages/skins/src/minimal/css/components/button.css b/packages/skins/src/minimal/css/components/button.css index cdf75e9b..ea451665 100644 --- a/packages/skins/src/minimal/css/components/button.css +++ b/packages/skins/src/minimal/css/components/button.css @@ -38,7 +38,8 @@ cursor: not-allowed; } - &[data-availability="unavailable"] { + &[data-availability="unavailable"], + &[data-availability="unsupported"] { display: none; } } diff --git a/packages/skins/src/minimal/tailwind/components/button.ts b/packages/skins/src/minimal/tailwind/components/button.ts index e17d6e5b..c55bcfb5 100644 --- a/packages/skins/src/minimal/tailwind/components/button.ts +++ b/packages/skins/src/minimal/tailwind/components/button.ts @@ -11,7 +11,8 @@ export const button = { 'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale', 'supports-[corner-shape:squircle]:rounded-[1rem]', 'supports-[corner-shape:squircle]:[corner-shape:squircle]', - 'data-[availability=unavailable]:hidden' + 'data-[availability=unavailable]:hidden', + 'data-[availability=unsupported]:hidden' ), primary: 'bg-white text-black font-medium text-shadow-none', subtle: cn( diff --git a/site/src/content/docs/reference/feature-cast.mdx b/site/src/content/docs/reference/feature-cast.mdx deleted file mode 100644 index 4726c4a6..00000000 --- a/site/src/content/docs/reference/feature-cast.mdx +++ /dev/null @@ -1,52 +0,0 @@ ---- -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/content/docs/reference/feature-remote-playback.mdx b/site/src/content/docs/reference/feature-remote-playback.mdx new file mode 100644 index 00000000..419166cc --- /dev/null +++ b/site/src/content/docs/reference/feature-remote-playback.mdx @@ -0,0 +1,52 @@ +--- +title: Remote Playback +description: Remote playback 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 remote playback to devices like Chromecast. Exits fullscreen before initiating a remote playback session. + + + +### Selector + + +Pass `selectRemotePlayback` to `usePlayer` to subscribe to remote playback state. Returns `undefined` if the remote playback feature is not configured. + + + +Pass `selectRemotePlayback` to `PlayerController` to subscribe to remote playback state. Returns `undefined` if the remote playback feature is not configured. + + + +```tsx title="CastButton.tsx" +import { selectRemotePlayback, usePlayer } from '@videojs/react'; + +function CastButton() { + const remotePlayback = usePlayer(selectRemotePlayback); + if (!remotePlayback || remotePlayback.remotePlaybackAvailability !== 'available') return null; + + return ( + + ); +} +``` + + + +```ts title="cast-button.ts" +import { createPlayer, MediaElement, selectRemotePlayback } from '@videojs/html'; +import { videoFeatures } from '@videojs/html/video'; + +const { PlayerController, context } = createPlayer({ features: videoFeatures }); + +class CastButton extends MediaElement { + readonly #remotePlayback = new PlayerController(this, context, selectRemotePlayback); +} +``` + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index e9b697b0..038d804c 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -124,13 +124,13 @@ 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' }, { slug: 'reference/feature-pip', sidebarLabel: 'Picture-in-picture' }, { slug: 'reference/feature-playback' }, { slug: 'reference/feature-playback-rate' }, + { slug: 'reference/feature-remote-playback' }, { slug: 'reference/feature-source' }, { slug: 'reference/feature-text-tracks' }, { slug: 'reference/feature-time' },