diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index f00e5fcd..a8bb3565 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -1,5 +1,7 @@ export * from './media/state'; export * from './media/types'; +export * from './ui/airplay-button/airplay-button-core'; +export * from './ui/airplay-button/airplay-button-data-attrs'; export * from './ui/alert-dialog/alert-dialog-core'; export * from './ui/alert-dialog/alert-dialog-data-attrs'; export * from './ui/buffering-indicator/buffering-indicator-core'; 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 new file mode 100644 index 00000000..06d8c014 --- /dev/null +++ b/packages/core/src/core/ui/airplay-button/airplay-button-core.ts @@ -0,0 +1,102 @@ +import { createState } from '@videojs/store'; +import { defaults } from '@videojs/utils/object'; +import { isFunction } from '@videojs/utils/predicate'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../media/state'; +import type { MediaFeatureAvailability } from '../../media/types'; +import type { ButtonState } from '../types'; + +export interface AirplayButtonProps { + /** Custom label for the button. */ + label?: string | ((state: AirplayButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export interface AirplayButtonState extends ButtonState { + airplayState: RemotePlaybackConnectionState; + availability: MediaFeatureAvailability; +} + +export class AirplayButtonCore { + static readonly defaultProps: NonNullableObject = { + label: '', + disabled: false, + }; + + readonly state = createState({ + airplayState: 'disconnected', + availability: 'unsupported', + label: '', + }); + + #props = { ...AirplayButtonCore.defaultProps }; + #media: MediaRemotePlaybackState | null = null; + + constructor(props?: AirplayButtonProps) { + if (props) this.setProps(props); + } + + setProps(props: AirplayButtonProps): void { + this.#props = defaults(props, AirplayButtonCore.defaultProps); + } + + getLabel(state: AirplayButtonState): string { + const { label } = this.#props; + + if (isFunction(label)) { + const customLabel = label(state); + if (customLabel) return customLabel; + } else if (label) { + return label; + } + + if (state.airplayState === 'connected') return 'Stop AirPlay'; + if (state.airplayState === 'connecting') return 'Connecting'; + return 'Start AirPlay'; + } + + getAttrs(state: AirplayButtonState) { + return { + 'aria-label': this.getLabel(state), + 'aria-disabled': this.#props.disabled ? 'true' : undefined, + }; + } + + setMedia(media: MediaRemotePlaybackState): void { + this.#media = media; + } + + 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; + + this.state.patch({ + airplayState: media.remotePlaybackState, + availability: airplaySupported ? media.remotePlaybackAvailability : 'unsupported', + }); + this.state.patch({ label: this.getLabel(this.state.current) }); + + return this.state.current; + } + + async toggle(state: MediaRemotePlaybackState): Promise { + if (this.#props.disabled) return; + if (state.remotePlaybackAvailability !== 'available') return; + + try { + await state.toggleRemotePlayback(); + } catch { + // AirPlay requests can fail (user cancelled, permissions, etc.) + } + } +} + +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 new file mode 100644 index 00000000..6e13b2c6 --- /dev/null +++ b/packages/core/src/core/ui/airplay-button/airplay-button-data-attrs.ts @@ -0,0 +1,7 @@ +import type { StateAttrMap } from '../types'; +import type { AirplayButtonState } from './airplay-button-core'; + +export const AirplayButtonDataAttrs = { + airplayState: 'data-airplay-state', + availability: 'data-availability', +} 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 new file mode 100644 index 00000000..cf6f9fe3 --- /dev/null +++ b/packages/core/src/core/ui/airplay-button/tests/airplay-button-core.test.ts @@ -0,0 +1,160 @@ +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'; + +// 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) { + const key = 'WebKitPlaybackTargetAvailabilityEvent'; + if (present) { + (globalThis as unknown as Record)[key] = class {}; + } else { + delete (globalThis as unknown as Record)[key]; + } +} + +function createMediaState(overrides: Partial = {}): MediaRemotePlaybackState { + return { + remotePlaybackState: 'disconnected', + remotePlaybackAvailability: 'available', + toggleRemotePlayback: vi.fn(async () => {}), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): AirplayButtonState { + return { + airplayState: 'disconnected', + availability: 'available', + label: '', + ...overrides, + }; +} + +describe('AirplayButtonCore', () => { + beforeEach(() => stubWebKit(true)); + afterEach(() => stubWebKit(false)); + + describe('getState', () => { + it('projects airplayState and availability', () => { + const core = new AirplayButtonCore(); + const media = createMediaState({ remotePlaybackState: 'connected' }); + core.setMedia(media); + const state = core.getState(); + + expect(state.airplayState).toBe('connected'); + expect(state.availability).toBe('available'); + }); + + it('reflects unsupported availability', () => { + const core = new AirplayButtonCore(); + core.setMedia(createMediaState({ remotePlaybackAvailability: 'unsupported' })); + const state = core.getState(); + + expect(state.availability).toBe('unsupported'); + }); + + it('reflects connecting state', () => { + const core = new AirplayButtonCore(); + core.setMedia(createMediaState({ remotePlaybackState: 'connecting' })); + const state = core.getState(); + + expect(state.airplayState).toBe('connecting'); + }); + + it('reports unsupported outside WebKit', () => { + stubWebKit(false); + const core = new AirplayButtonCore(); + core.setMedia(createMediaState({ remotePlaybackAvailability: 'available' })); + const state = core.getState(); + + expect(state.availability).toBe('unsupported'); + }); + }); + + describe('getLabel', () => { + it('returns Start AirPlay when disconnected', () => { + const core = new AirplayButtonCore(); + expect(core.getLabel(createState({ airplayState: 'disconnected' }))).toBe('Start AirPlay'); + }); + + it('returns Stop AirPlay when connected', () => { + const core = new AirplayButtonCore(); + expect(core.getLabel(createState({ airplayState: 'connected' }))).toBe('Stop AirPlay'); + }); + + it('returns Connecting when connecting', () => { + const core = new AirplayButtonCore(); + expect(core.getLabel(createState({ airplayState: 'connecting' }))).toBe('Connecting'); + }); + + it('returns custom string label', () => { + 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'), + }); + expect(core.getLabel(createState({ airplayState: 'connected' }))).toBe('Disconnect'); + }); + }); + + describe('getAttrs', () => { + it('returns aria-label', () => { + 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 attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBe('true'); + }); + }); + + describe('toggle', () => { + it('calls toggleRemotePlayback when disconnected', async () => { + 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 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 media = createMediaState(); + await core.toggle(media); + expect(media.toggleRemotePlayback).not.toHaveBeenCalled(); + }); + + it('does nothing when unsupported', async () => { + const core = new AirplayButtonCore(); + const media = createMediaState({ remotePlaybackAvailability: 'unsupported' }); + await core.toggle(media); + expect(media.toggleRemotePlayback).not.toHaveBeenCalled(); + }); + + it('catches AirPlay errors silently', async () => { + const core = new AirplayButtonCore(); + const media = createMediaState({ + toggleRemotePlayback: vi.fn(async () => { + throw new Error('user cancelled'); + }), + }); + await expect(core.toggle(media)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/dom/store/features/remote-playback.ts b/packages/core/src/dom/store/features/remote-playback.ts index 28ebd25f..b019baaf 100644 --- a/packages/core/src/dom/store/features/remote-playback.ts +++ b/packages/core/src/dom/store/features/remote-playback.ts @@ -6,6 +6,18 @@ 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 => ({ @@ -23,7 +35,7 @@ export const remotePlaybackFeature = definePlayerFeature({ await exitFullscreen(media); } - return requestRemotePlayback(media); + return await requestRemotePlayback(media); }, }), @@ -32,6 +44,31 @@ export const remotePlaybackFeature = definePlayerFeature({ if (!isMediaRemotePlaybackCapable(media)) return; + // Safari's W3C `media.remote` events don't fire reliably for AirPlay + // 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)) { + const syncConnection = () => { + set({ + remotePlaybackState: media.webkitCurrentPlaybackTargetIsWireless ? 'connected' : 'disconnected', + }); + }; + + const syncAvailability = (event: Event) => { + const { availability } = event as WebkitAvailabilityEvent; + set({ remotePlaybackAvailability: availability === 'available' ? 'available' : 'unavailable' }); + }; + + listen(media, 'webkitplaybacktargetavailabilitychanged', syncAvailability, { signal }); + listen(media, 'webkitcurrentplaybacktargetiswirelesschanged', syncConnection, { signal }); + + // Sync initial connection state in case AirPlay was already active. + syncConnection(); + return; + } + + // W3C Remote Playback path (Chromium / Edge with the cast extension). const syncState = () => set({ remotePlaybackState: media.remote.state as RemotePlaybackConnectionState }); syncState(); diff --git a/packages/html/src/define/live-video/minimal-skin.tailwind.ts b/packages/html/src/define/live-video/minimal-skin.tailwind.ts index 81149016..9289cf7f 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -90,6 +90,11 @@ function getTemplateHTML() { ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} + + ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} + ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts index 66717823..5b9478e6 100644 --- a/packages/html/src/define/live-video/minimal-skin.ts +++ b/packages/html/src/define/live-video/minimal-skin.ts @@ -77,6 +77,12 @@ function getTemplateHTML() { + + ${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })} + ${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })} + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} diff --git a/packages/html/src/define/live-video/minimal-ui.ts b/packages/html/src/define/live-video/minimal-ui.ts index d9c6fcb2..f4194553 100644 --- a/packages/html/src/define/live-video/minimal-ui.ts +++ b/packages/html/src/define/live-video/minimal-ui.ts @@ -2,6 +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 { MediaContainerElement } from '../../media/container-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; @@ -44,6 +45,7 @@ defineVolumeSlider(); defineTime(); // Standalone elements. +safeDefine(AirplayButtonElement); safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); safeDefine(CastButtonElement); diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index a34e43c9..ee521b29 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -92,6 +92,11 @@ function getTemplateHTML() { ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} + + ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} + ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts index 6495d7ba..f3bb3713 100644 --- a/packages/html/src/define/live-video/skin.ts +++ b/packages/html/src/define/live-video/skin.ts @@ -79,6 +79,12 @@ function getTemplateHTML() { + + ${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })} + ${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })} + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} diff --git a/packages/html/src/define/live-video/ui.ts b/packages/html/src/define/live-video/ui.ts index a7218d46..b2995e14 100644 --- a/packages/html/src/define/live-video/ui.ts +++ b/packages/html/src/define/live-video/ui.ts @@ -1,6 +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 { MediaContainerElement } from '../../media/container-element'; import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; @@ -43,6 +44,7 @@ defineVolumeSlider(); defineTime(); // Standalone elements. +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 new file mode 100644 index 00000000..11e0b674 --- /dev/null +++ b/packages/html/src/define/ui/airplay-button.ts @@ -0,0 +1,10 @@ +import { AirplayButtonElement } from '../../ui/airplay-button/airplay-button-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(AirplayButtonElement); + +declare global { + interface HTMLElementTagNameMap { + [AirplayButtonElement.tagName]: AirplayButtonElement; + } +} diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index c5b613e3..e8d30f42 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -150,6 +150,11 @@ function getTemplateHTML() { ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} + + ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} + ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index df6b61c8..ea2b0545 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -128,6 +128,12 @@ function getTemplateHTML() { ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} + + + ${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })} + ${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })} + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index d2db69ab..a4ddd63e 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -1,7 +1,9 @@ // Registers the video player, container, and all video UI custom elements // used by the minimal skin 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 { 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'; @@ -48,6 +50,7 @@ defineTime(); defineMenu(); // Standalone elements. +safeDefine(AirplayButtonElement); safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); safeDefine(CastButtonElement); diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 9b55b34b..076eef90 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -145,6 +145,11 @@ function getTemplateHTML() { ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} + + ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} + ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index cb9e0190..2e53ffad 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -124,6 +124,12 @@ function getTemplateHTML() { ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} + + + ${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })} + ${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })} + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index 39bfb50d..031d8359 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -2,6 +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 { 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,6 +51,7 @@ defineTime(); defineMenu(); // Standalone elements. +safeDefine(AirplayButtonElement); safeDefine(BufferingIndicatorElement); safeDefine(CaptionsButtonElement); safeDefine(CastButtonElement); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index f0ccb2df..add2a55c 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -25,6 +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 { 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 new file mode 100644 index 00000000..74febec6 --- /dev/null +++ b/packages/html/src/ui/airplay-button/airplay-button-element.ts @@ -0,0 +1,18 @@ +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 { + static readonly tagName = 'media-airplay-button'; + + protected readonly core = new AirplayButtonCore(); + protected readonly stateAttrMap = AirplayButtonDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectRemotePlayback); + + protected activate(state: MediaRemotePlaybackState): void { + this.core.toggle(state); + } +} diff --git a/packages/icons/src/assets/default/airplay-enter.svg b/packages/icons/src/assets/default/airplay-enter.svg new file mode 100644 index 00000000..4970675f --- /dev/null +++ b/packages/icons/src/assets/default/airplay-enter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/src/assets/default/airplay-exit.svg b/packages/icons/src/assets/default/airplay-exit.svg new file mode 100644 index 00000000..d3a59a42 --- /dev/null +++ b/packages/icons/src/assets/default/airplay-exit.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/packages/icons/src/assets/minimal/airplay-enter.svg b/packages/icons/src/assets/minimal/airplay-enter.svg new file mode 100644 index 00000000..82a900df --- /dev/null +++ b/packages/icons/src/assets/minimal/airplay-enter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/src/assets/minimal/airplay-exit.svg b/packages/icons/src/assets/minimal/airplay-exit.svg new file mode 100644 index 00000000..a7ba9a3f --- /dev/null +++ b/packages/icons/src/assets/minimal/airplay-exit.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8627c640..683053d8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -29,6 +29,7 @@ export { type ProviderProps, } from './player/create-player'; // UI +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 7ffa03e1..7375cfe5 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -18,6 +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, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, @@ -35,6 +37,7 @@ import { VolumeOffIcon, } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; +import { AirplayButton } from '@/ui/airplay-button'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { CaptionsButton } from '@/ui/captions-button'; import { CastButton } from '@/ui/cast-button'; @@ -219,6 +222,18 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + + + }> + + + + } + /> + + + , + AirplayButtonCore.Props {} + +/** A button that toggles AirPlay to a remote device. */ +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; +} diff --git a/packages/react/src/ui/airplay-button/index.ts b/packages/react/src/ui/airplay-button/index.ts new file mode 100644 index 00000000..5440b512 --- /dev/null +++ b/packages/react/src/ui/airplay-button/index.ts @@ -0,0 +1 @@ +export * from './airplay-button'; diff --git a/packages/skins/src/default/css/components/root.css b/packages/skins/src/default/css/components/root.css index bc4166cd..80359972 100644 --- a/packages/skins/src/default/css/components/root.css +++ b/packages/skins/src/default/css/components/root.css @@ -11,6 +11,7 @@ width: 100%; height: 100%; container: media-root / inline-size; + overflow: clip; font-family: Inter Variable, Inter, diff --git a/packages/skins/src/default/tailwind/components/root.ts b/packages/skins/src/default/tailwind/components/root.ts index d673b2d2..1bf1ada8 100644 --- a/packages/skins/src/default/tailwind/components/root.ts +++ b/packages/skins/src/default/tailwind/components/root.ts @@ -6,6 +6,7 @@ export const root = cn( // Layout & containment 'block relative isolate h-full w-full @container/media-root', // Appearance + 'overflow-clip', 'rounded-(--media-border-radius,2rem)', 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased', // Focus ring diff --git a/packages/skins/src/minimal/css/components/root.css b/packages/skins/src/minimal/css/components/root.css index 63a07044..1019cf28 100644 --- a/packages/skins/src/minimal/css/components/root.css +++ b/packages/skins/src/minimal/css/components/root.css @@ -11,6 +11,7 @@ width: 100%; height: 100%; container: media-root / inline-size; + overflow: clip; font-family: Inter Variable, Inter, diff --git a/packages/skins/src/minimal/tailwind/components/root.ts b/packages/skins/src/minimal/tailwind/components/root.ts index 3202f9c6..143b7788 100644 --- a/packages/skins/src/minimal/tailwind/components/root.ts +++ b/packages/skins/src/minimal/tailwind/components/root.ts @@ -6,6 +6,7 @@ export const root = cn( // Layout & containment 'block relative isolate h-full w-full @container/media-root', // Appearance + 'overflow-clip', 'rounded-(--media-border-radius,0.75rem)', 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased', // Focus ring diff --git a/packages/skins/src/shared/css/video/icon-state.css b/packages/skins/src/shared/css/video/icon-state.css index 672367e5..04584c62 100644 --- a/packages/skins/src/shared/css/video/icon-state.css +++ b/packages/skins/src/shared/css/video/icon-state.css @@ -20,6 +20,8 @@ .media-button--pip .media-icon--pip-exit, .media-button--cast .media-icon--cast-enter, .media-button--cast .media-icon--cast-exit, +.media-button--airplay .media-icon--airplay-enter, +.media-button--airplay .media-icon--airplay-exit, .media-button--captions .media-icon--captions-off, .media-button--captions .media-icon--captions-on { display: none; @@ -52,6 +54,10 @@ .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 */ +.media-button--airplay:not([data-airplay-state="connected"]) .media-icon--airplay-enter, +/* 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, /* Captions: active → captions on */ @@ -59,3 +65,14 @@ display: block; opacity: 1; } + +/* --- Pause keyframe animations on inactive icons --- */ + +/* The airplay-exit SVG defines its keyframes against CSS variables (mirroring + the spinner pattern). When the AirPlay session isn't active the SVG is + still in the DOM — just `display: none` — so its animations would keep + running. Set the variables to `none` to short-circuit the keyframes. */ +.media-button--airplay:not([data-airplay-state="connected"]) { + --media-icon--airplay__fill-animation: none; + --media-icon--airplay__triangle-animation: none; +} diff --git a/packages/skins/src/shared/tailwind/icon-state.ts b/packages/skins/src/shared/tailwind/icon-state.ts index 5e31bfde..37b7802d 100644 --- a/packages/skins/src/shared/tailwind/icon-state.ts +++ b/packages/skins/src/shared/tailwind/icon-state.ts @@ -39,4 +39,18 @@ export const iconState = { 'hidden opacity-0 group-not-data-[cast-state=connected]:block group-not-data-[cast-state=connected]:opacity-100', exit: 'hidden opacity-0 group-data-[cast-state=connected]:block group-data-[cast-state=connected]:opacity-100', }, + airplay: { + // `group` enables the icon-state variants below. The two CSS-variable + // overrides mirror the spinner pattern: the airplay-exit SVG stays in + // the DOM while inactive, so we short-circuit its keyframes by setting + // the animation variables to `none` whenever airplay isn't connected. + button: [ + 'group', + 'not-data-[airplay-state=connected]:[--media-icon--airplay__fill-animation:none]', + 'not-data-[airplay-state=connected]:[--media-icon--airplay__triangle-animation:none]', + ].join(' '), + enter: + 'hidden opacity-0 group-not-data-[airplay-state=connected]:block group-not-data-[airplay-state=connected]:opacity-100', + exit: 'hidden opacity-0 group-data-[airplay-state=connected]:block group-data-[airplay-state=connected]:opacity-100', + }, }; diff --git a/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.astro b/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.css b/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.css new file mode 100644 index 00000000..24559528 --- /dev/null +++ b/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.css @@ -0,0 +1,57 @@ +.video-player { + position: relative; + display: block; +} + +.video-player video { + width: 100%; +} + +.media-airplay-button { + position: absolute; + right: 10px; + bottom: 10px; + padding-block: 8px; + padding-inline: 20px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +.media-airplay-button .connected, +.media-airplay-button .not-connected, +.media-airplay-button .no-devices, +.media-airplay-button .unsupported { + display: none; +} + +/* Connected: AirPlay active */ +.media-airplay-button[data-airplay-state="connected"] .connected { + display: inline; +} + +/* Available, not connected: ready to AirPlay */ +.media-airplay-button:not([data-airplay-state="connected"])[data-availability="available"] .not-connected { + display: inline; +} + +/* Platform supports AirPlay but no receivers are visible. */ +.media-airplay-button[data-availability="unavailable"] .no-devices { + display: inline; +} + +/* AirPlay not supported on this platform. */ +.media-airplay-button[data-availability="unsupported"] .unsupported { + display: inline; +} + +/* Inactive states: the toggle is a no-op unless availability is "available". */ +.media-airplay-button[data-availability="unavailable"], +.media-airplay-button[data-availability="unsupported"] { + cursor: not-allowed; + opacity: 0.5; + filter: grayscale(1); +} diff --git a/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.html b/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.html new file mode 100644 index 00000000..2e5d839f --- /dev/null +++ b/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.html @@ -0,0 +1,12 @@ + + + + + Stop AirPlay + Start AirPlay + No AirPlay devices found + AirPlay not supported + + + \ No newline at end of file diff --git a/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.ts b/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.ts new file mode 100644 index 00000000..14879a23 --- /dev/null +++ b/site/src/components/docs/demos/airplay-button/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/airplay-button'; diff --git a/site/src/components/docs/demos/airplay-button/react/css/BasicUsage.css b/site/src/components/docs/demos/airplay-button/react/css/BasicUsage.css new file mode 100644 index 00000000..d44337da --- /dev/null +++ b/site/src/components/docs/demos/airplay-button/react/css/BasicUsage.css @@ -0,0 +1,29 @@ +.media-container { + position: relative; +} + +.media-container video { + width: 100%; +} + +.media-airplay-button { + position: absolute; + right: 10px; + bottom: 10px; + padding-block: 8px; + padding-inline: 20px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +/* Inactive states: the toggle is a no-op unless availability is "available". */ +.media-airplay-button[data-availability="unavailable"], +.media-airplay-button[data-availability="unsupported"] { + cursor: not-allowed; + opacity: 0.5; + filter: grayscale(1); +} 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 new file mode 100644 index 00000000..565e6fb4 --- /dev/null +++ b/site/src/components/docs/demos/airplay-button/react/css/BasicUsage.tsx @@ -0,0 +1,34 @@ +import { AirplayButton, createPlayer } from '@videojs/react'; +import { Video, videoFeatures } from '@videojs/react/video'; + +const Player = createPlayer({ features: videoFeatures }); + +export default function BasicUsage() { + return ( + + + + + ); +} diff --git a/site/src/content/docs/reference/airplay-button.mdx b/site/src/content/docs/reference/airplay-button.mdx new file mode 100644 index 00000000..8212dd55 --- /dev/null +++ b/site/src/content/docs/reference/airplay-button.mdx @@ -0,0 +1,127 @@ +--- +title: AirplayButton +frameworkTitle: + html: media-airplay-button +description: Accessible AirPlay toggle button that opens the WebKit playback target picker and reflects session state +--- + +import ComponentReference from "@/components/docs/api-reference/ComponentReference.astro"; +import DocsLink from "@/components/docs/DocsLink.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; +import StyleCase from "@/components/docs/StyleCase.astro"; +import Demo from "@/components/docs/demos/Demo.astro"; + +{/* React demos */} +import BasicUsageDemoReact from "@/components/docs/demos/airplay-button/react/css/BasicUsage"; +import basicUsageReactTsx from "@/components/docs/demos/airplay-button/react/css/BasicUsage.tsx?raw"; +import basicUsageReactCss from "@/components/docs/demos/airplay-button/react/css/BasicUsage.css?raw"; + +{/* HTML demos */} +import BasicUsageDemoHtml from "@/components/docs/demos/airplay-button/html/css/BasicUsage.astro"; +import basicUsageHtml from "@/components/docs/demos/airplay-button/html/css/BasicUsage.html?raw"; +import basicUsageHtmlCss from "@/components/docs/demos/airplay-button/html/css/BasicUsage.css?raw"; +import basicUsageHtmlTs from "@/components/docs/demos/airplay-button/html/css/BasicUsage.ts?raw"; + +## Anatomy + + +```tsx + +``` + + + +```html + +``` + + +## 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. + +The toggle is a no-op unless `availability` is `"available"` — clicking the button while `"unsupported"` or `"unavailable"` will not open the picker. Style the button accordingly (see [Styling](#styling) below) so its appearance matches its actual behavior. + +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. + +## Styling + +| Attribute | Values | Description | +|-----------|--------|-------------| +| `data-airplay-state` | `"disconnected"` \| `"connecting"` \| `"connected"` | Current AirPlay session state | +| `data-availability` | `"available"` \| `"unavailable"` \| `"unsupported"` | Whether AirPlay is reachable on the current platform | + +Use `data-airplay-state` to swap icons or labels based on the session state: + + +```css +/* AirPlay active */ +media-airplay-button[data-airplay-state="connected"] { + color: var(--accent); +} +``` + + + +React renders a `