Skip to content

Source: src/casting/AirPlay.js

import DomSmith from '../../lib/dom/DomSmith.js';

/**
 * The AirPlay component enables AirPlay functionality on compatible browsers such as Safari macOS and Safari iOS. When a compatible streaming device is found (e.g., Apple TV or AirPlay Server Software on macOS),
 * the user can stream the current video to this device. This component **also works with HLS streams** provided the video element exposes a regular `https://...m3u8` URL.
 * Blob-based MediaSource playback is not supported by AirPlay receivers. There is a heuristic **that falls back to** an MP4 rendition whenever both AV1 and MP4 are present, because AirPlay devices cannot decode AV1 (as of 2025).
 * @exports module:src/casting/AirPlay
 * @requires lib/dom/DomSmith
 * @author    Frank Kudermann - alphanull
 * @version   1.0.0
 * @license   MIT
 */
export default class AirPlay {

    /**
     * Configuration options for the AirPlay component.
     * @type     {Object}
     * @property {boolean} [showControllerButton=true]  Shows or hides the controller button.
     * @property {boolean} [showMenuButton=true]        Shows or hides the menu button.
     */
    #config = {
        showControllerButton: true,
        showMenuButton: false
    };

    /**
     * Reference to the main player instance.
     * @type {module:src/core/Player}
     */
    #player;

    /**
     * Holds tokens of subscriptions to player events, for later unsubscribe.
     * @type {number[]}
     */
    #subscriptions;

    /**
     * Secret key only known to the player instance and initialized components.
     * Used to be able to restrict access to API methods in conjunction with secure mode.
     * @type {symbol}
     */
    #apiKey;

    /**
     * DomSmith instance for the AirPlay menu button.
     * @type {module:lib/dom/DomSmith}
     */
    #buttonMenu;

    /**
     * DomSmith instance for the AirPlay controller button.
     * @type {module:lib/dom/DomSmith}
     */
    #buttonController;

    /**
     * Stores the currently active stream object used for AirPlay.
     * @type {module:src/core/Media~metaData}
     */
    #currentSource;

    /**
     * Reference to the native RemotePlayback API interface provided by the video element.
     * @type {RemotePlayback}
     */
    #remote;

    /**
     * Saved stream source when replacing streams for restoring later.
     * @type {string}
     */
    #savedSrc;

    /**
     * Stores the callback identifier used by RemotePlayback API to manage availability watches.
     * @type {number}
     */
    #callbackId = -1;

    /**
     * Creates an instance of the AirPlay component.
     * @param {module:src/core/Player}           player            Reference to the VisionPlayer instance.
     * @param {module:src/controller/Controller} parent            Reference to the parent instance.
     * @param {Object}                           [options]         Additional options.
     * @param {symbol}                           [options.apiKey]  Token for extended access to the player API.
     */
    constructor(player, parent, { apiKey }) {

        this.#config = player.initConfig('airPlay', this.#config);

        if (!this.#config || !window.WebKitPlaybackTargetAvailabilityEvent
          || this.#config.showControllerButton === false && this.#config.showMenuButton === false) return [false];

        this.#player = player;
        this.#apiKey = apiKey;

        const settingsPopup = this.#player.getComponent('ui.controller.popupSettings', apiKey);

        if (settingsPopup && this.#config.showMenuButton !== false) {

            const id = this.#player.getConfig('player.id');

            this.#buttonMenu = new DomSmith({
                _tag: 'label',
                for: `airplay-control-${id}`,
                click: e => {
                    e.preventDefault();
                    this.#showAirPlayMenu();
                },
                _nodes: [{
                    _ref: 'label',
                    _tag: 'span',
                    className: 'form-label-text',
                    _nodes: [{
                        _ref: 'text',
                        _text: this.#player.locale.t('airplay.available')
                    }]
                }, {
                    _ref: 'input',
                    _tag: 'input',
                    id: `airplay-control-${id}`,
                    name: `airplay-control-${id}`,
                    type: 'checkbox',
                    className: 'is-toggle'
                }]
            }, settingsPopup.getElement('top'));
        }

        const buttonContainer = this.#player.getComponent('ui.controller', apiKey);

        if (buttonContainer && this.#config.showControllerButton !== false) {

            this.#buttonController = new DomSmith({
                _tag: 'button',
                _ref: 'button',
                disabled: true,
                className: 'icon airplay',
                'data-sort': 52,
                ariaLabel: this.#player.locale.t('airplay.airplay'),
                click: this.#showAirPlayMenu,
                $tooltip: { player, text: this.#player.locale.t('airplay.airplay') }
            }, buttonContainer.getElement('right'));
        }

        if (typeof RemotePlayback === 'undefined' || this.#player.getClient('iOS')) {
            const videoEle = this.#player.media.getElement(apiKey);
            videoEle.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', this.#onAirPlayStatusChanged);
            videoEle.addEventListener('webkitplaybacktargetavailabilitychanged', this.#onAvailability);
        } else {
            this.#subscriptions = [this.#player.subscribe('media/ready', this.#onMediaReady)];
        }

        this.#player.setState('media.airPlayActive', {
            get: () => this.#remote
                ? this.#remote.state === 'connecting' || this.#remote.state === 'connected'
                : this.#player.media.getElement(this.#apiKey).webkitCurrentPlaybackTargetIsWireless
        }, this.#apiKey);

    }

    /**
     * Called when Video is ready for playback. Sets up the remote (if available).
     * @param {module:src/core/Media~metaData} metaData  The currently selected metaData.
     * @listens module:src/core/Media#media/ready
     */
    #onMediaReady = metaData => {

        this.#currentSource = metaData;

        const allowed = ['m3u8', 'm3u', 'mp4', 'm4v', 'mov', 'm4a', 'aac', 'mp3'],
              ext = metaData.src.split(/[#?]/)[0].split('.').pop().trim().toLowerCase(),
              isSupported = allowed.includes(ext);

        if (this.#remote) {

            this.#remote.cancelWatchAvailability(this.#callbackId).catch(() => { });
            this.#remote.removeEventListener('connect', this.#updateRemoteState);
            this.#remote.removeEventListener('connecting', this.#updateRemoteState);
            this.#remote.removeEventListener('disconnect', this.#updateRemoteState);
            this.#callbackId = -1;

        }

        if (!isSupported || metaData.src.startsWith('blob:')) {

            this.#onAvailability({ availability: 'not available' });

        } else {

            // HACK: Force hls.js to make stream available for streaming
            this.#player.media.getElement(this.#apiKey).disableRemotePlayback = false;

            this.#remote = this.#player.media.getElement(this.#apiKey).remote;
            this.#remote.addEventListener('connect', this.#updateRemoteState);
            this.#remote.addEventListener('connecting', this.#updateRemoteState);
            this.#remote.addEventListener('disconnect', this.#updateRemoteState);

            this.#updateRemoteState();

        }
    };

    /**
     * Called whenever the RemotePlayback state changes.
     */
    #updateRemoteState = async() => {

        if (this.#remote.state === 'connecting') {

            this.#onAvailability({ availability: 'available' });
            this.#onAirPlayStatusChanged();

        } else if (this.#remote.state === 'disconnected') {

            if (this.#savedSrc) {
                // restore stream if we switched before
                this.#player.media.load({ src: this.#savedSrc }, { rememberState: true });
                this.#savedSrc = '';
            }

            try {
                // Let's watch remote device availability when there's no connected remote device.
                this.#callbackId = await this.#remote.watchAvailability(availability => {
                    this.#onAvailability({ availability: availability ? 'available' : 'not available' });
                });

            } catch (error) {
                this.#onAvailability({ availability: 'not available' });
                console.error(error); // eslint-disable-line no-console
            }

            this.#onAirPlayStatusChanged();

        } else if (this.#callbackId !== -1) {
            try {
                // If remote device is connecting or connected, we should stop watching remote device availability to save power.
                await this.#remote.cancelWatchAvailability(this.#callbackId);
                this.#callbackId = -1;
            } catch (error) {
                console.error(error); // eslint-disable-line no-console
            }
        }
    };

    /**
     * Shows the AirPlay menu (natively rendered by the OS). Tries to use the remote API if possible.
     * If connected via the remote API, tries to switch to a non-AV1 stream if possible.
     * @param {Event} event  The original click event.
     */
    #showAirPlayMenu = async event => {

        if (this.#remote) {
            // use remote API if possible. So we have better control
            // and are actually to switch streams, in case there is AV1 with a mp4 alternative
            // most of AirPlay Clients are unable to handle AVi (yet)
            try {

                await this.#remote.prompt();

                if (this.#remote.state === 'connecting') {

                    const { src, mimeType, encodings } = this.#currentSource,
                          aV1Type = /^video\/mp4;\s*codecs=av01/i.test(mimeType); // 'video/mp4; codecs=av01.0.05M.08';

                    if (aV1Type && encodings) {

                        // cant play av1 on most cast reveivers, try to find alternate mp4
                        const found = encodings.find(encoding => encoding && !/^video\/mp4;\s*codecs=av01/i.test(encoding.mimeType) && this.#player.media.canPlay(encoding));

                        if (found) {
                            this.#player.media.load({ src: found.src }, { rememberState: true });
                            this.#savedSrc = src; // remember actual stream for later
                        }
                    } else {

                        this.#player.media.load({ src }, { rememberState: true });
                    }
                }

            } catch (error) {
                console.error(error); // eslint-disable-line no-console
            }

        } else {

            this.#player.media.getElement(this.#apiKey).webkitShowPlaybackTargetPicker();
            event.preventDefault();

        }

    };

    /**
     * Invoked when AirPlay availability changes.
     * @param {Event} event  The original WebKitPlaybackTargetAvailabilityEvent.
     * @listens webkitplaybacktargetavailabilitychanged
     */
    #onAvailability = (event = {}) => {

        if (event.availability === 'available') {

            if (this.#buttonMenu) {
                this.#buttonMenu.label.disabled = false;
                this.#buttonMenu.text.nodeValue = this.#player.locale.t('airplay.available');
            }

            if (this.#buttonController) this.#buttonController.button.disabled = false;

        } else {

            if (this.#buttonMenu) {
                this.#buttonMenu.label.disabled = true;
                this.#buttonMenu.text.nodeValue = this.#player.locale.t('airplay.unavailable');
            }

            if (this.#buttonController) this.#buttonController.button.disabled = true;

        }

    };

    /**
     * Invoked when AirPlay status changes, i.e., the user has activated or deactivated AirPlay.
     * @fires   module:src/casting/AirPlay#airplay/start
     * @fires   module:src/casting/AirPlay#airplay/stop
     */
    #onAirPlayStatusChanged = () => {

        const airPlayActive = this.#player.getState('media.airPlayActive'),
              rootEle = this.#player.dom.getElement(this.#apiKey);

        if (this.#buttonMenu) this.#buttonMenu.input.checked = airPlayActive;
        rootEle.classList.toggle('is-airplay', airPlayActive);
        this.#player.publish(`airplay/${airPlayActive ? 'start' : 'stop'}`, this.#apiKey);

    };

    /**
     * Removes all events, subscriptions, and DOM nodes created by this component.
     */
    destroy() {

        if (this.#remote) {
            this.#remote.cancelWatchAvailability(this.#callbackId).catch(() => {}); // TODO: should actually be awaited, but destroy() is not async (yet)
            this.#remote.removeEventListener('connect', this.#updateRemoteState);
            this.#remote.removeEventListener('connecting', this.#updateRemoteState);
            this.#remote.removeEventListener('disconnect', this.#updateRemoteState);
            this.#remote = null;
        } else {
            const videoEle = this.#player.media.getElement(this.#apiKey);
            videoEle.removeEventListener('webkitcurrentplaybacktargetiswirelesschanged', this.#onAirPlayStatusChanged);
            videoEle.removeEventListener('webkitplaybacktargetavailabilitychanged', this.#onAvailability);
        }

        this.#buttonMenu?.destroy();
        this.#buttonController?.destroy();
        this.#player.removeState('media.airPlayActive', this.#apiKey);
        this.#player.unsubscribe(this.#subscriptions);
        this.#player = this.#buttonMenu = this.#buttonController = this.#currentSource = this.#apiKey = null;

    }

}

/**
 * This event is fired when AirPlay was started.
 * @event  module:src/casting/AirPlay#airplay/start
 */

/**
 * This event is fired when AirPlay was stopped.
 * @event  module:src/casting/AirPlay#airplay/stop
 */