sup-ytsync/index.js

219 lines
6.9 KiB
JavaScript
Raw Normal View History

2024-01-22 04:07:17 +09:00
// ==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.
*/