mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(core): Support AirPlay on MSE (#1692)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ export type {
|
||||
export {
|
||||
isWebKitAirPlayCapable,
|
||||
supportsWebKitAirPlay,
|
||||
type WebKitAirPlayMedia,
|
||||
type WebKitDocument,
|
||||
type WebKitFullscreenElement,
|
||||
type WebKitPresentationMode,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user