Youtube API Exposer

Exposes API of Youtube to control Youtube remotely

// ==UserScript==
// @name Youtube API Exposer
// @description Exposes API of Youtube to control Youtube remotely
// @version 2.1.2
// @compatible firefox
// @namespace https://github.com/albionah
// @homepageURL https://github.com/albionah/YoutubeApiExposer
// @author albionah
// @match *://*.youtube.com/*
// @grant unsafeWindow
// @license GNU General Public License v3.0
// ==/UserScript==

let connection;

function getBasicElements() {
    return new Promise((resolve) => {
        const video = document.querySelector("video");
        const player = unsafeWindow.document.getElementById("movie_player");
        if (video && player) {
            resolve({video, player});
        }
        else {
            setTimeout(() => getBasicElements().then(resolve), 1000);
        }
    });
}

getBasicElements().then(({video, player}) => {
    video.addEventListener("canplaythrough", () => {
        console.debug("event canplaythrough");
        uploadBasicInfo(player);
    });
    video.addEventListener("play", () => {
        console.debug("event play");
        uploadBasicInfo(player);
    });
    video.addEventListener("pause", () => {
        console.debug("event pause");
        uploadBasicInfo(player);
    });

    connect(video, player);
});


function getMediaInfo(player) {
    return new Promise((resolve) => {
        const title = player.getVideoData()?.title;
        if (title) {
            const stats = player.getVideoStats();
            resolve({
                title: title ?? h1s[0].textContent,
                videoId: stats.docid,
                duration: Number.parseFloat(stats.len),
                currentPosition: {
                    position: Number.parseFloat(stats.lct),
                    timestamp: new Date().getTime()
                },
                isPlaying: stats.vpa !== "1"
            });
        } else {
            setTimeout(() => getMediaInfo().then(resolve), 100);
        }
    });
}

function uploadBasicInfo(player) {
    getMediaInfo(player).then((mediaInfo) => {
        console.debug("media info", mediaInfo);
        connection?.send(JSON.stringify(mediaInfo));
    });
}

const videoIdPattern = new RegExp("\/watch\\?v=(.+)");

function findSuitableLink() {
    const links = unsafeWindow.document.querySelectorAll("a.yt-simple-endpoint.ytd-thumbnail");
    const suitableLink = Array.from(links).find((el) => el.href.match(videoIdPattern));
    if (suitableLink) return suitableLink;
    else throw new Error("cannot hot reload");

}

function watchWithHotReload(newVideoId) {
    const element = findSuitableLink();
    element.data.commandMetadata.webCommandMetadata.url = "/watch?v=" + newVideoId;
    element.data.watchEndpoint.videoId = newVideoId;
    element.click();
}

function watchWithHardReload(videoId) {
    location.href = `?v=${videoId}`;
}

function watch(videoId) {
    try {
        watchWithHotReload(videoId);
    } catch (error) {
        console.warn(error.message);
        watchWithHardReload(videoId);
    }
}

function connect(video, player) {
    const websocket = new WebSocket("ws://localhost:7789");

    websocket.onopen = () => {
        console.debug("connected to Youtube controller");
        connection = websocket;
        uploadBasicInfo(player);
    }
    websocket.onmessage = (rawMessage) => {
        try {
            console.debug(rawMessage.data);
            const message = JSON.parse(rawMessage.data);
            switch (message.type) {
                case "playOrPause":
                    const event = new KeyboardEvent('keydown', {'keyCode': 75, 'which': 75});
                    document.dispatchEvent(event);
                    break;

                case "play":
                    video.play();
                    break;

                case "pause":
                    video.pause();
                    break;

                case "stop":
                    video.stop();
                    break;

                case "watchPrevious":
                    player.previousVideo();
                    break;

                case "watchNext":
                    player.nextVideo();
                    break;

                case "watch":
                    console.debug(`watch ${message.id}`);
                    watch(message.id);
                    break;
            }
        } catch (error) {
            console.error(error);
        }
    }

    websocket.onclose = (event) => {
        console.info('Socket is closed. Reconnect will be attempted in 3 seconds.', event.reason);
        connection = undefined;
        setTimeout(() => connect(video, player), 3000);
    };

    websocket.onerror = (error) => {
        console.error('Socket encountered error: ', error.message, 'Closing socket');
        websocket.close();
    };
}

window.addEventListener('beforeunload', () => {
    connection?.close();
});