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();
@@ -90,6 +90,11 @@ function getTemplateHTML() {
${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })}
</media-cast-button>
<media-tooltip id="cast-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-airplay-button commandfor="airplay-tooltip" class="${cn(button.base, button.subtle, button.icon, iconState.airplay.button)}">
${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })}
${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })}
</media-airplay-button>
<media-tooltip id="airplay-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-pip-button commandfor="pip-tooltip" class="${cn(button.base, button.subtle, button.icon, iconState.pip.button)}">
${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })}
${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })}
@@ -77,6 +77,12 @@ function getTemplateHTML() {
</media-cast-button>
<media-tooltip id="cast-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-airplay-button commandfor="airplay-tooltip" class="media-button media-button--subtle media-button--icon media-button--airplay">
${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })}
${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })}
</media-airplay-button>
<media-tooltip id="airplay-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-pip-button commandfor="pip-tooltip" class="media-button media-button--subtle media-button--icon media-button--pip">
${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })}
${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })}
@@ -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);
@@ -92,6 +92,11 @@ function getTemplateHTML() {
${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })}
</media-cast-button>
<media-tooltip id="cast-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-airplay-button commandfor="airplay-tooltip" class="${cn(button.base, button.subtle, button.icon, iconState.airplay.button)}">
${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })}
${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })}
</media-airplay-button>
<media-tooltip id="airplay-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-pip-button commandfor="pip-tooltip" class="${cn(button.base, button.subtle, button.icon, iconState.pip.button)}">
${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })}
${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })}
@@ -79,6 +79,12 @@ function getTemplateHTML() {
</media-cast-button>
<media-tooltip id="cast-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-airplay-button commandfor="airplay-tooltip" class="media-button media-button--subtle media-button--icon media-button--airplay">
${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })}
${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })}
</media-airplay-button>
<media-tooltip id="airplay-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-pip-button commandfor="pip-tooltip" class="media-button media-button--subtle media-button--icon media-button--pip">
${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })}
${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })}
@@ -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);
@@ -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;
}
}
@@ -150,6 +150,11 @@ function getTemplateHTML() {
${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })}
</media-cast-button>
<media-tooltip id="cast-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-airplay-button commandfor="airplay-tooltip" class="${cn(button.base, button.subtle, button.icon, iconState.airplay.button)}">
${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })}
${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })}
</media-airplay-button>
<media-tooltip id="airplay-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-pip-button commandfor="pip-tooltip" class="${cn(button.base, button.subtle, button.icon, iconState.pip.button)}">
${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })}
${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })}
@@ -128,6 +128,12 @@ function getTemplateHTML() {
${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })}
</media-cast-button>
<media-tooltip id="cast-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-airplay-button commandfor="airplay-tooltip" class="media-button media-button--subtle media-button--icon media-button--airplay">
${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })}
${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })}
</media-airplay-button>
<media-tooltip id="airplay-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-pip-button commandfor="pip-tooltip" class="media-button media-button--subtle media-button--icon media-button--pip">
${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })}
@@ -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);
@@ -145,6 +145,11 @@ function getTemplateHTML() {
${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })}
</media-cast-button>
<media-tooltip id="cast-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-airplay-button commandfor="airplay-tooltip" class="${cn(button.base, button.subtle, button.icon, iconState.airplay.button)}">
${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })}
${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })}
</media-airplay-button>
<media-tooltip id="airplay-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-pip-button commandfor="pip-tooltip" class="${cn(button.base, button.subtle, button.icon, iconState.pip.button)}">
${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })}
${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })}
+6
View File
@@ -124,6 +124,12 @@ function getTemplateHTML() {
${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })}
</media-cast-button>
<media-tooltip id="cast-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-airplay-button commandfor="airplay-tooltip" class="media-button media-button--subtle media-button--icon media-button--airplay">
${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })}
${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })}
</media-airplay-button>
<media-tooltip id="airplay-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-pip-button commandfor="pip-tooltip" class="media-button media-button--subtle media-button--icon media-button--pip">
${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })}
+2
View File
@@ -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);
+1
View File
@@ -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';
@@ -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<AirplayButtonCore> {
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);
}
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 18 18">
<path d="M14.5 2A3.5 3.5 0 0 1 18 5.5v5l-.005.18a3.5 3.5 0 0 1-3.027 3.288L13 12h1.5a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 14.5 4h-11A1.5 1.5 0 0 0 2 5.5v5A1.5 1.5 0 0 0 3.5 12H5l-1.968 1.967A3.5 3.5 0 0 1 0 10.5v-5A3.5 3.5 0 0 1 3.5 2z"/>
<path d="M8.631 10.902a.5.5 0 0 1 .738 0l4.363 4.76a.5.5 0 0 1-.369.838H4.637a.5.5 0 0 1-.369-.838z"/>
</svg>

After

Width:  |  Height:  |  Size: 459 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 18 18">
<style>
@keyframes media-icon--airplay__triangle{0%{translate:0 0}to{translate:0-2px}}@keyframes media-icon--airplay__fill{0%{fill-opacity:0}to{fill-opacity:.2}}
</style>
<path fill-opacity=".2" d="M14.5 2A3.5 3.5 0 0 1 18 5.5v5a3.5 3.5 0 0 1-3.032 3.468L9.354 8.354a.5.5 0 0 0-.708 0l-5.615 5.614A3.5 3.5 0 0 1 0 10.5v-5A3.5 3.5 0 0 1 3.5 2z" style="animation:var(--media-icon--airplay__fill-animation, media-icon--airplay__fill 1s ease-in-out infinite alternate)"/>
<path d="M14.5 2A3.5 3.5 0 0 1 18 5.5v5l-.005.18a3.5 3.5 0 0 1-3.027 3.288L13 12h1.5a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 14.5 4h-11A1.5 1.5 0 0 0 2 5.5v5A1.5 1.5 0 0 0 3.5 12H5l-1.968 1.967A3.5 3.5 0 0 1 0 10.5v-5A3.5 3.5 0 0 1 3.5 2z"/>
<path d="M8.631 10.902a.5.5 0 0 1 .738 0l4.363 4.76a.5.5 0 0 1-.369.838H4.637a.5.5 0 0 1-.369-.838z" style="animation:var(--media-icon--airplay__triangle-animation, media-icon--airplay__triangle 1s ease-in-out infinite alternate)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 18 18">
<path d="M15.154 2.004A3 3 0 0 1 18 5v6a3 3 0 0 1-2.846 2.996L15 14l-1.5-1.5H15a1.5 1.5 0 0 0 1.5-1.5V5A1.5 1.5 0 0 0 15 3.5H3A1.5 1.5 0 0 0 1.5 5v6A1.5 1.5 0 0 0 3 12.5h1.5L3 14a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h12z"/>
<path d="M8.631 10.902a.5.5 0 0 1 .738 0l4.363 4.76a.5.5 0 0 1-.369.838H4.637a.5.5 0 0 1-.369-.838z"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 18 18">
<style>
@keyframes media-icon--airplay__triangle{0%{translate:0 0}to{translate:0-2px}}@keyframes media-icon--airplay__fill{0%{fill-opacity:0}to{fill-opacity:.2}}
</style>
<path fill-opacity=".2" d="M14.5 2A3.5 3.5 0 0 1 18 5.5v5a3.5 3.5 0 0 1-3.032 3.468L9.354 8.354a.5.5 0 0 0-.708 0l-5.615 5.614A3.5 3.5 0 0 1 0 10.5v-5A3.5 3.5 0 0 1 3.5 2z" style="animation:var(--media-icon--airplay__fill-animation, media-icon--airplay__fill 1s ease-in-out infinite alternate)"/>
<path d="M14.5 2A3.5 3.5 0 0 1 18 5.5v5l-.005.18a3.5 3.5 0 0 1-3.027 3.288L13.5 12.5h1a2 2 0 0 0 2-2v-5a2 2 0 0 0-2-2h-11a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1l-1.468 1.467A3.5 3.5 0 0 1 0 10.5v-5A3.5 3.5 0 0 1 3.5 2z"/>
<path d="M8.631 10.902a.5.5 0 0 1 .738 0l4.363 4.76a.5.5 0 0 1-.369.838H4.637a.5.5 0 0 1-.369-.838z" style="animation:var(--media-icon--airplay__triangle-animation, media-icon--airplay__triangle 1s ease-in-out infinite alternate)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -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';
@@ -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):
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<AirplayButton className={iconState.airplay.button} render={<Button />}>
<AirplayEnterIcon className={cn(icon, iconState.airplay.enter)} />
<AirplayExitIcon className={cn(icon, iconState.airplay.exit)} />
</AirplayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
@@ -2,6 +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,
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
@@ -19,6 +21,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';
@@ -173,6 +176,18 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod
<Tooltip.Popup className="media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<AirplayButton className="media-button--airplay" render={<Button />}>
<AirplayEnterIcon className="media-icon media-icon--airplay-enter" />
<AirplayExitIcon className="media-icon media-icon--airplay-exit" />
</AirplayButton>
}
/>
<Tooltip.Popup className="media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
@@ -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';
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';
@@ -221,6 +224,18 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode {
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<AirplayButton className={iconState.airplay.button} render={<Button />}>
<AirplayEnterIcon className={cn(icon, iconState.airplay.enter)} />
<AirplayExitIcon className={cn(icon, iconState.airplay.exit)} />
</AirplayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
@@ -2,6 +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,
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
@@ -19,6 +21,7 @@ import {
VolumeOffIcon,
} from '@/icons';
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';
@@ -174,6 +177,18 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode {
<Tooltip.Popup className="media-surface media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<AirplayButton className="media-button--airplay" render={<Button />}>
<AirplayEnterIcon className="media-icon media-icon--airplay-enter" />
<AirplayExitIcon className="media-icon media-icon--airplay-exit" />
</AirplayButton>
}
/>
<Tooltip.Popup className="media-surface media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
@@ -25,6 +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,
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
@@ -45,6 +47,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';
@@ -315,6 +318,18 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<AirplayButton className={iconState.airplay.button} render={<Button />}>
<AirplayEnterIcon className={cn(icon, iconState.airplay.enter)} />
<AirplayExitIcon className={cn(icon, iconState.airplay.exit)} />
</AirplayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
@@ -2,6 +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,
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
@@ -22,6 +24,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';
@@ -248,6 +251,18 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
<Tooltip.Popup className="media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<AirplayButton className="media-button--airplay" render={<Button />}>
<AirplayEnterIcon className="media-icon media-icon--airplay-enter" />
<AirplayExitIcon className="media-icon media-icon--airplay-exit" />
</AirplayButton>
}
/>
<Tooltip.Popup className="media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
@@ -25,6 +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,
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
@@ -45,6 +47,7 @@ import {
VolumeOffIcon,
} from '@/icons';
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';
@@ -311,6 +314,18 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<AirplayButton className={iconState.airplay.button} render={<Button />}>
<AirplayEnterIcon className={cn(icon, iconState.airplay.enter)} />
<AirplayExitIcon className={cn(icon, iconState.airplay.exit)} />
</AirplayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
+15
View File
@@ -2,6 +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,
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
@@ -22,6 +24,7 @@ import {
VolumeOffIcon,
} from '@/icons';
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';
@@ -244,6 +247,18 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<Tooltip.Popup className="media-surface media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<AirplayButton className="media-button--airplay" render={<Button />}>
<AirplayEnterIcon className="media-icon media-icon--airplay-enter" />
<AirplayExitIcon className="media-icon media-icon--airplay-exit" />
</AirplayButton>
}
/>
<Tooltip.Popup className="media-surface media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
@@ -0,0 +1,25 @@
'use client';
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 {}
/** A button that toggles AirPlay to a remote device. */
export const AirplayButton = createMediaButton<AirplayButtonCore, AirplayButtonProps>({
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;
}
@@ -0,0 +1 @@
export * from './airplay-button';
@@ -11,6 +11,7 @@
width: 100%;
height: 100%;
container: media-root / inline-size;
overflow: clip;
font-family:
Inter Variable,
Inter,
@@ -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
@@ -11,6 +11,7 @@
width: 100%;
height: 100%;
container: media-root / inline-size;
overflow: clip;
font-family:
Inter Variable,
Inter,
@@ -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
@@ -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;
}
@@ -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',
},
};
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import "./BasicUsage.ts";
</script>
@@ -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);
}
@@ -0,0 +1,12 @@
<video-player class="video-player">
<media-container>
<video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" autoplay muted
playsinline loop></video>
<media-airplay-button class="media-airplay-button">
<span class="connected">Stop AirPlay</span>
<span class="not-connected">Start AirPlay</span>
<span class="no-devices">No AirPlay devices found</span>
<span class="unsupported">AirPlay not supported</span>
</media-airplay-button>
</media-container>
</video-player>
@@ -0,0 +1,2 @@
import '@videojs/html/video/player';
import '@videojs/html/ui/airplay-button';
@@ -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);
}
@@ -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 (
<Player.Provider>
<Player.Container className="media-container">
<Video
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4"
autoPlay
muted
playsInline
loop
/>
<AirplayButton
className="media-airplay-button"
render={(props, state) => {
const label =
state.availability === 'unsupported'
? 'AirPlay not supported'
: state.airplayState === 'connected'
? 'Stop AirPlay'
: state.availability === 'unavailable'
? 'No AirPlay devices found'
: 'Start AirPlay';
return <button {...props}>{label}</button>;
}}
/>
</Player.Container>
</Player.Provider>
);
}
@@ -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
<FrameworkCase frameworks={["react"]}>
```tsx
<AirplayButton />
```
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
```html
<media-airplay-button></media-airplay-button>
```
</FrameworkCase>
## 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 <DocsLink slug="reference/feature-remote-playback">`remotePlayback`</DocsLink> 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:
<FrameworkCase frameworks={["html"]}>
```css
/* AirPlay active */
media-airplay-button[data-airplay-state="connected"] {
color: var(--accent);
}
```
</FrameworkCase>
<FrameworkCase frameworks={["react"]}>
React renders a `<button>` element. Add a `className` and use it as the selector:
```css
/* AirPlay active */
.airplay-button[data-airplay-state="connected"] {
color: var(--accent);
}
```
</FrameworkCase>
Consider hiding the button on platforms where AirPlay isn't supported:
<FrameworkCase frameworks={["html"]}>
```css
media-airplay-button[data-availability="unsupported"] {
display: none;
}
```
</FrameworkCase>
<FrameworkCase frameworks={["react"]}>
```css
.airplay-button[data-availability="unsupported"] {
display: none;
}
```
</FrameworkCase>
## Accessibility
Renders a `<button>` with an automatic `aria-label`: "Start AirPlay" when disconnected, "Stop AirPlay" when connected. (The component also supports a `"Connecting"` label, but WebKit AirPlay never emits a `connecting` state, so that label is unreachable in practice.) Override with the `label` prop — either a string or a function that receives the current state. Keyboard activation: <kbd>Enter</kbd> / <kbd>Space</kbd>.
## Examples
### Basic Usage
<FrameworkCase frameworks={["react"]}>
<StyleCase styles={["css"]}>
<Demo files={[
{ title: "App.tsx", code: basicUsageReactTsx, lang: "tsx" },
{ title: "App.css", code: basicUsageReactCss, lang: "css" },
]}>
<BasicUsageDemoReact client:idle />
</Demo>
</StyleCase>
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
<StyleCase styles={["css"]}>
<Demo files={[
{ title: "index.html", code: basicUsageHtml, lang: "html" },
{ title: "index.css", code: basicUsageHtmlCss, lang: "css" },
{ title: "index.ts", code: basicUsageHtmlTs, lang: "ts" },
]}>
<BasicUsageDemoHtml />
</Demo>
</StyleCase>
</FrameworkCase>
<ComponentReference component="AirplayButton" />
@@ -7,7 +7,7 @@ import FeatureReference from "@/components/docs/api-reference/FeatureReference.a
import DocsLink from "@/components/docs/DocsLink.astro";
import FrameworkCase from "@/components/docs/FrameworkCase.astro";
Controls remote playback to devices like Chromecast. Exits fullscreen before initiating a remote playback session.
Controls remote playback to devices like Chromecast (Chromium) and AirPlay (Safari). Exits fullscreen before initiating a remote playback session.
<FeatureReference feature="remotePlayback" />
+1
View File
@@ -55,6 +55,7 @@ export const sidebar: Sidebar = [
{ slug: 'reference/player-provider' },
{ slug: 'reference/player-container' },
// sorted alphabetically
{ slug: 'reference/airplay-button' },
{ slug: 'reference/buffering-indicator' },
{ slug: 'reference/captions-button' },
{ slug: 'reference/controls' },