diff --git a/.github/release-please/release-please-config.json b/.github/release-please/release-please-config.json index 22ab3ea9e5..234690bb64 100644 --- a/.github/release-please/release-please-config.json +++ b/.github/release-please/release-please-config.json @@ -17,6 +17,7 @@ "@videojs/core", "@videojs/dash-video", "@videojs/element", + "@videojs/fcast", "@videojs/google-cast", "@videojs/hlsjs-video", "@videojs/html", @@ -63,6 +64,9 @@ "packages/element": { "component": "@videojs/element" }, + "packages/extensions/fcast": { + "component": "@videojs/fcast" + }, "packages/extensions/google-cast": { "component": "@videojs/google-cast" }, diff --git a/apps/sandbox/package.json b/apps/sandbox/package.json index e31e386967..3b2d0e1f47 100644 --- a/apps/sandbox/package.json +++ b/apps/sandbox/package.json @@ -17,6 +17,7 @@ "@videojs/cloudflare-video": "workspace:*", "@videojs/core": "workspace:*", "@videojs/dash-video": "workspace:*", + "@videojs/fcast": "workspace:*", "@videojs/google-cast": "workspace:*", "@videojs/hlsjs-video": "workspace:*", "@videojs/html": "workspace:*", diff --git a/apps/sandbox/scripts/sync-source-owned-skins.ts b/apps/sandbox/scripts/sync-source-owned-skins.ts index 5c12d88d41..5b39b0b65a 100644 --- a/apps/sandbox/scripts/sync-source-owned-skins.ts +++ b/apps/sandbox/scripts/sync-source-owned-skins.ts @@ -235,6 +235,7 @@ async function writeFixture(root: string, address: string, alias: string, baseSt // Every package the registry items depend on, so the CLI copies files without installing anything. dependencies: { '@videojs/core': '*', + '@videojs/fcast': '*', '@videojs/html': '*', '@videojs/react': '*', cn: '*', diff --git a/commitlint.config.js b/commitlint.config.js index 66e7592a29..328fd802ad 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -40,6 +40,7 @@ export default { 'youtube-video', 'native-hls-video', 'dash-video', + 'fcast', 'google-cast', 'hlsjs-video', 'mux-audio', diff --git a/packages/cdn/package.json b/packages/cdn/package.json index f222d63653..6463596bc2 100644 --- a/packages/cdn/package.json +++ b/packages/cdn/package.json @@ -86,6 +86,7 @@ "@videojs/cloudflare-video": "workspace:*", "@videojs/core": "workspace:*", "@videojs/dash-video": "workspace:*", + "@videojs/fcast": "workspace:*", "@videojs/google-cast": "workspace:*", "@videojs/hlsjs-video": "workspace:*", "@videojs/html": "workspace:*", diff --git a/packages/core/src/core/ui/airplay-button/core.ts b/packages/core/src/core/ui/airplay-button/core.ts index 1f0c56d5c1..a74382fe94 100644 --- a/packages/core/src/core/ui/airplay-button/core.ts +++ b/packages/core/src/core/ui/airplay-button/core.ts @@ -15,6 +15,8 @@ export interface AirPlayButtonProps { label?: Text | string | ((state: AirPlayButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; + /** Keep the button visible while AirPlay is unavailable. */ + showUnavailable?: boolean | undefined; } export interface AirPlayButtonState extends ButtonState { @@ -33,6 +35,7 @@ export class AirPlayButtonCore { static readonly defaultProps: NonNullableObject = { label: '', disabled: false, + showUnavailable: false, }; readonly state = createState({ @@ -89,7 +92,7 @@ export class AirPlayButtonCore { state: media.remotePlaybackState, availability, disabled: this.#props.disabled || availability !== 'available', - hidden: availability !== 'available', + hidden: availability !== 'available' && !this.#props.showUnavailable, }); this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); diff --git a/packages/core/src/core/ui/airplay-button/tests/core.test.ts b/packages/core/src/core/ui/airplay-button/tests/core.test.ts index a5f32d2fa2..b53af342d8 100644 --- a/packages/core/src/core/ui/airplay-button/tests/core.test.ts +++ b/packages/core/src/core/ui/airplay-button/tests/core.test.ts @@ -76,6 +76,16 @@ describe('AirPlayButtonCore', () => { expect(state.hidden).toBe(true); }); + it('keeps unavailable AirPlay visible and disabled when requested', () => { + const core = new AirPlayButtonCore({ showUnavailable: true }); + + core.setMedia(createMediaState({ remotePlaybackAvailability: 'unavailable' })); + const state = core.getState(); + + expect(state.disabled).toBe(true); + expect(state.hidden).toBe(false); + }); + it('reflects connecting state', () => { const core = new AirPlayButtonCore(); diff --git a/packages/core/src/core/ui/fcast-button/component.ts b/packages/core/src/core/ui/fcast-button/component.ts new file mode 100644 index 0000000000..6ee2f3a91e --- /dev/null +++ b/packages/core/src/core/ui/fcast-button/component.ts @@ -0,0 +1,11 @@ +import { defineComponent } from 'vjsc/components'; + +/** Skin authoring contract; the runtime sender is supplied by @videojs/fcast. */ +export default defineComponent<{ + sender?: unknown; + src?: string | undefined; + contentType?: string | undefined; + showUnavailable?: boolean | undefined; +}>({ + name: 'FCastButton', +}); diff --git a/packages/extensions/fcast/README.md b/packages/extensions/fcast/README.md new file mode 100644 index 0000000000..08dfef100a --- /dev/null +++ b/packages/extensions/fcast/README.md @@ -0,0 +1,63 @@ +# @videojs/fcast + +An FCast casting option for Video.js 10. The package connects player playback controls to an FCast sender supplied by +your application. The sender handles receiver discovery, the device picker, the FCast connection, and receiver state. +Browsers cannot use the FCast SDK's TCP and mDNS features directly. Use a native SDK sender or a local bridge behind the +`FCastBridge` interface (`FCastSender` remains a compatible alias). This package defines the bridge contract; it does not implement a bridge. + +```bash +pnpm add @videojs/react @videojs/fcast +# or +pnpm add @videojs/html @videojs/fcast +``` + +React: + +```tsx +import { FCastButton } from '@videojs/react/ui/fcast-button'; +import { CastButton } from '@videojs/react/ui/cast-button'; +import { AirPlayButton } from '@videojs/react/ui/airplay-button'; +import type { FCastSender } from '@videojs/fcast'; + +function CastingControls({ sender }: { sender: FCastSender }) { + return <> + + + FCast + ; +} +``` + +HTML: + +```ts +import '@videojs/html/ui/cast-button'; +import '@videojs/html/ui/airplay-button'; +import '@videojs/html/ui/fcast-button'; +import type { FCastSender } from '@videojs/fcast'; + +declare const sender: FCastSender; // Your application-provided native sender or bridge. +const button = document.querySelector('media-fcast-button'); +button!.sender = sender; +``` + +```html + + +FCast +``` + +Place the controls inside a Video.js player. The button registers its own extension, so it can be included beside the +other controls. The FCast sender's `snapshot` reports availability, connection, playback position, duration, volume, +mute state, and speed. It dispatches a `change` event whenever those values change. `prompt()` opens the application +device picker; `disconnect()` ends the session; `load()` and the remaining commands control the receiver. The button +uses `src`/`contentType` when the receiver needs a different playable URL or MIME type from the local media. + +The packaged video and live video skins include an FCast button, hidden until a sender is supplied. In React, pass +`fcastSender={sender}` to the skin. In HTML, assign `skin.fcastSender = sender` on the `` (or live video skin) +element. The skin also accepts `fcastSrc` and `fcastContentType` when the receiver needs a different source. + +See the [FCast SDK](https://docs.fcast.org/sdk/) for native sender libraries and the +[FCast protocol](https://docs.fcast.org/protocol/v4/) for a bridge implementation. + +The [FCast extension reference](https://videojs.org/docs/framework/react/reference/components/fcast) documents bridge lifecycle, command ordering, state normalization, and error events. diff --git a/packages/extensions/fcast/package.json b/packages/extensions/fcast/package.json new file mode 100644 index 0000000000..736c23358f --- /dev/null +++ b/packages/extensions/fcast/package.json @@ -0,0 +1,51 @@ +{ + "name": "@videojs/fcast", + "version": "10.0.1", + "description": "FCast sender integration for Video.js", + "keywords": [ + "cast", + "fcast", + "media", + "videojs" + ], + "homepage": "https://videojs.org", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/videojs/v10", + "directory": "packages/extensions/fcast" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "dist/default/index.js", + "module": "dist/default/index.js", + "types": "dist/dev/index.d.ts", + "exports": { + ".": { + "types": "./dist/dev/index.d.ts", + "development": "./dist/dev/index.js", + "default": "./dist/default/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build:watch": "vp pack --watch ./src --no-clean", + "dev": "pnpm run build:watch", + "test": "vp test run", + "clean": "rimraf --glob dist types '*.tsbuildinfo'" + }, + "dependencies": { + "@videojs/core": "workspace:*", + "@videojs/media": "workspace:*", + "@videojs/utils": "workspace:*" + }, + "devDependencies": { + "jsdom": "^26.1.0", + "vite-plus": "catalog:" + } +} diff --git a/packages/extensions/fcast/src/extension.ts b/packages/extensions/fcast/src/extension.ts new file mode 100644 index 0000000000..60770a26c5 --- /dev/null +++ b/packages/extensions/fcast/src/extension.ts @@ -0,0 +1,421 @@ +import type { MediaOverride, PlayerTarget } from '@videojs/core/dom'; +import { resolveMimeType } from '@videojs/media'; +import { getMediaElement, type HTMLMediaTargetLike } from '@videojs/media/dom'; +import { isUndefined } from '@videojs/utils/predicate'; + +import type { FCastSender, FCastSnapshot } from './sender'; + +const EMPTY_SNAPSHOT: FCastSnapshot = { + availability: 'unsupported', + connection: 'disconnected', + paused: true, + currentTime: 0, + duration: NaN, + volume: 1, + muted: false, + speed: 1, +}; + +/** @experimental */ +export interface FCastExtensionProps { + /** Native SDK sender or application bridge that discovers and controls FCast receivers. */ + sender?: FCastSender | undefined; + /** URL loaded by the receiver; defaults to the attached media's playable URL. */ + src?: string | undefined; + /** MIME type sent to the receiver; inferred from the URL when absent. */ + contentType?: string | undefined; +} + +/** + * Routes player playback controls to an application-provided FCast sender while connected. A separate FCast button can + * register this extension alongside Google Cast and AirPlay controls; FCast never replaces their Remote Playback API. + * + * @experimental + */ +export class FCastExtension extends EventTarget implements FCastExtensionProps { + static readonly defaultProps: FCastExtensionProps = { + sender: undefined, + src: undefined, + contentType: undefined, + }; + + #sender: FCastSender | undefined; + #src: string | undefined; + #contentType: string | undefined; + #media: HTMLMediaTargetLike | null = null; + #detachedMedia: HTMLMediaTargetLike | null = null; + #loadedSrc: string | null = null; + #pendingLoad: Promise | null = null; + #connected = false; + #wasPlaying = false; + #firstLoad = false; + #replaying = false; + #lastSnapshot = EMPTY_SNAPSHOT; + #override: MediaOverride; + #version = 0; + #session = 0; + #unmutedVolume = 1; + + constructor(props: FCastExtensionProps = {}) { + super(); + this.#override = this.#createOverride(); + Object.assign(this, props); + } + + /** @internal Player lifecycle. */ + attach({ media }: PlayerTarget): void { + // SAFETY: the DOM player resolves native elements and HTML media adapters with this shared surface. + const target = media as HTMLMediaTargetLike; + if (this.#media === target) return; + + this.detach(); + this.#media = target; + this.#detachedMedia = null; + target.addEventListener('loadstart', this.#onLoadStart); + + if (this.#connected && this.enabled) { + this.#wasPlaying = !target.paused; + target.pause(); + this.#firstLoad = true; + this.#loadedSrc = null; + } + + this.#followSource(); + this.#notify(); + } + + /** @internal Player lifecycle. */ + detach(): void { + const media = this.#media; + + media?.removeEventListener('loadstart', this.#onLoadStart); + this.#detachedMedia = media; + this.#media = null; + this.#invalidateLoads(); + + if (media) this.#notify(); + } + + destroy(): void { + this.disconnect(); + this.detach(); + this.#sender?.removeEventListener('change', this.#onSenderChange); + this.#sender?.removeEventListener('error', this.#onSenderError); + + this.#sender = undefined; + this.#connected = false; + this.#detachedMedia = null; + } + + /** @internal Player lifecycle. */ + disconnect(): void { + if (!this.#connected && this.snapshot.connection !== 'connecting') return; + + const wasConnected = this.#connected; + + this.#connected = false; + this.#invalidateLoads(); + + if (wasConnected) this.#restoreLocal(this.#lastSnapshot, this.#media ?? this.#detachedMedia); + + void this.#sender?.disconnect().catch(this.#reportError); + this.#notify(); + } + + /** @internal Read by the player media facade. */ + get mediaOverride(): MediaOverride | null { + return this.#connected && this.enabled ? this.#override : null; + } + + /** Last state published by the supplied sender. */ + get snapshot(): FCastSnapshot { + return this.#sender?.snapshot ?? EMPTY_SNAPSHOT; + } + + /** Increments whenever the sender publishes state; suitable for subscribing to this extension. */ + get version(): number { + return this.#version; + } + + /** Whether the attached media permits remote playback. */ + get enabled(): boolean { + return !!this.#media && !this.#media.disableRemotePlayback; + } + + get sender(): FCastSender | undefined { + return this.#sender; + } + + set sender(value: FCastSender | undefined) { + if (this.#sender === value) return; + + const previous = this.#sender; + + previous?.removeEventListener('change', this.#onSenderChange); + previous?.removeEventListener('error', this.#onSenderError); + + if (previous && previous.snapshot.connection !== 'disconnected') { + if (this.#connected) this.#restoreLocal(this.#lastSnapshot, this.#media ?? this.#detachedMedia); + + this.#connected = false; + void previous.disconnect().catch(this.#reportError); + } + + this.#invalidateLoads(); + this.#sender = value; + this.#lastSnapshot = EMPTY_SNAPSHOT; + value?.addEventListener('change', this.#onSenderChange); + value?.addEventListener('error', this.#onSenderError); + this.#onSenderChange(); + } + + get src(): string { + if (!isUndefined(this.#src)) return this.#src; + + const media = this.#media; + if (!media || !getMediaElement(media)) return ''; + + if (media.src && !media.src.startsWith('blob:')) return media.src; + + if (media.currentSrc && !media.currentSrc.startsWith('blob:')) return media.currentSrc; + + return media.querySelector('source')?.src ?? ''; + } + + set src(value: string | undefined) { + if (this.#src === value) return; + + this.#src = value; + this.#followSource(); + } + + get contentType(): string | undefined { + return this.#contentType; + } + + set contentType(value: string | undefined) { + if (this.#contentType === value) return; + + this.#contentType = value; + this.#loadedSrc = null; + this.#followSource(); + } + + /** Ask the supplied sender to pick a receiver, or disconnect the current one. */ + async toggle(): Promise { + const sender = this.#sender; + if (!sender) throw new DOMException('FCast sender is not configured.', 'NotSupportedError'); + + if (!this.enabled) throw new DOMException('Remote playback is disabled.', 'InvalidStateError'); + + if (sender.snapshot.connection === 'connected') await sender.disconnect(); + else if (sender.snapshot.connection !== 'connecting') await sender.prompt(); + } + + /** Load the current media URL on the connected receiver. */ + async load(): Promise { + const sender = this.#sender; + const media = this.#media; + const src = this.src; + if (!sender || !media || !this.#connected || !this.enabled || !src) return; + + if (src === this.#loadedSrc) return this.#pendingLoad ?? undefined; + + const firstLoad = this.#firstLoad; + const session = this.#session; + + this.#firstLoad = false; + this.#loadedSrc = src; + const request = { + url: src, + contentType: this.#contentType ?? resolveMimeType(src) ?? '', + time: sender.snapshot.ended ? 0 : media.currentTime || 0, + paused: this.#replaying ? false : firstLoad ? !this.#wasPlaying : sender.snapshot.paused, + volume: firstLoad ? (media.muted ? 0 : media.volume) : sender.snapshot.muted ? 0 : sender.snapshot.volume, + speed: firstLoad ? media.playbackRate : sender.snapshot.speed, + }; + + this.#replaying = false; + + const pending = (this.#pendingLoad ?? Promise.resolve()) + .catch(() => {}) + .then(() => { + if (session !== this.#session || sender !== this.#sender || !this.#connected || !this.enabled) return; + + return sender.load(request); + }) + .catch((error: unknown) => { + if (session === this.#session && this.#loadedSrc === src) { + this.#loadedSrc = null; + this.#firstLoad = firstLoad; + } + + throw error; + }); + + this.#pendingLoad = pending; + + try { + await pending; + } finally { + if (this.#pendingLoad === pending) this.#pendingLoad = null; + } + } + + #invalidateLoads(): void { + this.#session += 1; + this.#loadedSrc = null; + this.#pendingLoad = null; + } + + #onLoadStart = () => { + if (this.#connected && this.enabled) this.#media?.pause(); + + this.#followSource(); + }; + + #followSource(): void { + if (this.#connected && this.src && this.#loadedSrc !== this.src) { + void this.load().catch(this.#reportError); + } + } + + #onSenderChange = (): void => { + const next = this.snapshot; + const previous = this.#lastSnapshot; + const connected = next.connection === 'connected'; + const wasConnected = this.#connected; + + if (connected && !wasConnected) { + this.#wasPlaying = !this.#media?.paused; + + if (this.enabled) this.#media?.pause(); + + this.#connected = true; + this.#firstLoad = true; + this.#loadedSrc = null; + this.#followSource(); + } else if (!connected && wasConnected) { + this.#connected = false; + this.#invalidateLoads(); + this.#restoreLocal(previous, this.#media ?? this.#detachedMedia); + } + + if (next.volume > 0) this.#unmutedVolume = next.volume; + + if (connected && this.enabled) { + if (!wasConnected || next.paused !== previous.paused) { + this.#media?.dispatchEvent(new Event(next.paused ? 'pause' : 'play')); + } + + if (!wasConnected || next.currentTime !== previous.currentTime) + this.#media?.dispatchEvent(new Event('timeupdate')); + + if (!wasConnected || next.duration !== previous.duration) this.#media?.dispatchEvent(new Event('durationchange')); + + if (!wasConnected || next.volume !== previous.volume || next.muted !== previous.muted) { + this.#media?.dispatchEvent(new Event('volumechange')); + } + + if (!wasConnected || next.speed !== previous.speed) this.#media?.dispatchEvent(new Event('ratechange')); + + if (next.buffering && !previous.buffering) this.#media?.dispatchEvent(new Event('waiting')); + + if (!next.paused && !next.buffering && (!wasConnected || previous.paused || previous.buffering)) { + this.#media?.dispatchEvent(new Event('playing')); + } + + if (next.ended && !previous.ended) this.#media?.dispatchEvent(new Event('ended')); + } + + this.#lastSnapshot = { ...next }; + this.#notify(); + }; + + #notify(): void { + this.#version += 1; + this.dispatchEvent(new Event('change')); + } + + #restoreLocal(snapshot: FCastSnapshot, media = this.#media): void { + if (!media || media.disableRemotePlayback) return; + + if (Number.isFinite(snapshot.currentTime)) media.currentTime = snapshot.currentTime; + + media.volume = snapshot.volume; + media.muted = snapshot.muted; + media.playbackRate = snapshot.speed; + + if (!snapshot.paused && !snapshot.ended) void media.play().catch(this.#reportError); + else media.pause(); + } + + #createOverride(): MediaOverride { + const extension = this; + + return { + get paused() { + return extension.snapshot.paused; + }, + get ended() { + return extension.snapshot.ended ?? false; + }, + get readyState() { + return extension.snapshot.buffering ? 2 : 3; + }, + get currentTime() { + return extension.snapshot.currentTime; + }, + set currentTime(value: number) { + void extension.#sender?.seek(value).catch(extension.#reportError); + }, + get duration() { + return extension.snapshot.duration; + }, + get volume() { + return extension.snapshot.volume; + }, + set volume(value: number) { + void extension.#sender?.setVolume(value).catch(extension.#reportError); + }, + get muted() { + return extension.snapshot.muted; + }, + set muted(value: boolean) { + void extension.#sender?.setVolume(value ? 0 : extension.#unmutedVolume).catch(extension.#reportError); + }, + get playbackRate() { + return extension.snapshot.speed; + }, + set playbackRate(value: number) { + void extension.#sender?.setSpeed(value).catch(extension.#reportError); + }, + play() { + if (extension.snapshot.ended) { + extension.#loadedSrc = null; + extension.#replaying = true; + return extension.load(); + } + + return extension.#sender?.play() ?? Promise.resolve(); + }, + pause() { + void extension.#sender?.pause().catch(extension.#reportError); + }, + load() { + void extension.load().catch(extension.#reportError); + }, + }; + } + + #onSenderError = (event: Event): void => { + // SAFETY: FCastBridge specifies error events as CustomEvent. + this.#reportError((event as CustomEvent).detail); + }; + + #reportError = (error: unknown): void => { + this.dispatchEvent(new CustomEvent('error', { detail: error })); + + if (__DEV__) console.error('[FCast]', error); + }; +} diff --git a/packages/extensions/fcast/src/globals.d.ts b/packages/extensions/fcast/src/globals.d.ts new file mode 100644 index 0000000000..fdec227a9e --- /dev/null +++ b/packages/extensions/fcast/src/globals.d.ts @@ -0,0 +1,5 @@ +declare global { + const __DEV__: boolean; +} + +export {}; diff --git a/packages/extensions/fcast/src/index.ts b/packages/extensions/fcast/src/index.ts new file mode 100644 index 0000000000..b0a3293eaf --- /dev/null +++ b/packages/extensions/fcast/src/index.ts @@ -0,0 +1,2 @@ +export * from './extension'; +export type * from './sender'; diff --git a/packages/extensions/fcast/src/sender.ts b/packages/extensions/fcast/src/sender.ts new file mode 100644 index 0000000000..adc35b97ad --- /dev/null +++ b/packages/extensions/fcast/src/sender.ts @@ -0,0 +1,73 @@ +/** A media load request passed to an application-provided FCast bridge. */ +export interface FCastLoadRequest { + /** Absolute media URL reachable by the receiver, not a browser blob URL or embed page. */ + url: string; + /** Media MIME type. An empty string means the bridge must infer it or reject the load. */ + contentType: string; + /** Initial playback position in seconds. */ + time: number; + /** Desired state after loading; the bridge pauses after loading if the protocol starts playback automatically. */ + paused: boolean; + /** Initial volume between zero and one. Zero represents muted playback. */ + volume: number; + /** Positive playback rate multiplier. */ + speed: number; +} + +/** Normalized receiver state published by an application-provided FCast bridge. */ +export interface FCastSnapshot { + /** Unsupported means no usable bridge; unavailable means discovery currently has no receivers. */ + availability: 'available' | 'unavailable' | 'unsupported'; + connection: 'disconnected' | 'connecting' | 'connected'; + deviceName?: string | undefined; + paused: boolean; + /** Playback position in seconds, irrespective of the negotiated protocol's time units. */ + currentTime: number; + /** Duration in seconds, NaN when unknown, or Infinity for an unbounded live stream. */ + duration: number; + /** Receiver volume between zero and one. */ + volume: number; + /** True when receiver volume is zero; FCast has no independent mute command. */ + muted: boolean; + /** Positive playback rate multiplier. */ + speed: number; + buffering?: boolean | undefined; + ended?: boolean | undefined; +} + +/** + * Standard browser-facing contract for an application-provided native sender or local FCast bridge. + * + * The bridge owns discovery, permissions, receiver selection, protocol negotiation, transport, and heartbeat handling. + * It publishes a complete `snapshot` before dispatching `change`, including on connection loss. Discovery availability + * is independent of an existing connection. Playback state comes from receiver updates, not command completion. + * + * Commands reject on failure and when disconnected. A resolved command means it was sent, not acknowledged by the + * receiver. Asynchronous receiver or transport errors dispatch `CustomEvent('error', { detail: error })`. The + * bridge serializes commands, and disconnect cancels pending selection and unsent commands before stopping receiver + * playback and closing the session. No updates from an old session may overwrite a subsequent session's state. + * + * The application creates and disposes the bridge and owns its discovery resources. One bridge controls one player + * session at a time. Video.js only subscribes, issues commands, and disconnects on removal; it does not destroy the + * bridge. No transport, global injection mechanism, or bridge implementation is supplied by this package. + */ +export interface FCastBridge extends EventTarget { + readonly snapshot: FCastSnapshot; + /** Select a receiver. Publish connecting then connected; reject cancellation with AbortError and restore disconnected. */ + prompt(): Promise; + /** Idempotently stop remote playback, cancel a pending picker/connection, and publish disconnected before resolving. */ + disconnect(): Promise; + /** Replace receiver media and apply the requested position, volume, speed, and paused state in order. */ + load(request: FCastLoadRequest): Promise; + play(): Promise; + pause(): Promise; + /** Seek to a position in seconds. */ + seek(time: number): Promise; + /** Set volume in [0, 1]; zero mutes. Publish both volume and muted from receiver updates. */ + setVolume(volume: number): Promise; + /** Set a positive playback rate multiplier. */ + setSpeed(speed: number): Promise; +} + +/** Compatible name for the FCast bridge accepted by the `sender` property. */ +export type FCastSender = FCastBridge; diff --git a/packages/extensions/fcast/src/tests/extension.test.ts b/packages/extensions/fcast/src/tests/extension.test.ts new file mode 100644 index 0000000000..4e212e0eb2 --- /dev/null +++ b/packages/extensions/fcast/src/tests/extension.test.ts @@ -0,0 +1,304 @@ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; + +import { FCastExtension } from '../extension'; +import type { FCastLoadRequest, FCastSender, FCastSnapshot } from '../sender'; + +class TestSender extends EventTarget implements FCastSender { + snapshot: FCastSnapshot = { + availability: 'available', + connection: 'disconnected', + paused: true, + currentTime: 0, + duration: 120, + volume: 1, + muted: false, + speed: 1, + }; + + loads: FCastLoadRequest[] = []; + play = vi.fn(async () => {}); + pause = vi.fn(async () => {}); + seek = vi.fn(async (_time: number) => {}); + setVolume = vi.fn(async (_volume: number) => {}); + setSpeed = vi.fn(async (_speed: number) => {}); + + async prompt() { + this.update({ connection: 'connected', deviceName: 'Living room' }); + } + + async disconnect() { + this.update({ connection: 'disconnected' }); + } + + async load(request: FCastLoadRequest) { + this.loads.push(request); + } + + update(patch: Partial) { + this.snapshot = { ...this.snapshot, ...patch }; + this.dispatchEvent(new Event('change')); + } +} + +function setup() { + const sender = new TestSender(); + const video = document.createElement('video'); + + video.src = 'https://example.com/video.mp4'; + video.pause = vi.fn(); + video.play = vi.fn(async () => {}); + + const extension = new FCastExtension({ sender }); + + extension.attach({ media: video, container: null }); + + return { sender, video, extension }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('FCastExtension', () => { + it('loads the attached media on a selected receiver and routes playback controls', async () => { + const { sender, video, extension } = setup(); + + await extension.toggle(); + await extension.load(); + + expect(video.pause).toHaveBeenCalledOnce(); + expect(sender.loads).toEqual([ + { + url: 'https://example.com/video.mp4', + contentType: 'video/mp4', + time: 0, + paused: true, + volume: 1, + speed: 1, + }, + ]); + + await extension.mediaOverride?.play?.(); + extension.mediaOverride!.currentTime = 42; + extension.mediaOverride!.volume = 0.5; + + expect(sender.play).toHaveBeenCalledOnce(); + expect(sender.seek).toHaveBeenCalledWith(42); + expect(sender.setVolume).toHaveBeenCalledWith(0.5); + + extension.destroy(); + }); + + it('follows a new source and restores local playback after disconnect', async () => { + const { sender, video, extension } = setup(); + + await extension.toggle(); + await extension.load(); + + video.src = 'https://example.com/next.m3u8'; + video.dispatchEvent(new Event('loadstart')); + await extension.load(); + + expect(sender.loads.at(-1)).toMatchObject({ + url: 'https://example.com/next.m3u8', + contentType: 'application/x-mpegurl', + }); + + sender.update({ currentTime: 37, paused: false, volume: 0.4 }); + await extension.toggle(); + + expect(extension.mediaOverride).toBeNull(); + expect(video.currentTime).toBe(37); + expect(video.volume).toBe(0.4); + expect(video.play).toHaveBeenCalledOnce(); + + extension.destroy(); + }); + + it('keeps the player local until a sender is connected', () => { + const { extension } = setup(); + + expect(extension.mediaOverride).toBeNull(); + expect(extension.snapshot.availability).toBe('available'); + + extension.destroy(); + }); + + it('pauses local media when attached to an already connected sender', async () => { + const sender = new TestSender(); + + sender.update({ connection: 'connected' }); + + const video = document.createElement('video'); + + video.src = 'https://example.com/video.mp4'; + video.pause = vi.fn(); + + const extension = new FCastExtension({ sender }); + + extension.attach({ media: video, container: null }); + await extension.load(); + + expect(video.pause).toHaveBeenCalledOnce(); + expect(sender.loads).toHaveLength(1); + + extension.destroy(); + }); + + it('reloads ended media when the player requests play again', async () => { + const { sender, extension } = setup(); + + await extension.toggle(); + await extension.load(); + + sender.update({ ended: true, paused: true }); + await extension.mediaOverride?.play?.(); + + expect(sender.loads).toHaveLength(2); + expect(sender.loads.at(-1)?.paused).toBe(false); + + extension.destroy(); + }); + + it('restores local playback when the control is removed', async () => { + const { sender, video, extension } = setup(); + + await extension.toggle(); + sender.update({ currentTime: 29, paused: false }); + extension.detach(); + extension.disconnect(); + + expect(video.currentTime).toBe(29); + expect(video.play).toHaveBeenCalledOnce(); + expect(sender.snapshot.connection).toBe('disconnected'); + + extension.destroy(); + }); + + it('does not send queued loads after disconnect or sender replacement', async () => { + const { sender, extension } = setup(); + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const load = vi.spyOn(sender, 'load').mockReturnValueOnce(pending); + + await extension.toggle(); + await vi.waitFor(() => expect(load).toHaveBeenCalledOnce()); + extension.src = 'https://example.com/stale.mp4'; + const stale = extension.load(); + const replacement = new TestSender(); + + extension.sender = replacement; + await extension.toggle(); + await extension.load(); + + expect(replacement.loads).toHaveLength(1); + finish(); + await stale; + expect(load).toHaveBeenCalledOnce(); + extension.destroy(); + }); + + it('preserves remote volume and speed across sources and restores the last audible volume', async () => { + const { sender, extension } = setup(); + + await extension.toggle(); + await extension.load(); + sender.update({ volume: 0.35, speed: 1.5 }); + extension.mediaOverride!.muted = true; + sender.update({ volume: 0, muted: true }); + extension.mediaOverride!.muted = false; + expect(sender.setVolume).toHaveBeenLastCalledWith(0.35); + + extension.src = 'https://example.com/next.mp4'; + await extension.load(); + expect(sender.loads.at(-1)).toMatchObject({ volume: 0, speed: 1.5 }); + extension.destroy(); + }); + + it('replays from zero even when local playback was handed off mid-stream', async () => { + const { sender, video, extension } = setup(); + + video.currentTime = 45; + await extension.toggle(); + await extension.load(); + sender.update({ ended: true, currentTime: 120, volume: 0.4, speed: 2 }); + await extension.mediaOverride!.play!(); + expect(sender.loads.at(-1)).toMatchObject({ time: 0, paused: false, volume: 0.4, speed: 2 }); + extension.destroy(); + }); + + it('does not take over media that disables remote playback', async () => { + const { sender, video, extension } = setup(); + + video.disableRemotePlayback = true; + sender.update({ connection: 'connected' }); + await extension.load(); + expect(sender.loads).toHaveLength(0); + expect(extension.mediaOverride).toBeNull(); + expect(video.pause).not.toHaveBeenCalled(); + await expect(extension.toggle()).rejects.toMatchObject({ name: 'InvalidStateError' }); + extension.destroy(); + }); + + it('cancels a pending connection when destroyed', () => { + const { sender, extension } = setup(); + + sender.update({ connection: 'connecting' }); + extension.destroy(); + expect(sender.snapshot.connection).toBe('disconnected'); + }); + + it('publishes command failures and retries a failed initial load with its original playing state', async () => { + const { sender, video, extension } = setup(); + + vi.spyOn(video, 'paused', 'get').mockReturnValue(false); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new Error('Receiver unavailable'); + + vi.spyOn(sender, 'load').mockRejectedValueOnce(failure); + const onError = vi.fn(); + + extension.addEventListener('error', onError); + await extension.toggle(); + await expect(extension.load()).rejects.toThrow(failure); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ detail: failure })); + await extension.load(); + expect(sender.loads.at(-1)?.paused).toBe(false); + extension.destroy(); + }); + + it('emits playing only for resumed playback, including after buffering', async () => { + const { sender, video, extension } = setup(); + const playing = vi.fn(); + + video.addEventListener('playing', playing); + await extension.toggle(); + await extension.load(); + sender.update({ buffering: true }); + sender.update({ buffering: false }); + expect(playing).not.toHaveBeenCalled(); + sender.update({ paused: false }); + expect(playing).toHaveBeenCalledOnce(); + sender.update({ buffering: true }); + sender.update({ buffering: false }); + expect(playing).toHaveBeenCalledTimes(2); + extension.destroy(); + }); + + it('forwards bridge errors and removes listeners from replaced senders', () => { + const { sender, extension } = setup(); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + const onError = vi.fn(); + + extension.addEventListener('error', onError); + const error = new Error('Transport lost'); + + sender.dispatchEvent(new CustomEvent('error', { detail: error })); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ detail: error })); + extension.sender = undefined; + sender.dispatchEvent(new CustomEvent('error', { detail: error })); + expect(onError).toHaveBeenCalledOnce(); + extension.destroy(); + }); +}); diff --git a/packages/extensions/fcast/tsconfig.dts.json b/packages/extensions/fcast/tsconfig.dts.json new file mode 100644 index 0000000000..44aa4531f2 --- /dev/null +++ b/packages/extensions/fcast/tsconfig.dts.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "incremental": false, + "declarationMap": false, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "useDefineForClassFields": false + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/extensions/fcast/tsconfig.json b/packages/extensions/fcast/tsconfig.json new file mode 100644 index 0000000000..3f387eab75 --- /dev/null +++ b/packages/extensions/fcast/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "declarationDir": "types", + "useDefineForClassFields": false + }, + "references": [ + { "path": "../../core" }, + { "path": "../../core/src/dom" }, + { "path": "../../media" }, + { "path": "../../media/src/dom" }, + { "path": "../../utils" } + ], + "include": ["src"] +} diff --git a/packages/extensions/fcast/vite.config.ts b/packages/extensions/fcast/vite.config.ts new file mode 100644 index 0000000000..b7a7a0b48c --- /dev/null +++ b/packages/extensions/fcast/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite-plus'; +import type { UserConfig as PackUserConfig } from 'vite-plus/pack'; + +import { type PackageBuildMode, packageBuildConfig, packageBuildModes } from '../../../build/pack.ts'; +import { cachedTaskInputs, packageTestTask, workspaceTaskDependencies } from '../../../build/task.ts'; + +const createPackConfig = (mode: PackageBuildMode): PackUserConfig => ({ + ...packageBuildConfig(mode, 'browser'), + entry: { index: './src/index.ts' }, +}); + +export default defineConfig({ + run: { + tasks: { + build: { + command: 'vp pack', + dependsOn: workspaceTaskDependencies(), + cache: { input: cachedTaskInputs, output: ['dist/**'] }, + }, + 'test:ci': packageTestTask(), + }, + }, + define: { __DEV__: 'true' }, + test: { environment: 'jsdom' }, + pack: packageBuildModes.map(createPackConfig), +}); diff --git a/packages/html/package.json b/packages/html/package.json index cbf7814f88..4bfb5d2180 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -173,6 +173,7 @@ "dependencies": { "@videojs/core": "workspace:*", "@videojs/element": "workspace:*", + "@videojs/fcast": "workspace:*", "@videojs/media": "workspace:*", "@videojs/native-hls-video": "workspace:*", "@videojs/spf": "workspace:*", diff --git a/packages/html/src/define/ui/fcast-button.ts b/packages/html/src/define/ui/fcast-button.ts new file mode 100644 index 0000000000..22c3ac5ba3 --- /dev/null +++ b/packages/html/src/define/ui/fcast-button.ts @@ -0,0 +1,10 @@ +import { safeDefine } from '../../registration/safe-define'; +import { FCastButtonElement } from '../../ui/fcast-button/element'; + +safeDefine(FCastButtonElement); + +declare global { + interface HTMLElementTagNameMap { + [FCastButtonElement.tagName]: FCastButtonElement; + } +} diff --git a/packages/html/src/extensions/player-extension-element.ts b/packages/html/src/extensions/player-extension-element.ts index cb56c95883..0db5c89102 100644 --- a/packages/html/src/extensions/player-extension-element.ts +++ b/packages/html/src/extensions/player-extension-element.ts @@ -27,6 +27,23 @@ export abstract class PlayerExtensionElement extends U */ protected abstract createExtension(): Extension & PlayerExtension; + /** Whether this element currently has enough configuration to register its extension. */ + protected get shouldRegisterExtension(): boolean { + return true; + } + + /** Re-evaluate registration after a required property changes. */ + protected refreshExtensionRegistration(): void { + if (!this.#register) return; + + if (this.shouldRegisterExtension) { + if (!this.#release) this.#release = this.#register(this.#getExtension()); + } else { + this.#release?.(); + this.#release = null; + } + } + /** The player extension instance registered with the player. */ protected get extension(): Extension { return this.#getExtension(); @@ -62,7 +79,7 @@ export abstract class PlayerExtensionElement extends U this.#release = null; this.#register = register; - if (register) this.#release = register(this.#getExtension()); + if (register && this.shouldRegisterExtension) this.#release = register(this.#getExtension()); } #getExtension(): Extension & PlayerExtension { diff --git a/packages/html/src/presets/live-video/compat-skin.ts b/packages/html/src/presets/live-video/compat-skin.ts index df8953448b..a2ed4f9454 100644 --- a/packages/html/src/presets/live-video/compat-skin.ts +++ b/packages/html/src/presets/live-video/compat-skin.ts @@ -1,12 +1,12 @@ import { createShadowStyle } from '@videojs/utils/dom'; import { template } from '../../internal/skins/compat-live-video/template'; -import { SkinElement } from '../skin'; +import { FCastVideoSkinElement } from '../video-skin'; import styles from '../../define/live-video/compat-skin.css?inline'; /** Packaged compat live-video UI registered as ``. */ -export class CompatLiveVideoSkinElement extends SkinElement { +export class CompatLiveVideoSkinElement extends FCastVideoSkinElement { static readonly tagName = 'live-video-compat-skin'; static styles = createShadowStyle(styles); static template = template; diff --git a/packages/html/src/presets/live-video/neutral-skin.ts b/packages/html/src/presets/live-video/neutral-skin.ts index eff6c12472..bcf6019709 100644 --- a/packages/html/src/presets/live-video/neutral-skin.ts +++ b/packages/html/src/presets/live-video/neutral-skin.ts @@ -1,12 +1,12 @@ import { createShadowStyle } from '@videojs/utils/dom'; import { template } from '../../internal/skins/neutral-live-video/template'; -import { SkinElement } from '../skin'; +import { FCastVideoSkinElement } from '../video-skin'; import styles from '../../define/live-video/neutral-skin.css?inline'; /** Packaged Neutral live-video UI registered as ``. */ -export class NeutralLiveVideoSkinElement extends SkinElement { +export class NeutralLiveVideoSkinElement extends FCastVideoSkinElement { static readonly tagName = 'live-video-neutral-skin'; static styles: CSSStyleSheet | string = createShadowStyle(styles); static template = template; diff --git a/packages/html/src/presets/live-video/skin.ts b/packages/html/src/presets/live-video/skin.ts index 2adbf7fc92..d7ac06415a 100644 --- a/packages/html/src/presets/live-video/skin.ts +++ b/packages/html/src/presets/live-video/skin.ts @@ -1,12 +1,12 @@ import { createShadowStyle } from '@videojs/utils/dom'; import { template } from '../../internal/skins/default-live-video/template'; -import { SkinElement } from '../skin'; +import { FCastVideoSkinElement } from '../video-skin'; import styles from '../../define/live-video/skin.css?inline'; /** Packaged default live-video UI registered as ``. */ -export class LiveVideoSkinElement extends SkinElement { +export class LiveVideoSkinElement extends FCastVideoSkinElement { static readonly tagName = 'live-video-skin'; static styles: CSSStyleSheet | string = createShadowStyle(styles); static template = template; diff --git a/packages/html/src/presets/tests/video-skin.test.ts b/packages/html/src/presets/tests/video-skin.test.ts new file mode 100644 index 0000000000..2945e6e9e9 --- /dev/null +++ b/packages/html/src/presets/tests/video-skin.test.ts @@ -0,0 +1,60 @@ +import type { FCastSender, FCastSnapshot } from '@videojs/fcast'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; + +import { template as videoTemplate } from '../../internal/skins/default-video/template'; +import { FCastVideoSkinElement } from '../video-skin'; + +let tagId = 0; + +afterEach(() => { + document.body.replaceChildren(); + document.getElementById('__media-styles')?.remove(); +}); + +describe('FCastVideoSkinElement', () => { + it('forwards FCast configuration to the built-in video control', () => { + const tag = `test-fcast-video-skin-${tagId++}`; + + customElements.define( + tag, + class extends FCastVideoSkinElement { + static template = videoTemplate; + } + ); + const skin = document.createElement(tag); + if (!(skin instanceof FCastVideoSkinElement)) throw new Error(`Failed to create ${tag}`); + + const button = skin.shadowRoot?.querySelector('media-fcast-button'); + + expect(button).not.toBeNull(); + const snapshot: FCastSnapshot = { + availability: 'available', + connection: 'disconnected', + paused: true, + currentTime: 0, + duration: 0, + volume: 1, + muted: false, + speed: 1, + }; + const sender = Object.assign(new EventTarget(), { + snapshot, + prompt: async () => {}, + disconnect: async () => {}, + load: async () => {}, + play: async () => {}, + pause: async () => {}, + seek: async () => {}, + setVolume: async () => {}, + setSpeed: async () => {}, + }) satisfies FCastSender; + + skin.fcastSender = sender; + skin.fcastSrc = 'https://example.com/receiver.m3u8'; + skin.fcastContentType = 'application/vnd.apple.mpegurl'; + + expect(skin.fcastSender).toBe(sender); + expect(skin.fcastSrc).toBe('https://example.com/receiver.m3u8'); + expect(skin.fcastContentType).toBe('application/vnd.apple.mpegurl'); + }); +}); diff --git a/packages/html/src/presets/video-skin.ts b/packages/html/src/presets/video-skin.ts new file mode 100644 index 0000000000..602462b32f --- /dev/null +++ b/packages/html/src/presets/video-skin.ts @@ -0,0 +1,36 @@ +import type { FCastSender } from '@videojs/fcast'; + +import type { FCastButtonElement } from '../ui/fcast-button/element'; +import { SkinElement } from './skin'; + +/** Shared FCast configuration for packaged video and live-video skins. @internal */ +export class FCastVideoSkinElement extends SkinElement { + /** FCast bridge used by the built-in FCast control. */ + get fcastSender(): FCastSender | undefined { + return this.#fcastButton?.sender; + } + + set fcastSender(value: FCastSender | undefined) { + if (this.#fcastButton) this.#fcastButton.sender = value; + } + + get fcastSrc(): string | undefined { + return this.#fcastButton?.src; + } + + set fcastSrc(value: string | undefined) { + if (this.#fcastButton) this.#fcastButton.src = value; + } + + get fcastContentType(): string | undefined { + return this.#fcastButton?.contentType; + } + + set fcastContentType(value: string | undefined) { + if (this.#fcastButton) this.#fcastButton.contentType = value; + } + + get #fcastButton(): FCastButtonElement | null { + return this.shadowRoot?.querySelector('media-fcast-button') ?? null; + } +} diff --git a/packages/html/src/presets/video/compat-skin.ts b/packages/html/src/presets/video/compat-skin.ts index 60b8ed31da..05cbd97e53 100644 --- a/packages/html/src/presets/video/compat-skin.ts +++ b/packages/html/src/presets/video/compat-skin.ts @@ -1,12 +1,12 @@ import { createShadowStyle } from '@videojs/utils/dom'; import { template } from '../../internal/skins/compat-video/template'; -import { SkinElement } from '../skin'; +import { FCastVideoSkinElement } from '../video-skin'; import styles from '../../define/video/compat-skin.css?inline'; /** Packaged compat video UI registered as ``. */ -export class CompatVideoSkinElement extends SkinElement { +export class CompatVideoSkinElement extends FCastVideoSkinElement { static readonly tagName = 'video-compat-skin'; static styles = createShadowStyle(styles); static template = template; diff --git a/packages/html/src/presets/video/neutral-skin.ts b/packages/html/src/presets/video/neutral-skin.ts index 797c84000e..788e58d919 100644 --- a/packages/html/src/presets/video/neutral-skin.ts +++ b/packages/html/src/presets/video/neutral-skin.ts @@ -1,12 +1,12 @@ import { createShadowStyle } from '@videojs/utils/dom'; import { template } from '../../internal/skins/neutral-video/template'; -import { SkinElement } from '../skin'; +import { FCastVideoSkinElement } from '../video-skin'; import styles from '../../define/video/neutral-skin.css?inline'; /** Packaged Neutral video UI registered as ``. */ -export class NeutralVideoSkinElement extends SkinElement { +export class NeutralVideoSkinElement extends FCastVideoSkinElement { static readonly tagName = 'video-neutral-skin'; static styles: CSSStyleSheet | string = createShadowStyle(styles); static template = template; diff --git a/packages/html/src/presets/video/skin.ts b/packages/html/src/presets/video/skin.ts index b8c57abd7c..3fd94a9ac9 100644 --- a/packages/html/src/presets/video/skin.ts +++ b/packages/html/src/presets/video/skin.ts @@ -1,12 +1,12 @@ import { createShadowStyle } from '@videojs/utils/dom'; import { template } from '../../internal/skins/default-video/template'; -import { SkinElement } from '../skin'; +import { FCastVideoSkinElement } from '../video-skin'; import styles from '../../define/video/skin.css?inline'; /** Packaged default video UI registered as ``. */ -export class VideoSkinElement extends SkinElement { +export class VideoSkinElement extends FCastVideoSkinElement { static readonly tagName = 'video-skin'; static styles: CSSStyleSheet | string = createShadowStyle(styles); static template = template; diff --git a/packages/html/src/ui/airplay-button/element.ts b/packages/html/src/ui/airplay-button/element.ts index e6c131fc0b..b99a2b0afd 100644 --- a/packages/html/src/ui/airplay-button/element.ts +++ b/packages/html/src/ui/airplay-button/element.ts @@ -1,5 +1,6 @@ import { AirPlayButtonCore, AirPlayButtonDataAttrs, type AirPlayButtonState } from '@videojs/core'; import { selectRemotePlayback } from '@videojs/core/dom'; +import type { PropertyDeclarationMap } from '@videojs/element'; import type { MediaRemotePlaybackState } from '@videojs/media'; import { playerContext } from '../../player/context'; @@ -9,6 +10,12 @@ import { MediaButtonElement } from '../media-button-element'; export class AirPlayButtonElement extends MediaButtonElement { static readonly tagName = 'media-airplay-button'; + static override properties = { + showUnavailable: { type: Boolean, attribute: 'show-unavailable' }, + } satisfies PropertyDeclarationMap; + + showUnavailable = false; + protected readonly core = new AirPlayButtonCore(); protected readonly stateAttrMap = AirPlayButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectRemotePlayback); diff --git a/packages/html/src/ui/fcast-button/element.ts b/packages/html/src/ui/fcast-button/element.ts new file mode 100644 index 0000000000..578e2a0cde --- /dev/null +++ b/packages/html/src/ui/fcast-button/element.ts @@ -0,0 +1,127 @@ +import { applyElementProps, createButton } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { FCastExtension, type FCastSender } from '@videojs/fcast'; + +import { PlayerExtensionElement } from '../../extensions/player-extension-element'; + +/** + * FCast control that registers a sender-backed player extension. It can be used directly or in the packaged cast menu. + * + * @experimental + */ +export class FCastButtonElement extends PlayerExtensionElement { + static readonly tagName = 'media-fcast-button'; + + static override properties = { + src: { type: String }, + contentType: { type: String, attribute: 'content-type' }, + disabled: { type: Boolean }, + showUnavailable: { type: Boolean, attribute: 'show-unavailable' }, + } satisfies PropertyDeclarationMap; + + disabled = false; + showUnavailable = false; + #controller: AbortController | null = null; + + protected createExtension(): FCastExtension { + const extension = new FCastExtension(); + + extension.addEventListener('change', () => this.requestUpdate()); + return extension; + } + + protected override get shouldRegisterExtension(): boolean { + return !!this.sender; + } + + get sender(): FCastSender | undefined { + return this.extension.sender; + } + + set sender(value: FCastSender | undefined) { + this.extension.sender = value; + this.refreshExtensionRegistration(); + this.requestUpdate(); + } + + get src(): string { + return this.extension.src; + } + + set src(value: string | null | undefined) { + this.extension.src = value ?? undefined; + } + + get contentType(): string | undefined { + return this.extension.contentType; + } + + set contentType(value: string | null | undefined) { + this.extension.contentType = value ?? undefined; + } + + override connectedCallback(): void { + super.connectedCallback(); + + if (this.destroyed) return; + + this.#controller = new AbortController(); + applyElementProps( + this, + createButton({ + isDisabled: () => this.#isDisabled(), + onActivate: () => { + void this.extension.toggle().catch((error: unknown) => { + if (__DEV__) console.error('[media-fcast-button]', error); + }); + }, + }), + { signal: this.#controller.signal } + ); + } + + override disconnectedCallback(): void { + this.#controller?.abort(); + this.#controller = null; + super.disconnectedCallback(); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const { availability, connection, deviceName } = this.extension.snapshot; + const label = + connection === 'connected' + ? `Disconnect from ${deviceName ?? 'FCast'}` + : connection === 'connecting' + ? 'Connecting to FCast' + : 'Cast with FCast'; + + applyElementProps(this, { + 'aria-label': label, + 'aria-disabled': this.#isDisabled() ? 'true' : undefined, + hidden: + (!this.sender || (availability === 'unsupported' && connection !== 'connected')) && !this.showUnavailable + ? '' + : undefined, + 'data-hidden': + (!this.sender || (availability === 'unsupported' && connection !== 'connected')) && !this.showUnavailable + ? '' + : undefined, + 'data-fcast-state': connection, + 'data-availability': availability, + }); + } + + #isDisabled(): boolean { + const { availability, connection } = this.extension.snapshot; + + return ( + this.disabled || + (!this.sender && this.showUnavailable) || + !this.extension.enabled || + connection === 'connecting' || + (connection !== 'connected' && availability !== 'available') + ); + } +} diff --git a/packages/html/src/ui/fcast-button/tests/element.test.ts b/packages/html/src/ui/fcast-button/tests/element.test.ts new file mode 100644 index 0000000000..7c6efcecd7 --- /dev/null +++ b/packages/html/src/ui/fcast-button/tests/element.test.ts @@ -0,0 +1,75 @@ +import { FCastExtension, type FCastLoadRequest, type FCastSender, type FCastSnapshot } from '@videojs/fcast'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; + +import { TestExtensionProvider } from '../../../extensions/tests/test-utils'; +import { FCastButtonElement } from '../element'; + +class TestSender extends EventTarget implements FCastSender { + snapshot: FCastSnapshot = { + availability: 'available', + connection: 'disconnected', + paused: true, + currentTime: 0, + duration: 0, + volume: 1, + muted: false, + speed: 1, + }; + prompt = vi.fn(async () => {}); + disconnect = vi.fn(async () => {}); + load = vi.fn(async (_request: FCastLoadRequest) => {}); + play = vi.fn(async () => {}); + pause = vi.fn(async () => {}); + seek = vi.fn(async (_time: number) => {}); + setVolume = vi.fn(async (_volume: number) => {}); + setSpeed = vi.fn(async (_speed: number) => {}); +} + +customElements.define('test-fcast-provider', TestExtensionProvider); +customElements.define('test-fcast-button', FCastButtonElement); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('FCastButtonElement', () => { + it('reflects sender availability and activates its picker', async () => { + const provider = new TestExtensionProvider(); + const button = new FCastButtonElement(); + const sender = new TestSender(); + + provider.append(button); + document.body.append(provider); + provider.extensions.attach({ media: document.createElement('video'), container: null }); + await button.updateComplete; + + expect(button.hidden).toBe(true); + expect(button.hasAttribute('data-hidden')).toBe(true); + expect(provider.extensions.get(FCastExtension)).toBeUndefined(); + + button.sender = sender; + await button.updateComplete; + + expect(button.getAttribute('aria-label')).toBe('Cast with FCast'); + expect(button.hidden).toBe(false); + expect(button.hasAttribute('data-hidden')).toBe(false); + expect(button.getAttribute('data-availability')).toBe('available'); + expect(provider.extensions.get(FCastExtension)?.enabled).toBe(true); + expect(button.getAttribute('aria-disabled')).toBeNull(); + + button.click(); + expect(sender.prompt).toHaveBeenCalledOnce(); + + sender.snapshot = { ...sender.snapshot, availability: 'unsupported', connection: 'connected' }; + sender.dispatchEvent(new Event('change')); + await button.updateComplete; + + expect(button.hidden).toBe(false); + expect(button.getAttribute('aria-label')).toBe('Disconnect from FCast'); + + button.sender = undefined; + await button.updateComplete; + expect(provider.extensions.get(FCastExtension)).toBeUndefined(); + expect(button.hidden).toBe(true); + }); +}); diff --git a/packages/react/package.json b/packages/react/package.json index 5c7607282d..312eafd558 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -43,6 +43,11 @@ "development": "./dist/dev/ui/*-radio-group/index.js", "default": "./dist/default/ui/*-radio-group/index.js" }, + "./ui/fcast-button": { + "types": "./dist/dev/ui/fcast-button/index.d.ts", + "development": "./dist/dev/ui/fcast-button/index.js", + "default": "./dist/default/ui/fcast-button/index.js" + }, "./*.css": "./dist/default/*.css", "./icons": { "types": "./dist/dev/icons/index.d.ts", @@ -168,6 +173,7 @@ }, "dependencies": { "@videojs/core": "workspace:*", + "@videojs/fcast": "workspace:*", "@videojs/media": "workspace:*", "@videojs/native-hls-video": "workspace:*", "@videojs/spf": "workspace:*", diff --git a/packages/react/src/internal/skin-primitives.ts b/packages/react/src/internal/skin-primitives.ts index 47ae380bdd..23a87e0015 100644 --- a/packages/react/src/internal/skin-primitives.ts +++ b/packages/react/src/internal/skin-primitives.ts @@ -10,6 +10,7 @@ export { CaptionsRadioGroup } from '../ui/captions-radio-group'; export { CastButton } from '../ui/cast-button'; export { Controls } from '../ui/controls'; export { ErrorDialog } from '../ui/error-dialog'; +export { FCastButton } from '../ui/fcast-button'; export { FullscreenButton } from '../ui/fullscreen-button'; export { Gesture } from '../ui/gesture'; export { Hotkey } from '../ui/hotkey'; diff --git a/packages/react/src/presets/types.ts b/packages/react/src/presets/types.ts index 3d310b8bde..62d42ad1f5 100644 --- a/packages/react/src/presets/types.ts +++ b/packages/react/src/presets/types.ts @@ -1,3 +1,4 @@ +import type { FCastSender } from '@videojs/fcast'; import type { CSSProperties, PropsWithChildren } from 'react'; import type { Poster } from '@/ui/poster'; @@ -16,6 +17,12 @@ export type BaseSkinProps = PropsWithChildren< /** Shared props for video skins, including poster rendering customization. */ export type BaseVideoSkinProps = BaseSkinProps & { + /** FCast bridge used by the skin's built-in FCast control. */ + fcastSender?: FCastSender; + /** Receiver-playable URL when the local media source cannot be sent directly. */ + fcastSrc?: string; + /** MIME type of the receiver-playable URL. */ + fcastContentType?: string; /** * Draws the poster image, in place of the `` the skin renders. The URL still comes from the player, as `src` * alongside the rest of the image props — undefined until one resolves. diff --git a/packages/react/src/presets/video/tests/skin.test.tsx b/packages/react/src/presets/video/tests/skin.test.tsx index d95b579582..ba630a6254 100644 --- a/packages/react/src/presets/video/tests/skin.test.tsx +++ b/packages/react/src/presets/video/tests/skin.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, render } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { FCastExtension, type FCastSender, type FCastSnapshot } from '@videojs/fcast'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { createPlayerWrapper } from '../../../testing/mocks'; import { VideoSkin } from '../skin'; @@ -22,6 +23,48 @@ function wrapper(overrides: Record = {}) { } describe('VideoSkin', () => { + it('shows its FCast control when a sender is configured', () => { + const { Wrapper, extensions } = createPlayerWrapper({ + controlsVisible: true, + userActive: true, + requestControlsLock: () => () => {}, + }); + const snapshot: FCastSnapshot = { + availability: 'available', + connection: 'disconnected', + paused: true, + currentTime: 0, + duration: 0, + volume: 1, + muted: false, + speed: 1, + }; + const prompt = vi.fn(async () => {}); + const sender = Object.assign(new EventTarget(), { + snapshot, + prompt, + disconnect: async () => {}, + load: async () => {}, + play: async () => {}, + pause: async () => {}, + seek: async () => {}, + setVolume: async () => {}, + setSpeed: async () => {}, + }) satisfies FCastSender; + + const view = render(, { wrapper: Wrapper }); + + expect(screen.queryByRole('button', { name: 'Cast with FCast' })).toBeNull(); + expect(extensions.get(FCastExtension)).toBeUndefined(); + + view.rerender(); + act(() => extensions.attach({ media: document.createElement('video'), container: null })); + + fireEvent.click(screen.getByRole('button', { name: 'Cast with FCast' })); + expect(extensions.get(FCastExtension)?.sender).toBe(sender); + expect(prompt).toHaveBeenCalledOnce(); + }); + it('renders component-owned backdrops', () => { const { container } = render(, { wrapper: wrapper({ diff --git a/packages/react/src/ui/fcast-button/component.tsx b/packages/react/src/ui/fcast-button/component.tsx new file mode 100644 index 0000000000..d8f9f778ef --- /dev/null +++ b/packages/react/src/ui/fcast-button/component.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { FCastExtension, type FCastExtensionProps, type FCastSnapshot } from '@videojs/fcast'; +import type { ForwardedRef } from 'react'; +import { forwardRef, useSyncExternalStore } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { usePlayerExtension } from '../../utils/use-player-extension'; +import { renderElement } from '../../utils/use-render'; +import { useSyncProps } from '../../utils/use-sync-props'; +import { useButton } from '../hooks/use-button'; + +/** @experimental */ +export interface FCastButtonState { + connection: FCastSnapshot['connection']; + availability: FCastSnapshot['availability']; + deviceName?: string | undefined; + disabled: boolean; +} + +/** @experimental */ +export interface FCastButtonProps extends UIComponentProps<'button', FCastButtonState>, FCastExtensionProps { + /** Keep an unavailable control visible and disabled. */ + showUnavailable?: boolean | undefined; +} + +/** + * FCast control that registers a sender-backed player extension. It can be used directly or in the packaged cast menu. + * + * @experimental + */ +export const FCastButton = forwardRef(function FCastButton( + props: FCastButtonProps, + forwardedRef: ForwardedRef +) { + if (!props.sender && !props.showUnavailable) return null; + + return ; +}); + +function ActiveFCastButton({ + sender, + src, + contentType, + showUnavailable, + disabled, + render, + className, + style, + forwardedRef, + ...elementProps +}: FCastButtonProps & { forwardedRef: ForwardedRef }) { + const extension = usePlayerExtension(FCastExtension); + + useSyncProps(extension, { contentType, src, sender }, FCastExtension.defaultProps); + + useSyncExternalStore( + (notify) => { + extension.addEventListener('change', notify); + return () => extension.removeEventListener('change', notify); + }, + () => extension.version, + () => 0 + ); + + const snapshot = extension.snapshot; + const state: FCastButtonState = { + connection: snapshot.connection, + availability: snapshot.availability, + deviceName: snapshot.deviceName, + disabled: + !!disabled || + (!sender && !!showUnavailable) || + !extension.enabled || + snapshot.connection === 'connecting' || + (snapshot.connection !== 'connected' && snapshot.availability !== 'available'), + }; + const label = + state.connection === 'connected' + ? `Disconnect from ${state.deviceName ?? 'FCast'}` + : state.connection === 'connecting' + ? 'Connecting to FCast' + : 'Cast with FCast'; + + const { getButtonProps, buttonRef } = useButton({ + displayName: 'FCastButton', + isDisabled: () => state.disabled, + onActivate: () => { + void extension.toggle().catch((error: unknown) => { + if (__DEV__) console.error('[FCastButton]', error); + }); + }, + }); + + if (!showUnavailable && state.availability === 'unsupported' && state.connection !== 'connected') return null; + + return renderElement( + 'button', + { render, className, style }, + { + state, + ref: [forwardedRef, buttonRef], + props: [ + getButtonProps(), + { + 'aria-label': label, + 'aria-disabled': state.disabled ? 'true' : undefined, + 'data-fcast-state': state.connection, + 'data-availability': state.availability, + }, + elementProps, + ], + } + ); +} + +if (__DEV__) FCastButton.displayName = 'FCastButton'; + +/** @experimental */ +export namespace FCastButton { + export type Props = FCastButtonProps; + export type State = FCastButtonState; +} diff --git a/packages/react/src/ui/fcast-button/index.ts b/packages/react/src/ui/fcast-button/index.ts new file mode 100644 index 0000000000..6f53ec6a2f --- /dev/null +++ b/packages/react/src/ui/fcast-button/index.ts @@ -0,0 +1 @@ +export { FCastButton, type FCastButtonProps, type FCastButtonState } from './component'; diff --git a/packages/react/src/ui/fcast-button/tests/component.test.tsx b/packages/react/src/ui/fcast-button/tests/component.test.tsx new file mode 100644 index 0000000000..d6a1f82f99 --- /dev/null +++ b/packages/react/src/ui/fcast-button/tests/component.test.tsx @@ -0,0 +1,57 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { FCastExtension, type FCastLoadRequest, type FCastSender, type FCastSnapshot } from '@videojs/fcast'; +import { describe, expect, it, vi } from 'vite-plus/test'; + +import { createPlayerWrapper } from '../../../testing/mocks'; +import { FCastButton } from '../component'; + +class TestSender extends EventTarget implements FCastSender { + snapshot: FCastSnapshot = { + availability: 'available', + connection: 'disconnected', + paused: true, + currentTime: 0, + duration: 0, + volume: 1, + muted: false, + speed: 1, + }; + prompt = vi.fn(async () => { + this.snapshot = { ...this.snapshot, connection: 'connected', deviceName: 'Living room' }; + this.dispatchEvent(new Event('change')); + }); + disconnect = vi.fn(async () => {}); + load = vi.fn(async (_request: FCastLoadRequest) => {}); + play = vi.fn(async () => {}); + pause = vi.fn(async () => {}); + seek = vi.fn(async (_time: number) => {}); + setVolume = vi.fn(async (_volume: number) => {}); + setSpeed = vi.fn(async (_speed: number) => {}); +} + +describe('FCastButton', () => { + it('shows its own FCast state and invokes the sender picker', async () => { + const { Wrapper, extensions } = createPlayerWrapper(); + const sender = new TestSender(); + + render(FCast, { wrapper: Wrapper }); + act(() => extensions.attach({ media: document.createElement('video'), container: null })); + + const button = screen.getByRole('button', { name: 'Cast with FCast' }); + + expect(extensions.get(FCastExtension)?.enabled).toBe(true); + expect(button.getAttribute('aria-disabled')).toBeNull(); + fireEvent.click(button); + + expect(sender.prompt).toHaveBeenCalledOnce(); + expect( + (await screen.findByRole('button', { name: 'Disconnect from Living room' })).getAttribute('data-fcast-state') + ).toBe('connected'); + + act(() => { + sender.snapshot = { ...sender.snapshot, availability: 'unsupported' }; + sender.dispatchEvent(new Event('change')); + }); + expect(screen.getByRole('button', { name: 'Disconnect from Living room' })).toBeDefined(); + }); +}); diff --git a/packages/skins/build/packages/react.ts b/packages/skins/build/packages/react.ts index fe3ae1ea41..97799b2c47 100644 --- a/packages/skins/build/packages/react.ts +++ b/packages/skins/build/packages/react.ts @@ -11,9 +11,10 @@ import { addCopiedFiles, addGenerated, generatedFiles } from './utils.ts'; const packageRoot = 'packages/react/src'; const internalRoot = `${packageRoot}/internal/skins`; -const radioGroupImports = new Set([ +const skinPrimitiveImports = new Set([ '@videojs/react/ui/audio-track-radio-group', '@videojs/react/ui/captions-radio-group', + '@videojs/react/ui/fcast-button', '@videojs/react/ui/playback-rate-radio-group', '@videojs/react/ui/quality-radio-group', ]); @@ -169,7 +170,7 @@ function collectSharedSourcePaths(skins: readonly SkinRoot[]): ReadonlySet>; diff --git a/packages/skins/package.json b/packages/skins/package.json index ccfe1d4d30..ba12530593 100644 --- a/packages/skins/package.json +++ b/packages/skins/package.json @@ -31,6 +31,7 @@ "@types/react-dom": "^19.2.3", "@videojs/core": "workspace:*", "@videojs/element": "workspace:*", + "@videojs/fcast": "workspace:*", "@videojs/icons": "workspace:*", "@videojs/installation": "workspace:*", "@videojs/utils": "workspace:*", diff --git a/packages/skins/src/components/buttons/airplay-button.tsx b/packages/skins/src/components/buttons/airplay-button.tsx index 82f5348db7..55950659e6 100644 --- a/packages/skins/src/components/buttons/airplay-button.tsx +++ b/packages/skins/src/components/buttons/airplay-button.tsx @@ -1,18 +1,21 @@ import type { AirPlayButtonProps as CoreProps } from '@videojs/core'; import * as $ from '@videojs/core/vjsc'; import { AirPlayEnterIcon, AirPlayExitIcon } from '@videojs/icons/vjsc'; -import type { Props } from 'vjsc/components'; +import { Box, type PropsWithChildren } from 'vjsc/components'; import type { SkinComponentDescription } from '../../meta'; import styles from '../../styles/buttons/airplay-button.styles'; import buttonStyles from '../../styles/buttons/button.styles'; import { Button } from './button'; -export function AirPlayButton({ className, ...props }: Props = {}) { +export function AirPlayButton({ children, className, ...props }: PropsWithChildren = {}) { return ( <$.AirPlayButton $render={Button} className={[styles.root, className]} {...props}> - - + + + + + {children} ); } diff --git a/packages/skins/src/components/buttons/cast-button.tsx b/packages/skins/src/components/buttons/cast-button.tsx index 9c6cd6dc57..dc7f23d82d 100644 --- a/packages/skins/src/components/buttons/cast-button.tsx +++ b/packages/skins/src/components/buttons/cast-button.tsx @@ -1,18 +1,21 @@ import type { CastButtonProps as CoreProps } from '@videojs/core'; import * as $ from '@videojs/core/vjsc'; import { CastEnterIcon, CastExitIcon } from '@videojs/icons/vjsc'; -import type { Props } from 'vjsc/components'; +import { Box, type PropsWithChildren } from 'vjsc/components'; import type { SkinComponentDescription } from '../../meta'; import buttonStyles from '../../styles/buttons/button.styles'; import styles from '../../styles/buttons/cast-button.styles'; import { Button } from './button'; -export function CastButton({ className, ...props }: Props = {}) { +export function CastButton({ children, className, ...props }: PropsWithChildren = {}) { return ( <$.CastButton $render={Button} className={[styles.root, className]} {...props}> - - + + + + + {children} ); } diff --git a/packages/skins/src/components/buttons/fcast-button.tsx b/packages/skins/src/components/buttons/fcast-button.tsx new file mode 100644 index 0000000000..0fd045a46a --- /dev/null +++ b/packages/skins/src/components/buttons/fcast-button.tsx @@ -0,0 +1,42 @@ +import * as $ from '@videojs/core/vjsc'; +import type { FCastSender } from '@videojs/fcast'; +import { CastEnterIcon, CastExitIcon } from '@videojs/icons/vjsc'; +import { Box, type ClassNameValue, type VjscNode } from 'vjsc/components'; + +import type { SkinComponentDescription } from '../../meta'; +import buttonStyles from '../../styles/buttons/button.styles'; +import styles from '../../styles/buttons/fcast-button.styles'; +import { Button } from './button'; + +export interface FCastButtonProps { + className?: ClassNameValue; + children?: VjscNode; + sender?: FCastSender | undefined; + src?: string | undefined; + contentType?: string | undefined; + showUnavailable?: boolean | undefined; +} + +export function FCastButton({ className, children, sender, src, contentType, showUnavailable }: FCastButtonProps = {}) { + return ( + <$.FCastButton + $render={Button} + className={[styles.root, className]} + sender={sender} + src={src} + contentType={contentType} + showUnavailable={showUnavailable} + > + + + + + {children} + + ); +} + +export const meta = { + title: 'FCast Button', + description: 'A state-aware button for an application-provided FCast sender.', +} as const satisfies SkinComponentDescription; diff --git a/packages/skins/src/skins/compat/live-video/controls.tsx b/packages/skins/src/skins/compat/live-video/controls.tsx index 01e68c48c5..fe7f946d5d 100644 --- a/packages/skins/src/skins/compat/live-video/controls.tsx +++ b/packages/skins/src/skins/compat/live-video/controls.tsx @@ -1,12 +1,23 @@ import * as $ from '@videojs/core/vjsc'; +import type { FCastSender } from '@videojs/fcast'; import { CaptionsMenu } from '../shared/captions-menu'; import { VideoControlsContent } from '../shared/video-controls'; -export function LiveVideoControls() { +export function LiveVideoControls({ + fcast, +}: { + fcast?: + | { + sender?: FCastSender | undefined; + src?: string | undefined; + contentType?: string | undefined; + } + | undefined; +} = {}) { return ( <$.Controls.Root> - } /> + } fcast={fcast} /> ); } diff --git a/packages/skins/src/skins/compat/live-video/skin.tsx b/packages/skins/src/skins/compat/live-video/skin.tsx index 7eae4e9f0b..ea3c87cfbc 100644 --- a/packages/skins/src/skins/compat/live-video/skin.tsx +++ b/packages/skins/src/skins/compat/live-video/skin.tsx @@ -1,4 +1,5 @@ import * as $ from '@videojs/core/vjsc'; +import type { FCastSender } from '@videojs/fcast'; import { type PropsOf, Slot, type VjscNode } from 'vjsc/components'; import type { SkinDescription } from '../../../meta'; @@ -15,9 +16,20 @@ import { LiveVideoControls } from './controls'; export interface LiveVideoSkinProps extends Omit, 'children'> { children?: VjscNode; renderPoster?: PropsOf['children']; + fcastSender?: FCastSender; + fcastSrc?: string; + fcastContentType?: string; } -export function LiveVideoSkin({ children, className, renderPoster, ...props }: LiveVideoSkinProps = {}) { +export function LiveVideoSkin({ + children, + className, + renderPoster, + fcastSender, + fcastSrc, + fcastContentType, + ...props +}: LiveVideoSkinProps = {}) { return ( <$.Container className={['media-skin', containerStyles.root, containerStyles.video, className]} @@ -30,7 +42,7 @@ export function LiveVideoSkin({ children, className, renderPoster, ...props }: L - <LiveVideoControls /> + <LiveVideoControls fcast={{ sender: fcastSender, src: fcastSrc, contentType: fcastContentType }} /> <LiveVideoHotkeys /> <LiveVideoGestures /> diff --git a/packages/skins/src/skins/compat/shared/airplay-button.tsx b/packages/skins/src/skins/compat/shared/airplay-button.tsx index 12904e689e..1fd77295e8 100644 --- a/packages/skins/src/skins/compat/shared/airplay-button.tsx +++ b/packages/skins/src/skins/compat/shared/airplay-button.tsx @@ -1,19 +1,40 @@ +import type { AirPlayButtonProps } from '@videojs/core'; import * as $ from '@videojs/core/vjsc'; import { AirPlayEnterIcon, AirPlayExitIcon } from '@videojs/icons/vjsc'; -import type { ClassNameValue } from 'vjsc/components'; +import { Box, type ClassNameValue, type VjscNode } from 'vjsc/components'; import airplayButtonStyles from './airplay-button.styles'; import { Button } from './button'; import buttonStyles from './button.styles'; import { ButtonTooltip } from './tooltip'; -export function AirPlayButton({ popupClassName }: { popupClassName?: ClassNameValue } = {}) { - return ( - <ButtonTooltip popupClassName={popupClassName}> - <$.AirPlayButton $render={Button} className={airplayButtonStyles.root}> +export function AirPlayButton({ + className, + popupClassName, + showTooltip = true, + showUnavailable, + children, +}: { + className?: ClassNameValue; + popupClassName?: ClassNameValue; + showTooltip?: boolean; + children?: VjscNode; +} & Pick<AirPlayButtonProps, 'showUnavailable'> = {}) { + const button = ( + <$.AirPlayButton + $render={Button} + className={[airplayButtonStyles.root, className]} + showUnavailable={showUnavailable} + > + <Box className={buttonStyles.iconGroup}> <AirPlayEnterIcon className={[buttonStyles.icon, airplayButtonStyles.enterIcon]} /> <AirPlayExitIcon className={[buttonStyles.icon, airplayButtonStyles.exitIcon]} /> - </$.AirPlayButton> - </ButtonTooltip> + </Box> + {children} + </$.AirPlayButton> ); + + if (!showTooltip) return button; + + return <ButtonTooltip popupClassName={popupClassName}>{button}</ButtonTooltip>; } diff --git a/packages/skins/src/skins/compat/shared/button.styles.ts b/packages/skins/src/skins/compat/shared/button.styles.ts index aa1588f586..23cd5cf089 100644 --- a/packages/skins/src/skins/compat/shared/button.styles.ts +++ b/packages/skins/src/skins/compat/shared/button.styles.ts @@ -25,5 +25,8 @@ export default styles({ iconBase: { utilities: 'col-start-1 row-start-1 hidden size-4.5', }, + iconGroup: { + utilities: 'relative grid size-5 shrink-0 place-items-center', + }, }, }); diff --git a/packages/skins/src/skins/compat/shared/fcast-button.tsx b/packages/skins/src/skins/compat/shared/fcast-button.tsx new file mode 100644 index 0000000000..e585bb4d14 --- /dev/null +++ b/packages/skins/src/skins/compat/shared/fcast-button.tsx @@ -0,0 +1,49 @@ +import * as $ from '@videojs/core/vjsc'; +import type { FCastSender } from '@videojs/fcast'; +import { CastEnterIcon, CastExitIcon } from '@videojs/icons/vjsc'; +import { Box, type ClassNameValue, type VjscNode } from 'vjsc/components'; + +import fcastStyles from '../../../styles/buttons/fcast-button.styles'; +import { Button } from './button'; +import buttonStyles from './button.styles'; +import { ButtonTooltip } from './tooltip'; +import tooltipStyles from './tooltip.styles'; + +export function FCastButton({ + className, + sender, + src, + contentType, + showTooltip = true, + showUnavailable, + children, +}: { + className?: ClassNameValue; + sender?: FCastSender | undefined; + src?: string | undefined; + contentType?: string | undefined; + showTooltip?: boolean; + showUnavailable?: boolean | undefined; + children?: VjscNode; +} = {}) { + const button = ( + <$.FCastButton + $render={Button} + className={[fcastStyles.root, className]} + sender={sender} + src={src} + contentType={contentType} + showUnavailable={showUnavailable} + > + <Box className={buttonStyles.iconGroup}> + <CastEnterIcon className={[buttonStyles.icon, fcastStyles.enterIcon]} /> + <CastExitIcon className={[buttonStyles.icon, fcastStyles.exitIcon]} /> + </Box> + {children} + </$.FCastButton> + ); + + if (!showTooltip) return button; + + return <ButtonTooltip popupClassName={tooltipStyles.screenPopup}>{button}</ButtonTooltip>; +} diff --git a/packages/skins/src/skins/compat/shared/screen-controls.tsx b/packages/skins/src/skins/compat/shared/screen-controls.tsx index 7547f07331..153bc215f8 100644 --- a/packages/skins/src/skins/compat/shared/screen-controls.tsx +++ b/packages/skins/src/skins/compat/shared/screen-controls.tsx @@ -1,4 +1,5 @@ import * as $ from '@videojs/core/vjsc'; +import type { FCastSender } from '@videojs/fcast'; import { CastEnterIcon, CastExitIcon, @@ -7,24 +8,55 @@ import { PipEnterIcon, PipExitIcon, } from '@videojs/icons/vjsc'; +import { Box, Text } from 'vjsc/components'; +import castMenuStyles from '../../../styles/menus/cast-menu.styles'; +import popupStyles from '../../../styles/popups/popup.styles'; import { AirPlayButton } from './airplay-button'; import { Button } from './button'; import buttonStyles from './button.styles'; import castButtonStyles from './cast-button.styles'; +import { FCastButton } from './fcast-button'; import fullscreenButtonStyles from './fullscreen-button.styles'; +import menuStyles from './menu.styles'; import pipButtonStyles from './pip-button.styles'; import { ButtonTooltip } from './tooltip'; import tooltipStyles from './tooltip.styles'; function CastButton() { return ( - <ButtonTooltip popupClassName={tooltipStyles.screenPopup}> - <$.CastButton $render={Button} className={castButtonStyles.root}> + <$.CastButton $render={Button} className={[castButtonStyles.root, castMenuStyles.item]}> + <Box className={buttonStyles.iconGroup}> <CastEnterIcon className={[buttonStyles.icon, castButtonStyles.enterIcon]} /> <CastExitIcon className={[buttonStyles.icon, castButtonStyles.exitIcon]} /> - </$.CastButton> - </ButtonTooltip> + </Box> + <Text>Chromecast</Text> + </$.CastButton> + ); +} + +function CastMenu({ + fcast, +}: { + fcast?: { sender?: FCastSender | undefined; src?: string | undefined; contentType?: string | undefined } | undefined; +}) { + return ( + <$.Menu.Root side="top" align="center"> + <$.Menu.Trigger $render={Button} aria-label="Cast options"> + <CastEnterIcon className={buttonStyles.icon} /> + </$.Menu.Trigger> + <$.Menu.Popup keepMounted className={[popupStyles.popup, popupStyles.surface, menuStyles.popup]}> + <$.Menu.Content className={[menuStyles.content, castMenuStyles.content]}> + <CastButton /> + <AirPlayButton className={castMenuStyles.item} showTooltip={false} showUnavailable> + <Text>AirPlay</Text> + </AirPlayButton> + <FCastButton {...fcast} className={castMenuStyles.item} showTooltip={false} showUnavailable> + <Text>FCast</Text> + </FCastButton> + </$.Menu.Content> + </$.Menu.Popup> + </$.Menu.Root> ); } @@ -50,11 +82,20 @@ function FullscreenButton() { ); } -export function ScreenControls() { +export function ScreenControls({ + fcast, +}: { + fcast?: + | { + sender?: FCastSender | undefined; + src?: string | undefined; + contentType?: string | undefined; + } + | undefined; +} = {}) { return ( <> - <AirPlayButton popupClassName={tooltipStyles.screenPopup} /> - <CastButton /> + <CastMenu fcast={fcast} /> <PiPButton /> <FullscreenButton /> </> diff --git a/packages/skins/src/skins/compat/shared/video-controls.tsx b/packages/skins/src/skins/compat/shared/video-controls.tsx index 7d980731cb..c4a038a585 100644 --- a/packages/skins/src/skins/compat/shared/video-controls.tsx +++ b/packages/skins/src/skins/compat/shared/video-controls.tsx @@ -1,3 +1,4 @@ +import type { FCastSender } from '@videojs/fcast'; import type { VjscNode } from 'vjsc/components'; import { ControlsContent, type ControlsSlots } from './controls'; @@ -10,7 +11,19 @@ export function VideoControlsContent({ renderThumbnail, seekBackward, seekForward, -}: ControlsSlots & { center?: boolean; live?: boolean; menu?: VjscNode } = {}) { + fcast, +}: ControlsSlots & { + center?: boolean; + live?: boolean; + menu?: VjscNode; + fcast?: + | { + sender?: FCastSender | undefined; + src?: string | undefined; + contentType?: string | undefined; + } + | undefined; +} = {}) { return ( <ControlsContent center={center} @@ -19,7 +32,7 @@ export function VideoControlsContent({ renderThumbnail={renderThumbnail} seekBackward={seekBackward} seekForward={seekForward} - top={<ScreenControls />} + top={<ScreenControls fcast={fcast} />} /> ); } diff --git a/packages/skins/src/skins/compat/video/controls.tsx b/packages/skins/src/skins/compat/video/controls.tsx index fda632b0e6..e6bd832466 100644 --- a/packages/skins/src/skins/compat/video/controls.tsx +++ b/packages/skins/src/skins/compat/video/controls.tsx @@ -1,4 +1,5 @@ import * as $ from '@videojs/core/vjsc'; +import type { FCastSender } from '@videojs/fcast'; import controlsStyles from '../shared/controls.styles'; import { SeekButton } from '../shared/seek-button'; @@ -6,13 +7,25 @@ import { SettingsMenu } from '../shared/settings-menu'; import type { ThumbnailSlot } from '../shared/time-slider'; import { VideoControlsContent } from '../shared/video-controls'; -export function VideoControls({ renderThumbnail }: ThumbnailSlot = {}) { +export function VideoControls({ + renderThumbnail, + fcast, +}: ThumbnailSlot & { + fcast?: + | { + sender?: FCastSender | undefined; + src?: string | undefined; + contentType?: string | undefined; + } + | undefined; +} = {}) { return ( <$.Controls.Root> <VideoControlsContent center menu={<SettingsMenu />} renderThumbnail={renderThumbnail} + fcast={fcast} seekBackward={ <SeekButton className={[controlsStyles.centerButton, controlsStyles.centerSeek]} diff --git a/packages/skins/src/skins/compat/video/skin.tsx b/packages/skins/src/skins/compat/video/skin.tsx index 1ed30eec15..9b1d375231 100644 --- a/packages/skins/src/skins/compat/video/skin.tsx +++ b/packages/skins/src/skins/compat/video/skin.tsx @@ -1,4 +1,5 @@ import * as $ from '@videojs/core/vjsc'; +import type { FCastSender } from '@videojs/fcast'; import { type PropsOf, Slot, type VjscNode } from 'vjsc/components'; import type { SkinDescription } from '../../../meta'; @@ -17,9 +18,21 @@ export interface VideoSkinProps extends Omit<PropsOf<typeof $.Container>, 'child children?: VjscNode; renderPoster?: PropsOf<typeof $.Poster.Image>['children']; renderThumbnail?: PropsOf<typeof $.Slider.Thumbnail.Image>['children']; + fcastSender?: FCastSender; + fcastSrc?: string; + fcastContentType?: string; } -export function VideoSkin({ children, className, renderPoster, renderThumbnail, ...props }: VideoSkinProps = {}) { +export function VideoSkin({ + children, + className, + renderPoster, + renderThumbnail, + fcastSender, + fcastSrc, + fcastContentType, + ...props +}: VideoSkinProps = {}) { return ( <$.Container className={['media-skin', containerStyles.root, containerStyles.video, className]} @@ -32,7 +45,10 @@ export function VideoSkin({ children, className, renderPoster, renderThumbnail, <BufferingIndicator /> <ErrorDialog /> <Title /> - <VideoControls renderThumbnail={renderThumbnail} /> + <VideoControls + renderThumbnail={renderThumbnail} + fcast={{ sender: fcastSender, src: fcastSrc, contentType: fcastContentType }} + /> <VideoHotkeys /> <VideoGestures /> diff --git a/packages/skins/src/skins/default/live-video/layout/controls.tsx b/packages/skins/src/skins/default/live-video/layout/controls.tsx index febb81f0d6..fdaa83cbca 100644 --- a/packages/skins/src/skins/default/live-video/layout/controls.tsx +++ b/packages/skins/src/skins/default/live-video/layout/controls.tsx @@ -1,9 +1,8 @@ import * as $ from '@videojs/core/vjsc'; import { Box } from 'vjsc/components'; -import { AirPlayButton } from '../../../../components/buttons/airplay-button'; import { ButtonTooltip } from '../../../../components/buttons/button-tooltip'; -import { CastButton } from '../../../../components/buttons/cast-button'; +import type { FCastButtonProps } from '../../../../components/buttons/fcast-button'; import { FullscreenButton } from '../../../../components/buttons/fullscreen-button'; import { LiveButton } from '../../../../components/buttons/live-button'; import { PiPButton } from '../../../../components/buttons/pip-button'; @@ -11,9 +10,10 @@ import { PlayButton } from '../../../../components/buttons/play-button'; import { CaptionsMenu } from '../../../../components/menus/captions-menu'; import { VolumePopover } from '../../../../components/menus/volume-popover'; import controlsStyles from '../../../../styles/layout/controls.styles'; +import { CastMenu } from '../../../shared/video/menus/cast-menu'; import styles from './controls.styles'; -export function DefaultLiveVideoControls() { +export function DefaultLiveVideoControls({ fcast }: { fcast?: FCastButtonProps | undefined } = {}) { return ( <$.Controls.Root> <$.Controls.Backdrop className={controlsStyles.backdrop} /> @@ -27,12 +27,7 @@ export function DefaultLiveVideoControls() { <Box aria-hidden="true" className={controlsStyles.spacer} /> <VolumePopover /> <CaptionsMenu className={styles.captionsMenu} /> - <ButtonTooltip side="top"> - <CastButton /> - </ButtonTooltip> - <ButtonTooltip side="top"> - <AirPlayButton /> - </ButtonTooltip> + <CastMenu fcast={fcast} /> <ButtonTooltip side="top"> <PiPButton /> </ButtonTooltip> diff --git a/packages/skins/src/skins/default/live-video/skin.tsx b/packages/skins/src/skins/default/live-video/skin.tsx index 9efb0a8b3c..c3e4084c57 100644 --- a/packages/skins/src/skins/default/live-video/skin.tsx +++ b/packages/skins/src/skins/default/live-video/skin.tsx @@ -1,3 +1,4 @@ +import type { FCastSender } from '@videojs/fcast'; import { type PropsOf, Slot, type VjscNode } from 'vjsc/components'; import { ErrorDialog } from '../../../components/dialogs/error-dialog'; @@ -15,9 +16,20 @@ import { DefaultLiveVideoControls } from './layout/controls'; export interface LiveVideoSkinProps extends Omit<PropsOf<typeof Container>, 'children'> { children?: VjscNode; renderPoster?: PropsOf<typeof Poster>['renderImage']; + fcastSender?: FCastSender; + fcastSrc?: string; + fcastContentType?: string; } -export function LiveVideoSkin({ children, className, renderPoster, ...props }: LiveVideoSkinProps = {}) { +export function LiveVideoSkin({ + children, + className, + renderPoster, + fcastSender, + fcastSrc, + fcastContentType, + ...props +}: LiveVideoSkinProps = {}) { return ( <Container className={[videoSkinStyles.root, className]} data-theme="default" data-preset="live-video" {...props}> <Slot>{children}</Slot> @@ -25,7 +37,7 @@ export function LiveVideoSkin({ children, className, renderPoster, ...props }: L <BufferingIndicator /> <ErrorDialog /> <Title /> - <DefaultLiveVideoControls /> + <DefaultLiveVideoControls fcast={{ sender: fcastSender, src: fcastSrc, contentType: fcastContentType }} /> <LiveVideoHotkeys /> <LiveVideoGestures /> <LiveVideoStatusIndicators /> diff --git a/packages/skins/src/skins/default/video/layout/controls.tsx b/packages/skins/src/skins/default/video/layout/controls.tsx index f415bd411f..5a571c82aa 100644 --- a/packages/skins/src/skins/default/video/layout/controls.tsx +++ b/packages/skins/src/skins/default/video/layout/controls.tsx @@ -1,10 +1,9 @@ import * as $ from '@videojs/core/vjsc'; import type { PropsOf } from 'vjsc/components'; -import { AirPlayButton } from '../../../../components/buttons/airplay-button'; import { ButtonTooltip } from '../../../../components/buttons/button-tooltip'; import { CaptionsButton } from '../../../../components/buttons/captions-button'; -import { CastButton } from '../../../../components/buttons/cast-button'; +import type { FCastButtonProps } from '../../../../components/buttons/fcast-button'; import { FullscreenButton } from '../../../../components/buttons/fullscreen-button'; import { PiPButton } from '../../../../components/buttons/pip-button'; import { PlayButton } from '../../../../components/buttons/play-button'; @@ -12,14 +11,16 @@ import { VolumePopover } from '../../../../components/menus/volume-popover'; import { TimeSlider } from '../../../../components/sliders/time-slider'; import timeStyles from '../../../../styles/display/time.styles'; import controlsStyles from '../../../../styles/layout/controls.styles'; +import { CastMenu } from '../../../shared/video/menus/cast-menu'; import { VideoSettingsMenu } from '../../../shared/video/menus/settings-menu'; import styles from './controls.styles'; export interface DefaultVideoControlsProps { renderThumbnail?: PropsOf<typeof TimeSlider>['renderThumbnail']; + fcast?: FCastButtonProps | undefined; } -export function DefaultVideoControls({ renderThumbnail }: DefaultVideoControlsProps = {}) { +export function DefaultVideoControls({ renderThumbnail, fcast }: DefaultVideoControlsProps = {}) { return ( <$.Controls.Root> <$.Controls.Backdrop className={controlsStyles.backdrop} /> @@ -44,12 +45,7 @@ export function DefaultVideoControls({ renderThumbnail }: DefaultVideoControlsPr </$.Controls.Group> <$.Controls.Group className={controlsStyles.secondary}> - <ButtonTooltip side="top"> - <CastButton /> - </ButtonTooltip> - <ButtonTooltip side="top"> - <AirPlayButton /> - </ButtonTooltip> + <CastMenu fcast={fcast} /> <ButtonTooltip side="top"> <PiPButton /> </ButtonTooltip> diff --git a/packages/skins/src/skins/default/video/skin.tsx b/packages/skins/src/skins/default/video/skin.tsx index 3f9e8e659f..cf990c5dc5 100644 --- a/packages/skins/src/skins/default/video/skin.tsx +++ b/packages/skins/src/skins/default/video/skin.tsx @@ -1,3 +1,4 @@ +import type { FCastSender } from '@videojs/fcast'; import { type PropsOf, Slot, type VjscNode } from 'vjsc/components'; import { ErrorDialog } from '../../../components/dialogs/error-dialog'; @@ -16,9 +17,21 @@ export interface VideoSkinProps extends Omit<PropsOf<typeof Container>, 'childre children?: VjscNode; renderPoster?: PropsOf<typeof Poster>['renderImage']; renderThumbnail?: PropsOf<typeof DefaultVideoControls>['renderThumbnail']; + fcastSender?: FCastSender; + fcastSrc?: string; + fcastContentType?: string; } -export function VideoSkin({ children, className, renderPoster, renderThumbnail, ...props }: VideoSkinProps = {}) { +export function VideoSkin({ + children, + className, + renderPoster, + renderThumbnail, + fcastSender, + fcastSrc, + fcastContentType, + ...props +}: VideoSkinProps = {}) { return ( <Container className={[videoSkinStyles.root, className]} data-theme="default" data-preset="video" {...props}> <Slot>{children}</Slot> @@ -27,7 +40,10 @@ export function VideoSkin({ children, className, renderPoster, renderThumbnail, <ErrorDialog /> <Title /> - <DefaultVideoControls renderThumbnail={renderThumbnail} /> + <DefaultVideoControls + renderThumbnail={renderThumbnail} + fcast={{ sender: fcastSender, src: fcastSrc, contentType: fcastContentType }} + /> <VideoHotkeys /> <VideoGestures /> diff --git a/packages/skins/src/skins/neutral/live-video/layout/controls.tsx b/packages/skins/src/skins/neutral/live-video/layout/controls.tsx index 97588281e8..ab30c97c0f 100644 --- a/packages/skins/src/skins/neutral/live-video/layout/controls.tsx +++ b/packages/skins/src/skins/neutral/live-video/layout/controls.tsx @@ -1,9 +1,8 @@ import * as $ from '@videojs/core/vjsc'; import { Box } from 'vjsc/components'; -import { AirPlayButton } from '../../../../components/buttons/airplay-button'; import { ButtonTooltip } from '../../../../components/buttons/button-tooltip'; -import { CastButton } from '../../../../components/buttons/cast-button'; +import type { FCastButtonProps } from '../../../../components/buttons/fcast-button'; import { FullscreenButton } from '../../../../components/buttons/fullscreen-button'; import { LiveButton } from '../../../../components/buttons/live-button'; import { PiPButton } from '../../../../components/buttons/pip-button'; @@ -11,9 +10,10 @@ import { PlayButton } from '../../../../components/buttons/play-button'; import { CaptionsMenu } from '../../../../components/menus/captions-menu'; import { VolumePopover } from '../../../../components/menus/volume-popover'; import controlsStyles from '../../../../styles/layout/controls.styles'; +import { CastMenu } from '../../../shared/video/menus/cast-menu'; import styles from './controls.styles'; -export function NeutralLiveVideoControls() { +export function NeutralLiveVideoControls({ fcast }: { fcast?: FCastButtonProps | undefined } = {}) { return ( <$.Controls.Root> <$.Controls.Backdrop className={controlsStyles.backdrop} /> @@ -31,12 +31,7 @@ export function NeutralLiveVideoControls() { <$.Controls.Group className={styles.end}> <CaptionsMenu /> - <ButtonTooltip side="top"> - <CastButton /> - </ButtonTooltip> - <ButtonTooltip side="top"> - <AirPlayButton /> - </ButtonTooltip> + <CastMenu fcast={fcast} /> <ButtonTooltip side="top"> <PiPButton /> </ButtonTooltip> diff --git a/packages/skins/src/skins/neutral/live-video/skin.tsx b/packages/skins/src/skins/neutral/live-video/skin.tsx index 9ba08ea3eb..a242b59baf 100644 --- a/packages/skins/src/skins/neutral/live-video/skin.tsx +++ b/packages/skins/src/skins/neutral/live-video/skin.tsx @@ -1,3 +1,4 @@ +import type { FCastSender } from '@videojs/fcast'; import { type PropsOf, Slot, type VjscNode } from 'vjsc/components'; import { ErrorDialog } from '../../../components/dialogs/error-dialog'; @@ -15,9 +16,20 @@ import { NeutralLiveVideoControls } from './layout/controls'; export interface LiveVideoSkinProps extends Omit<PropsOf<typeof Container>, 'children'> { children?: VjscNode; renderPoster?: PropsOf<typeof Poster>['renderImage']; + fcastSender?: FCastSender; + fcastSrc?: string; + fcastContentType?: string; } -export function LiveVideoSkin({ children, className, renderPoster, ...props }: LiveVideoSkinProps = {}) { +export function LiveVideoSkin({ + children, + className, + renderPoster, + fcastSender, + fcastSrc, + fcastContentType, + ...props +}: LiveVideoSkinProps = {}) { return ( <Container className={[videoSkinStyles.root, className]} data-theme="neutral" data-preset="live-video" {...props}> <Slot>{children}</Slot> @@ -25,7 +37,7 @@ export function LiveVideoSkin({ children, className, renderPoster, ...props }: L <BufferingIndicator /> <ErrorDialog /> <Title /> - <NeutralLiveVideoControls /> + <NeutralLiveVideoControls fcast={{ sender: fcastSender, src: fcastSrc, contentType: fcastContentType }} /> <LiveVideoHotkeys /> <LiveVideoGestures /> <LiveVideoStatusIndicators /> diff --git a/packages/skins/src/skins/neutral/video/layout/controls.tsx b/packages/skins/src/skins/neutral/video/layout/controls.tsx index c205e77ec2..6df9c534aa 100644 --- a/packages/skins/src/skins/neutral/video/layout/controls.tsx +++ b/packages/skins/src/skins/neutral/video/layout/controls.tsx @@ -1,10 +1,9 @@ import * as $ from '@videojs/core/vjsc'; import type { PropsOf } from 'vjsc/components'; -import { AirPlayButton } from '../../../../components/buttons/airplay-button'; import { ButtonTooltip } from '../../../../components/buttons/button-tooltip'; import { CaptionsButton } from '../../../../components/buttons/captions-button'; -import { CastButton } from '../../../../components/buttons/cast-button'; +import type { FCastButtonProps } from '../../../../components/buttons/fcast-button'; import { FullscreenButton } from '../../../../components/buttons/fullscreen-button'; import { PiPButton } from '../../../../components/buttons/pip-button'; import { PlayButton } from '../../../../components/buttons/play-button'; @@ -12,14 +11,16 @@ import { VolumePopover } from '../../../../components/menus/volume-popover'; import { TimeSlider } from '../../../../components/sliders/time-slider'; import timeStyles from '../../../../styles/display/time.styles'; import controlsStyles from '../../../../styles/layout/controls.styles'; +import { CastMenu } from '../../../shared/video/menus/cast-menu'; import { VideoSettingsMenu } from '../../../shared/video/menus/settings-menu'; import styles from './controls.styles'; export interface NeutralVideoControlsProps { renderThumbnail?: PropsOf<typeof TimeSlider>['renderThumbnail']; + fcast?: FCastButtonProps | undefined; } -export function NeutralVideoControls({ renderThumbnail }: NeutralVideoControlsProps = {}) { +export function NeutralVideoControls({ renderThumbnail, fcast }: NeutralVideoControlsProps = {}) { return ( <$.Controls.Root> <$.Controls.Backdrop className={controlsStyles.backdrop} /> @@ -47,12 +48,7 @@ export function NeutralVideoControls({ renderThumbnail }: NeutralVideoControlsPr </ButtonTooltip> <VideoSettingsMenu /> <$.Controls.Group className={styles.trailing}> - <ButtonTooltip side="top"> - <CastButton /> - </ButtonTooltip> - <ButtonTooltip side="top"> - <AirPlayButton /> - </ButtonTooltip> + <CastMenu fcast={fcast} /> <ButtonTooltip side="top"> <PiPButton /> </ButtonTooltip> diff --git a/packages/skins/src/skins/neutral/video/skin.tsx b/packages/skins/src/skins/neutral/video/skin.tsx index 8e24b0fe68..6ade14dc51 100644 --- a/packages/skins/src/skins/neutral/video/skin.tsx +++ b/packages/skins/src/skins/neutral/video/skin.tsx @@ -1,3 +1,4 @@ +import type { FCastSender } from '@videojs/fcast'; import { type PropsOf, Slot, type VjscNode } from 'vjsc/components'; import { ErrorDialog } from '../../../components/dialogs/error-dialog'; @@ -16,9 +17,21 @@ export interface VideoSkinProps extends Omit<PropsOf<typeof Container>, 'childre children?: VjscNode; renderPoster?: PropsOf<typeof Poster>['renderImage']; renderThumbnail?: PropsOf<typeof NeutralVideoControls>['renderThumbnail']; + fcastSender?: FCastSender; + fcastSrc?: string; + fcastContentType?: string; } -export function VideoSkin({ children, className, renderPoster, renderThumbnail, ...props }: VideoSkinProps = {}) { +export function VideoSkin({ + children, + className, + renderPoster, + renderThumbnail, + fcastSender, + fcastSrc, + fcastContentType, + ...props +}: VideoSkinProps = {}) { return ( <Container className={[videoSkinStyles.root, className]} data-theme="neutral" data-preset="video" {...props}> <Slot>{children}</Slot> @@ -27,7 +40,10 @@ export function VideoSkin({ children, className, renderPoster, renderThumbnail, <ErrorDialog /> <Title /> - <NeutralVideoControls renderThumbnail={renderThumbnail} /> + <NeutralVideoControls + renderThumbnail={renderThumbnail} + fcast={{ sender: fcastSender, src: fcastSrc, contentType: fcastContentType }} + /> <VideoHotkeys /> <VideoGestures /> diff --git a/packages/skins/src/skins/shared/video/menus/cast-menu.tsx b/packages/skins/src/skins/shared/video/menus/cast-menu.tsx new file mode 100644 index 0000000000..d2e8e53d78 --- /dev/null +++ b/packages/skins/src/skins/shared/video/menus/cast-menu.tsx @@ -0,0 +1,35 @@ +import * as $ from '@videojs/core/vjsc'; +import { CastEnterIcon } from '@videojs/icons/vjsc'; +import { Text } from 'vjsc/components'; + +import { AirPlayButton } from '../../../../components/buttons/airplay-button'; +import { Button } from '../../../../components/buttons/button'; +import { CastButton } from '../../../../components/buttons/cast-button'; +import { FCastButton, type FCastButtonProps } from '../../../../components/buttons/fcast-button'; +import buttonStyles from '../../../../styles/buttons/button.styles'; +import castMenuStyles from '../../../../styles/menus/cast-menu.styles'; +import menuStyles from '../../../../styles/menus/menu.styles'; +import popupStyles from '../../../../styles/popups/popup.styles'; + +export function CastMenu({ fcast }: { fcast?: FCastButtonProps | undefined } = {}) { + return ( + <$.Menu.Root side="top" align="center"> + <$.Menu.Trigger $render={Button} aria-label="Cast options"> + <CastEnterIcon className={buttonStyles.icon} /> + </$.Menu.Trigger> + <$.Menu.Popup keepMounted className={[popupStyles.popup, popupStyles.surface, menuStyles.popup]}> + <$.Menu.Content className={[menuStyles.content, castMenuStyles.content]}> + <CastButton className={castMenuStyles.item}> + <Text>Chromecast</Text> + </CastButton> + <AirPlayButton className={castMenuStyles.item} showUnavailable> + <Text>AirPlay</Text> + </AirPlayButton> + <FCastButton {...fcast} className={castMenuStyles.item} showUnavailable> + <Text>FCast</Text> + </FCastButton> + </$.Menu.Content> + </$.Menu.Popup> + </$.Menu.Root> + ); +} diff --git a/packages/skins/src/styles/buttons/button.styles.ts b/packages/skins/src/styles/buttons/button.styles.ts index 9974b6ad89..d6069f38f0 100644 --- a/packages/skins/src/styles/buttons/button.styles.ts +++ b/packages/skins/src/styles/buttons/button.styles.ts @@ -25,6 +25,9 @@ export default styles({ iconBase: { utilities: icon, }, + iconGroup: { + utilities: 'relative grid size-5 shrink-0 place-items-center', + }, icon: { utilities: [icon, 'transition-[opacity,scale] duration-media-base ease-out'], }, diff --git a/packages/skins/src/styles/buttons/fcast-button.styles.ts b/packages/skins/src/styles/buttons/fcast-button.styles.ts new file mode 100644 index 0000000000..1ac7ae1e27 --- /dev/null +++ b/packages/skins/src/styles/buttons/fcast-button.styles.ts @@ -0,0 +1,17 @@ +import { styles } from 'vjsc/styles'; + +export default styles({ + file: 'buttons.css', + prefix: 'media-fcast-button', + rules: { + root: { + utilities: 'group/fcast relative data-hidden:hidden', + }, + enterIcon: { + utilities: 'opacity-0 group-not-data-[fcast-state=connected]/fcast:opacity-100', + }, + exitIcon: { + utilities: 'opacity-0 group-data-[fcast-state=connected]/fcast:opacity-100', + }, + }, +}); diff --git a/packages/skins/src/styles/menus/cast-menu.styles.ts b/packages/skins/src/styles/menus/cast-menu.styles.ts new file mode 100644 index 0000000000..b000a48081 --- /dev/null +++ b/packages/skins/src/styles/menus/cast-menu.styles.ts @@ -0,0 +1,17 @@ +import { styles } from 'vjsc/styles'; + +export default styles({ + file: 'menus.css', + prefix: 'media-cast-menu', + rules: { + content: { + utilities: 'gap-0!', + }, + item: { + utilities: [ + 'relative flex! w-full! h-auto! min-h-0! shrink-0 items-center! justify-start! gap-1.5! rounded-media-menu-item px-2 py-1 text-start', + 'hover:highlight-media focus-visible:highlight-media focus-visible:outline-media-ring', + ], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8efa4ff85a..10f2d438ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: '@videojs/dash-video': specifier: workspace:* version: link:../../packages/adapters/dash-video + '@videojs/fcast': + specifier: workspace:* + version: link:../../packages/extensions/fcast '@videojs/google-cast': specifier: workspace:* version: link:../../packages/extensions/google-cast @@ -762,6 +765,9 @@ importers: '@videojs/dash-video': specifier: workspace:* version: link:../adapters/dash-video + '@videojs/fcast': + specifier: workspace:* + version: link:../extensions/fcast '@videojs/google-cast': specifier: workspace:* version: link:../extensions/google-cast @@ -873,6 +879,25 @@ importers: specifier: 'catalog:' version: 1.0.0(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(esbuild@0.28.1)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@27.4.0(supports-color@8.1.1))(terser@5.50.0)(tsx@4.23.1)(typescript@6.0.2)(unrun@0.2.39)(yaml@2.9.0) + packages/extensions/fcast: + dependencies: + '@videojs/core': + specifier: workspace:* + version: link:../../core + '@videojs/media': + specifier: workspace:* + version: link:../../media + '@videojs/utils': + specifier: workspace:* + version: link:../../utils + devDependencies: + jsdom: + specifier: ^26.1.0 + version: 26.1.0(supports-color@8.1.1) + vite-plus: + specifier: 'catalog:' + version: 1.0.0(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(esbuild@0.28.1)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@8.1.1))(terser@5.50.0)(tsx@4.23.1)(typescript@6.0.2)(unrun@0.2.39)(yaml@2.9.0) + packages/extensions/google-cast: dependencies: '@videojs/core': @@ -934,6 +959,9 @@ importers: '@videojs/element': specifier: workspace:* version: link:../element + '@videojs/fcast': + specifier: workspace:* + version: link:../extensions/fcast '@videojs/media': specifier: workspace:* version: link:../media @@ -1082,6 +1110,9 @@ importers: '@videojs/core': specifier: workspace:* version: link:../core + '@videojs/fcast': + specifier: workspace:* + version: link:../extensions/fcast '@videojs/media': specifier: workspace:* version: link:../media @@ -1191,6 +1222,9 @@ importers: '@videojs/element': specifier: workspace:* version: link:../element + '@videojs/fcast': + specifier: workspace:* + version: link:../extensions/fcast '@videojs/icons': specifier: workspace:* version: link:../icons diff --git a/site/src/content/docs/guides/casting.mdx b/site/src/content/docs/guides/casting.mdx index 0df2eeb587..d0c7ec6fc3 100644 --- a/site/src/content/docs/guides/casting.mdx +++ b/site/src/content/docs/guides/casting.mdx @@ -1,6 +1,6 @@ --- -title: 'Cast to AirPlay and Chromecast' -description: 'Send playback to AirPlay and Google Cast devices, with availability detection and connection state.' +title: 'Cast to AirPlay, Chromecast, and FCast' +description: 'Send playback to AirPlay, Google Cast, and FCast receivers.' --- import Demo from '@/components/docs/demos/Demo.astro'; @@ -168,6 +168,61 @@ Use `remotePlaybackAvailability` to decide whether to offer a route and `remoteP Use `remotePlaybackAvailability` to decide whether to offer a route and `remotePlaybackState` to show whether it is disconnected, connecting, or connected. `<media-cast-button>` and `<media-airplay-button>` already apply these states to their presentation; read them directly only when another part of your app needs them. </FrameworkCase> +### FCast receivers + +FCast receivers use a separate sender. Browsers cannot discover receivers over mDNS or open the FCast TCP connection, so provide an `FCastSender` backed by a native [FCast SDK](https://docs.fcast.org/sdk/) or an application bridge. The sender reports receiver availability and playback state, opens the device picker, and sends playback commands. + +The packaged video and live video skins include an FCast control beside Cast and AirPlay. It stays hidden until you supply a sender. Install `@videojs/fcast` when creating the sender in your application. + +<FrameworkCase frameworks={["react"]}> +Pass the sender to the skin: + +```tsx +<VideoSkin fcastSender={sender} /> +``` + +Set `fcastSrc` and `fcastContentType` when the receiver needs a different playable URL or MIME type. +</FrameworkCase> + +<FrameworkCase frameworks={["html"]}> +Assign the sender to the skin element: + +```ts +const skin = document.querySelector('video-skin')!; +skin.fcastSender = sender; +``` + +Set `skin.fcastSrc` and `skin.fcastContentType` when the receiver needs a different playable URL or MIME type. +</FrameworkCase> + +For a custom UI, add the FCast button beside the other casting controls: + +<FrameworkCase frameworks={["react"]}> +Pass your sender to `FCastButton`: + +```tsx +import { FCastButton } from '@videojs/react/ui/fcast-button'; + +<FCastButton sender={sender}>FCast</FCastButton> +``` +</FrameworkCase> + +<FrameworkCase frameworks={["html"]}> +Register the custom element and assign the sender property: + +```ts +import '@videojs/html/ui/fcast-button'; + +document.querySelector('media-fcast-button')!.sender = sender; +``` + +```html +<media-fcast-button>FCast</media-fcast-button> +``` +</FrameworkCase> + +The FCast button registers its own player extension, so existing play, pause, seek, volume, and speed controls follow the FCast receiver while connected. It reads the sender's `snapshot`, separate from `remotePlaybackState`, and exposes `data-fcast-state` and `data-availability` for styling. Set `src` and `contentType` on a custom FCast button when the receiver needs a different playable URL or MIME type. + ## Troubleshooting ### The cast button doesn't render, or renders disabled diff --git a/site/src/content/docs/reference/components/airplay-button.mdx b/site/src/content/docs/reference/components/airplay-button.mdx index c1c805da77..9d4ed8232c 100644 --- a/site/src/content/docs/reference/components/airplay-button.mdx +++ b/site/src/content/docs/reference/components/airplay-button.mdx @@ -43,14 +43,14 @@ import basicUsageHtmlTs from "@/components/docs/demos/airplay-button/html/css/Ba ## Behavior -Opens the WebKit AirPlay playback target picker and reflects session state. AirPlay is a WebKit-only feature, so the button reports `availability: "unsupported"` outside Safari (macOS and iOS). On supported platforms availability flips to `"available"` once Safari discovers at least one AirPlay receiver on the local network, and `"unavailable"` otherwise. +Opens the WebKit AirPlay playback target picker and reflects session state. AirPlay is a WebKit-only feature, so the button reports `availability: "unsupported"` outside Safari (macOS and iOS). On supported platforms availability flips to `"available"` once Safari discovers at least one AirPlay receiver on the local network, and `"unavailable"` otherwise. Set `showUnavailable` (or the HTML `show-unavailable` attribute) to keep an unavailable or unsupported button visible and disabled, as used by the cast menu. <FrameworkCase frameworks={["html"]}> -The button is hidden until `availability` is `"available"`: the element receives the native `hidden` attribute. +The button is hidden until `availability` is `"available"` unless `show-unavailable` is set: the element receives the native `hidden` attribute. </FrameworkCase> <FrameworkCase frameworks={["react"]}> -The button is hidden until `availability` is `"available"`: the component returns `null`. +The button is hidden until `availability` is `"available"` unless `showUnavailable` is set: the component returns `null`. </FrameworkCase> An explicitly disabled, otherwise available button stays visible and focusable with `aria-disabled="true"` and `data-disabled`, but does not open the picker. @@ -116,7 +116,7 @@ media-airplay-button[data-disabled] { ``` </FrameworkCase> -Unavailable and unsupported buttons are hidden automatically. No availability selector or extra hiding CSS is required. +Unavailable and unsupported buttons are hidden automatically unless `showUnavailable` is enabled. With that option, they remain visible and receive `aria-disabled="true"` and `data-disabled`. ## Accessibility diff --git a/site/src/content/docs/reference/components/fcast-button.mdx b/site/src/content/docs/reference/components/fcast-button.mdx new file mode 100644 index 0000000000..17fc60a6b9 --- /dev/null +++ b/site/src/content/docs/reference/components/fcast-button.mdx @@ -0,0 +1,62 @@ +--- +title: FCastButton +frameworkTitle: + html: media-fcast-button +description: FCast receiver control backed by an application-provided sender +stability: unstable +--- + +import FrameworkCase from '@/components/docs/FrameworkCase.astro'; +import DocsLink from '@/components/docs/DocsLink.astro'; + +`FCastButton` registers an FCast player extension and displays a separate casting control. It requires an `FCastSender` supplied by the application. The packaged video skins expose FCast as an option in the cast menu; set `fcastSender` on the skin to activate it. See <DocsLink slug="reference/components/fcast">FCast</DocsLink> for the bridge contract and <DocsLink slug="guides/casting">Casting</DocsLink> for the setup and the [FCast SDK](https://docs.fcast.org/sdk/) for native sender libraries. + +## Import + +<FrameworkCase frameworks={["react"]}> +```tsx +import { FCastButton } from '@videojs/react/ui/fcast-button'; +import type { FCastSender } from '@videojs/fcast'; +``` +</FrameworkCase> + +<FrameworkCase frameworks={["html"]}> +```ts +import '@videojs/html/ui/fcast-button'; +import type { FCastSender } from '@videojs/fcast'; +``` +</FrameworkCase> + +## Anatomy + +<FrameworkCase frameworks={["react"]}> +```tsx +<FCastButton sender={sender}>FCast</FCastButton> +``` +</FrameworkCase> + +<FrameworkCase frameworks={["html"]}> +```html +<media-fcast-button>FCast</media-fcast-button> +``` + +Assign `sender` as a property on the element. +</FrameworkCase> + +## Behavior + +The sender's `snapshot` supplies availability, connection, device name, playback position, duration, volume, mute state, speed, and optional buffering and ended states. The sender dispatches `change` whenever this snapshot changes. The button calls `prompt()` to choose a receiver and `disconnect()` to end a session. While connected, the extension forwards the player's load, play, pause, seek, volume, and speed actions to the sender. + +The button is hidden when no sender is assigned, or when the disconnected sender reports `unsupported`, unless `showUnavailable` is true. In that case it stays visible with `aria-disabled="true"`. It also stays visible and disabled when no receiver is available. A connected session can be disconnected even if discovery later reports no available devices. The sender handles receiver discovery and the FCast protocol; the browser control uses its published state. + +| Input | React | HTML | Description | +|---|---|---|---| +| Sender | `sender` prop | `sender` property | Native SDK sender or application bridge implementing `FCastSender` | +| Receiver URL | `src` prop | `src` attribute or property | Playable URL to send instead of the local media URL | +| MIME type | `contentType` prop | `content-type` attribute or `contentType` property | MIME type sent with the load request | +| Disabled | `disabled` prop | `disabled` attribute or property | Keeps the control focusable and prevents activation | +| Show unavailable | `showUnavailable` prop | `show-unavailable` attribute or `showUnavailable` property | Keeps the control visible and disabled when no sender is assigned or the sender reports `unsupported` | + +## Styling + +The button exposes `data-fcast-state` (`disconnected`, `connecting`, or `connected`) and `data-availability` (`unsupported`, `unavailable`, or `available`). Its label names the next action and includes the device name when connected. React renders a native `<button>`; the HTML element uses button semantics with Enter and Space activation. diff --git a/site/src/content/docs/reference/components/fcast.mdx b/site/src/content/docs/reference/components/fcast.mdx new file mode 100644 index 0000000000..08509f4534 --- /dev/null +++ b/site/src/content/docs/reference/components/fcast.mdx @@ -0,0 +1,104 @@ +--- +title: FCast +description: FCast playback extension and the application bridge contract for receiver discovery and control +stability: unstable +apis: + - FCastExtension + - FCastExtensionProps + - FCastBridge + - FCastSender + - FCastLoadRequest + - FCastSnapshot +--- + +import FrameworkCase from '@/components/docs/FrameworkCase.astro'; +import DocsLink from '@/components/docs/DocsLink.astro'; + +The FCast extension routes player controls to a receiver through an application-provided bridge. The <DocsLink slug="reference/components/fcast-button">FCastButton</DocsLink> registers the extension inside a player and supplies its casting control. Google Cast and AirPlay remain separate options in the cast menu. See <DocsLink slug="guides/casting">Casting</DocsLink> for player setup. + +Browsers cannot perform FCast's mDNS discovery or open its native TCP transport. `@videojs/fcast` defines the browser-facing `FCastBridge` interface; it does not include a bridge, device picker, discovery service, or protocol transport. An application supplies an implementation backed by a native host or companion service. Without one, the FCast button is hidden unless `showUnavailable` is enabled on the button. + +## Import + +```ts +import { FCastExtension } from '@videojs/fcast'; +import type { FCastBridge, FCastLoadRequest, FCastSnapshot } from '@videojs/fcast'; +``` + +## Usage + +These examples receive an application-owned bridge. `FCastSender` is a compatible alias for `FCastBridge`; the component input remains named `sender`. + +<FrameworkCase frameworks={["react"]}> +```tsx +import { FCastButton } from '@videojs/react/ui/fcast-button'; +import type { FCastBridge } from '@videojs/fcast'; + +export function CastingControl({ bridge }: { bridge: FCastBridge }) { + return <FCastButton sender={bridge}>FCast</FCastButton>; +} +``` + +`CastingControl` belongs inside the player. +</FrameworkCase> + +<FrameworkCase frameworks={["html"]}> +```html +<media-fcast-button>FCast</media-fcast-button> +``` + +```ts +import '@videojs/html/ui/fcast-button'; +import type { FCastBridge } from '@videojs/fcast'; + +export function connectCastingControl(bridge: FCastBridge) { + const button = document.querySelector('media-fcast-button'); + if (button) button.sender = bridge; +} +``` + +The element belongs inside the player. `sender` is a property, not an HTML attribute. +</FrameworkCase> + +## Options + +| Option | Description | +|---|---| +| `sender` | Application-owned `FCastBridge`. Defaults to absent. | +| `src` | Receiver-accessible media URL. Defaults to the attached media's playable source. Embed pages require an explicit stream URL. Browser blob URLs are not portable to receivers. | +| `contentType` | MIME type sent with the load request. Inferred from the URL when absent; an unknown type is sent as an empty string for the bridge to resolve or reject. | + +## Playback behavior + +On connection, local playback pauses and its position, volume, rate, and playing state become the initial load request. Player play, pause, seek, volume, mute, and rate controls then target the receiver. Source changes load the new media while retaining the receiver's volume and rate. Replay after an ended update starts at zero. + +On disconnect, the extension restores the last receiver position, volume, mute, rate, and playback state to the local media. Removal also disconnects the bridge, including pending receiver selection. Unsent extension loads are discarded when the sender or attached media changes. Media with `disableRemotePlayback` does not receive a remote override or load request. + +FCast has no independent mute command. Muting sends volume zero; unmuting restores the last nonzero receiver volume. Receiver updates drive media events and the player state. The extension emits `change` when its observable state changes and `CustomEvent('error')` with the failure in `detail` for asynchronous load, control, or bridge failures. Methods returning promises also reject to their caller. + +## Bridge contract + +`FCastBridge` extends `EventTarget` and exposes a complete `snapshot`. Each update replaces or updates the snapshot **before** dispatching `change`. Availability is independent of connection state: discovery can become unavailable while a session remains connected. + +| Member | Contract | +|---|---| +| `snapshot` | `FCastSnapshot`: availability, connection, optional device name, paused state, position, duration, volume, mute, rate, and optional buffering/ended state. | +| `prompt()` | Owns permission requests and receiver selection; publishes `connecting`, then `connected`. Cancellation restores `disconnected` and rejects with `AbortError`. | +| `disconnect()` | Idempotently cancels pending selection and unsent commands, stops remote playback, closes the connection, and publishes `disconnected` before resolving. | +| `load(request)` | Replaces media and applies the requested position, volume, speed, and paused state in order. | +| `play()` / `pause()` | Requests receiver playback or pause. | +| `seek(time)` | Requests a position in seconds. | +| `setVolume(volume)` | Requests volume from zero to one; zero is muted. | +| `setSpeed(speed)` | Requests a positive playback rate multiplier. | + +Playback commands reject when disconnected or when sending fails. Resolution means the command was sent, not that the receiver acknowledged it. The bridge serializes commands and reports actual state from receiver updates. Receiver and transport failures arriving later emit `CustomEvent('error', { detail: error })`; connection loss also publishes `disconnected`. Updates from an old session cannot overwrite a newer session. + +Positions and durations use seconds, including fractional seconds. Unknown duration is `NaN`; an unbounded live duration is `Infinity`. Volume is normalized to `[0, 1]`, `muted` reflects zero volume, and speed is a positive multiplier. `load()` includes the desired paused state even when the negotiated protocol requires a separate pause after loading. + +The application owns bridge creation, disposal, discovery resources, and access permissions. Each bridge serves one player session at a time. Video.js subscribes to the bridge and disconnects its session on removal; the application disposes the bridge itself. + +## Protocol boundary + +The bridge implements discovery, receiver selection, version negotiation, message encoding, heartbeats, and transport security according to the [FCast protocol](https://docs.fcast.org/protocol/). Native libraries are listed in the [FCast SDK documentation](https://docs.fcast.org/sdk/). Protocol-specific time units and playback states are normalized to `FCastSnapshot` at this boundary. + +The browser contract covers single-media playback. It does not expose protocol-specific queues, mirroring, track selection, or companion-resource serving. The receiver must be able to fetch the media URL independently; browser cookies and local object URLs are not forwarded. diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index 45a6bfc406..894ef442ab 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -117,7 +117,7 @@ export const sidebar: Sidebar = [ }, { slug: 'guides/casting', - sidebarLabel: 'AirPlay and Chromecast', + sidebarLabel: 'AirPlay, Chromecast, and FCast', }, { slug: 'guides/playback-errors', @@ -223,7 +223,11 @@ export const sidebar: Sidebar = [ { sidebarLabel: 'Extensions', llmsDescription: 'API reference for extensions that connect external services to the player.', - contents: [{ slug: 'reference/components/google-cast' }, { slug: 'reference/components/mux-data' }], + contents: [ + { slug: 'reference/components/google-cast' }, + { slug: 'reference/components/fcast' }, + { slug: 'reference/components/mux-data' }, + ], }, { sidebarLabel: 'Buttons', @@ -239,6 +243,7 @@ export const sidebar: Sidebar = [ { slug: 'reference/components/live-button' }, { slug: 'reference/components/airplay-button' }, { slug: 'reference/components/cast-button' }, + { slug: 'reference/components/fcast-button' }, ], }, { diff --git a/tsconfig.json b/tsconfig.json index 39f5d44302..4a932bb686 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ { "path": "packages/adapters/wistia-video" }, { "path": "packages/adapters/youtube-video" }, + { "path": "packages/extensions/fcast" }, { "path": "packages/extensions/google-cast" }, { "path": "packages/extensions/mux-data" },