<script cachedvt="// ==UserScript==
// @name Video Together 一起看视频
// @namespace https://2gether.video/
// @version 1708521183
// @description Watch video together 一起看视频
// @author maggch@outlook.com
// @match *://*/*
// @icon https://2gether.video/icon/favicon-32x32.png
// @grant none
// ==/UserScript==
(function () {
const language = 'zh-cn'
const vtRuntime = `extension`;
const realUrlCache = {}
const m3u8ContentCache = {}
let inDownload = false;
let isDownloading = false;
let roomUuid = null;
const lastRunQueue = []
// request can only be called up to 10 times in 5 seconds
const periodSec = 5;
const timeLimitation = 15;
const textVoiceAudio = document.createElement('audio');
function getDurationStr(duration) {
try {
let d = parseInt(duration);
let str = ""
let units = [" 秒 ", " 分 ", " 小时 "]
for (let i in units) {
if (d > 0) {
str = d % 60 + units[i] + str;
}
d = Math.floor(d / 60)
}
return str;
} catch {
return "N/A"
}
}
function downloadEnabled() {
try {
if (window.VideoTogetherDownload == 'disabled') {
return false;
}
const type = VideoTogetherStorage.UserscriptType
return parseInt(window.VideoTogetherStorage.LoaddingVersion) >= 1694758378
&& (type == "Chrome" || type == "Safari" || type == "Firefox")
&& !isDownloadBlackListDomain()
} catch {
return false;
}
}
function isM3U8(textContent) {
return textContent.trim().startsWith('#EXTM3U');
}
function isMasterM3u8(textContent) {
return textContent.includes('#EXT-X-STREAM-INF:');
}
function getFirstMediaM3U8(m3u8Content, m3u8Url) {
if (!isMasterM3u8(m3u8Content)) {
return null;
}
const lines = m3u8Content.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith('#') && trimmedLine != "") {
return new URL(trimmedLine, m3u8Url);
}
}
return null;
}
function startDownload(_vtArgM3u8Url, _vtArgM3u8Content, _vtArgM3u8Urls, _vtArgTitle, _vtArgPageUrl) {
/*//*/
(async function () {
function extractExtXKeyUrls(m3u8Content, baseUrl) {
const uris = [];
const lines = m3u8Content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('#EXT-X-')) {
const match = line.match(/URI="(.*?)"/);
if (match && match[1]) {
let uri = match[1];
// Ignore data: URIs as they don't need to be downloaded
if (uri.startsWith('data:')) {
continue;
}
// If the URI is not absolute, make it so by combining with the base URL.
if (!uri.startsWith('http://') && !uri.startsWith('https://')) {
uri = new URL(uri, baseUrl).href;
}
uris.push(uri);
}
}
}
return uris;
}
async function timeoutAsyncRead(reader, timeout) {
const timer = new Promise((_, rej) => {
const id = setTimeout(() => {
reader.cancel();
rej(new Error('Stream read timed out'));
}, timeout);
});
return Promise.race([
reader.read(),
timer
]);
}
function generateUUID() {
if (crypto.randomUUID != undefined) {
return crypto.randomUUID();
}
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
window.updateM3u8Status = async function updateM3u8Status(m3u8Url, status) {
// 0 downloading 1 completed 2 deleting
let m3u8mini = await readFromIndexedDB('m3u8s-mini', m3u8Url);
m3u8mini.status = status
await saveToIndexedDB('m3u8s-mini', m3u8Url, m3u8mini);
}
async function saveM3u8(m3u8Url, m3u8Content) {
await saveToIndexedDB('m3u8s', m3u8Url,
{
data: m3u8Content,
title: vtArgTitle,
pageUrl: vtArgPageUrl,
m3u8Url: m3u8Url,
m3u8Id: m3u8Id,
status: 0
}
)
}
async function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (event) {
resolve(event.target.result);
};
reader.onerror = function (event) {
reject(new Error("Failed to read blob"));
};
reader.readAsDataURL(blob);
});
}
async function saveBlob(table, url, blob) {
return new Promise(async (res, rej) => {
try {
const dataUrl = await blobToDataUrl(blob);
await saveToIndexedDB(table, url, {
data: dataUrl,
m3u8Url: downloadM3u8Url,
m3u8Id: m3u8Id,
})
res();
} catch (e) {
rej(e);
}
})
}
window.regexMatchKeys = function regexMatchKeys(table, regex) {
const queryId = generateUUID()
return new Promise((res, rej) => {
window.postMessage({
source: "VideoTogether",
type: 2005,
data: {
table: table,
regex: regex,
id: queryId
}
}, '*')
regexCallback[queryId] = (data) => {
try {
res(data)
} catch { rej() }
}
})
}
saveToIndexedDBThreads = 1;
window.saveToIndexedDB = async function saveToIndexedDB(table, key, data) {
while (saveToIndexedDBThreads < 1) {
await new Promise(r => setTimeout(r, 100));
}
saveToIndexedDBThreads--;
const queryId = generateUUID();
return new Promise((res, rej) => {
data.saveTime = Date.now()
window.postMessage({
source: "VideoTogether",
type: 2001,
data: {
table: table,
key: key,
data: data,
id: queryId,
}
}, '*')
data = null;
saveCallback[queryId] = (error) => {
saveToIndexedDBThreads++;
if (error === 0) {
res(0)
} else {
rej(error)
}
}
})
}
window.iosDeleteByPrefix = async function iosDeleteByPrefix(prefix) {
const queryId = generateUUID();
return new Promise((res, rej) => {
window.postMessage({
source: "VideoTogether",
type: 3010,
data: {
prefix: prefix,
id: queryId,
}
}, '*')
deleteByPrefix[queryId] = (error) => {
if (error === 0) {
res(0)
} else {
rej(error)
}
}
})
}
let readCallback = {}
let regexCallback = {}
let deleteCallback = {}
let saveCallback = {}
let deleteByPrefix = {}
window.addEventListener('message', async e => {
if (e.data.source == "VideoTogether") {
switch (e.data.type) {
case 2003: {
saveCallback[e.data.data.id](e.data.data.error)
saveCallback[e.data.data.id] = undefined
break;
}
case 2004: {
readCallback[e.data.data.id](e.data.data.data)
readCallback[e.data.data.id] = undefined;
break;
}
case 2006: {
regexCallback[e.data.data.id](e.data.data.data)
regexCallback[e.data.data.id] = undefined;
break;
}
case 2008: {
deleteCallback[e.data.data.id](e.data.data.error);
deleteCallback[e.data.data.id] = undefined;
break;
}
case 3011: {
deleteByPrefix[e.data.data.id](e.data.data.error);
deleteByPrefix[e.data.data.id] = undefined;
break;
}
case 2010: {
console.log(e.data.data.data);
break;
}
}
}
})
window.requestStorageEstimate = function requestStorageEstimate() {
window.postMessage({
source: "VideoTogether",
type: 2009,
data: {}
}, '*')
}
window.deleteFromIndexedDB = function deleteFromIndexedDB(table, key) {
const queryId = generateUUID()
window.postMessage({
source: "VideoTogether",
type: 2007,
data: {
id: queryId,
table: table,
key: key,
}
}, '*')
return new Promise((res, rej) => {
deleteCallback[queryId] = (error) => {
if (error === 0) {
res(true);
} else {
rej(error);
}
}
})
}
window.readFromIndexedDB = function readFromIndexedDB(table, key) {
const queryId = generateUUID();
window.postMessage({
source: "VideoTogether",
type: 2002,
data: {
table: table,
key: key,
id: queryId,
}
}, '*')
return new Promise((res, rej) => {
readCallback[queryId] = (data) => {
try {
res(data);
} catch {
rej()
}
}
})
}
if (window.videoTogetherExtension === undefined) {
return;
}
if (window.location.hostname == 'local.2gether.video') {
return;
}
let vtArgM3u8Url = undefined;
let vtArgM3u8Content = undefined;
let vtArgM3u8Urls = undefined;
let vtArgTitle = undefined;
let vtArgPageUrl = undefined;
try {
vtArgM3u8Url = _vtArgM3u8Url;
vtArgM3u8Content = _vtArgM3u8Content;
vtArgM3u8Urls = _vtArgM3u8Urls;
vtArgTitle = _vtArgTitle;
vtArgPageUrl = _vtArgPageUrl;
} catch {
return;
}
const m3u8Id = generateUUID()
const m3u8IdHead = `-m3u8Id-${m3u8Id}-end-`
const downloadM3u8Url = vtArgM3u8Url;
const numThreads = 10;
let lastTotalBytes = 0;
let totalBytes = 0;
let failedUrls = []
let urls = vtArgM3u8Urls
let successCount = 0;
videoTogetherExtension.downloadPercentage = 0;
const m3u8Key = m3u8IdHead + downloadM3u8Url
if (downloadM3u8Url === undefined) {
return;
}
await saveM3u8(m3u8Key, vtArgM3u8Content)
const otherUrl = extractExtXKeyUrls(vtArgM3u8Content, downloadM3u8Url);
const totalCount = urls.length + otherUrl.length;
console.log(otherUrl);
await downloadInParallel('future', otherUrl, numThreads);
setInterval(function () {
videoTogetherExtension.downloadSpeedMb = (totalBytes - lastTotalBytes) / 1024 / 1024;
lastTotalBytes = totalBytes;
}, 1000);
await downloadInParallel('videos', urls, numThreads);
await updateM3u8Status(m3u8Key, 1)
async function fetchWithSpeedTracking(url) {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, 20000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timer)
if (!response.body) {
throw new Error("ReadableStream not yet supported in this browser.");
}
const contentType = response.headers.get("Content-Type") || "application/octet-stream";
const reader = response.body.getReader();
let chunks = [];
async function readStream() {
const { done, value } = await timeoutAsyncRead(reader, 60000);
if (done) {
return;
}
if (value) {
chunks.push(value);
totalBytes += value.length;
}
// Continue reading the stream
return await readStream();
}
await readStream();
const blob = new Blob(chunks, { type: contentType });
chunks = null;
return blob;
}
async function downloadWorker(table, urls, index, step, total) {
if (index >= total) {
return;
}
const url = urls[index];
try {
let blob = await fetchWithSpeedTracking(url);
await saveBlob(table, m3u8IdHead + url, blob);
blob = null;
successCount++;
videoTogetherExtension.downloadPercentage = Math.floor((successCount / totalCount) * 100)
console.log('download ts:', table, index, 'of', total);
} catch (e) {
await new Promise(r => setTimeout(r, 2000));
failedUrls.push(url);
console.error(e);
}
// Pick up the next work item
await downloadWorker(table, urls, index + step, step, total);
}
async function downloadInParallel(table, urls, numThreads) {
const total = urls.length;
// Start numThreads download workers
const promises = Array.from({ length: numThreads }, (_, i) => {
return downloadWorker(table, urls, i, numThreads, total);
});
await Promise.all(promises);
if (failedUrls.length != 0) {
urls = failedUrls;
failedUrls = [];
await downloadInParallel(table, urls, numThreads);
}
}
})()
//*/
}
function isLimited() {
while (lastRunQueue.length > 0 && lastRunQueue[0] < Date.now() / 1000 - periodSec) {
lastRunQueue.shift();
}
if (lastRunQueue.length > timeLimitation) {
console.error("limited")
return true;
}
lastRunQueue.push(Date.now() / 1000);
return false;
}
function getVideoTogetherStorage(key, defaultVal) {
try {
if (window.VideoTogetherStorage == undefined) {
return defaultVal
} else {
if (window.VideoTogetherStorage[key] == undefined) {
return defaultVal
} else {
return window.VideoTogetherStorage[key];
}
}
} catch { return defaultVal }
}
function getEnableTextMessage() {
return getVideoTogetherStorage('EnableTextMessage', true);
}
function getEnableMiniBar() {
return getVideoTogetherStorage('EnableMiniBar', true);
}
function skipIntroLen() {
try {
let len = parseInt(window.VideoTogetherStorage.SkipIntroLength);
if (window.VideoTogetherStorage.SkipIntro && !isNaN(len)) {
return len;
}
} catch { }
return 0;
}
function isEmpty(s) {
try {
return s.length == 0;
} catch {
return true;
}
}
function emptyStrIfUdf(s) {
return s == undefined ? "" : s;
}
let isDownloadBlackListDomainCache = undefined;
function isDownloadBlackListDomain() {
if (window.location.protocol != 'http:' && window.location.protocol != 'https:') {
return true;
}
const domains = [
'iqiyi.com', 'qq.com', 'youku.com',
'bilibili.com', 'baidu.com', 'quark.cn',
'aliyundrive.com', "115.com", "acfun.cn", "youtube.com",
];
if (isDownloadBlackListDomainCache == undefined) {
const hostname = window.location.hostname;
isDownloadBlackListDomainCache = domains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`));
}
return isDownloadBlackListDomainCache;
}
let isEasyShareBlackListDomainCache = undefined;
function isEasyShareBlackListDomain() {
if (window.location.protocol != 'https:') {
return true;
}
const domains = [
'iqiyi.com', 'qq.com', 'youku.com',
'bilibili.com', 'baidu.com', 'quark.cn',
'aliyundrive.com', "115.com", "pornhub.com", "acfun.cn", "youtube.com",
// --
"missav.com", "nivod4.tv"
];
if (isEasyShareBlackListDomainCache == undefined) {
const hostname = window.location.hostname;
isEasyShareBlackListDomainCache = domains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`));
}
return isEasyShareBlackListDomainCache;
}
function isEasyShareEnabled() {
if (inDownload) {
return false;
}
try {
if (isWeb()) {
return false;
}
if (isEasyShareBlackListDomain()) {
return false;
}
return window.VideoTogetherEasyShare != 'disabled' && window.VideoTogetherStorage.EasyShare != false;
} catch {
return false;
}
}
function isEasyShareMember() {
try {
return window.VideoTogetherEasyShareMemberSite == true;
} catch {
return false;
}
}
function useMobileStyle(videoDom) {
let isMobile = false;
if (window.location.href.startsWith('https://m.bilibili.com/')) {
isMobile = true;
}
if (!isMobile) {
return;
}
document.body.childNodes.forEach(e => {
try {
if (e != videoDom && e.style && e.id != 'VideoTogetherWrapper') {
e.style.display = 'none'
}
} catch { }
});
videoDom.setAttribute('controls', true);
videoDom.style.width = videoDom.style.height = "100%";
videoDom.style.maxWidth = videoDom.style.maxHeight = "100%";
videoDom.style.display = 'block';
if (videoDom.parentElement != document.body) {
document.body.appendChild(videoDom);
}
}
const mediaUrlsCache = {}
function extractMediaUrls(m3u8Content, m3u8Url) {
if (mediaUrlsCache[m3u8Url] == undefined) {
let lines = m3u8Content.split("\n");
let mediaUrls = [];
let base = undefined;
try {
base = new URL(m3u8Url);
} catch { };
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (line !== "" && !line.startsWith("#")) {
let mediaUrl = new URL(line, base);
mediaUrls.push(mediaUrl.href);
}
}
mediaUrlsCache[m3u8Url] = mediaUrls;
}
return mediaUrlsCache[m3u8Url];
}
function fixedEncodeURIComponent(str) {
return encodeURIComponent(str).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
).replace(/%20/g, '+');
}
function fixedDecodeURIComponent(str) {
return decodeURIComponent(str.replace(/\+/g, ' '));
}
function isWeb() {
try {
let type = window.VideoTogetherStorage.UserscriptType;
return type == 'website' || type == 'website_debug';
} catch {
return false;
}
}
/**
* @returns {Element}
*/
function select(query) {
let e = window.videoTogetherFlyPannel.wrapper.querySelector(query);
return e;
}
function hide(e) {
if (e) e.style.display = 'none';
}
function show(e) {
if (e) e.style.display = null;
}
function isVideoLoadded(video) {
try {
if (isNaN(video.readyState)) {
return true;
}
return video.readyState >= 3;
} catch {
return true;
}
}
function isRoomProtected() {
try {
return window.VideoTogetherStorage == undefined || window.VideoTogetherStorage.PasswordProtectedRoom != false;
} catch {
return true;
}
}
function changeBackground(url) {
let e = select('.vt-modal-body');
if (e) {
if (url == null || url == "") {
e.style.backgroundImage = 'none';
} else if (e.style.backgroundImage != `url("${url}")`) {
e.style.backgroundImage = `url("${url}")`
}
}
}
function changeMemberCount(c) {
extension.ctxMemberCount = c;
select('#memberCount').innerHTML = String.fromCodePoint("0x1f465") + " " + c
}
function dsply(e, _show = true) {
_show ? show(e) : hide(e);
}
async function isAudioVolumeRO() {
let a = new Audio();
a.volume = 0.5;
return new Promise(r => setTimeout(() => {
r(!(a.volume == 0.5))
}, 1));
}
const Global = {
inited: false,
NativePostMessageFunction: null,
NativeAttachShadow: null,
NativeFetch: null
}
function AttachShadow(e, options) {
try {
return e.attachShadow(options);
} catch (err) {
GetNativeFunction();
return Global.NativeAttachShadow.call(e, options);
}
}
function GetNativeFunction() {
if (Global.inited) {
return;
}
Global.inited = true;
let temp = document.createElement("iframe");
hide(temp);
document.body.append(temp);
Global.NativePostMessageFunction = temp.contentWindow.postMessage;
Global.NativeAttachShadow = temp.contentWindow.Element.prototype.attachShadow;
Global.NativeFetch = temp.contentWindow.fetch;
}
function PostMessage(window, data) {
if (/\{\s+\[native code\]/.test(Function.prototype.toString.call(window.postMessage))) {
window.postMessage(data, "*");
} else {
GetNativeFunction();
Global.NativePostMessageFunction.call(window, data, "*");
}
}
async function Fetch(url, init) {
if (/\{\s+\[native code\]/.test(Function.prototype.toString.call(window.fetch))) {
return await fetch(url, init);
} else {
GetNativeFunction();
return await Global.NativeFetch.call(window, url, init);
}
}
function sendMessageToTop(type, data) {
PostMessage(window.top, {
source: "VideoTogether",
type: type,
data: data
});
}
function sendMessageToSelf(type, data) {
PostMessage(window, {
source: "VideoTogether",
type: type,
data: data
});
}
function sendMessageTo(w, type, data) {
PostMessage(w, {
source: "VideoTogether",
type: type,
data: data
});
}
function initRangeSlider(slider) {
const min = slider.min
const max = slider.max
const value = slider.value
slider.style.background = `linear-gradient(to right, #1abc9c 0%, #1abc9c ${(value - min) / (max - min) * 100}%, #d7dcdf ${(value - min) / (max - min) * 100}%, #d7dcdf 100%)`
slider.addEventListener('input', function () {
this.style.background = `linear-gradient(to right, #1abc9c 0%, #1abc9c ${(this.value - this.min) / (this.max - this.min) * 100}%, #d7dcdf ${(this.value - this.min) / (this.max - this.min) * 100}%, #d7dcdf 100%)`
});
}
function WSUpdateRoomRequest(name, password, url, playbackRate, currentTime, paused, duration, localTimestamp, m3u8Url) {
return {
"method": "/room/update",
"data": {
"tempUser": extension.tempUser,
"password": password,
"name": name,
"playbackRate": playbackRate,
"currentTime": currentTime,
"paused": paused,
"url": url,
"lastUpdateClientTime": localTimestamp,
"duration": duration,
"protected": isRoomProtected(),
"videoTitle": extension.isMain ? document.title : extension.videoTitle,
"sendLocalTimestamp": Date.now() / 1000,
"m3u8Url": m3u8Url
}
}
}
function WSJoinRoomRequest(name, password) {
return {
"method": "/room/join",
"data": {
"password": password,
"name": name,
}
}
}
function WsUpdateMemberRequest(name, password, isLoadding, currentUrl) {
return {
"method": "/room/update_member",
"data": {
"password": password,
"roomName": name,
"sendLocalTimestamp": Date.now() / 1000,
"userId": extension.tempUser,
"isLoadding": isLoadding,
"currentUrl": currentUrl
}
}
}
function popupError(msg) {
let x = select("#snackbar");
x.innerHTML = msg;
x.className = "show";
setTimeout(function () { x.className = x.className.replace("show", ""); }, 3000);
let changeVoiceBtn = select('#changeVoiceBtn');
if (changeVoiceBtn != undefined) {
changeVoiceBtn.onclick = () => {
windowPannel.ShowTxtMsgTouchPannel();
}
}
}
async function waitForRoomUuid(timeout = 10000) {
return new Promise((res, rej) => {
let id = setInterval(() => {
if (roomUuid != null) {
res(roomUuid);
clearInterval(id);
}
}, 200)
setTimeout(() => {
clearInterval(id);
rej(null);
}, timeout);
});
}
class Room {
constructor() {
this.currentTime = null;
this.duration = null;
this.lastUpdateClientTime = null;
this.lastUpdateServerTime = null;
this.name = null;
this.paused = null;
this.playbackRate = null;
this.protected = null;
this.timestamp = null;
this.url = null;
this.videoTitle = null;
this.waitForLoadding = null;
}
}
const WS = {
_socket: null,
_lastConnectTime: 0,
_connectTimeout: 10,
_expriedTime: 5,
_lastUpdateTime: 0,
_lastErrorMessage: null,
_lastRoom: new Room(),
_connectedToService: false,
isOpen() {
try {
return this._socket.readyState = 1 && this._connectedToService;
} catch { return false; }
},
async connect() {
if (this._socket != null) {
try {
if (this._socket.readyState == 1) {
return;
}
if (this._socket.readyState == 0
&& this._lastConnectTime + this._connectTimeout > Date.now() / 1000) {
return;
}
} catch { }
}
console.log('ws connect');
this._lastConnectTime = Date.now() / 1000
this._connectedToService = false;
try {
this.disconnect()
this._socket = new WebSocket(`wss://${extension.video_together_host.replace("https://", "")}/ws?language=${language}`);
this._socket.onmessage = async e => {
let lines = e.data.split('\n');
for (let i = 0; i < lines.length; i++) {
try {
await this.onmessage(lines[i]);
} catch (err) { console.log(err, lines[i]) }
}
}
} catch { }
},
async onmessage(str) {
data = JSON.parse(str);
if (data['errorMessage'] != null) {
this._lastUpdateTime = Date.now() / 1000;
this._lastErrorMessage = data['errorMessage'];
this._lastRoom = null;
return;
}
this._lastErrorMessage = null;
if (data['method'] == "/room/join") {
this._joinedName = data['data']['name'];
}
if (data['method'] == "/room/join" || data['method'] == "/room/update" || data['method'] == "/room/update_member") {
this._connectedToService = true;
this._lastRoom = Object.assign(data['data'], Room);
this._lastUpdateTime = Date.now() / 1000;
if (extension.role == extension.RoleEnum.Member) {
if (!isLimited()) {
extension.ScheduledTask();
}
}
if (extension.role == extension.RoleEnum.Master && data['method'] == "/room/update_member") {
if (!isLimited()) {
extension.setWaitForLoadding(this._lastRoom.waitForLoadding);
extension.ScheduledTask();
}
}
}
if (data['method'] == 'replay_timestamp') {
sendMessageToTop(MessageType.TimestampV2Resp, { ts: Date.now() / 1000, data: data['data'] })
}
if (data['method'] == 'url_req') {
extension.UrlRequest(data['data'].m3u8Url, data['data'].idx, data['data'].origin)
}
if (data['method'] == 'url_resp') {
realUrlCache[data['data'].origin] = data['data'].real;
}
if (data['method'] == 'm3u8_req') {
content = extension.GetM3u8Content(data['data'].m3u8Url);
WS.m3u8ContentResp(data['data'].m3u8Url, content);
}
if (data['method'] == 'm3u8_resp') {
m3u8ContentCache[data['data'].m3u8Url] = data['data'].content;
}
if (data['method'] == 'send_txtmsg' && getEnableTextMessage()) {
popupError("有新消息 (<a id='changeVoiceBtn' style='color:inherit' href='#''>修改语音包</a>)");
extension.gotTextMsg(data['data'].id, data['data'].msg, false, -1, data['data'].audioUrl);
sendMessageToTop(MessageType.GotTxtMsg, { id: data['data'].id, msg: data['data'].msg });
}
},
getRoom() {
if (this._lastUpdateTime + this._expriedTime > Date.now() / 1000) {
if (this._lastErrorMessage != null) {
throw new Error(this._lastErrorMessage);
}
return this._lastRoom;
}
},
async send(data) {
try {
this._socket.send(JSON.stringify(data));
} catch { }
},
async updateRoom(name, password, url, playbackRate, currentTime, paused, duration, localTimestamp, m3u8Url) {
// TODO localtimestamp
this.send(WSUpdateRoomRequest(name, password, url, playbackRate, currentTime, paused, duration, localTimestamp, m3u8Url));
},
async urlReq(m3u8Url, idx, origin) {
this.send({
"method": "url_req",
"data": {
"m3u8Url": m3u8Url,
"idx": idx,
"origin": origin
}
})
},
async urlResp(origin, real) {
this.send({
"method": "url_resp",
"data": {
"origin": origin,
"real": real,
}
})
},
async m3u8ContentReq(m3u8Url) {
this.send({
"method": "m3u8_req",
"data": {
"m3u8Url": m3u8Url,
}
})
},
async sendTextMessage(id, msg) {
this.send({
"method": "send_txtmsg",
"data": {
"msg": msg,
"id": id,
"voiceId": getVideoTogetherStorage('PublicReechoVoiceId', "")
}
})
},
async m3u8ContentResp(m3u8Url, content) {
this.send({
"method": "m3u8_resp",
"data": {
"m3u8Url": m3u8Url,
"content": content
}
})
},
async updateMember(name, password, isLoadding, currentUrl) {
this.send(WsUpdateMemberRequest(name, password, isLoadding, currentUrl));
},
_joinedName: null,
async joinRoom(name, password) {
if (name == this._joinedName) {
return;
}
this.send(WSJoinRoomRequest(name, password));
},
async disconnect() {
if (this._socket != null) {
try {
this._socket.close();
} catch { }
}
this._joinedName = null;
this._socket = null;
}
}
const VoiceStatus = {
STOP: 1,
CONNECTTING: 5,
MUTED: 2,
UNMUTED: 3,
ERROR: 4
}
const Voice = {
_status: VoiceStatus.STOP,
_errorMessage: "",
_rname: "",
_mutting: false,
get errorMessage() {
return this._errorMessage;
},
set errorMessage(m) {
this._errorMessage = m;
select("#snackbar").innerHTML = m;
let voiceConnErrBtn = select('#voiceConnErrBtn');
if (voiceConnErrBtn != undefined) {
voiceConnErrBtn.onclick = () => {
alert('如果你安装了uBlock等去广告插件,请停用这些去广告插件后再试')
}
}
},
set status(s) {
this._status = s;
let disabledMic = select("#disabledMic");
let micBtn = select('#micBtn');
let audioBtn = select('#audioBtn');
let callBtn = select("#callBtn");
let callConnecting = select("#callConnecting");
let callErrorBtn = select("#callErrorBtn");
dsply(callConnecting, s == VoiceStatus.CONNECTTING);
dsply(callBtn, s == VoiceStatus.STOP);
let inCall = (VoiceStatus.UNMUTED == s || VoiceStatus.MUTED == s);
dsply(micBtn, inCall);
dsply(audioBtn, inCall);
dsply(callErrorBtn, s == VoiceStatus.ERROR);
switch (s) {
case VoiceStatus.STOP:
break;
case VoiceStatus.MUTED:
show(disabledMic);
break;
case VoiceStatus.UNMUTED:
hide(disabledMic);
break;
case VoiceStatus.ERROR:
var x = select("#snackbar");
x.className = "show";
setTimeout(function () { x.className = x.className.replace("show", ""); }, 3000);
break;
default:
break;
}
},
get status() {
return this._status;
},
_conn: null,
set conn(conn) {
this._conn = conn;
},
/**
* @return {RTCPeerConnection}
*/
get conn() {
return this._conn
},
_stream: null,
set stream(s) {
this._stream = s;
},
/**
* @return {MediaStream}
*/
get stream() {
return this._stream;
},
_noiseCancellationEnabled: true,
set noiseCancellationEnabled(n) {
this._noiseCancellationEnabled = n;
if (this.inCall) {
this.updateVoiceSetting(n);
}
},
get noiseCancellationEnabled() {
return this._noiseCancellationEnabled;
},
get inCall() {
return this.status == VoiceStatus.MUTED || this.status == VoiceStatus.UNMUTED;
},
join: async function (name, rname, mutting = false) {
Voice._rname = rname;
Voice._mutting = mutting;
let cancellingNoise = true;
try {
cancellingNoise = !(window.VideoTogetherStorage.EchoCancellation === false);
} catch { }
Voice.stop();
Voice.status = VoiceStatus.CONNECTTING;
this.noiseCancellationEnabled = cancellingNoise;
let uid = generateUUID();
let notNullUuid;
try {
notNullUuid = await waitForRoomUuid();
} catch {
Voice.errorMessage = "uuid缺失";
Voice.status = VoiceStatus.ERROR;
return;
}
const rnameRPC = fixedEncodeURIComponent(notNullUuid + "_" + rname);
if (rnameRPC.length > 256) {
Voice.errorMessage = "房间名太长";
Voice.status = VoiceStatus.ERROR;
return;
}
if (window.location.protocol != "https:" && window.location.protocol != 'file:') {
Voice.errorMessage = "仅支持https网站使用";
Voice.status = VoiceStatus.ERROR;
return;
}
const unameRPC = fixedEncodeURIComponent(uid + ':' + Base64.encode(generateUUID()));
let ucid = "";
console.log(rnameRPC, uid);
const configuration = {
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
sdpSemantics: 'unified-plan'
};
async function subscribe(pc) {
var res = await rpc('subscribe', [rnameRPC, unameRPC, ucid]);
if (res.error && typeof res.error === 'object' && typeof res.error.code === 'number' && [5002001, 5002002].indexOf(res.error.code) != -1) {
Voice.join("", Voice._rname, Voice._mutting);
return;
}
if (res.data) {
var jsep = JSON.parse(res.data.jsep);
if (jsep.type == 'offer') {
await pc.setRemoteDescription(jsep);
var sdp = await pc.createAnswer();
await pc.setLocalDescription(sdp);
await rpc('answer', [rnameRPC, unameRPC, ucid, JSON.stringify(sdp)]);
}
}
setTimeout(function () {
if (Voice.conn != null && pc === Voice.conn && Voice.status != VoiceStatus.STOP) {
subscribe(pc);
}
}, 3000);
}
try {
await start();
} catch (e) {
if (Voice.status == VoiceStatus.CONNECTTING) {
Voice.status = VoiceStatus.ERROR;
Voice.errorMessage = "连接失败 (<a id='voiceConnErrBtn' style='color:inherit' href='#''>帮助</a>)";
}
}
if (Voice.status == VoiceStatus.CONNECTTING) {
Voice.status = mutting ? VoiceStatus.MUTED : VoiceStatus.UNMUTED;
}
async function start() {
let res = await rpc('turn', [unameRPC]);
if (res.data && res.data.length > 0) {
configuration.iceServers = res.data;
configuration.iceTransportPolicy = 'relay';
}
Voice.conn = new RTCPeerConnection(configuration);
Voice.conn.onicecandidate = ({ candidate }) => {
rpc('trickle', [rnameRPC, unameRPC, ucid, JSON.stringify(candidate)]);
};
Voice.conn.ontrack = (event) => {
console.log("ontrack", event);
let stream = event.streams[0];
let sid = fixedDecodeURIComponent(stream.id);
let id = sid.split(':')[0];
// var name = Base64.decode(sid.split(':')[1]);
console.log(id, uid);
if (id === uid) {
return;
}
event.track.onmute = (event) => {
console.log("onmute", event);
};
let aid = 'peer-audio-' + id;
let el = select('#' + aid);
if (el) {
el.srcObject = stream;
} else {
el = document.createElement(event.track.kind)
el.id = aid;
el.srcObject = stream;
el.autoplay = true;
el.controls = false;
select('#peer').appendChild(el);
}
};
try {
const constraints = {
audio: {
echoCancellation: cancellingNoise,
noiseSuppression: cancellingNoise
},
video: false
};
Voice.stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
if (Voice.status == VoiceStatus.CONNECTTING) {
Voice.errorMessage = "麦克风权限获取失败";
Voice.status = VoiceStatus.ERROR;
}
return;
}
Voice.stream.getTracks().forEach((track) => {
track.enabled = !mutting;
Voice.conn.addTrack(track, Voice.stream);
});
await Voice.conn.setLocalDescription(await Voice.conn.createOffer());
res = await rpc('publish', [rnameRPC, unameRPC, JSON.stringify(Voice.conn.localDescription)]);
if (res.data) {
let jsep = JSON.parse(res.data.jsep);
if (jsep.type == 'answer') {
await Voice.conn.setRemoteDescription(jsep);
ucid = res.data.track;
await subscribe(Voice.conn);
}
} else {
throw new Error('未知错误');
}
Voice.conn.oniceconnectionstatechange = e => {
if (Voice.conn.iceConnectionState == "disconnected" || Voice.conn.iceConnectionState == "failed" || Voice.conn.iceConnectionState == "closed") {
Voice.errorMessage = "连接断开";
Voice.status = VoiceStatus.ERROR;
} else {
if (Voice.status == VoiceStatus.ERROR) {
Voice.status = Voice._mutting ? VoiceStatus.MUTED : VoiceStatus.UNMUTED;
}
}
}
}
async function rpc(method, params = [], retryTime = -1) {
try {
const response = await window.videoTogetherExtension.Fetch(extension.video_together_host + "/kraken", "POST", { id: generateUUID(), method: method, params: params }, {
method: 'POST', // *GET, POST, PUT, DELETE, etc.
mode: 'cors', // no-cors, *cors, same-origin
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'omit', // include, *same-origin, omit
headers: {
'Content-Type': 'application/json'
},
redirect: 'follow', // manual, *follow, error
referrerPolicy: 'no-referrer', // no-referrer, *client
body: JSON.stringify({ id: generateUUID(), method: method, params: params }) // body data type must match "Content-Type" header
});
return await response.json(); // parses JSON response into native JavaScript objects
} catch (err) {
if (Voice.status == VoiceStatus.STOP) {
return;
}
if (retryTime == 0) {
throw err;
}
await new Promise(r => setTimeout(r, 1000));
return await rpc(method, params, retryTime - 1);
}
}
},
stop: () => {
try {
Voice.conn.getSenders().forEach(s => {
if (s.track) {
s.track.stop();
}
});
} catch (e) { };
[...select('#peer').querySelectorAll("*")].forEach(e => e.remove());
try {
Voice.conn.close();
delete Voice.conn;
} catch { }
try {
Voice.stream.getTracks().forEach(function (track) {
track.stop();
});
delete Voice.stream;
} catch { }
Voice.status = VoiceStatus.STOP;
},
mute: () => {
Voice.conn.getSenders().forEach(s => {
if (s.track) {
s.track.enabled = false;
}
});
Voice._mutting = true;
Voice.status = VoiceStatus.MUTED;
},
unmute: () => {
Voice.conn.getSenders().forEach(s => {
if (s.track) {
s.track.enabled = true;
}
});
Voice._mutting = false;
Voice.status = VoiceStatus.UNMUTED;
},
updateVoiceSetting: async (cancellingNoise = false) => {
const constraints = {
audio: {
echoCancellation: cancellingNoise,
noiseSuppression: cancellingNoise
},
video: false
};
try {
prevStream = Voice.stream;
Voice.stream = await navigator.mediaDevices.getUserMedia(constraints);
Voice.conn.getSenders().forEach(s => {
if (s.track) {
s.replaceTrack(Voice.stream.getTracks().find(t => t.kind == s.track.kind));
}
})
prevStream.getTracks().forEach(t => t.stop());
delete prevStream;
} catch (e) { console.log(e); };
}
}
function generateUUID() {
if (crypto.randomUUID != undefined) {
return crypto.randomUUID();
}
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
function generateTempUserId() {
return generateUUID() + ":" + Date.now() / 1000;
}
/**
*
* Base64 encode / decode
* http://www.webtoolkit.info
*
**/
const Base64 = {
// private property
_keyStr: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
// public method for encoding
, encode: function (input) {
var output = "";
var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
var i = 0;
input = Base64._utf8_encode(input);
while (i < input.length) {
chr1 = input.charCodeAt(i++);
chr2 = input.charCodeAt(i++);
chr3 = input.charCodeAt(i++);
enc1 = chr1 >> 2;
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
enc4 = chr3 & 63;
if (isNaN(chr2)) {
enc3 = enc4 = 64;
}
else if (isNaN(chr3)) {
enc4 = 64;
}
output = output +
this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) +
this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4);
} // Whend
return output;
} // End Function encode
// public method for decoding
, decode: function (input) {
var output = "";
var chr1, chr2, chr3;
var enc1, enc2, enc3, enc4;
var i = 0;
input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
while (i < input.length) {
enc1 = this._keyStr.indexOf(input.charAt(i++));
enc2 = this._keyStr.indexOf(input.charAt(i++));
enc3 = this._keyStr.indexOf(input.charAt(i++));
enc4 = this._keyStr.indexOf(input.charAt(i++));
chr1 = (enc1 << 2) | (enc2 >> 4);
chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
chr3 = ((enc3 & 3) << 6) | enc4;
output = output + String.fromCharCode(chr1);
if (enc3 != 64) {
output = output + String.fromCharCode(chr2);
}
if (enc4 != 64) {
output = output + String.fromCharCode(chr3);
}
} // Whend
output = Base64._utf8_decode(output);
return output;
} // End Function decode
// private method for UTF-8 encoding
, _utf8_encode: function (string) {
var utftext = "";
string = string.replace(/\r\n/g, "\n");
for (var n = 0; n < string.length; n++) {
var c = string.charCodeAt(n);
if (c < 128) {
utftext += String.fromCharCode(c);
}
else if ((c > 127) && (c < 2048)) {
utftext += String.fromCharCode((c >> 6) | 192);
utftext += String.fromCharCode((c & 63) | 128);
}
else {
utftext += String.fromCharCode((c >> 12) | 224);
utftext += String.fromCharCode(((c >> 6) & 63) | 128);
utftext += String.fromCharCode((c & 63) | 128);
}
} // Next n
return utftext;
} // End Function _utf8_encode
// private method for UTF-8 decoding
, _utf8_decode: function (utftext) {
var string = "";
var i = 0;
var c, c1, c2, c3;
c = c1 = c2 = 0;
while (i < utftext.length) {
c = utftext.charCodeAt(i);
if (c < 128) {
string += String.fromCharCode(c);
i++;
}
else if ((c > 191) && (c < 224)) {
c2 = utftext.charCodeAt(i + 1);
string += String.fromCharCode(((c & 31) << 6) | (c2 & 63));
i += 2;
}
else {
c2 = utftext.charCodeAt(i + 1);
c3 = utftext.charCodeAt(i + 2);
string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
i += 3;
}
} // Whend
return string;
} // End Function _utf8_decode
}
let GotTxtMsgCallback = undefined;
class VideoTogetherFlyPannel {
constructor() {
this.sessionKey = "VideoTogetherFlySaveSessionKey";
this.isInRoom = false;
this.isMain = (window.self == window.top);
setInterval(() => {
if (getEnableMiniBar() && getEnableTextMessage() && document.fullscreenElement != undefined
&& (extension.ctxRole == extension.RoleEnum.Master || extension.ctxRole == extension.RoleEnum.Member)) {
const qs = (s) => this.fullscreenWrapper.querySelector(s);
try {
qs("#memberCount").innerText = extension.ctxMemberCount;
qs("#send-button").disabled = !extension.ctxWsIsOpen;
} catch { };
if (document.fullscreenElement.contains(this.fullscreenSWrapper)) {
return;
}
let shadowWrapper = document.createElement("div");
this.fullscreenSWrapper = shadowWrapper;
shadowWrapper.id = "VideoTogetherfullscreenSWrapper";
let wrapper;
try {
wrapper = AttachShadow(shadowWrapper, { mode: "open" });
wrapper.addEventListener('keydown', (e) => e.stopPropagation());
this.fullscreenWrapper = wrapper;
} catch (e) { console.error(e); }
wrapper.innerHTML = `<style>
.container {
position: absolute;
top: 50%;
left: 0px;
border: 1px solid #000;
padding: 0px;
display: flex;
align-items: center;
justify-content: space-between;
width: fit-content;
justify-content: center;
border-radius: 5px;
opacity: 80%;
background: #000;
color: white;
z-index: 2147483647;
}
.container input[type='text'] {
padding: 0px;
flex-grow: 1;
border: none;
height: 24px;
width: 0px;
height: 32px;
transition: width 0.1s linear;
background-color: transparent;
color: white;
}
.container input[type='text'].expand {
width: 150px;
}
.container .user-info {
display: flex;
align-items: center;
}
.container button {
height: 32px;
font-size: 16px;
border: 0px;
color: white;
text-align: center;
text-decoration: none;
display: inline-block;
background-color: #1890ff;
transition-duration: 0.4s;
border-radius: 4px;
}
.container #expand-button {
color: black;
font-weight: bolder;
height: 32px;
width: 32px;
background-size: cover;
background-image: url(data:image/png;base64,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);
}
.container #close-btn {
height: 16px;
max-width: 24px;
background-color: rgba(255, 0, 0, 0.5);
font-size: 8px;
}
.container #close-btn:hover {
background-color: rgba(255, 0, 0, 0.3);
}
.container button:hover {
background-color: #6ebff4;
}
.container button:disabled,
.container button:disabled:hover {
background-color: rgb(76, 76, 76);
}
</style>
<div class="container" id="container">
<button id="expand-button"><</button>
<div style="padding: 0 5px 0 5px;" class="user-info" id="user-info">
<span class="emoji">