Skip to content

Source: src/controller/MediaSession.js

/**
 * The MediaSession component bridges VisionPlayer with the browser Media Session API.
 * It populates the session metadata (title and artwork) as soon as media data is ready
 * and wires chapter data to next/previous track actions when available.
 * @exports module:src/controller/MediaSession
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license  MIT
 */
export default class MediaSession {

    /**
     * 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;

    /**
     * Reference to the current MediaSession instance.
     * @type {MediaSession}
     */
    #mediaSession;

    /**
     * Normalized list of chapters for quick lookup.
     * @type {module:src/core/Data~mediaItem_chapter[]}
     */
    #chapters = [];

    /**
     * Creates an instance of the MediaSession component.
     * @param {module:src/core/Player} player  Reference to the VisionPlayer instance.
     */
    constructor(player) {

        if (typeof navigator === 'undefined' || !navigator.mediaSession) return [false];
        if (!player.initConfig('mediaSession', true)) return [false];

        this.#player = player;

        this.#subscriptions = [
            ['data/ready', this.#onDataUpdate],
            ['data/update', this.#onDataUpdate],
            ['data/nomedia', this.#resetMetadata],
            ['media/ready', this.#onTimeUpdate],
            ['media/play', () => this.#updatePlaybackState('playing')],
            ['media/pause', () => this.#updatePlaybackState('paused')],
            ['media/ended', () => this.#updatePlaybackState('paused')],
            ['media/timeupdate', this.#onTimeUpdate]
        ].map(([event, handler]) => this.#player.subscribe(event, handler));

        if (!navigator.mediaSession) return;

        this.#mediaSession = navigator.mediaSession;
        try { this.#mediaSession.setActionHandler('play', this.#onPlayAction); } catch {}
        try { this.#mediaSession.setActionHandler('pause', this.#onPauseAction); } catch {}
        try { this.#mediaSession.setActionHandler('seekto', this.#onSeekTo); } catch {}
        try { this.#mediaSession.setActionHandler('previoustrack', this.#onPreviousPlaylist); } catch {}
        try { this.#mediaSession.setActionHandler('nexttrack', this.#onNextPlaylist); } catch {}
        try { this.#mediaSession.setActionHandler('seekbackward', this.#onPreviousChapter); } catch {}
        try { this.#mediaSession.setActionHandler('seekforward', this.#onNextChapter); } catch {}

    }

    /**
     * Central handler to (re)apply metadata and chapter state.
     * Accepts full media objects or partial data/update fragments.
     * @param   {Object} [update={}]  Full media item or partial update fragment.
     * @returns {void}
     */
    #onDataUpdate = (update = {}) => {

        const baseMedia = this.#player?.data?.getMediaData ? this.#player.data.getMediaData() : {},
              mediaItem = { ...baseMedia, ...update },
              chapters = Array.isArray(mediaItem?.chapters) && mediaItem.chapters.length
                  ? [...mediaItem.chapters].filter(({ start }) => typeof start === 'number').sort((a, b) => a.start - b.start)
                  : [];

        this.#chapters = chapters;

        if (!this.#mediaSession || typeof window?.MediaMetadata === 'undefined') return;

        const lang = this.#player.getConfig('locale.lang'),
              resolve = data => (typeof data === 'object' ? data?.[lang] ?? Object.values(data)[0] : data),
              title = resolve(mediaItem?.title) || '',
              artist = resolve(mediaItem?.titleSecondary) || '',
              overlays = mediaItem?.overlays,
              poster = mediaItem?.poster,
              overlayPoster = Array.isArray(overlays)
                  ? overlays.find(({ type, show }) => {
                      const isPoster = type === 'poster' || type === 'poster-end';
                      const isImage = type === 'image' && (!show || show === 'start' || show === 'always');
                      return isPoster || isImage;
                  })?.src
                  : null,
              artworkSrc = poster || overlayPoster,
              chapterInfo = this.#chapters.map(({ title: chTitle, start }) => ({
                  title: typeof chTitle === 'object' ? chTitle[lang] ?? Object.values(chTitle)[0] : chTitle,
                  startTime: start
              }));

        if (!title && !artworkSrc) this.#resetMetadata(); else {
            try {
                this.#mediaSession.metadata = new window.MediaMetadata({
                    title: title || '',
                    artist,
                    artwork: artworkSrc ? [{ src: artworkSrc }] : [],
                    chapterInfo
                });
            } catch {} // best-effort only
        }

    };

    /**
     * Clears session metadata.
     * @listens module:src/core/Data#data/nomedia
     */
    #resetMetadata = () => {

        if (!this.#mediaSession) return;
        try { this.#mediaSession.metadata = null; } catch {}
        this.#chapters = [];

    };

    /**
     * Handler for "next track" action: advances playlist.
     */
    #onNextPlaylist = () => {

        const index = this.#player.data.getMediaData('index'),
              all = this.#player.data.getMediaData('all'),
              length = Array.isArray(all?.media) ? all.media.length : 0;

        if (length > 1 && index < length - 1) this.#player.data.setMediaIndex(index + 1).catch(() => {});

    };

    /**
     * Handler for "previous track" action: moves playlist backward.
     */
    #onPreviousPlaylist = () => {

        const index = this.#player.data.getMediaData('index'),
              all = this.#player.data.getMediaData('all'),
              length = Array.isArray(all?.media) ? all.media.length : 0;

        if (length > 1 && index > 0) this.#player.data.setMediaIndex(index - 1).catch(() => {});

    };

    /**
     * Handler for "play" action via Media Session.
     */
    #onPlayAction = () => {

        try { this.#player.media.play(); } catch {}

    };

    /**
     * Handler for "pause" action via Media Session.
     */
    #onPauseAction = () => {

        try { this.#player.media.pause(); } catch {}

    };

    /**
     * Sets mediaSession playbackState if available.
     * @param {'none'|'paused'|'playing'} state  Playback state.
     * @listens module:src/core/Media#media/play
     * @listens module:src/core/Media#media/pause
     * @listens module:src/core/Media#media/ended
     */
    #updatePlaybackState = state => {

        try { if (this.#mediaSession) this.#mediaSession.playbackState = state; } catch {}

    };

    /**
     * Handler for "seekto" action via Media Session.
     * @param {Object} [details]  Seek details provided by Media Session.
     */
    #onSeekTo = details => {

        if (this.#player.getState('media.liveStream') === true) return;
        const { seekTime } = details || {};
        if (typeof seekTime !== 'number' || Number.isNaN(seekTime)) return;
        try { this.#player.media.seek(seekTime); } catch {}

    };

    /**
     * Updates mediaSession position state if supported; skipped for live.
     * @listens module:src/core/Media#media/timeupdate
     */
    #onTimeUpdate = () => {

        if (!navigator.mediaSession?.setPositionState) return;

        const live = this.#player.getState('media.liveStream'),
              duration = this.#player.getState('media.duration') ?? 0,
              position = this.#player.getState('media.currentTime') ?? 0,
              rate = this.#player.getState('media.playbackRate') ?? 1;

        try {
            navigator.mediaSession.setPositionState({
                duration: live ? 0 : duration,
                position: live ? 0 : position,
                playbackRate: rate
            });
        } catch {}

    };

    /**
     * Handler for "seek forward" action, mapped to next chapter.
     */
    #onNextChapter = () => {

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

        const current = this.#player.getState('media.currentTime'),
              chapterIndex = this.#getChapterIndex(current);

        if (chapterIndex < this.#chapters.length - 1) {
            this.#seekToChapter(chapterIndex + 1);
        }

    };

    /**
     * Handler for "seek backward" action, mapped to chapter rewind.
     */
    #onPreviousChapter = () => {

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

        const current = this.#player.getState('media.currentTime'),
              chapterIndex = this.#getChapterIndex(current),
              currentStart = this.#chapters[chapterIndex]?.start ?? 0,
              targetIndex = currentStart > current - 2 ? chapterIndex - 1 : chapterIndex;

        this.#seekToChapter(targetIndex < 0 ? 0 : targetIndex);

    };

    /**
     * Finds the active chapter index for the given playback time.
     * @param   {number} time  Current playback time in seconds.
     * @returns {number}       Chapter index.
     */
    #getChapterIndex = time => {

        let index = 0;
        for (let i = 0; i < this.#chapters.length; i += 1) {
            if (this.#chapters[i].start <= time) index = i;
            else break;
        }
        return index;

    };

    /**
     * Seeks the media element to a chapter start time.
     * @param {number} index  Chapter index to seek to.
     */
    #seekToChapter = index => {

        const chapter = this.#chapters[index];
        if (!chapter) return;
        this.#player.media.seek(chapter.start);

    };

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

        this.#resetMetadata();
        this.#player.unsubscribe(this.#subscriptions);

        if (this.#mediaSession?.setActionHandler) {
            ['play', 'pause', 'seekto', 'previoustrack', 'nexttrack', 'seekbackward', 'seekforward'].forEach(action => {
                try { this.#mediaSession.setActionHandler(action, null); } catch {}
            });
        }

        this.#player = this.#mediaSession = null;
        this.#subscriptions = null;

    }
}