Home Manual Reference Source Test

src/browser/providers/youtube.js

/**
 *
 * This file adds support for Youtube specific playback
 *
 * by extending and overriding the classes {@link Page},
 * {@link Playback}, and {@link Player}.
 * we can define how we'll interact with youtube's site
 *
 *
 */

/**
 * The metadata sent to the mpris host is defined
 * by the {@link Player} implementation
 *
 * For youtube we get:
 *  - the song title from the html
 *  - the artist from the uploader
 *  - the cover image from the static resource of the thumbnail
 */
class YouTubePlayer extends Player {

    /**
     * A youtube video can have 4 different baseURLs
     *
     * 1. /watch?v=ID_OF_VIDEO - when watching the default way
     * 2. /embed/ID_OF_VIDEO - when it is embedded on a different site
     * 3. /user/nprmusic - when in a users page
     * 4. /channel/UCC6mthPyZTpbk-Klz9RMxMw - when in a channels page
     *
     * In cases 3 and 4 we don't have a way to figure out it's video id
     * so the player id will be the full URL
     *
     * @returns {string}
     */
    getId () {
        if (this.URL.pathname === '/watch') {
            return this.URL.searchParams.get('v');
        } else if (this.URL.pathname.match('/embed')) {
            return this.URL.pathname.split('/').pop();
        } else
            return this.getUrl();
    }

    getTitle () {
        let title = document.querySelector('.ytp-title-text');
        return (title && title.textContent) || super.getTitle();
    }

    getArtists () {
        let owner = document.querySelector('#owner-name a');
        return owner && [owner.textContent];
    }

    /**
     * If we couldn't figure out the video id (see {@link getId})
     * then the player's id will be the element's baseURI, if that's the case
     * we won't be able to get the thumbnail. so call super
     *
     * @returns {string}
     */
    getCover () {
        if (this.getId().indexOf('/') !== -1)
            return super.getCover();
        return `https://i.ytimg.com/vi/${this.getId()}/hqdefault.jpg`;
    }
}

Player = YouTubePlayer;

/**
 * YouTube Playback
 */
class YouTubePlayback extends Playback {

    setRate (rate) {
        if (rate <= 0)
            return;
        const closestRate = rate <= 1.75 ? Math.ceil(rate * 4) : 7;

        // first make the settings menu appear
        document.querySelector('.ytp-settings-button').click();
        // then the "speed" submenu
        document.querySelectorAll('.ytp-settings-menu .ytp-menuitem')
          .forEach(each => {
              if (each.firstElementChild.innerText === 'Speed')
                  each.click();
          });

        // set a timeout because of animation delays
        setTimeout(() => {
            // select the closest speed
            document.querySelectorAll('.ytp-settings-menu .ytp-menuitem')
              [closestRate - 1].click();
            // and close the settings menu again
            document.querySelector('.ytp-settings-button').click();
        }, 300);
    }

    setVolume (volume) {
        super.setVolume(volume);
        if (volume === 0 && this.controls.volumeButton && this.controls.volumeButton.title.indexOf('Mute') !== -1)
            this.controls.volumeButton.click();
        else {
            if (this.controls.volumeButton && this.controls.volumeButton.title.indexOf('Unmute') !== -1)
                this.controls.volumeButton.click();

            if (this.controls.volumeHandle) {
                this.controls.volumeHandle.style.left = `${40 * volume}px`;
            }
        }

    };

    isShuffle () {
        return this.isControlAvailable(this.controls.shuffleButton) &&
          this.isActionPressed(this.controls.shuffleButton);
    }

    setShuffle (isShuffle) {
        if (this.controls.shuffleButton)
            this.controls.shuffleButton.click();
    }

    getLoopStatus () {
        if (this.isControlAvailable(this.controls.loopPlaylistButton) &&
          this.isActionPressed(this.controls.loopPlaylistButton))
            return LoopStatus.PLAYLIST;
        else
            return super.getLoopStatus();
    }

    setLoopStatus (status) {
        if (this.isControlAvailable(this.controls.loopPlaylistButton)) {
            if (status === LoopStatus.PLAYLIST) {
                if (!this.isActionPressed(this.controls.loopPlaylistButton))
                    this.controls.loopPlaylistButton.click();
            } else {
                if (this.isActionPressed(this.controls.loopPlaylistButton))
                    this.controls.loopPlaylistButton.click();
                super.setLoopStatus(status);
            }
        } else
            super.setLoopStatus(status);
    }

    canGoNext () {
        return !this.isButtonDisabled(this.controls.nextButton);
    }

    canGoPrevious () {
        return !this.isButtonDisabled(this.controls.previousButton);
    }

    next () {
        if (!this.isButtonDisabled(this.controls.nextButton))
            this.controls.nextButton.click();
    }

    previous () {
        if (!this.isButtonDisabled(this.controls.previousButton))
            this.controls.previousButton.click();
    }

    /**
     *
     * @param {HTMLElement} button
     * @returns {boolean}
     */
    isActionPressed (button) {
        return button.firstElementChild.getAttribute('aria-pressed') === 'true';
    }

    /**
     *
     * @param {HTMLElement} button
     * @returns {*|string}
     */
    isButtonDisabled (button) {
        return !button || button.getAttribute('aria-disabled') === 'true';
    }

    /**
     *
     * @param {HTMLElement} element
     * @returns {boolean}
     */
    isControlAvailable (element) {
        return !!(element && element.offsetParent);
    }
}

Playback = YouTubePlayback;

/**
 * YouTube Page
 */
class YouTubePage extends Page {

    registerPlayer (element) {
        if (this.players.find(player => player.element === element)) {
            return;
        }

        let player = new Player(this, this.host, element);

        this.players.push(player);

        let container = element.parentElement.parentElement;

        this.playback.controls = {
            ...this.playback.controls,
            nextButton: (container || document).querySelector('.ytp-next-button'),
            previousButton: (container || document).querySelector('.ytp-prev-button'),
            volumeButton: (container || document).querySelector('.ytp-mute-button.ytp-button'),
            volumeHandle: (container || document).querySelector('.ytp-volume-slider-handle')
        };

        // Ignore short sounds, they are most likely a chat notification sound
        // but still allow when undetermined (e.g. video stream)
        if (player.isPlaying() && !(isNaN(element.duration) || (element.duration > 0 && element.duration < 5))) {
            this.setActivePlayer(player);
        }
    }

}

Page = YouTubePage;

/*
    for youtube we need to listen for yt-page-data-updated
    we subscribe to mpris2-setup to make sure page is defined
 */
window.addEventListener('mpris2-setup', () => {
    window.addEventListener('yt-page-data-updated', function () {
        let playlistActionsButtons = document.querySelectorAll('#playlist-actions a');

        playlistActionsButtons.forEach(each => {
            page.observeForChanges(each.firstElementChild, {
                attributeFilter: ['aria-pressed']
            });
        });

        page.playback.controls = {
            ...page.playback.controls,
            shuffleButton: playlistActionsButtons[1],
            loopPlaylistButton: playlistActionsButtons[0]
        };

        document.querySelectorAll('video,audio')
          .forEach(player => page.registerPlayer(player));

        if (page.getActivePlayer()) {
            if (page.getActivePlayer().isHidden() && !page.getActivePlayer().isPlaying())
                page.host.quit(page.getActivePlayer());
            else
                page.host.start(page.getActivePlayer());
        }
    });
});