From 3f2f4a4a5a8c41a22fa0dbefd46ed4e725dfa109 Mon Sep 17 00:00:00 2001 From: Santiago Puppo Date: Tue, 30 Jun 2026 14:31:49 -0300 Subject: [PATCH] feat(core): Support AirPlay on MSE (#1692) --- .../src/dom/media/hls-js/airplay-bridge.ts | 87 ++++++++ .../core/src/dom/media/hls-js/hls-js-only.ts | 15 +- .../media/hls-js/tests/airplay-bridge.test.ts | 198 ++++++++++++++++++ packages/core/src/dom/media/video-host.ts | 4 + packages/utils/src/dom/index.ts | 1 - packages/utils/src/dom/webkit.ts | 9 +- 6 files changed, 301 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/dom/media/hls-js/airplay-bridge.ts create mode 100644 packages/core/src/dom/media/hls-js/tests/airplay-bridge.test.ts diff --git a/packages/core/src/dom/media/hls-js/airplay-bridge.ts b/packages/core/src/dom/media/hls-js/airplay-bridge.ts new file mode 100644 index 00000000..4b46b560 --- /dev/null +++ b/packages/core/src/dom/media/hls-js/airplay-bridge.ts @@ -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 `` 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>(BaseClass: Base) { + class HlsJsMediaAirPlay extends (BaseClass as Constructor) { + #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; +} diff --git a/packages/core/src/dom/media/hls-js/hls-js-only.ts b/packages/core/src/dom/media/hls-js/hls-js-only.ts index 3627affd..7f9460f4 100644 --- a/packages/core/src/dom/media/hls-js/hls-js-only.ts +++ b/packages/core/src/dom/media/hls-js/hls-js-only.ts @@ -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))) + ) ) ) ) diff --git a/packages/core/src/dom/media/hls-js/tests/airplay-bridge.test.ts b/packages/core/src/dom/media/hls-js/tests/airplay-bridge.test.ts new file mode 100644 index 00000000..5ab1b988 --- /dev/null +++ b/packages/core/src/dom/media/hls-js/tests/airplay-bridge.test.ts @@ -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 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 +) 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 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 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).mockClear(); + (engine.startLoad as ReturnType).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).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).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 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).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(); + }); +}); diff --git a/packages/core/src/dom/media/video-host.ts b/packages/core/src/dom/media/video-host.ts index f522e9a9..44b9166e 100644 --- a/packages/core/src/dom/media/video-host.ts +++ b/packages/core/src/dom/media/video-host.ts @@ -38,6 +38,10 @@ export class HTMLVideoElementHost extends HTMLMediaElementHost