219 lines
6.9 KiB
JavaScript
219 lines
6.9 KiB
JavaScript
|
// ==UserScript==
|
||
|
// @name sup-ytsync
|
||
|
// @namespace http://tampermonkey.net/
|
||
|
// @version 0.1.0
|
||
|
// @description sup39
|
||
|
// @author sup39
|
||
|
// @match https://www.youtube.com/watch*
|
||
|
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
|
||
|
// @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;
|
||
|
},
|
||
|
};
|
||
|
}
|
||
|
|
||
|
// 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<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 {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+'+/+');
|
||
|
});
|
||
|
});
|
||
|
|
||
|
// Based on https://stackoverflow.com/a/61511955 by Yong Wang (2020)
|
||
|
/**
|
||
|
* @param {string} selector
|
||
|
* @returns {Promise<Element>}
|
||
|
*/
|
||
|
const untilElementLoaded = selector => new Promise(resolve => {
|
||
|
const elm = document.querySelector(selector);
|
||
|
if (elm != null) return elm;
|
||
|
|
||
|
const observer = new MutationObserver(() => {
|
||
|
const elm = document.querySelector(selector);
|
||
|
if (elm != null) {
|
||
|
observer.disconnect();
|
||
|
resolve(elm);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
observer.observe(document.body, {
|
||
|
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.
|
||
|
*/
|