feat(core): Support AirPlay on MSE (#1692)

This commit is contained in:
Santiago Puppo
2026-06-30 10:31:49 -07:00
committed by GitHub
parent 5d44c4debf
commit 3f2f4a4a5a
6 changed files with 301 additions and 13 deletions
@@ -0,0 +1,87 @@
import { isWebKitAirPlayCapable, listen, type WebKitVideoElement } from '@videojs/utils/dom';
import type { Constructor } from '@videojs/utils/types';
import Hls from 'hls.js';
import type { HlsEngineHost } from './types';
/**
* Adds an AirPlay-capable fallback `<source>` to the attached video element so
* Safari can hand the original HLS manifest off to AirPlay receivers while
* local playback continues through hls.js (MSE).
* When wireless-target changes, suspends hls.js loading so we don't double-fetch
* alongside the AirPlay receiver.
*
* Implements the WebKit-recommended pattern:
* https://webkit.org/blog/15036/how-to-use-media-source-extensions-with-airplay/
*
* No-op on non-WebKit platforms (Chromium, Firefox).
*/
export function HlsJsMediaAirPlayMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
class HlsJsMediaAirPlay extends (BaseClass as Constructor<HlsEngineHost>) {
#sourceEl: HTMLSourceElement | null = null;
#disconnect: AbortController | null = null;
constructor(...args: any[]) {
super(...args);
this.engine?.on(Hls.Events.MEDIA_ATTACHED, () => this.#init());
this.engine?.on(Hls.Events.MEDIA_DETACHED, () => this.#destroy());
this.engine?.on(Hls.Events.DESTROYING, () => this.#destroy());
this.engine?.on(Hls.Events.MANIFEST_LOADING, (_event, data) => {
if (this.#sourceEl) this.#sourceEl.src = data.url;
});
}
#init(): void {
this.#destroy();
const target = this.target;
if (!target || !isWebKitAirPlayCapable(target)) return;
// Counter the `disableRemotePlayback = true` that other code paths may
// set for MSE; AirPlay requires the picker to be available on this
// element.
target.disableRemotePlayback = false;
this.#attachSource(target);
this.#setupLoadControl(target);
}
#attachSource(target: WebKitVideoElement) {
this.#sourceEl = document.createElement('source');
this.#sourceEl.type = 'application/x-mpegURL';
this.#sourceEl.src = this.engine?.url ?? '';
target.append(this.#sourceEl);
}
#setupLoadControl(target: WebKitVideoElement) {
const sync = () => {
/*
* From HLS.loadStart "Depending on default config,
* client starts loading automatically when a source is set."
* Safari re-sets the source when we turn AirPlay off, so there
* is no need to call start load here when current playback
* target is not wireless.
*/
if (target.webkitCurrentPlaybackTargetIsWireless) {
this.engine?.stopLoad();
}
};
this.#disconnect = new AbortController();
listen(target as EventTarget, 'webkitcurrentplaybacktargetiswirelesschanged', sync, {
signal: this.#disconnect.signal,
});
// AirPlay may already be active at (re)attach.
sync();
}
#destroy(): void {
this.#disconnect?.abort();
this.#disconnect = null;
this.#sourceEl?.remove();
this.#sourceEl = null;
}
}
return HlsJsMediaAirPlay as unknown as Base;
}
@@ -9,6 +9,7 @@ import type {
MediaStreamTypeCapability,
} from '../../../core/media/types';
import { HTMLVideoElementHost } from '../video-host';
import { HlsJsMediaAirPlayMixin } from './airplay-bridge';
import { HlsJsMediaErrorsMixin } from './errors';
import { HlsJsMediaLiveMixin } from './live';
import { HlsJsMediaMediaTracksMixin } from './media-tracks';
@@ -73,12 +74,14 @@ interface HlsJsMediaCapabilities
readonly error: MediaError | null;
}
const HlsJsOnlyMediaComposed = HlsJsMediaPreloadMixin(
HlsJsMediaLiveMixin(
HlsJsMediaStreamTypeMixin(
HlsJsMediaMediaTracksMixin(
HlsJsMediaMetadataTracksMixin(
HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(MediaTracksMixin(HlsJsOnlyMediaBase)))
const HlsJsOnlyMediaComposed = HlsJsMediaAirPlayMixin(
HlsJsMediaPreloadMixin(
HlsJsMediaLiveMixin(
HlsJsMediaStreamTypeMixin(
HlsJsMediaMediaTracksMixin(
HlsJsMediaMetadataTracksMixin(
HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(MediaTracksMixin(HlsJsOnlyMediaBase)))
)
)
)
)
@@ -0,0 +1,198 @@
import type { Constructor } from '@videojs/utils/types';
import Hls from 'hls.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HlsJsMediaAirPlayMixin } from '../airplay-bridge';
import type { HlsEngineHost } from '../types';
function createEngine(url = ''): Hls {
const listeners = new Map<string, Set<(...args: any[]) => void>>();
return {
url,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(fn);
},
off(event: string, fn: (...args: any[]) => void) {
listeners.get(event)?.delete(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(event, ...args);
},
startLoad: vi.fn(),
stopLoad: vi.fn(),
} as unknown as Hls;
}
// The real engine host exposes `target` as a protected getter; the mixin reads
// it internally. Here we model a minimal host with a writable `target` so tests
// can simulate attachment, then bridge to the mixin's expected host shape.
class FakeHost extends EventTarget {
engine: Hls | null;
target: HTMLMediaElement | null = null;
constructor(engine: Hls | null = null) {
super();
this.engine = engine;
}
}
const AirPlayHost = HlsJsMediaAirPlayMixin(
FakeHost as unknown as Constructor<HlsEngineHost>
) as unknown as typeof FakeHost;
function createVideo(initialWireless = false): HTMLVideoElement & { webkitCurrentPlaybackTargetIsWireless: boolean } {
const video = document.createElement('video') as HTMLVideoElement & {
webkitCurrentPlaybackTargetIsWireless: boolean;
};
let wireless = initialWireless;
Object.defineProperty(video, 'webkitCurrentPlaybackTargetIsWireless', {
configurable: true,
get: () => wireless,
set: (v: boolean) => {
wireless = v;
},
});
return video;
}
describe('HlsJsMediaAirPlayMixin', () => {
beforeEach(() => {
// Stub the WebKit AirPlay capability check (jsdom lacks it).
(globalThis as any).WebKitPlaybackTargetAvailabilityEvent = class {};
});
afterEach(() => {
delete (globalThis as any).WebKitPlaybackTargetAvailabilityEvent;
});
it('appends a fallback <source> element on MEDIA_ATTACHED', () => {
const engine = createEngine('https://example.com/master.m3u8');
const host = new AirPlayHost(engine);
const video = createVideo();
host.target = video;
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
const source = video.querySelector('source');
expect(source).not.toBeNull();
expect(source?.type).toBe('application/x-mpegURL');
expect(source?.src).toContain('master.m3u8');
});
it('sets disableRemotePlayback = false on the target', () => {
const engine = createEngine();
const host = new AirPlayHost(engine);
const video = createVideo();
video.disableRemotePlayback = true;
host.target = video;
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
expect(video.disableRemotePlayback).toBe(false);
});
it('updates the <source> src on MANIFEST_LOADING', () => {
const engine = createEngine('https://example.com/old.m3u8');
const host = new AirPlayHost(engine);
const video = createVideo();
host.target = video;
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
(engine as any).emit(Hls.Events.MANIFEST_LOADING, { url: 'https://example.com/new.m3u8' });
expect(video.querySelector('source')?.src).toContain('new.m3u8');
});
it('calls stopLoad when AirPlay activates', () => {
const engine = createEngine();
const host = new AirPlayHost(engine);
const video = createVideo();
host.target = video;
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
(engine.stopLoad as ReturnType<typeof vi.fn>).mockClear();
(engine.startLoad as ReturnType<typeof vi.fn>).mockClear();
video.webkitCurrentPlaybackTargetIsWireless = true;
video.dispatchEvent(new Event('webkitcurrentplaybacktargetiswirelesschanged'));
expect(engine.stopLoad).toHaveBeenCalled();
expect(engine.startLoad).not.toHaveBeenCalled();
});
it('does not call startLoad when AirPlay deactivates', () => {
// Safari re-sets the source when AirPlay turns off, so hls.js resumes
// loading on its own — the bridge never calls startLoad.
const engine = createEngine();
const host = new AirPlayHost(engine);
const video = createVideo(true);
host.target = video;
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
(engine.startLoad as ReturnType<typeof vi.fn>).mockClear();
video.webkitCurrentPlaybackTargetIsWireless = false;
video.dispatchEvent(new Event('webkitcurrentplaybacktargetiswirelesschanged'));
expect(engine.startLoad).not.toHaveBeenCalled();
});
it('never calls startLoad across the connect burst', () => {
// WebKit fires `true → false → true` on first connect. The transient
// `false` must not resume loading against the MSE mid-handoff; the bridge
// only ever suspends, so startLoad is never called.
const engine = createEngine();
const host = new AirPlayHost(engine);
const video = createVideo();
host.target = video;
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
(engine.startLoad as ReturnType<typeof vi.fn>).mockClear();
for (const wireless of [true, false, true]) {
video.webkitCurrentPlaybackTargetIsWireless = wireless;
video.dispatchEvent(new Event('webkitcurrentplaybacktargetiswirelesschanged'));
}
expect(engine.startLoad).not.toHaveBeenCalled();
});
it('suspends loading when AirPlay is already active at attach', () => {
const engine = createEngine();
const host = new AirPlayHost(engine);
const video = createVideo(true);
host.target = video;
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
expect(engine.stopLoad).toHaveBeenCalled();
});
it('removes the <source> and stops listening on MEDIA_DETACHED', () => {
const engine = createEngine();
const host = new AirPlayHost(engine);
const video = createVideo();
host.target = video;
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
(engine as any).emit(Hls.Events.MEDIA_DETACHED);
expect(video.querySelector('source')).toBeNull();
(engine.stopLoad as ReturnType<typeof vi.fn>).mockClear();
video.webkitCurrentPlaybackTargetIsWireless = true;
video.dispatchEvent(new Event('webkitcurrentplaybacktargetiswirelesschanged'));
expect(engine.stopLoad).not.toHaveBeenCalled();
});
it('no-ops when target lacks WebKit AirPlay APIs', () => {
delete (globalThis as any).WebKitPlaybackTargetAvailabilityEvent;
const engine = createEngine();
const host = new AirPlayHost(engine);
const video = document.createElement('video');
host.target = video;
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
expect(video.querySelector('source')).toBeNull();
expect(engine.stopLoad).not.toHaveBeenCalled();
expect(engine.startLoad).not.toHaveBeenCalled();
});
});
@@ -38,6 +38,10 @@ export class HTMLVideoElementHost extends HTMLMediaElementHost<HTMLVideoTargetLi
setProp(this, 'disablePictureInPicture', value);
}
get webkitCurrentPlaybackTargetIsWireless() {
return (this.target as WebKitVideoElement | null)?.webkitCurrentPlaybackTargetIsWireless;
}
get webkitPresentationMode() {
return (this.target as WebKitVideoElement | null)?.webkitPresentationMode;
}
-1
View File
@@ -44,7 +44,6 @@ export type {
export {
isWebKitAirPlayCapable,
supportsWebKitAirPlay,
type WebKitAirPlayMedia,
type WebKitDocument,
type WebKitFullscreenElement,
type WebKitPresentationMode,
+3 -6
View File
@@ -1,13 +1,10 @@
/** WebKit-only addition to HTMLMediaElement exposing the active AirPlay flag. */
export interface WebKitAirPlayMedia extends HTMLMediaElement {
readonly webkitCurrentPlaybackTargetIsWireless: boolean;
}
/** WebKit presentation mode values for iOS Safari. */
export type WebKitPresentationMode = 'inline' | 'fullscreen' | 'picture-in-picture';
/** Extended HTMLVideoElement with WebKit vendor APIs. */
export interface WebKitVideoElement extends HTMLVideoElement {
/** Whether the current playback target is wireless (WebKit) */
webkitCurrentPlaybackTargetIsWireless?: boolean;
/** Current WebKit presentation mode (iOS Safari). */
webkitPresentationMode?: WebKitPresentationMode;
/** Set WebKit presentation mode (iOS Safari). */
@@ -39,6 +36,6 @@ export function supportsWebKitAirPlay(): boolean {
}
/** Whether `media` exposes WebKit's AirPlay APIs. */
export function isWebKitAirPlayCapable(media: EventTarget): media is WebKitAirPlayMedia {
export function isWebKitAirPlayCapable(media: EventTarget): media is WebKitVideoElement {
return supportsWebKitAirPlay() && 'webkitCurrentPlaybackTargetIsWireless' in media;
}