From 24a78d253a661e112c46ef6278329884a7f2a2d8 Mon Sep 17 00:00:00 2001 From: Santiago Puppo Date: Wed, 3 Jun 2026 17:54:28 -0300 Subject: [PATCH] refactor(core): airplay button (#1614) --- .claude/plans/player-api-implementation.md | 2 +- .../ui/airplay-button/airplay-button-core.ts | 57 +++++++-------- .../airplay-button-data-attrs.ts | 18 +++-- .../tests/airplay-button-core.test.ts | 70 +++++++++---------- .../ui/cast-button/cast-button-data-attrs.ts | 10 +++ .../src/dom/media/tests/video-host.test.ts | 2 +- packages/core/src/dom/media/video-host.ts | 2 +- .../core/src/dom/presentation/fullscreen.ts | 2 +- packages/core/src/dom/presentation/index.ts | 1 - packages/core/src/dom/presentation/pip.ts | 2 +- .../core/src/dom/store/features/fullscreen.ts | 3 +- packages/core/src/dom/store/features/pip.ts | 3 +- .../src/dom/store/features/remote-playback.ts | 16 +---- .../store/features/tests/fullscreen.test.ts | 2 +- .../src/dom/store/features/tests/pip.test.ts | 2 +- .../html/src/define/live-video/minimal-ui.ts | 4 +- packages/html/src/define/live-video/ui.ts | 4 +- packages/html/src/define/ui/airplay-button.ts | 6 +- packages/html/src/define/video/minimal-ui.ts | 4 +- packages/html/src/define/video/ui.ts | 4 +- packages/html/src/index.ts | 2 +- .../airplay-button/airplay-button-element.ts | 8 +-- packages/icons/scripts/build.ts | 28 ++++---- packages/icons/scripts/icon-bases.ts | 30 ++++++++ packages/react/src/index.ts | 2 +- .../live-video/minimal-skin.tailwind.tsx | 14 ++-- .../src/presets/live-video/minimal-skin.tsx | 14 ++-- .../src/presets/live-video/skin.tailwind.tsx | 14 ++-- .../react/src/presets/live-video/skin.tsx | 14 ++-- .../presets/video/minimal-skin.tailwind.tsx | 14 ++-- .../react/src/presets/video/minimal-skin.tsx | 14 ++-- .../react/src/presets/video/skin.tailwind.tsx | 14 ++-- packages/react/src/presets/video/skin.tsx | 14 ++-- .../src/ui/airplay-button/airplay-button.tsx | 22 +++--- .../skins/src/shared/css/video/icon-state.css | 4 +- packages/utils/src/dom/index.ts | 10 +++ packages/utils/src/dom/tests/webkit.test.ts | 48 +++++++++++++ .../types.ts => utils/src/dom/webkit.ts} | 18 +++++ .../airplay-button/react/css/BasicUsage.tsx | 6 +- .../content/docs/reference/airplay-button.mdx | 8 +-- 40 files changed, 314 insertions(+), 198 deletions(-) create mode 100644 packages/icons/scripts/icon-bases.ts create mode 100644 packages/utils/src/dom/tests/webkit.test.ts rename packages/{core/src/dom/presentation/types.ts => utils/src/dom/webkit.ts} (57%) diff --git a/.claude/plans/player-api-implementation.md b/.claude/plans/player-api-implementation.md index 56fa3be1..610adb5d 100644 --- a/.claude/plans/player-api-implementation.md +++ b/.claude/plans/player-api-implementation.md @@ -1423,7 +1423,7 @@ fullscreen.fullscreenAvailability // Picture-in-Picture availability pip.pipAvailability -// Airplay/Cast availability +// AirPlay/Cast availability cast.castAvailability ``` diff --git a/packages/core/src/core/ui/airplay-button/airplay-button-core.ts b/packages/core/src/core/ui/airplay-button/airplay-button-core.ts index 06d8c014..8224de37 100644 --- a/packages/core/src/core/ui/airplay-button/airplay-button-core.ts +++ b/packages/core/src/core/ui/airplay-button/airplay-button-core.ts @@ -1,4 +1,5 @@ import { createState } from '@videojs/store'; +import { supportsWebKitAirPlay } from '@videojs/utils/dom'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; import type { NonNullableObject } from '@videojs/utils/types'; @@ -7,42 +8,43 @@ import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '.. import type { MediaFeatureAvailability } from '../../media/types'; import type { ButtonState } from '../types'; -export interface AirplayButtonProps { +export interface AirPlayButtonProps { /** Custom label for the button. */ - label?: string | ((state: AirplayButtonState) => string) | undefined; + label?: string | ((state: AirPlayButtonState) => string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; } -export interface AirplayButtonState extends ButtonState { - airplayState: RemotePlaybackConnectionState; +export interface AirPlayButtonState extends ButtonState { + /** Current AirPlay connection state. */ + state: RemotePlaybackConnectionState; + /** Whether AirPlay is available on the active platform and media. */ availability: MediaFeatureAvailability; } - -export class AirplayButtonCore { - static readonly defaultProps: NonNullableObject = { +export class AirPlayButtonCore { + static readonly defaultProps: NonNullableObject = { label: '', disabled: false, }; - readonly state = createState({ - airplayState: 'disconnected', + readonly state = createState({ + state: 'disconnected', availability: 'unsupported', label: '', }); - #props = { ...AirplayButtonCore.defaultProps }; + #props = { ...AirPlayButtonCore.defaultProps }; #media: MediaRemotePlaybackState | null = null; - constructor(props?: AirplayButtonProps) { + constructor(props?: AirPlayButtonProps) { if (props) this.setProps(props); } - setProps(props: AirplayButtonProps): void { - this.#props = defaults(props, AirplayButtonCore.defaultProps); + setProps(props: AirPlayButtonProps): void { + this.#props = defaults(props, AirPlayButtonCore.defaultProps); } - getLabel(state: AirplayButtonState): string { + getLabel(state: AirPlayButtonState): string { const { label } = this.#props; if (isFunction(label)) { @@ -52,12 +54,12 @@ export class AirplayButtonCore { return label; } - if (state.airplayState === 'connected') return 'Stop AirPlay'; - if (state.airplayState === 'connecting') return 'Connecting'; + if (state.state === 'connected') return 'Stop AirPlay'; + if (state.state === 'connecting') return 'Connecting'; return 'Start AirPlay'; } - getAttrs(state: AirplayButtonState) { + getAttrs(state: AirPlayButtonState) { return { 'aria-label': this.getLabel(state), 'aria-disabled': this.#props.disabled ? 'true' : undefined, @@ -68,16 +70,16 @@ export class AirplayButtonCore { this.#media = media; } - getState(): AirplayButtonState { + getState(): AirPlayButtonState { const media = this.#media!; - // WebKit (Safari macOS/iOS) is the only platform that surfaces AirPlay - // through the W3C Remote Playback API. Mirrors the Chromium gate on - // CastButtonCore so each button only shows on its supported platform. - const airplaySupported = 'WebKitPlaybackTargetAvailabilityEvent' in globalThis; + // WebKit (Safari macOS/iOS) is the only platform that surfaces AirPlay. + // Mirrors the Chromium gate on CastButtonCore so each button only shows + // on its supported platform. + const isAirPlaySupported = supportsWebKitAirPlay(); this.state.patch({ - airplayState: media.remotePlaybackState, - availability: airplaySupported ? media.remotePlaybackAvailability : 'unsupported', + state: media.remotePlaybackState, + availability: isAirPlaySupported ? media.remotePlaybackAvailability : 'unsupported', }); this.state.patch({ label: this.getLabel(this.state.current) }); @@ -86,7 +88,6 @@ export class AirplayButtonCore { async toggle(state: MediaRemotePlaybackState): Promise { if (this.#props.disabled) return; - if (state.remotePlaybackAvailability !== 'available') return; try { await state.toggleRemotePlayback(); @@ -96,7 +97,7 @@ export class AirplayButtonCore { } } -export namespace AirplayButtonCore { - export type Props = AirplayButtonProps; - export type State = AirplayButtonState; +export namespace AirPlayButtonCore { + export type Props = AirPlayButtonProps; + export type State = AirPlayButtonState; } diff --git a/packages/core/src/core/ui/airplay-button/airplay-button-data-attrs.ts b/packages/core/src/core/ui/airplay-button/airplay-button-data-attrs.ts index 6e13b2c6..b25f4381 100644 --- a/packages/core/src/core/ui/airplay-button/airplay-button-data-attrs.ts +++ b/packages/core/src/core/ui/airplay-button/airplay-button-data-attrs.ts @@ -1,7 +1,17 @@ import type { StateAttrMap } from '../types'; -import type { AirplayButtonState } from './airplay-button-core'; +import type { AirPlayButtonState } from './airplay-button-core'; -export const AirplayButtonDataAttrs = { - airplayState: 'data-airplay-state', +export const AirPlayButtonDataAttrs = { + /** + * Current AirPlay connection state. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback/state + */ + state: 'data-airplay-state', + /** + * Whether AirPlay is available on the active platform and media. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback + */ availability: 'data-availability', -} as const satisfies StateAttrMap; +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/airplay-button/tests/airplay-button-core.test.ts b/packages/core/src/core/ui/airplay-button/tests/airplay-button-core.test.ts index cf6f9fe3..b788f2c3 100644 --- a/packages/core/src/core/ui/airplay-button/tests/airplay-button-core.test.ts +++ b/packages/core/src/core/ui/airplay-button/tests/airplay-button-core.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MediaRemotePlaybackState } from '../../../media/state'; -import type { AirplayButtonState } from '../airplay-button-core'; -import { AirplayButtonCore } from '../airplay-button-core'; +import type { AirPlayButtonState } from '../airplay-button-core'; +import { AirPlayButtonCore } from '../airplay-button-core'; -// AirplayButtonCore reports `availability: 'unsupported'` outside WebKit +// AirPlayButtonCore reports `availability: 'unsupported'` outside WebKit // (detected via `WebKitPlaybackTargetAvailabilityEvent` on globalThis). // jsdom lacks that constructor, so stub it for every test. function stubWebKit(present: boolean) { @@ -25,32 +25,32 @@ function createMediaState(overrides: Partial = {}): Me }; } -function createState(overrides: Partial = {}): AirplayButtonState { +function createState(overrides: Partial = {}): AirPlayButtonState { return { - airplayState: 'disconnected', + state: 'disconnected', availability: 'available', label: '', ...overrides, }; } -describe('AirplayButtonCore', () => { +describe('AirPlayButtonCore', () => { beforeEach(() => stubWebKit(true)); afterEach(() => stubWebKit(false)); describe('getState', () => { - it('projects airplayState and availability', () => { - const core = new AirplayButtonCore(); + it('projects state and availability', () => { + const core = new AirPlayButtonCore(); const media = createMediaState({ remotePlaybackState: 'connected' }); core.setMedia(media); - const state = core.getState(); + const result = core.getState(); - expect(state.airplayState).toBe('connected'); - expect(state.availability).toBe('available'); + expect(result.state).toBe('connected'); + expect(result.availability).toBe('available'); }); it('reflects unsupported availability', () => { - const core = new AirplayButtonCore(); + const core = new AirPlayButtonCore(); core.setMedia(createMediaState({ remotePlaybackAvailability: 'unsupported' })); const state = core.getState(); @@ -58,16 +58,16 @@ describe('AirplayButtonCore', () => { }); it('reflects connecting state', () => { - const core = new AirplayButtonCore(); + const core = new AirPlayButtonCore(); core.setMedia(createMediaState({ remotePlaybackState: 'connecting' })); - const state = core.getState(); + const result = core.getState(); - expect(state.airplayState).toBe('connecting'); + expect(result.state).toBe('connecting'); }); it('reports unsupported outside WebKit', () => { stubWebKit(false); - const core = new AirplayButtonCore(); + const core = new AirPlayButtonCore(); core.setMedia(createMediaState({ remotePlaybackAvailability: 'available' })); const state = core.getState(); @@ -77,42 +77,42 @@ describe('AirplayButtonCore', () => { describe('getLabel', () => { it('returns Start AirPlay when disconnected', () => { - const core = new AirplayButtonCore(); - expect(core.getLabel(createState({ airplayState: 'disconnected' }))).toBe('Start AirPlay'); + const core = new AirPlayButtonCore(); + expect(core.getLabel(createState({ state: 'disconnected' }))).toBe('Start AirPlay'); }); it('returns Stop AirPlay when connected', () => { - const core = new AirplayButtonCore(); - expect(core.getLabel(createState({ airplayState: 'connected' }))).toBe('Stop AirPlay'); + const core = new AirPlayButtonCore(); + expect(core.getLabel(createState({ state: 'connected' }))).toBe('Stop AirPlay'); }); it('returns Connecting when connecting', () => { - const core = new AirplayButtonCore(); - expect(core.getLabel(createState({ airplayState: 'connecting' }))).toBe('Connecting'); + const core = new AirPlayButtonCore(); + expect(core.getLabel(createState({ state: 'connecting' }))).toBe('Connecting'); }); it('returns custom string label', () => { - const core = new AirplayButtonCore({ label: 'AirPlay' }); + const core = new AirPlayButtonCore({ label: 'AirPlay' }); expect(core.getLabel(createState())).toBe('AirPlay'); }); it('returns custom function label', () => { - const core = new AirplayButtonCore({ - label: (state) => (state.airplayState === 'connected' ? 'Disconnect' : 'Connect'), + const core = new AirPlayButtonCore({ + label: (state) => (state.state === 'connected' ? 'Disconnect' : 'Connect'), }); - expect(core.getLabel(createState({ airplayState: 'connected' }))).toBe('Disconnect'); + expect(core.getLabel(createState({ state: 'connected' }))).toBe('Disconnect'); }); }); describe('getAttrs', () => { it('returns aria-label', () => { - const core = new AirplayButtonCore(); + const core = new AirPlayButtonCore(); const attrs = core.getAttrs(createState()); expect(attrs['aria-label']).toBe('Start AirPlay'); }); it('sets aria-disabled when disabled', () => { - const core = new AirplayButtonCore({ disabled: true }); + const core = new AirPlayButtonCore({ disabled: true }); const attrs = core.getAttrs(createState()); expect(attrs['aria-disabled']).toBe('true'); }); @@ -120,35 +120,35 @@ describe('AirplayButtonCore', () => { describe('toggle', () => { it('calls toggleRemotePlayback when disconnected', async () => { - const core = new AirplayButtonCore(); + const core = new AirPlayButtonCore(); const media = createMediaState({ remotePlaybackState: 'disconnected' }); await core.toggle(media); expect(media.toggleRemotePlayback).toHaveBeenCalled(); }); it('calls toggleRemotePlayback when connected', async () => { - const core = new AirplayButtonCore(); + const core = new AirPlayButtonCore(); const media = createMediaState({ remotePlaybackState: 'connected' }); await core.toggle(media); expect(media.toggleRemotePlayback).toHaveBeenCalled(); }); it('does nothing when disabled', async () => { - const core = new AirplayButtonCore({ disabled: true }); + const core = new AirPlayButtonCore({ disabled: true }); const media = createMediaState(); await core.toggle(media); expect(media.toggleRemotePlayback).not.toHaveBeenCalled(); }); - it('does nothing when unsupported', async () => { - const core = new AirplayButtonCore(); + it('toggles regardless of availability (the button is hidden when unavailable)', async () => { + const core = new AirPlayButtonCore(); const media = createMediaState({ remotePlaybackAvailability: 'unsupported' }); await core.toggle(media); - expect(media.toggleRemotePlayback).not.toHaveBeenCalled(); + expect(media.toggleRemotePlayback).toHaveBeenCalled(); }); it('catches AirPlay errors silently', async () => { - const core = new AirplayButtonCore(); + const core = new AirPlayButtonCore(); const media = createMediaState({ toggleRemotePlayback: vi.fn(async () => { throw new Error('user cancelled'); diff --git a/packages/core/src/core/ui/cast-button/cast-button-data-attrs.ts b/packages/core/src/core/ui/cast-button/cast-button-data-attrs.ts index 039ca5eb..22febcb7 100644 --- a/packages/core/src/core/ui/cast-button/cast-button-data-attrs.ts +++ b/packages/core/src/core/ui/cast-button/cast-button-data-attrs.ts @@ -2,6 +2,16 @@ import type { StateAttrMap } from '../types'; import type { CastButtonState } from './cast-button-core'; export const CastButtonDataAttrs = { + /** + * Current remote playback connection state. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback/state + */ castState: 'data-cast-state', + /** + * Whether remote playback can be requested on this platform. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback + */ availability: 'data-availability', } as const satisfies StateAttrMap; diff --git a/packages/core/src/dom/media/tests/video-host.test.ts b/packages/core/src/dom/media/tests/video-host.test.ts index 2323bafc..dd9e0721 100644 --- a/packages/core/src/dom/media/tests/video-host.test.ts +++ b/packages/core/src/dom/media/tests/video-host.test.ts @@ -1,5 +1,5 @@ +import type { WebKitDocument, WebKitVideoElement } from '@videojs/utils/dom'; import { afterEach, describe, expect, it } from 'vitest'; -import type { WebKitDocument, WebKitVideoElement } from '../../presentation/types'; import { HTMLVideoElementHost } from '../video-host'; afterEach(() => { diff --git a/packages/core/src/dom/media/video-host.ts b/packages/core/src/dom/media/video-host.ts index b08d4ddb..cbcf1670 100644 --- a/packages/core/src/dom/media/video-host.ts +++ b/packages/core/src/dom/media/video-host.ts @@ -1,6 +1,6 @@ +import type { WebKitDocument, WebKitPresentationMode, WebKitVideoElement } from '@videojs/utils/dom'; import { isFunction } from '@videojs/utils/predicate'; import type { Video, VideoEvents } from '../../core/media/types'; -import type { WebKitDocument, WebKitPresentationMode, WebKitVideoElement } from '../presentation/types'; import { HTMLMediaElementHost } from './media-host'; export class HTMLVideoElementHost extends HTMLMediaElementHost implements Video { diff --git a/packages/core/src/dom/presentation/fullscreen.ts b/packages/core/src/dom/presentation/fullscreen.ts index c0ca8022..5d177fbb 100644 --- a/packages/core/src/dom/presentation/fullscreen.ts +++ b/packages/core/src/dom/presentation/fullscreen.ts @@ -1,6 +1,6 @@ +import type { WebKitDocument, WebKitFullscreenElement, WebKitVideoElement } from '@videojs/utils/dom'; import { isFunction } from '@videojs/utils/predicate'; import type { MediaFullscreenCapability } from '../../core/media/types'; -import type { WebKitDocument, WebKitFullscreenElement, WebKitVideoElement } from './types'; export function isFullscreenEnabled() { const doc = document as WebKitDocument; diff --git a/packages/core/src/dom/presentation/index.ts b/packages/core/src/dom/presentation/index.ts index 8fc3d9c7..77407c1a 100644 --- a/packages/core/src/dom/presentation/index.ts +++ b/packages/core/src/dom/presentation/index.ts @@ -1,4 +1,3 @@ export * from './fullscreen'; export * from './pip'; export * from './remote-playback'; -export * from './types'; diff --git a/packages/core/src/dom/presentation/pip.ts b/packages/core/src/dom/presentation/pip.ts index ae347ceb..1418decf 100644 --- a/packages/core/src/dom/presentation/pip.ts +++ b/packages/core/src/dom/presentation/pip.ts @@ -1,6 +1,6 @@ +import type { WebKitVideoElement } from '@videojs/utils/dom'; import { isFunction } from '@videojs/utils/predicate'; import type { MediaPictureInPictureCapability } from '../../core/media/types'; -import type { WebKitVideoElement } from './types'; export function isPictureInPictureEnabled() { if (document.pictureInPictureEnabled) { diff --git a/packages/core/src/dom/store/features/fullscreen.ts b/packages/core/src/dom/store/features/fullscreen.ts index f9cbc558..cd254f10 100644 --- a/packages/core/src/dom/store/features/fullscreen.ts +++ b/packages/core/src/dom/store/features/fullscreen.ts @@ -1,10 +1,9 @@ -import { listen } from '@videojs/utils/dom'; +import { listen, type WebKitVideoElement } from '@videojs/utils/dom'; import type { MediaFullscreenState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; import { exitFullscreen, isFullscreen, isFullscreenEnabled, requestFullscreen } from '../../presentation/fullscreen'; import { exitPictureInPicture, isPictureInPicture } from '../../presentation/pip'; -import type { WebKitVideoElement } from '../../presentation/types'; export const fullscreenFeature = definePlayerFeature({ name: 'fullscreen', diff --git a/packages/core/src/dom/store/features/pip.ts b/packages/core/src/dom/store/features/pip.ts index c0888b9e..8385447a 100644 --- a/packages/core/src/dom/store/features/pip.ts +++ b/packages/core/src/dom/store/features/pip.ts @@ -1,4 +1,4 @@ -import { listen } from '@videojs/utils/dom'; +import { listen, type WebKitVideoElement } from '@videojs/utils/dom'; import type { MediaPictureInPictureState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; @@ -9,7 +9,6 @@ import { isPictureInPictureEnabled, requestPictureInPicture, } from '../../presentation/pip'; -import type { WebKitVideoElement } from '../../presentation/types'; export const pipFeature = definePlayerFeature({ name: 'pip', diff --git a/packages/core/src/dom/store/features/remote-playback.ts b/packages/core/src/dom/store/features/remote-playback.ts index b019baaf..045e3f46 100644 --- a/packages/core/src/dom/store/features/remote-playback.ts +++ b/packages/core/src/dom/store/features/remote-playback.ts @@ -1,4 +1,4 @@ -import { listen } from '@videojs/utils/dom'; +import { isWebKitAirPlayCapable, listen, type WebkitAvailabilityEvent } from '@videojs/utils/dom'; import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; @@ -6,18 +6,6 @@ import { isMediaRemotePlaybackCapable } from '../../media/predicate'; import { exitFullscreen, isFullscreen } from '../../presentation/fullscreen'; import { isRemotePlaybackConnected, requestRemotePlayback } from '../../presentation/remote-playback'; -/** WebKit-only addition to HTMLMediaElement exposing the active AirPlay flag. */ -interface WebKitAirplayMedia extends HTMLMediaElement { - readonly webkitCurrentPlaybackTargetIsWireless: boolean; -} - -/** WebKit-specific availability event payload (not in lib.dom). */ -type WebkitAvailabilityEvent = Event & { availability: 'available' | 'not-available' }; - -function isWebKitAirplayCapable(media: EventTarget): media is WebKitAirplayMedia { - return 'WebKitPlaybackTargetAvailabilityEvent' in globalThis && 'webkitCurrentPlaybackTargetIsWireless' in media; -} - export const remotePlaybackFeature = definePlayerFeature({ name: 'remotePlayback', state: ({ target }): MediaRemotePlaybackState => ({ @@ -48,7 +36,7 @@ export const remotePlaybackFeature = definePlayerFeature({ // session changes. When WebKit's AirPlay APIs are available, drive both // state slices off the WebKit events and skip the W3C listeners entirely // so the two paths can't double-write or conflict. - if (isWebKitAirplayCapable(media)) { + if (isWebKitAirPlayCapable(media)) { const syncConnection = () => { set({ remotePlaybackState: media.webkitCurrentPlaybackTargetIsWireless ? 'connected' : 'disconnected', diff --git a/packages/core/src/dom/store/features/tests/fullscreen.test.ts b/packages/core/src/dom/store/features/tests/fullscreen.test.ts index 2562bf6f..aad4bfee 100644 --- a/packages/core/src/dom/store/features/tests/fullscreen.test.ts +++ b/packages/core/src/dom/store/features/tests/fullscreen.test.ts @@ -1,8 +1,8 @@ import { createStore } from '@videojs/store'; +import type { WebKitVideoElement } from '@videojs/utils/dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PlayerTarget } from '../../../media/types'; import { HTMLVideoElementHost } from '../../../media/video-host'; -import type { WebKitVideoElement } from '../../../presentation/types'; import { createMockVideo } from '../../../tests/test-helpers'; import { fullscreenFeature } from '../fullscreen'; diff --git a/packages/core/src/dom/store/features/tests/pip.test.ts b/packages/core/src/dom/store/features/tests/pip.test.ts index c609ac37..2d9f9543 100644 --- a/packages/core/src/dom/store/features/tests/pip.test.ts +++ b/packages/core/src/dom/store/features/tests/pip.test.ts @@ -1,8 +1,8 @@ import { createStore } from '@videojs/store'; +import type { WebKitVideoElement } from '@videojs/utils/dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PlayerTarget } from '../../../media/types'; import { HTMLVideoElementHost } from '../../../media/video-host'; -import type { WebKitVideoElement } from '../../../presentation/types'; import { createMockVideo } from '../../../tests/test-helpers'; import { pipFeature } from '../pip'; diff --git a/packages/html/src/define/live-video/minimal-ui.ts b/packages/html/src/define/live-video/minimal-ui.ts index f4194553..f7067de7 100644 --- a/packages/html/src/define/live-video/minimal-ui.ts +++ b/packages/html/src/define/live-video/minimal-ui.ts @@ -2,7 +2,7 @@ // elements used by the minimal skin without creating a skin element. Use // this entry when building an ejected (light DOM) player layout for live // HLS / DASH streams. -import { AirplayButtonElement } from '@/ui/airplay-button/airplay-button-element'; +import { AirPlayButtonElement } from '@/ui/airplay-button/airplay-button-element'; import { MediaContainerElement } from '../../media/container-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; @@ -45,7 +45,7 @@ defineVolumeSlider(); defineTime(); // Standalone elements. -safeDefine(AirplayButtonElement); +safeDefine(AirPlayButtonElement); safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); safeDefine(CastButtonElement); diff --git a/packages/html/src/define/live-video/ui.ts b/packages/html/src/define/live-video/ui.ts index b2995e14..4ae369b9 100644 --- a/packages/html/src/define/live-video/ui.ts +++ b/packages/html/src/define/live-video/ui.ts @@ -1,7 +1,7 @@ // Registers the live video player, container, and all video UI custom // elements without creating a skin element. Use this entry when building an // ejected (light DOM) player layout for live HLS / DASH streams. -import { AirplayButtonElement } from '@/ui/airplay-button/airplay-button-element'; +import { AirPlayButtonElement } from '@/ui/airplay-button/airplay-button-element'; import { MediaContainerElement } from '../../media/container-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; @@ -44,7 +44,7 @@ defineVolumeSlider(); defineTime(); // Standalone elements. -safeDefine(AirplayButtonElement); +safeDefine(AirPlayButtonElement); safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); safeDefine(CastButtonElement); diff --git a/packages/html/src/define/ui/airplay-button.ts b/packages/html/src/define/ui/airplay-button.ts index 11e0b674..09a3bbf8 100644 --- a/packages/html/src/define/ui/airplay-button.ts +++ b/packages/html/src/define/ui/airplay-button.ts @@ -1,10 +1,10 @@ -import { AirplayButtonElement } from '../../ui/airplay-button/airplay-button-element'; +import { AirPlayButtonElement } from '../../ui/airplay-button/airplay-button-element'; import { safeDefine } from '../safe-define'; -safeDefine(AirplayButtonElement); +safeDefine(AirPlayButtonElement); declare global { interface HTMLElementTagNameMap { - [AirplayButtonElement.tagName]: AirplayButtonElement; + [AirPlayButtonElement.tagName]: AirPlayButtonElement; } } diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index a4ddd63e..88121216 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -3,7 +3,7 @@ // when building an ejected (light DOM) player layout. import { MediaContainerElement } from '../../media/container-element'; -import { AirplayButtonElement } from '../../ui/airplay-button/airplay-button-element'; +import { AirPlayButtonElement } from '../../ui/airplay-button/airplay-button-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; @@ -50,7 +50,7 @@ defineTime(); defineMenu(); // Standalone elements. -safeDefine(AirplayButtonElement); +safeDefine(AirPlayButtonElement); safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); safeDefine(CastButtonElement); diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index 031d8359..de098bfa 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -2,7 +2,7 @@ // without creating a skin element. Use this entry when building an ejected // (light DOM) player layout. import { MediaContainerElement } from '../../media/container-element'; -import { AirplayButtonElement } from '../../ui/airplay-button/airplay-button-element'; +import { AirPlayButtonElement } from '../../ui/airplay-button/airplay-button-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; @@ -51,7 +51,7 @@ defineTime(); defineMenu(); // Standalone elements. -safeDefine(AirplayButtonElement); +safeDefine(AirPlayButtonElement); safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); safeDefine(CastButtonElement); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 813a8a80..ef0f3c4e 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -25,7 +25,7 @@ export * from './store/media-attach-mixin'; export * from './store/provider-mixin'; export * from './store/types'; // UI Components -export { AirplayButtonElement } from './ui/airplay-button/airplay-button-element'; +export { AirPlayButtonElement } from './ui/airplay-button/airplay-button-element'; export { AlertDialogCloseElement } from './ui/alert-dialog/alert-dialog-close-element'; export { AlertDialogDescriptionElement } from './ui/alert-dialog/alert-dialog-description-element'; export { AlertDialogElement } from './ui/alert-dialog/alert-dialog-element'; diff --git a/packages/html/src/ui/airplay-button/airplay-button-element.ts b/packages/html/src/ui/airplay-button/airplay-button-element.ts index 74febec6..175397bc 100644 --- a/packages/html/src/ui/airplay-button/airplay-button-element.ts +++ b/packages/html/src/ui/airplay-button/airplay-button-element.ts @@ -1,15 +1,15 @@ -import { AirplayButtonCore, AirplayButtonDataAttrs, type MediaRemotePlaybackState } from '@videojs/core'; +import { AirPlayButtonCore, AirPlayButtonDataAttrs, type MediaRemotePlaybackState } from '@videojs/core'; import { selectRemotePlayback } from '@videojs/core/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; import { MediaButtonElement } from '../media-button-element'; -export class AirplayButtonElement extends MediaButtonElement { +export class AirPlayButtonElement extends MediaButtonElement { static readonly tagName = 'media-airplay-button'; - protected readonly core = new AirplayButtonCore(); - protected readonly stateAttrMap = AirplayButtonDataAttrs; + protected readonly core = new AirPlayButtonCore(); + protected readonly stateAttrMap = AirPlayButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectRemotePlayback); protected activate(state: MediaRemotePlaybackState): void { diff --git a/packages/icons/scripts/build.ts b/packages/icons/scripts/build.ts index 73cf197d..c8b888da 100644 --- a/packages/icons/scripts/build.ts +++ b/packages/icons/scripts/build.ts @@ -4,10 +4,10 @@ import { join } from 'node:path'; const isWatch = process.argv.includes('--watch'); import { transform } from '@svgr/core'; -import { camelCase, pascalCase } from '@videojs/utils/string'; import { transform as esbuildTransform } from 'esbuild'; import { optimize } from 'svgo'; +import { iconBases } from './icon-bases.js'; import { ASSETS_DIR, createSvgoConfig, @@ -270,21 +270,23 @@ function buildElementBaseTypes(): string { function buildIndexExports(icons: { name: string; varName: string }[], framework: 'react' | 'html'): string { return icons .map(({ name, varName }) => { + const { pascal, camel } = iconBases(varName); if (framework === 'react') { - return `export { default as ${pascalCase(varName)}Icon } from './${name}.js';`; + return `export { default as ${pascal}Icon } from './${name}.js';`; } - return `export { ${camelCase(varName)}Icon } from './${name}.js';`; + return `export { ${camel}Icon } from './${name}.js';`; }) .join('\n'); } function buildIndexTypes(icons: { name: string; varName: string }[], framework: 'react' | 'html'): string { - const types = icons.map(({ varName }) => - framework === 'react' - ? `export declare const ${pascalCase(varName)}Icon: React.ForwardRefExoticComponent & React.RefAttributes>;` - : `export declare const ${camelCase(varName)}Icon: string;` - ); + const types = icons.map(({ varName }) => { + const { pascal, camel } = iconBases(varName); + return framework === 'react' + ? `export declare const ${pascal}Icon: React.ForwardRefExoticComponent & React.RefAttributes>;` + : `export declare const ${camel}Icon: string;`; + }); return `/// \n${types.join('\n')}\n`; } @@ -317,8 +319,10 @@ async function buildIconSet(setName: string): Promise { for (const icon of icons) { const { name, varName, content } = icon; + const { pascal, camel } = iconBases(varName); + if (framework === 'react') { - const componentName = `${pascalCase(varName)}Icon`; + const componentName = `${pascal}Icon`; const { js, tsx } = await buildReactComponent(content, componentName); writeFileSync(join(outDir, `${name}.js`), js); writeFileSync(join(outDir, `${name}.tsx`), tsx); @@ -327,9 +331,9 @@ async function buildIconSet(setName: string): Promise { `import * as React from 'react';\ndeclare const ${componentName}: React.ForwardRefExoticComponent & React.RefAttributes>;\nexport default ${componentName};\n` ); } else { - const varNameCamel = camelCase(varName); - writeFileSync(join(outDir, `${name}.js`), buildHtmlExport(content, `${varNameCamel}Icon`)); - writeFileSync(join(outDir, `${name}.d.ts`), `export declare const ${varNameCamel}Icon: string;\n`); + const constName = `${camel}Icon`; + writeFileSync(join(outDir, `${name}.js`), buildHtmlExport(content, constName)); + writeFileSync(join(outDir, `${name}.d.ts`), `export declare const ${constName}: string;\n`); } } diff --git a/packages/icons/scripts/icon-bases.ts b/packages/icons/scripts/icon-bases.ts new file mode 100644 index 00000000..561bab40 --- /dev/null +++ b/packages/icons/scripts/icon-bases.ts @@ -0,0 +1,30 @@ +import { camelCase, pascalCase } from '@videojs/utils/string'; + +/* + * Per-icon identifier overrides. Use when an SVG filename can't be split + * into the desired PascalCase or CamelCase form alone + * e.g. `airplay` has no separator between `air` and `play`, + * so `pascalCase('airplay-enter')` yields `AirplayEnter`, not `AirPlayEnter`. + * + * Key: SVG filename stem. + * Value: the PascalCase base (no `Icon` suffix). + */ +const PASCAL_CASE_ICON_NAME_OVERRIDES: Record = { + 'airplay-enter': 'AirPlayEnter', + 'airplay-exit': 'AirPlayExit', +}; + +const CAMEL_CASE_ICON_NAME_OVERRIDES: Record = { + 'airplay-enter': 'airPlayEnter', + 'airplay-exit': 'airPlayExit', +}; + +/** + * Resolves an SVG filename stem to its PascalCase and camelCase identifier + * bases checking overrides first. + */ +export function iconBases(varName: string): { pascal: string; camel: string } { + const pascal = PASCAL_CASE_ICON_NAME_OVERRIDES[varName] ?? pascalCase(varName); + const camel = CAMEL_CASE_ICON_NAME_OVERRIDES[varName] ?? camelCase(varName); + return { pascal, camel }; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 683053d8..b7c86e33 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -29,7 +29,7 @@ export { type ProviderProps, } from './player/create-player'; // UI -export { AirplayButton, type AirplayButtonProps } from './ui/airplay-button/airplay-button'; +export { AirPlayButton, type AirPlayButtonProps } from './ui/airplay-button/airplay-button'; export { AlertDialog, type AlertDialogContextValue, useAlertDialogContext } from './ui/alert-dialog'; export { BufferingIndicator, type BufferingIndicatorProps } from './ui/buffering-indicator/buffering-indicator'; export { CaptionsButton, type CaptionsButtonProps } from './ui/captions-button/captions-button'; diff --git a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx index c76db477..a171433d 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -18,8 +18,8 @@ import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { - AirplayEnterIcon, - AirplayExitIcon, + AirPlayEnterIcon, + AirPlayExitIcon, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, @@ -37,7 +37,7 @@ import { VolumeOffIcon, } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; -import { AirplayButton } from '@/ui/airplay-button'; +import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; import { CastButton } from '@/ui/cast-button'; @@ -225,10 +225,10 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): }> - - - + }> + + + } /> diff --git a/packages/react/src/presets/live-video/minimal-skin.tsx b/packages/react/src/presets/live-video/minimal-skin.tsx index 9bbb0095..691898e8 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tsx @@ -2,8 +2,8 @@ import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { - AirplayEnterIcon, - AirplayExitIcon, + AirPlayEnterIcon, + AirPlayExitIcon, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, @@ -21,7 +21,7 @@ import { VolumeOffIcon, } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; -import { AirplayButton } from '@/ui/airplay-button'; +import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; import { CastButton } from '@/ui/cast-button'; @@ -179,10 +179,10 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod }> - - - + }> + + + } /> diff --git a/packages/react/src/presets/live-video/skin.tailwind.tsx b/packages/react/src/presets/live-video/skin.tailwind.tsx index 5dbdd7de..d166b542 100644 --- a/packages/react/src/presets/live-video/skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/skin.tailwind.tsx @@ -18,8 +18,8 @@ import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { - AirplayEnterIcon, - AirplayExitIcon, + AirPlayEnterIcon, + AirPlayExitIcon, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, @@ -37,7 +37,7 @@ import { VolumeOffIcon, } from '@/icons'; import { Container, usePlayer } from '@/player/context'; -import { AirplayButton } from '@/ui/airplay-button'; +import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; import { CastButton } from '@/ui/cast-button'; @@ -227,10 +227,10 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { }> - - - + }> + + + } /> diff --git a/packages/react/src/presets/live-video/skin.tsx b/packages/react/src/presets/live-video/skin.tsx index ca6f347c..8dd347e4 100644 --- a/packages/react/src/presets/live-video/skin.tsx +++ b/packages/react/src/presets/live-video/skin.tsx @@ -2,8 +2,8 @@ import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { - AirplayEnterIcon, - AirplayExitIcon, + AirPlayEnterIcon, + AirPlayExitIcon, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, @@ -21,7 +21,7 @@ import { VolumeOffIcon, } from '@/icons'; import { Container, usePlayer } from '@/player/context'; -import { AirplayButton } from '@/ui/airplay-button'; +import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; import { CastButton } from '@/ui/cast-button'; @@ -180,10 +180,10 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode { }> - - - + }> + + + } /> diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 88c532f9..7bd2dffb 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -25,8 +25,8 @@ import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { - AirplayEnterIcon, - AirplayExitIcon, + AirPlayEnterIcon, + AirPlayExitIcon, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, @@ -47,7 +47,7 @@ import { VolumeOffIcon, } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; -import { AirplayButton } from '@/ui/airplay-button'; +import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; import { CastButton } from '@/ui/cast-button'; @@ -324,10 +324,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod }> - - - + }> + + + } /> diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index ffc8d6e3..178d1cd8 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -2,8 +2,8 @@ import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { - AirplayEnterIcon, - AirplayExitIcon, + AirPlayEnterIcon, + AirPlayExitIcon, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, @@ -24,7 +24,7 @@ import { VolumeOffIcon, } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; -import { AirplayButton } from '@/ui/airplay-button'; +import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; import { CastButton } from '@/ui/cast-button'; @@ -258,10 +258,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { }> - - - + }> + + + } /> diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 9b0382d0..6f154c0a 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -25,8 +25,8 @@ import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { - AirplayEnterIcon, - AirplayExitIcon, + AirPlayEnterIcon, + AirPlayExitIcon, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, @@ -47,7 +47,7 @@ import { VolumeOffIcon, } from '@/icons'; import { Container, usePlayer } from '@/player/context'; -import { AirplayButton } from '@/ui/airplay-button'; +import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; import { CastButton } from '@/ui/cast-button'; @@ -320,10 +320,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { }> - - - + }> + + + } /> diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index ae782a52..17096ac3 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -2,8 +2,8 @@ import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { - AirplayEnterIcon, - AirplayExitIcon, + AirPlayEnterIcon, + AirPlayExitIcon, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, @@ -24,7 +24,7 @@ import { VolumeOffIcon, } from '@/icons'; import { Container, usePlayer } from '@/player/context'; -import { AirplayButton } from '@/ui/airplay-button'; +import { AirPlayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; import { CastButton } from '@/ui/cast-button'; @@ -254,10 +254,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { }> - - - + }> + + + } /> diff --git a/packages/react/src/ui/airplay-button/airplay-button.tsx b/packages/react/src/ui/airplay-button/airplay-button.tsx index e76da248..c9791777 100644 --- a/packages/react/src/ui/airplay-button/airplay-button.tsx +++ b/packages/react/src/ui/airplay-button/airplay-button.tsx @@ -1,25 +1,25 @@ 'use client'; -import { AirplayButtonCore, AirplayButtonDataAttrs } from '@videojs/core'; +import { AirPlayButtonCore, AirPlayButtonDataAttrs } from '@videojs/core'; import { selectRemotePlayback } from '@videojs/core/dom'; import type { UIComponentProps } from '../../utils/types'; import { createMediaButton } from '../create-media-button'; -export interface AirplayButtonProps - extends UIComponentProps<'button', AirplayButtonCore.State>, - AirplayButtonCore.Props {} +export interface AirPlayButtonProps + extends UIComponentProps<'button', AirPlayButtonCore.State>, + AirPlayButtonCore.Props {} /** A button that toggles AirPlay to a remote device. */ -export const AirplayButton = createMediaButton({ - displayName: 'AirplayButton', - core: AirplayButtonCore, - stateAttrMap: AirplayButtonDataAttrs, +export const AirPlayButton = createMediaButton({ + displayName: 'AirPlayButton', + core: AirPlayButtonCore, + stateAttrMap: AirPlayButtonDataAttrs, selector: selectRemotePlayback, action: (core, state) => core.toggle(state), }); -export namespace AirplayButton { - export type Props = AirplayButtonProps; - export type State = AirplayButtonCore.State; +export namespace AirPlayButton { + export type Props = AirPlayButtonProps; + export type State = AirPlayButtonCore.State; } diff --git a/packages/skins/src/shared/css/video/icon-state.css b/packages/skins/src/shared/css/video/icon-state.css index a91e76c6..fbb49109 100644 --- a/packages/skins/src/shared/css/video/icon-state.css +++ b/packages/skins/src/shared/css/video/icon-state.css @@ -55,9 +55,9 @@ .media-button--cast:not([data-cast-state="connected"]) .media-icon--cast-enter, /* Cast: connected → exit */ .media-button--cast[data-cast-state="connected"] .media-icon--cast-exit, -/* Airplay: not connected → enter */ +/* AirPlay: not connected → enter */ .media-button--airplay:not([data-airplay-state="connected"]) .media-icon--airplay-enter, -/* Airplay: connected → exit */ +/* AirPlay: connected → exit */ .media-button--airplay[data-airplay-state="connected"] .media-icon--airplay-exit, /* Captions: not active → captions off */ .media-button--captions:not([data-active]) .media-icon--captions-off, diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts index 43bff596..9fe75f9d 100644 --- a/packages/utils/src/dom/index.ts +++ b/packages/utils/src/dom/index.ts @@ -34,3 +34,13 @@ export { } from './text-track'; export { serializeTimeRanges } from './time-ranges'; export type { CustomElement, CustomElementCallbacks } from './types'; +export { + isWebKitAirPlayCapable, + supportsWebKitAirPlay, + type WebKitAirPlayMedia, + type WebKitDocument, + type WebKitFullscreenElement, + type WebKitPresentationMode, + type WebKitVideoElement, + type WebkitAvailabilityEvent, +} from './webkit'; diff --git a/packages/utils/src/dom/tests/webkit.test.ts b/packages/utils/src/dom/tests/webkit.test.ts new file mode 100644 index 00000000..0b749f38 --- /dev/null +++ b/packages/utils/src/dom/tests/webkit.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { isWebKitAirPlayCapable, supportsWebKitAirPlay } from '../webkit'; + +// jsdom lacks WebKit's AirPlay APIs, so stub the global support flag per test. +function stubWebKit(present: boolean) { + const key = 'WebKitPlaybackTargetAvailabilityEvent'; + if (present) { + (globalThis as unknown as Record)[key] = class {}; + } else { + delete (globalThis as unknown as Record)[key]; + } +} + +describe('webkit', () => { + afterEach(() => stubWebKit(false)); + + describe('supportsWebKitAirPlay', () => { + it('returns true when the WebKit availability event is present', () => { + stubWebKit(true); + expect(supportsWebKitAirPlay()).toBe(true); + }); + + it('returns false when absent', () => { + stubWebKit(false); + expect(supportsWebKitAirPlay()).toBe(false); + }); + }); + + describe('isWebKitAirPlayCapable', () => { + it('returns true when supported and the media exposes the AirPlay flag', () => { + stubWebKit(true); + const media = { webkitCurrentPlaybackTargetIsWireless: false } as unknown as EventTarget; + expect(isWebKitAirPlayCapable(media)).toBe(true); + }); + + it('returns false when WebKit is unsupported', () => { + stubWebKit(false); + const media = { webkitCurrentPlaybackTargetIsWireless: false } as unknown as EventTarget; + expect(isWebKitAirPlayCapable(media)).toBe(false); + }); + + it('returns false when the media lacks the AirPlay flag', () => { + stubWebKit(true); + expect(isWebKitAirPlayCapable(new EventTarget())).toBe(false); + }); + }); +}); diff --git a/packages/core/src/dom/presentation/types.ts b/packages/utils/src/dom/webkit.ts similarity index 57% rename from packages/core/src/dom/presentation/types.ts rename to packages/utils/src/dom/webkit.ts index f0e26d7c..2307350b 100644 --- a/packages/core/src/dom/presentation/types.ts +++ b/packages/utils/src/dom/webkit.ts @@ -1,3 +1,8 @@ +/** WebKit-only addition to HTMLMediaElement exposing the active AirPlay flag. */ +export interface WebKitAirPlayMedia extends HTMLMediaElement { + readonly webkitCurrentPlaybackTargetIsWireless: boolean; +} + /** WebKit presentation mode values for iOS Safari. */ export type WebKitPresentationMode = 'inline' | 'fullscreen' | 'picture-in-picture'; @@ -24,3 +29,16 @@ export interface WebKitDocument extends Document { /** Exit fullscreen (WebKit). */ webkitExitFullscreen?: () => Promise; } + +/** WebKit-specific AirPlay availability event payload (not in lib.dom). */ +export type WebkitAvailabilityEvent = Event & { availability: 'available' | 'not-available' }; + +/** Whether WebKit's AirPlay APIs are present in this realm (Safari macOS/iOS). */ +export function supportsWebKitAirPlay(): boolean { + return 'WebKitPlaybackTargetAvailabilityEvent' in globalThis; +} + +/** Whether `media` exposes WebKit's AirPlay APIs. */ +export function isWebKitAirPlayCapable(media: EventTarget): media is WebKitAirPlayMedia { + return supportsWebKitAirPlay() && 'webkitCurrentPlaybackTargetIsWireless' in media; +} diff --git a/site/src/components/docs/demos/airplay-button/react/css/BasicUsage.tsx b/site/src/components/docs/demos/airplay-button/react/css/BasicUsage.tsx index 565e6fb4..cbcbd319 100644 --- a/site/src/components/docs/demos/airplay-button/react/css/BasicUsage.tsx +++ b/site/src/components/docs/demos/airplay-button/react/css/BasicUsage.tsx @@ -1,4 +1,4 @@ -import { AirplayButton, createPlayer } from '@videojs/react'; +import { AirPlayButton, createPlayer } from '@videojs/react'; import { Video, videoFeatures } from '@videojs/react/video'; const Player = createPlayer({ features: videoFeatures }); @@ -14,13 +14,13 @@ export default function BasicUsage() { playsInline loop /> - { const label = state.availability === 'unsupported' ? 'AirPlay not supported' - : state.airplayState === 'connected' + : state.state === 'connected' ? 'Stop AirPlay' : state.availability === 'unavailable' ? 'No AirPlay devices found' diff --git a/site/src/content/docs/reference/airplay-button.mdx b/site/src/content/docs/reference/airplay-button.mdx index 8212dd55..3e8bacf0 100644 --- a/site/src/content/docs/reference/airplay-button.mdx +++ b/site/src/content/docs/reference/airplay-button.mdx @@ -1,5 +1,5 @@ --- -title: AirplayButton +title: AirPlayButton frameworkTitle: html: media-airplay-button description: Accessible AirPlay toggle button that opens the WebKit playback target picker and reflects session state @@ -26,7 +26,7 @@ import basicUsageHtmlTs from "@/components/docs/demos/airplay-button/html/css/Ba ```tsx - + ``` @@ -44,7 +44,7 @@ The toggle is a no-op unless `availability` is `"available"` — clicking the bu The component consumes the unified `remotePlayback` store feature alongside `CastButton`: both buttons drive their state from the same feature, but each only surfaces on its supported platform (WebKit for AirPlay, Chromium for Cast). -WebKit does not expose a `"connecting"` intermediate state — `airplayState` flips directly between `"disconnected"` and `"connected"` when the AirPlay session changes. When connected, the picker itself acts as the disconnect UI. +WebKit does not expose a `"connecting"` intermediate state — the `state` slice flips directly between `"disconnected"` and `"connected"` when the AirPlay session changes. When connected, the picker itself acts as the disconnect UI. ## Styling @@ -124,4 +124,4 @@ Renders a `