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();
|
||||
|
||||
@@ -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) })}
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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 |
@@ -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={
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user