mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): airplay button (#1531)
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user