feat(packages): airplay button (#1531)

This commit is contained in:
Santiago Puppo
2026-05-27 10:22:31 -07:00
committed by GitHub
parent 760870fdf2
commit 338020e1d5
50 changed files with 869 additions and 2 deletions
+2
View File
@@ -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';
@@ -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<AirplayButtonProps> = {
label: '',
disabled: false,
};
readonly state = createState<AirplayButtonState>({
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<void> {
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;
}
@@ -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<AirplayButtonState>;
@@ -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<string, unknown>)[key] = class {};
} else {
delete (globalThis as unknown as Record<string, unknown>)[key];
}
}
function createMediaState(overrides: Partial<MediaRemotePlaybackState> = {}): MediaRemotePlaybackState {
return {
remotePlaybackState: 'disconnected',
remotePlaybackAvailability: 'available',
toggleRemotePlayback: vi.fn(async () => {}),
...overrides,
};
}
function createState(overrides: Partial<AirplayButtonState> = {}): 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();
});
});
});
@@ -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();