Skip to content

Source: src/providers/Vimeo.js

import DomSmith from '../../lib/dom/DomSmith.js';
import AsyncTask from '../../lib/util/AsyncTask.js';
import ExtendedMediaError from '../util/ExtendedMediaError.js';
import { clone } from '../../lib/util/object.js';
import scriptLoader from '../../lib/util/scriptLoader.js';

/**
 * VisionPlayer engine that connects the Vimeo Player API with the media API and controls the Vimeo iframe element.
 * Emits synthetic media events and mirrors Vimeo state into the unified player state.
 * Supports playback, pause, loop, seek, playback rate, PiP, volume/mute, subtitles, quality and language.
 * @exports  module:src/providers/Vimeo
 * @requires module:lib/dom/DomSmith
 * @requires module:lib/util/AsyncTask
 * @requires module:src/util/ExtendedMediaError
 * @requires module:lib/util/object
 * @requires module:lib/util/scriptLoader
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license  MIT
 */
export default class Vimeo {

    /**
     * Holds the instance configuration for this component.
     * @type     {Object}
     * @property {boolean} [lazyLoadLib=true]  If `true`, load Vimeo Player API on engine activation as opposed to the earlier initialisation stage.
     * @property {boolean} [doNotTrack=true]   If `true`, enables Vimeo's Do Not Track parameter to reduce tracking and cookie usage. Note: Essential cookies (e.g., for security and bot protection) may still be set even when enabled.
     */
    #config = {
        lazyLoadLib: true,
        doNotTrack: true
    };

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

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

    /**
     * Holds metadata information provided by media.load.
     * @type {module:src/core/Media~metaData}
     */
    #metaData = {};

    /**
     * Reference to the Vimeo player instance.
     * @type {Object}
     */
    #vimeoPlayer;

    /**
     * Reference to the Async Task instance. Used to handle async tasks, which can be cancelled, resolved or rejected.
     * @type {module:lib/util/AsyncTask}
     */
    #loadTask;

    /**
     * Cancelable promise for script loading. Used to prevent callbacks when destroyed.
     * @type {Promise|null}
     */
    #scriptLoadPromise = null;

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

    /**
     * Local state mirror (backing values).
     * @type {Object}
     */
    #state = Vimeo.#defaultState;

    /**
     * DOM helper wrapper around the Vimeo iframe container.
     * @type {module:lib/dom/DomSmith}
     */
    #container;

    /**
     * Subtitle renderer instance used to mirror cue changes into the player UI.
     * @type {{ update: Function }|null}
     */
    #subtitleRenderer;

    /**
     * Internal flag for PiP state.
     * @type {boolean}
     */
    #pipActive = false;

    /**
     * Remembers the last invoked Vimeo API method to disambiguate error handling.
     * @type {'requestPictureInPicture'|null}
     */
    #pendingVimeoMethod = null;

    /**
     * List of available qualities (could be numeric or textual, but can also be "null" which means "auto").
     * @type {Array<(null|number|string)>}
     */
    #qualities = [240, 360, 480, 720, 1080, 1440, 2160];

    /**
     * Creates an instance of the Vimeo engine.
     * @param {module:src/core/Player} player          Reference to the player instance.
     * @param {Object}                 parent          Reference to the parent component (unused for engines).
     * @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('vimeo', this.#config);

        if (!this.#config) return [false];

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

        this.#player.addEngine('vimeo', this, {
            capabilities: {
                play: true,
                playbackRate: true,
                loop: true,
                seek: true,
                volume: true,
                pictureInPicture: true,
                title: true,
                time: true
            }
        }, this.#apiKey);

        [
            ['vimeo:media.load', this.#load],
            ['vimeo:media.getMetaData', this.#getMetaData],
            ['vimeo:media.canPlay', this.canPlay],
            ['vimeo:media.play', this.#play],
            ['vimeo:media.pause', this.#pause],
            ['vimeo:media.loop', this.#loop],
            ['vimeo:media.playbackRate', this.#playbackRate],
            ['vimeo:media.seek', this.#seek],
            ['vimeo:media.volume', this.#volume],
            ['vimeo:media.mute', this.#mute],
            ['vimeo:media.getElement', this.#getIFrameElement],
            ['vimeo:media.requestPictureInPicture', this.#requestPictureInPicture],
            ['vimeo:media.exitPictureInPicture', this.#exitPictureInPicture]
        ].map(([name, handler]) => this.#player.setApi(name, handler, this.#apiKey));

        this.#container = new DomSmith({
            _ref: 'root',
            id: 'vip-engine-vimeo',
            className: 'vip-engine-wrapper'
        });

        if (!this.#config.lazyLoadLib) this.#scriptLoadPromise = this.#loadLib().catch(() => {});

    }

    /**
     * Checks if this engine can play the given media data, by checking if the source contains a valid Vimeo video ID.
     * @param   {module:src/core/Media~metaData} metaData      The data to test.
     * @param   {string}                         metaData.src  The source URL to test.
     * @returns {'probably'|''}                                Indicates if stream can be played.
     */
    canPlay({ src }) { // eslint-disable-line class-methods-use-this

        const id = Vimeo.#extractVideoId(src);
        return id ? 'probably' : '';

    }

    /**
     * Loads Vimeo Player API via CDN if not present.
     * Uses the centralized scriptLoader utility for deduplication and reliability.
     * @returns {Promise<Object>} Cancelable promise that resolves with the Vimeo namespace.
     */
    #loadLib() {

        const promise = scriptLoader.request('https://player.vimeo.com/api/player.js', {
            global: 'Vimeo',
            checkAvailable: () => window.Vimeo?.Player
        }).then(vimeo => vimeo).catch(error => {
            // Show error notification for load errors (not for cancellation)
            if (error.name !== 'AbortError') {
                this.#player.dom.getElement(this.#apiKey).classList.add('has-no-media');
                this.#player.publish('data/nomedia', this.#apiKey);
                this.#player.publish('notification', {
                    type: 'error',
                    title: this.#player.locale.t('errors.library.scriptLoaderErrorTitle'),
                    message: this.#player.locale.t('errors.library.scriptLoaderErrorMessage', { libraryName: 'Vimeo' }),
                    messageSecondary: error.message
                }, this.#apiKey);
            }
            throw error; // Always propagate error (including AbortError)
        });

        // Auto-cleanup when promise settles
        promise.finally(() => {
            if (this.#scriptLoadPromise === promise) {
                this.#scriptLoadPromise = null;
            }
        });

        return promise;

    }

    /**
     * Loads media into the Vimeo player.
     * @param   {module:src/core/Media~metaData}          metaData                  The media data to load.
     * @param   {Object}                                  [options]                 Additional options.
     * @param   {number}                                  [options.seek]            Seek position.
     * @param   {boolean}                                 [options.play]            Whether to play after loading.
     * @param   {boolean}                                 [options.paused=true]     When true, keep paused unless `play` overrides.
     * @param   {number}                                  [options.volume=1]        Initial volume between 0 and 1.
     * @param   {boolean}                                 [options.muted=false]     Whether to start muted.
     * @param   {boolean}                                 [options.ignoreAutoplay]  If true, ignore autoplay setting.
     * @returns {Promise<module:src/core/Media~metaData>}                           Resolves with current metadata once API is available.
     */
    #load = async(metaData, options = {}) => {

        const videoId = Vimeo.#extractVideoId(metaData?.src);
        if (!videoId) throw new Error('[VisionPlayer] Invalid Vimeo video id.');

        const { play, paused = true, seek = 0, volume = 1, muted = false } = options,
              doPlay = typeof play === 'boolean' ? play : !paused,
              prevTask = this.#loadTask;

        if (metaData.src && metaData.src === this.#metaData?.src && prevTask?.status === 'pending') return prevTask.promise;
        if (prevTask?.status === 'pending') await prevTask.cancel().catch(() => {});

        this.#metaData = metaData;
        this.#loadTask = new AsyncTask();
        this.#player.publish('media/loadstart', this.#apiKey);
        this.#state.paused = !doPlay;

        if (this.#vimeoPlayer) {
            const loadOptions = { id: videoId };
            if (seek > 0) loadOptions.start = seek;

            this.#vimeoPlayer.loadVideo(loadOptions).then(() => {
                if (doPlay) this.#vimeoPlayer.play().catch(() => {});
                this.#vimeoPlayer.setVolume(volume).catch(() => {});
                this.#vimeoPlayer.setMuted(muted).catch(() => {});
                this.#onLoaded();
            }).catch(error => {
                const wrapped = new ExtendedMediaError(2, { status: 400, message: `ERROR: ${error?.message || error}` });
                this.#loadTask.reject(wrapped);
                throw wrapped;
            }).then();
            return this.#loadTask.promise;
        }

        this.#scriptLoadPromise = this.#loadLib();
        let VimeoLib;
        try {
            VimeoLib = await this.#scriptLoadPromise;
        } catch (error) {
            if (error.name === 'AbortError') return; // Cancelled - stop execution
            // Reject loadTask instead of throwing to prevent uncaught exception
            if (this.#loadTask?.status === 'pending') this.#loadTask.reject(error);
            return this.#loadTask.promise;
        }

        const bestQuality = this.#getBestQuality(this.#player.getState('ui.playerHeight') * (window.devicePixelRatio ?? 1));

        this.#vimeoPlayer = new VimeoLib.Player(this.#container.root, {
            id: videoId,
            autoplay: Boolean(play),
            controls: false,
            muted: false,
            responsive: false,
            pip: true,
            playsinline: true,
            background: false,
            chromecast: false,
            dnt: this.#config.doNotTrack,
            quality: `${bestQuality}p`,
            preload: 'auto'
        });

        this.#vimeoPlayer.on('play', this.#onPlay);
        this.#vimeoPlayer.on('playing', this.#onPlaying);
        this.#vimeoPlayer.on('pause', this.#onPause);
        this.#vimeoPlayer.on('ended', this.#onEnded);
        this.#vimeoPlayer.on('timeupdate', this.#onTimeUpdate);
        this.#vimeoPlayer.on('progress', this.#onProgress);
        this.#vimeoPlayer.on('seeked', this.#onSeeked);
        this.#vimeoPlayer.on('bufferstart', this.#onBufferStart);
        this.#vimeoPlayer.on('bufferend', this.#onBufferEnd);
        this.#vimeoPlayer.on('playbackratechange', this.#onRateChange);
        this.#vimeoPlayer.on('volumechange', this.#onVolumeChange);
        this.#vimeoPlayer.on('cuechange', this.#onCueChange);
        this.#vimeoPlayer.on('error', this.#onError);
        this.#vimeoPlayer.on('enterpictureinpicture', this.#onPipEnter);
        this.#vimeoPlayer.on('leavepictureinpicture', this.#onPipExit);

        this.#vimeoPlayer.ready?.().then(() => {
            if (doPlay) this.#vimeoPlayer.play().catch(() => {});
            this.#vimeoPlayer.setVolume(volume).catch(() => {});
            this.#vimeoPlayer.setMuted(muted).catch(() => {});
            this.#onLoaded();
        }).catch(error => {
            // Catch early readiness errors (e.g., invalid ID) and reject loadTask
            const wrapped = new ExtendedMediaError(2, { status: 404, message: `[Vimeo] ${error?.message || error}` });
            this.#player.publish('media/error', { error: wrapped }, this.#apiKey);
            if (this.#loadTask?.status === 'pending') this.#loadTask.reject(wrapped);
        });

        return this.#loadTask.promise;

    };

    /**
     * Syncs metadata after load and emits Media API events.
     * @returns {Promise<void>} Resolves when metadata propagation is complete.
     * @fires module:src/settings/Quality#quality/update
     * @fires module:src/settings/Quality#quality/active
     * @fires module:src/settings/Language#language/update
     * @fires module:src/settings/Language#language/active
     * @fires module:src/core/Media#media/loadedmetadata
     * @fires module:src/core/Media#media/ready
     * @fires module:src/core/Media#media/loadeddata
     * @fires module:src/core/Media#media/canplay
     * @fires module:src/core/Media#media/canplaythrough
     * @fires module:src/core/Media#media/progress
     */
    #onLoaded = async() => {

        try {
            // extract metadata, keep in mind this is an async operation and the values are not immediately available
            const [currentTime, duration, title, videoWidth, videoHeight, qualities, textTracks, audioTracks, enabledAudioTrack, chapters, currentQuality] = await Promise.all([
                this.#vimeoPlayer?.getCurrentTime?.().catch(() => null),
                this.#vimeoPlayer?.getDuration?.().catch(() => null),
                this.#vimeoPlayer?.getVideoTitle?.().catch(() => null),
                this.#vimeoPlayer?.getVideoWidth?.().catch(() => null),
                this.#vimeoPlayer?.getVideoHeight?.().catch(() => null),
                this.#vimeoPlayer?.getQualities?.().catch(() => null),
                this.#vimeoPlayer?.getTextTracks?.().catch(() => null),
                this.#vimeoPlayer?.getAudioTracks?.().catch(() => null),
                this.#vimeoPlayer?.getEnabledAudioTrack?.().catch(() => null),
                this.#vimeoPlayer?.getChapters?.().catch(() => null),
                this.#vimeoPlayer?.getQuality?.().catch(() => null)
            ]);

            if (title) {
                this.#metaData.title = title;
                this.#player.data.updateMediaData(title, { property: 'title' });
            }

            this.#metaData.duration = Number.isFinite(duration) ? duration : Infinity;
            this.#metaData.width = videoWidth ?? NaN;
            this.#metaData.height = videoHeight ?? NaN;
            this.#metaData.quality = currentQuality ?? NaN;
            this.#metaData.language = enabledAudioTrack?.language ?? null;

            this.#state.currentTime = currentTime ?? 0;
            this.#state.duration = this.#metaData.duration;
            this.#state.remainingTime = Number.isFinite(this.#state.duration) ? this.#state.duration - this.#state.currentTime : NaN;
            this.#state.videoWidth = this.#metaData.width = videoWidth ?? NaN;
            this.#state.videoHeight = this.#metaData.height = this.#metaData.quality = videoHeight ?? NaN;
            this.#state.bufferedRange = Vimeo.#emptyRange;
            this.#state.playedRange = Vimeo.#emptyRange;

            // update chapters if available
            if (chapters?.length) {
                const mappedChapters = chapters.map(chapter => ({ title: chapter.title, start: chapter.startTime }));
                this.#player.data.updateMediaData(mappedChapters, { property: 'chapters' });
            }

            // check support for quality settings and make selected quality available if possible
            if (qualities?.length > 2) {
                const qualityDataRaw = Array.isArray(qualities)
                    ? qualities.map(q => (q?.id === 'auto' ? null : Number(q?.id?.replace?.('p', '') || q))).filter(v => Number.isFinite(v) || v === null)
                    : [];

                this.#qualities = qualityDataRaw;
                this.#state.videoHeight = NaN;
                this.#state.videoWidth = NaN;

                try {

                    const playerHeight = this.#player.getState('ui.playerHeight') * (window.devicePixelRatio ?? 1),
                          bestQuality = currentQuality === 'auto'
                              ? `${this.#getBestQuality(playerHeight)}p`
                              : currentQuality;

                    await this.#vimeoPlayer.setQuality(bestQuality);

                    const activeQuality = Number(bestQuality?.replace?.('p', '')),
                          qualityData = [...new Set(this.#qualities)].sort((a, b) => (a === null ? -1 : b === null ? 1 : a - b));

                    this.#state.videoHeight = this.#metaData.height = this.#metaData.quality = activeQuality;
                    this.#player.publish('quality/update', { qualityData }, this.#apiKey);
                    this.#player.publish('quality/active', { value: activeQuality }, this.#apiKey);

                } catch {
                    this.#qualities = [];
                }

            } else {
                this.#qualities = [];
            }

            // check support for language (aka audiotrack) settings and make selection via the language menu available if possible
            if (audioTracks?.length > 1) {

                const mappedLanguages = audioTracks.map(track => ({ language: track.language })).filter(entry => entry.language),
                      currentAudio = enabledAudioTrack?.language ? { language: enabledAudioTrack.language } : mappedLanguages.find(l => l.language === this.#metaData.language) || null,
                      initialLang = currentAudio?.language || mappedLanguages[0]?.language;

                try {
                    await this.#vimeoPlayer.selectAudioTrack(initialLang);
                    this.#metaData.language = initialLang;
                    this.#player.publish('language/update', { languages: mappedLanguages, current: { language: initialLang } }, this.#apiKey);
                    this.#player.publish('language/active', { language: initialLang }, this.#apiKey);
                } catch {}
            }

            // next we need to check if there are text tracks and if so, we need to publish the text tracks to the player
            if (textTracks?.length) {

                const mappedTextTracks = textTracks.map(textTrack => ({
                    language: textTrack.language,
                    label: textTrack.label,
                    kind: textTrack.kind
                })).filter(track => track.language);

                this.#subtitleRenderer = this.#player.getComponent('subtitles.subtitleRendererVTT', this.#apiKey);
                this.#player.data.updateMediaData(mappedTextTracks, { property: 'text' }, this.#apiKey);
            }

            // Publish (synthetic) media events to match the Media API
            this.#player.publish('media/loadedmetadata', this.#apiKey);
            this.#player.publish('media/ready', clone(this.#metaData), this.#apiKey);
            this.#player.publish('media/loadeddata', this.#apiKey);
            this.#player.publish('media/canplay', this.#apiKey);
            this.#player.publish('media/canplaythrough', this.#apiKey);
            this.#player.publish('media/progress', this.#apiKey);

            if (this.#loadTask?.status === 'pending') this.#loadTask.resolve(clone(this.#metaData));
        } catch (error) {
            if (this.#loadTask?.status === 'pending') this.#loadTask.reject(error);
        }

    };

    /**
     * Returns the current metadata.
     * @returns {module:src/core/Media~metaData} The current metadata.
     */
    #getMetaData = () => this.#metaData;

    /**
     * Plays the media.
     * @fires module:src/core/Media#media/play
     */
    #play = () => {

        this.#state.isPaused = false;
        this.#vimeoPlayer.play().catch(() => {});
        this.#player.publish('media/play', this.#apiKey);

    };

    /**
     * Pauses the media.
     * @fires module:src/core/Media#media/pause
     */
    #pause = () => {

        this.#state.isPaused = true;
        this.#vimeoPlayer.pause().catch(() => {});
        this.#player.publish('media/pause', this.#apiKey);

    };

    /**
     * Sets the loop state.
     * @param {boolean} loop  Whether to loop.
     * @fires module:src/core/Media#media/loop
     */
    #loop = loop => {

        const doLoop = Boolean(loop);
        this.#state.doLoop = doLoop;
        this.#vimeoPlayer.setLoop(doLoop).catch(() => {});
        this.#player.publish('media/loop', this.#apiKey);

    };

    /**
     * Sets the playback rate.
     * @param {number} rate  The playback rate (0.5 to 2).
     * @fires module:src/core/Media#media/ratechange
     */
    #playbackRate = rate => {

        const parsed = Number(rate);
        if (!Number.isFinite(parsed) || parsed <= 0) return;

        this.#vimeoPlayer.setPlaybackRate(parsed).then(() => {
            this.#state.playbackRate = parsed;
            this.#player.publish('media/ratechange', this.#apiKey);
        }).catch(() => {});

    };

    /**
     * Seeks to a specific time.
     * @param {number} time  The time to seek to (in seconds).
     * @fires module:src/core/Media#media/seeking
     * @fires module:src/core/Media#media/seeked
     * @fires module:src/core/Media#media/canplay
     * @fires module:src/core/Media#media/canplaythrough
     * @fires module:src/core/Media#media/progress
     */
    #seek = time => {

        this.#player.publish('media/seeking', this.#apiKey);
        const clamped = Math.max(0, Number(time) || 0);
        this.#vimeoPlayer.setCurrentTime(clamped).then(() => {
            this.#state.currentTime = clamped;
            this.#state.playedRange = Vimeo.#toRange(0, Math.min(clamped, this.#state.duration));
            this.#state.remainingTime = Number.isFinite(this.#state.duration) ? this.#state.duration - clamped : NaN;
            this.#player.publish('media/timeupdate', this.#apiKey);
            this.#player.publish('media/seeked', this.#apiKey);
            this.#player.publish('media/canplay', this.#apiKey);
            this.#player.publish('media/canplaythrough', this.#apiKey);
            this.#player.publish('media/progress', this.#apiKey);
        }).catch(() => {});

    };

    /**
     * Sets the volume.
     * @param {number} volume  The volume (0 to 1).
     * @fires module:src/core/Media#media/volumechange
     */
    #volume = volume => {

        const vol = Math.min(1, Math.max(0, Number(volume) || 0));
        this.#vimeoPlayer.setVolume(vol).then(() => {
            this.#state.volume = vol;
            this.#state.isMuted = vol === 0;
            this.#player.publish('media/volumechange', this.#apiKey);
        }).catch(() => {});

    };

    /**
     * Sets the mute state.
     * @param {boolean} mute  Whether to mute.
     * @fires module:src/core/Media#media/volumechange
     */
    #mute = mute => {

        const doMute = Boolean(mute);
        const mutedPromise = typeof this.#vimeoPlayer.setMuted === 'function'
            ? this.#vimeoPlayer.setMuted(doMute)
            : this.#vimeoPlayer.setVolume(doMute ? 0 : this.#state.volume || 1);

        Promise.resolve(mutedPromise).then(() => {
            this.#state.isMuted = doMute || this.#state.volume === 0;
            this.#player.publish('media/volumechange', this.#apiKey);
        }).catch(() => {});

    };

    /**
     * Requests Picture-in-Picture mode via Vimeo API.
     * @fires module:src/core/Media#media/enterpictureinpicture
     */
    #requestPictureInPicture = async() => {

        this.#pendingVimeoMethod = 'requestPictureInPicture';
        try {
            await this.#vimeoPlayer.requestPictureInPicture();
        } catch { }

    };

    /**
     * Exits Picture-in-Picture mode via Vimeo API.
     * @fires module:src/core/Media#media/leavepictureinpicture
     */
    #exitPictureInPicture = async() => {

        try {
            await this.#vimeoPlayer.exitPictureInPicture();
        } catch {}

    };

    /**
     * Handles Vimeo "play" to keep internal state aligned and propagate the Media API event.
     * @fires module:src/core/Media#media/play
     */
    #onPlay = () => {

        this.#state.isPaused = false;
        this.#state.isEnded = false;
        this.#player.publish('media/play', this.#apiKey);

    };

    /**
     * Handles Vimeo "playing" to mirror the active playback state.
     * @fires module:src/core/Media#media/playing
     */
    #onPlaying = () => {

        this.#state.isPaused = false;
        this.#player.publish('media/playing', this.#apiKey);

    };

    /**
     * Handles Vimeo "pause" and forwards the Media API event.
     * @fires module:src/core/Media#media/pause
     */
    #onPause = () => {

        this.#state.isPaused = true;
        this.#player.publish('media/pause', this.#apiKey);

    };

    /**
     * Handles Vimeo "ended", optionally restarts on loop, and fires Media API events.
     * @fires module:src/core/Media#media/ended
     */
    #onEnded = () => {

        if (this.#state.doLoop) {
            this.#vimeoPlayer.setCurrentTime(0).then(() => this.#vimeoPlayer.play()).catch(() => {});
            return;
        }

        this.#state.isPaused = true;
        this.#state.isEnded = true;
        this.#player.publish('media/ended', this.#apiKey);

    };

    /**
     * Handles Vimeo "timeupdate" payloads and recomputes derived timing state.
     * @param {Object} data             Event data from Vimeo.
     * @param {number} [data.duration]  Reported duration.
     * @param {number} [data.seconds]   Current playback position.
     * @fires module:src/core/Media#media/timeupdate
     */
    #onTimeUpdate = data => {

        const duration = Number.isFinite(data?.duration) ? data.duration : this.#state.duration,
              current = Number.isFinite(data?.seconds) ? data.seconds : this.#state.currentTime;

        this.#state.duration = duration ?? Infinity;
        this.#state.currentTime = current ?? 0;
        this.#state.playedRange = Vimeo.#toRange(0, Math.min(this.#state.currentTime, this.#state.duration));
        this.#state.remainingTime = Number.isFinite(this.#state.duration) ? this.#state.duration - this.#state.currentTime : NaN;
        this.#player.publish('media/timeupdate', this.#apiKey);

    };

    /**
     * Handles Vimeo "progress" events and updates buffered range.
     * @param {Object} data             Event data from Vimeo.
     * @param {number} [data.duration]  Reported duration.
     * @param {number} [data.percent]   Buffered percent (0..1).
     * @fires module:src/core/Media#media/progress
     */
    #onProgress = data => {

        const duration = Number.isFinite(data?.duration) ? data.duration : this.#state.duration,
              buffered = Number.isFinite(duration) && Number.isFinite(data?.percent) ? duration * data.percent : NaN;

        if (Number.isFinite(buffered) && buffered >= 0) {
            this.#state.bufferedRange = Vimeo.#toRange(0, Math.min(buffered, duration));
        } else {
            this.#state.bufferedRange = Vimeo.#emptyRange;
        }

        this.#player.publish('media/progress', this.#apiKey);

    };

    /**
     * Handles Vimeo "seeked" and forwards Media API event.
     * @fires module:src/core/Media#media/seeked
     */
    #onSeeked = () => { this.#player.publish('media/seeked', this.#apiKey); };

    /**
     * Handles Vimeo "bufferstart".
     * @fires module:src/core/Media#media/waiting
     */
    #onBufferStart = () => { this.#player.publish('media/waiting', this.#apiKey); };

    /**
     * Handles Vimeo "bufferend".
     * @fires module:src/core/Media#media/canplay
     */
    #onBufferEnd = () => { this.#player.publish('media/canplay', this.#apiKey); };

    /**
     * Handles Vimeo cue changes and feeds them into the subtitle renderer.
     * @param {Object} event         Vimeo cue event.
     * @param {Array}  [event.cues]  Active cues.
     */
    #onCueChange = event => { this.#subtitleRenderer?.update?.(event?.cues, this.#apiKey); };

    /**
     * Handles Vimeo playback-rate changes.
     * @param {Object} data                 Event data from Vimeo.
     * @param {number} [data.playbackRate]  Updated playback rate.
     * @fires module:src/core/Media#media/ratechange
     */
    #onRateChange = data => {

        const rate = Number(data?.playbackRate);
        if (Number.isFinite(rate) && rate > 0) this.#state.playbackRate = rate;
        this.#player.publish('media/ratechange', this.#apiKey);

    };

    /**
     * Handles Vimeo volume changes.
     * @param {Object}  data           Event data from Vimeo.
     * @param {number}  [data.volume]  Updated volume (0..1).
     * @param {boolean} [data.muted]   Muted flag.
     * @fires module:src/core/Media#media/volumechange
     */
    #onVolumeChange = data => {

        const volume = Number.isFinite(data?.volume) ? data.volume : this.#state.volume;
        this.#state.volume = volume;
        this.#state.isMuted = volume === 0 || Boolean(data?.muted);
        this.#player.publish('media/volumechange', this.#apiKey);

    };

    /**
     * Handles Vimeo enter-PiP.
     * @fires module:src/core/Media#media/enterpictureinpicture
     */
    #onPipEnter = () => {

        this.#pendingVimeoMethod = null;
        this.#pipActive = true;
        this.#player.publish('media/enterpictureinpicture', this.#apiKey);

    };

    /**
     * Handles Vimeo leave-PiP.
     * @fires module:src/core/Media#media/leavepictureinpicture
     */
    #onPipExit = () => {

        this.#pendingVimeoMethod = null;
        this.#pipActive = false;
        this.#player.publish('media/leavepictureinpicture', this.#apiKey);

    };

    /**
     * Reacts to player subtitle selection updates.
     * @param {Object}  [payload]           Selection payload.
     * @param {string}  [payload.language]  Target language or null to disable.
     * @param {boolean} [payload.off]       When true, disable subtitles.
     * @param {string}  [payload.type]      Track kind, defaults to "subtitles".
     * @param {number}  [payload.index]     Index of the selected track.
     * @fires module:src/core/Player#subtitles/active
     * @listens module:src/core/Player#subtitles/selected
     */
    #onSubtitlesSelected = ({ language, off, type, index } = {}) => {

        if (!this.#vimeoPlayer?.enableTextTrack) return;

        // Disable subtitles explicitly and notify UI state
        if (off || language === null || typeof language === 'undefined') {
            this.#vimeoPlayer.disableTextTrack?.().catch(() => {});
            this.#subtitleRenderer?.update?.([], this.#apiKey);
            this.#player.publish('subtitles/active', { index: -1, language: null, type: null }, this.#apiKey);
            return;
        }

        const targetLang = language;
        const targetKind = type || 'subtitles';

        // showing=false disables native rendering while still emitting cue events (see player.js docs)
        this.#vimeoPlayer.enableTextTrack(targetLang, targetKind, false).then(track => {

            const kind = track?.kind || targetKind;
            this.#player.publish('subtitles/active', {
                index: typeof index === 'number' ? index : 0,
                language: targetLang,
                type: kind
            }, this.#apiKey);

        }).catch(() => { });

    };

    /**
     * Applies a quality selection coming from the player UI.
     * @param {Object}      payload          Payload emitted by the quality menu.
     * @param {number|null} payload.quality  Selected resolution (e.g., 720) or null for auto.
     * @fires module:src/settings/Quality#quality/active
     * @listens module:src/settings/Quality#quality/selected
     */
    #onQualitySelected = ({ quality }) => {

        const target = typeof quality === 'undefined' || quality === null
            ? `${this.#getBestQuality(this.#player.getState('ui.playerHeight') * (window.devicePixelRatio ?? 1))}p`
            : `${quality}p`;

        this.#vimeoPlayer?.setQuality?.(target).then(resolvedQuality => {
            const active = resolvedQuality === 'auto' ? null : Number(resolvedQuality.replace('p', ''));
            this.#state.videoHeight = this.#metaData.height = this.#metaData.quality = active;
            this.#player.publish('quality/active', { value: active }, this.#apiKey);
        }).catch(() => {});

    };

    /**
     * Handler for 'quality/resize' event. Caps the max bitrate if display size is small.
     * @param {Object} size         Object containing size information.
     * @param {number} size.height  The new container height in px.
     * @listens module:src/settings/Quality#quality/resize
     */
    #onQualityResize = ({ height }) => {

        if (!this.#qualities.length) return;

        const bestQuality = this.#getBestQuality(height);
        this.#vimeoPlayer.setQuality(`${bestQuality}p`).catch(() => { });
        this.#player.publish('quality/active', { value: bestQuality }, this.#apiKey);

    };

    /**
     * Picks the best quality for a given height.
     * @param   {number}      height  The height in pixels.
     * @returns {number|null}         The best quality or null if no quality is available.
     */
    #getBestQuality = height => {

        const bestQuality = this.#qualities
            .sort((a, b) => (a === null ? -1 : b === null ? 1 : a - b))
            .reduce((acc, h) => h < height * 1.2 ? h : acc, {});

        return bestQuality;

    };

    /**
     * Applies an audio language selection coming from the player UI.
     * @param {Object} langObj             Payload emitted by the language menu.
     * @param {string} [langObj.language]  Language code.
     * @param {string} [langObj.value]     Alternative language key.
     * @fires module:src/settings/Language#language/active
     * @listens module:src/settings/Language#language/selected
     */
    #onLanguageSelected = langObj => {

        const target = langObj?.language || langObj?.value;
        if (!target) return;

        this.#vimeoPlayer.selectAudioTrack(target).then(() => {
            this.#player.publish('language/active', { language: target }, this.#apiKey);
        }).catch(() => {});

    };

    /**
     * Normalizes Vimeo errors into ExtendedMediaError and forwards notifications where appropriate.
     * @param {Object} error  Vimeo error object.
     * @fires module:src/core/Media#media/error
     * @fires module:src/ui/Notification#notification
     */
    #onError = error => {

        if (error?.method === 'setQuality' || error?.message?.includes('is not a valid quality')) return; // ignore quality errors

        if (this.#pendingVimeoMethod === 'requestPictureInPicture' && !error.method) {
            // catch errors from the requestPictureInPicture method and publish a notification instead of an error
            this.#pendingVimeoMethod = null;
            this.#player.publish('media/leavepictureinpicture', this.#apiKey);
            this.#player.publish('notification', {
                type: 'warning',
                title: this.#player.locale.t('pip.iFrameNotEnabled'),
                message: this.#player.locale.t('pip.enableIframe'),
                options: { timeout: 5 }
            }, this.#apiKey);
            return;
        }

        const wrapped = new ExtendedMediaError(2, { status: 404, message: `ERROR: ${error?.message || error}` });
        this.#player.publish('media/error', { error: wrapped }, this.#apiKey);
        if (this.#loadTask?.status === 'pending') this.#loadTask.reject(wrapped);

    };

    /**
     * Returns the Vimeo iframe element (falls back to the wrapper if not mounted yet).
     * @returns {HTMLElement|null} The Vimeo iframe element or wrapper node.
     */
    #getIFrameElement = () => this.#container?.root?.querySelector?.('iframe') || this.#container?.root || null;

    /**
     * Enables the Vimeo engine by loading the API, mounting the iframe container and exposing state accessors.
     */
    async enable() {

        try {
            this.#scriptLoadPromise = this.#loadLib();
            await this.#scriptLoadPromise;
        } catch (error) {
            if (error.name === 'AbortError') return; // Cancelled - stop execution
            throw error; // Real error - propagate
        }

        this.#container.mount({ ele: this.#player.dom.getElement(this.#apiKey) });
        this.#state = clone(Vimeo.#defaultState);

        const stateFuncs = {
            src: () => this.#metaData?.src,
            duration: () => this.#state.duration ?? NaN,
            currentTime: () => this.#state.currentTime ?? 0,
            remainingTime: () => this.#state.remainingTime ?? NaN,
            paused: () => this.#state.isPaused,
            ended: () => this.#state.isEnded,
            loop: () => Boolean(this.#state.doLoop),
            volume: () => this.#state.volume ?? 1,
            muted: () => this.#state.isMuted,
            playbackRate: () => this.#state.playbackRate ?? 1,
            videoWidth: () => this.#state.videoWidth ?? NaN,
            videoHeight: () => this.#state.videoHeight ?? NaN,
            buffered: () => this.#state.bufferedRange || Vimeo.#emptyRange,
            played: () => this.#state.playedRange || Vimeo.#emptyRange,
            pictureInPicture: () => this.#pipActive,
            liveStream: () => {
                const dur = this.#state.duration;
                return !Number.isFinite(dur) || dur === 0;
            }
        };

        for (const [key, fn] of Object.entries(stateFuncs)) {
            const descriptor = { get: fn, enumerable: true, configurable: true };
            this.#player.setState(`media.${key}`, descriptor, this.#apiKey);
        }

        this.#subscriptions = [
            this.#player.subscribe('quality/selected', this.#onQualitySelected),
            this.#player.subscribe('quality/resize', this.#onQualityResize),
            this.#player.subscribe('language/selected', this.#onLanguageSelected),
            this.#player.subscribe('subtitles/selected', this.#onSubtitlesSelected)
        ];

    }

    /**
     * Disables the Vimeo engine and removes all registered state accessors.
     */
    disable() {

        if (this.#loadTask?.status === 'pending') this.#loadTask.cancel().catch(() => { });

        // Cancel script loading if still in progress
        if (this.#scriptLoadPromise) {
            this.#scriptLoadPromise.cancel().catch(() => {});
            this.#scriptLoadPromise = null;
        }

        Object.keys(this.#state).forEach(key => {
            this.#player.removeState(`media.${key}`, this.#apiKey);
            delete this.#state[key];
        });

        this.#vimeoPlayer?.destroy?.();
        this.#vimeoPlayer = null;
        this.#container.unmount();
        this.#player.unsubscribe(this.#subscriptions);
        this.#subscriptions = null;

    }

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

        this.disable();
        this.#container.destroy();
        this.#player.removeEngine('vimeo', this.#apiKey);
        // eslint-disable-next-line @stylistic/max-len
        this.#player.removeApi(['vimeo:media.load', 'vimeo:media.getMetaData', 'vimeo:media.canPlay', 'vimeo:media.play', 'vimeo:media.pause', 'vimeo:media.loop', 'vimeo:media.playbackRate', 'vimeo:media.seek', 'vimeo:media.volume', 'vimeo:media.mute', 'vimeo:media.getElement', 'vimeo:media.requestPictureInPicture', 'vimeo:media.exitPictureInPicture'], this.#apiKey);
        this.#player = this.#vimeoPlayer = this.#metaData = this.#apiKey = null;

    }

    /**
     * Fallback empty TimeRanges-like object.
     * @type {{length:number, start:Function, end:Function}}
     */
    static #emptyRange = {
        length: 0,
        start: () => NaN,
        end: () => NaN
    };

    /**
     * Default state object for the Vimeo engine.
     * @type {Object}
     */
    static #defaultState = {
        doLoop: false,
        isMuted: false,
        isPaused: true,
        isEnded: false,
        bufferedRange: null,
        playedRange: null,
        duration: Infinity,
        liveStream: false,
        currentTime: 0,
        remainingTime: NaN,
        volume: 1,
        playbackRate: 1,
        pictureInPicture: false,
        videoWidth: NaN,
        videoHeight: NaN
    };

    /**
     * Converts start and end times to a TimeRanges-like object.
     * @param   {number}                                        start  Start time.
     * @param   {number}                                        end    End time.
     * @returns {{length:number, start:Function, end:Function}}        TimeRanges-like object.
     */
    static #toRange = (start, end) => !Number.isFinite(start) || !Number.isFinite(end) || end < start
        ? Vimeo.#emptyRange
        : {
            length: 1,
            start: index => (index === 0 ? start : NaN),
            end: index => (index === 0 ? end : NaN)
        };

    /**
     * Extracts a Vimeo video id from a URL.
     * @param   {string}      src  Source URL.
     * @returns {string|null}      Video id if found.
     */
    static #extractVideoId = src => {

        if (typeof src === 'string' && src.startsWith('vimeo:')) {
            const directId = src.slice('vimeo:'.length).trim().replace(/[^\d]/g, '');
            return /^\d{6,}$/.test(directId) ? directId : null;
        }

        let url;
        try {
            url = new URL(src);
        } catch { return null; }

        const host = url.hostname.toLowerCase(),
              isVimeoHost = host.includes('vimeo.com');

        if (!isVimeoHost) return null;

        const segments = url.pathname.split('/').filter(Boolean),
              idCandidate = segments.pop() || '',
              videoId = idCandidate.replace(/[^\d]/g, '');

        return /^\d{6,}$/.test(videoId) ? videoId : null;

    };

}