// ==UserScript== // @name sup-ytsync // @namespace https://forgejo.sup39.dev/sup39/sup-ytsync/ // @version 2024-01-30 // @description A script to sync Youtube video progress with others via MQTT // @author sup39 // @match https://www.youtube.com/watch* // @grant none // ==/UserScript== (() => { 'use strict'; const url = '...'; const username = '...'; const password = '...'; const roomId = '...'; const clientId = `ytsync-${username}-${+new Date()}`; // topic = ${topicPrefixBase}/${roomId}/${videoId}/${commander.clientId}/${command} const topicPrefixBase = 'sup-ytsync'; const mqttScriptUrl = 'https://unpkg.com/mqtt@5.3.4/dist/mqtt.min.js'; /** * @param {HTMLVideoElement} element * @param {VideoCommandBroadcaster} commandBroadcaster */ function Video(element, commandBroadcaster) { /** @type {Set} */ const queuedCommands = new Set(); for (const [eventName, command] of (/**@type{const}*/([ ['play', 'play'], ['pause', 'pause'], ['seeked', 'seek'], ]))) { element.addEventListener(eventName, () => { // ignore queued command if (queuedCommands.delete(command)) return; // ignore seeked event when playing if (eventName === 'seeked' && !element.paused) return; // callback const {currentTime} = element; commandBroadcaster(command, {currentTime}); }); } return { /** @param {number} t */ seek(t) { queuedCommands.add('seek'); element.currentTime = t; }, play() { queuedCommands.add('play'); element.play(); }, pause() { queuedCommands.add('pause'); element.pause(); }, get isPaused() { return element.paused; }, }; } // FIXME const Buffer = /** @type{Buffer}*//**@type{any}*/(Uint8Array); const videoId = new URLSearchParams(window.location.search).get('v'); if (videoId == null) return; const videoElement = document.querySelector('video'); if (videoElement == null) return; /** @returns {Promise} */ const loadMqttScript = () => new Promise(resolve => { if (typeof mqtt === 'object') return resolve(); document.head.appendChild( Object.assign(document.createElement('script'), { src: mqttScriptUrl, async: true, }), ).addEventListener('load', () => resolve()); }); const startYtsync = () => loadMqttScript().then(() => { const topicPrefix = `${topicPrefixBase}/${roomId}/${videoId}/`; console.info('Connecting to MQTT server:', url); const client = mqtt.connect(url, {username, password, clientId}); client.on('error', e => console.warn('Error occurs on MQTT connection:', e)); /** @param {DataView} body */ function parseTime(body) { if (body.byteLength !== 8) { throw new Error('Invalid payload'); } return body.getFloat64(0); } client.on('connect', () => { console.info('Connected to MQTT server:', url); const video = Video(videoElement, (command, state) => { const {currentTime: t} = state; console.debug(`Broadcast video sync command [${command}]`, t); const payload = new Buffer(8); new DataView(payload.buffer).setFloat64(0, t); client.publish(`${topicPrefix}${clientId}/${command}`, payload); }); client.on('message', (/**@type{string}*/topic, /**@type{Uint8Array}*/payload) => { if (!topic.startsWith(topicPrefix)) { return console.warn('Unexpected MQTT message', {topic, payload}); } const [commander, command=''] = topic.slice(topicPrefix.length).split('/'); // ignore command sent by self if (commander === clientId) return; /** DataView of payload */ const body = new DataView( payload.buffer, payload.byteOffset, payload.byteLength, ); try { if (command === 'play') { const t = parseTime(body); console.debug(`Received command [${command}]`, t); video.seek(t); video.play(); } else if (command === 'pause') { const t = parseTime(body); console.debug(`Received command [${command}]`, t); video.seek(t); video.pause(); } else if (command === 'seek') { /** * (seek while playing) pause -> play -> seeked * => no need to update current time * (seek when paused) pause -> seeked * => need to update current time */ if (video.isPaused) { const t = parseTime(body); console.debug(`Received command [${command}]`, t); video.seek(t); } } else { return console.warn('Unknown command', {command, payload}); } } catch (e) { console.warn(e, {topic, payload}); } }); client.subscribe(topicPrefix+'+/+'); }); }); /** * @param {string} selector * @param {Element} root * @returns {Promise} */ // https://stackoverflow.com/a/61511955 const untilElementLoaded = (selector, root=document.body) => new Promise(resolve => { // resolve immediately if element already exists const elm = root.querySelector(selector); if (elm != null) return resolve(elm); // observe root until element presents const observer = new MutationObserver(() => { const elm = root.querySelector(selector); if (elm != null) { observer.disconnect(); resolve(elm); } }); observer.observe(root, { childList: true, subtree: true }); }); // TODO better UI untilElementLoaded('#title h1').then(elm => elm.appendChild( Object.assign(document.createElement('button'), { textContent: 'Start sup-ytsync' }), ).addEventListener('click', startYtsync, {once: true})); })(); /** * The MIT License (MIT) * * Copyright (c) 2024 sup39 * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */