212 lines
6.8 KiB
JavaScript
212 lines
6.8 KiB
JavaScript
// ==UserScript==
|
|
// @name sup-ytsync
|
|
// @namespace https://forgejo.sup39.dev/sup39/sup-ytsync/
|
|
// @version 2024-02-03
|
|
// @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<string>} */
|
|
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;
|
|
},
|
|
};
|
|
}
|
|
|
|
const videoId = new URLSearchParams(window.location.search).get('v');
|
|
if (videoId == null) return;
|
|
|
|
const videoElement = document.querySelector('video');
|
|
if (videoElement == null) return;
|
|
|
|
/** @returns {Promise<void>} */
|
|
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 {Buffer} payload */
|
|
function parseTime(payload) {
|
|
if (payload.byteLength !== 8) {
|
|
throw new Error('Invalid payload');
|
|
}
|
|
return payload.readDoubleBE(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 Uint8Array(8);
|
|
new DataView(payload.buffer).setFloat64(0, t);
|
|
client.publish(
|
|
`${topicPrefix}${clientId}/${command}`,
|
|
// FIXME
|
|
/**@type{Buffer}*/(/**@type{unknown}*/(payload)),
|
|
);
|
|
});
|
|
|
|
client.on('message', (topic, 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;
|
|
|
|
try {
|
|
if (command === 'play') {
|
|
const t = parseTime(payload);
|
|
console.debug(`Received command [${command}]`, t);
|
|
video.seek(t);
|
|
video.play();
|
|
} else if (command === 'pause') {
|
|
const t = parseTime(payload);
|
|
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(payload);
|
|
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<Element>}
|
|
*/
|
|
// 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.
|
|
*/
|