mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +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,
|
MediaStreamTypeCapability,
|
||||||
} from '../../../core/media/types';
|
} from '../../../core/media/types';
|
||||||
import { HTMLVideoElementHost } from '../video-host';
|
import { HTMLVideoElementHost } from '../video-host';
|
||||||
|
import { HlsJsMediaAirPlayMixin } from './airplay-bridge';
|
||||||
import { HlsJsMediaErrorsMixin } from './errors';
|
import { HlsJsMediaErrorsMixin } from './errors';
|
||||||
import { HlsJsMediaLiveMixin } from './live';
|
import { HlsJsMediaLiveMixin } from './live';
|
||||||
import { HlsJsMediaMediaTracksMixin } from './media-tracks';
|
import { HlsJsMediaMediaTracksMixin } from './media-tracks';
|
||||||
@@ -73,12 +74,14 @@ interface HlsJsMediaCapabilities
|
|||||||
readonly error: MediaError | null;
|
readonly error: MediaError | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HlsJsOnlyMediaComposed = HlsJsMediaPreloadMixin(
|
const HlsJsOnlyMediaComposed = HlsJsMediaAirPlayMixin(
|
||||||
HlsJsMediaLiveMixin(
|
HlsJsMediaPreloadMixin(
|
||||||
HlsJsMediaStreamTypeMixin(
|
HlsJsMediaLiveMixin(
|
||||||
HlsJsMediaMediaTracksMixin(
|
HlsJsMediaStreamTypeMixin(
|
||||||
HlsJsMediaMetadataTracksMixin(
|
HlsJsMediaMediaTracksMixin(
|
||||||
HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(MediaTracksMixin(HlsJsOnlyMediaBase)))
|
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);
|
setProp(this, 'disablePictureInPicture', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get webkitCurrentPlaybackTargetIsWireless() {
|
||||||
|
return (this.target as WebKitVideoElement | null)?.webkitCurrentPlaybackTargetIsWireless;
|
||||||
|
}
|
||||||
|
|
||||||
get webkitPresentationMode() {
|
get webkitPresentationMode() {
|
||||||
return (this.target as WebKitVideoElement | null)?.webkitPresentationMode;
|
return (this.target as WebKitVideoElement | null)?.webkitPresentationMode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export type {
|
|||||||
export {
|
export {
|
||||||
isWebKitAirPlayCapable,
|
isWebKitAirPlayCapable,
|
||||||
supportsWebKitAirPlay,
|
supportsWebKitAirPlay,
|
||||||
type WebKitAirPlayMedia,
|
|
||||||
type WebKitDocument,
|
type WebKitDocument,
|
||||||
type WebKitFullscreenElement,
|
type WebKitFullscreenElement,
|
||||||
type WebKitPresentationMode,
|
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. */
|
/** WebKit presentation mode values for iOS Safari. */
|
||||||
export type WebKitPresentationMode = 'inline' | 'fullscreen' | 'picture-in-picture';
|
export type WebKitPresentationMode = 'inline' | 'fullscreen' | 'picture-in-picture';
|
||||||
|
|
||||||
/** Extended HTMLVideoElement with WebKit vendor APIs. */
|
/** Extended HTMLVideoElement with WebKit vendor APIs. */
|
||||||
export interface WebKitVideoElement extends HTMLVideoElement {
|
export interface WebKitVideoElement extends HTMLVideoElement {
|
||||||
|
/** Whether the current playback target is wireless (WebKit) */
|
||||||
|
webkitCurrentPlaybackTargetIsWireless?: boolean;
|
||||||
/** Current WebKit presentation mode (iOS Safari). */
|
/** Current WebKit presentation mode (iOS Safari). */
|
||||||
webkitPresentationMode?: WebKitPresentationMode;
|
webkitPresentationMode?: WebKitPresentationMode;
|
||||||
/** Set WebKit presentation mode (iOS Safari). */
|
/** Set WebKit presentation mode (iOS Safari). */
|
||||||
@@ -39,6 +36,6 @@ export function supportsWebKitAirPlay(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether `media` exposes WebKit's AirPlay APIs. */
|
/** 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;
|
return supportsWebKitAirPlay() && 'webkitCurrentPlaybackTargetIsWireless' in media;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user