// ==UserScript==
// @name VideoSync视频同步
// @description 允许用户之间建立连接实现视频同步播放(内嵌的网页视频无法同步)
// @namespace https://github.com/RiverYale/Userscripts/
// @homepage https://riveryale.github.io/Userscripts/
// @version 1.0
// @author RiverYale
// @include *
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAAEnQAABJ0Ad5mH3gAAAG6SURBVDhPjZM7z0FBEIZfx6EXEhGFQqHVEAoiCqJSqLT8B71GoxGFQiVRqiQ6lc4lKolLonOJEIkGcd3vzFiCRPI9zdmZs+/OzM4shKRerwu/3y9Wq5X0fLJcLkUwGBTNZlN6hFAg2e126Pf7SKVS0vNJMplEr9fDZrORHkCVX1itVng8HrTbbVSrVVgsFlwuFxiNRsxmMwwGA3i9XthsNqkAdNfrVUSjUY5KG0lwv99hMBhwu92g1+txPp+hqiqv6b9WHhqNBtR8Po/xeIxWqwWHw8ECEmslyfO1CDodFEVh8XQ6RSwWQ6FQAKUqyuXy4wb+SbFYFKFQSCh0GkUiFosFut0ur7/pdDrQOsFrTc9laNkoL3E2m0UkEsF+v2f7yXa7RTgcRi6XY5v2cylsSejkw+GA9XotPQ/m8zmOx+Mr8pMP8el0et32O3SJlCod8M6H2OfzwW63w+l0Ss8Dt9vNcxAIBKTngfLsJUE1U4rf0L1QyplMhu3nDChmsxmTyYSd/2U4HMJkMkFNp9NIJBJcp8vl4pp/Qe0ZjUYolUo8YTpqeq1WQ6VS4cdBKf6CAtDM0+OJx+P4A5YgKxpQJCX1AAAAAElFTkSuQmCC
// @run-at document-end
// @require https://unpkg.com/peerjs@1.4.5/dist/peerjs.min.js
// @require https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.min.js
// @require https://cdn.jsdelivr.net/npm/clipboard@2.0.10/dist/clipboard.min.js
// @compatible chrome
// @compatible edge
// @license MIT License
// @downloadURL none
// ==/UserScript==
/*================= 更新脚本前注意保存自己修改的内容! =================*/
/*================= 更新脚本前注意保存自己修改的内容! =================*/
const videoSyncCss = `
.sync-app-wrapper {
display: inline-block;
position: fixed;
right: 10px;
bottom: 20px;
user-select: none;
filter: drop-shadow(5px 5px 7px rgb(0 0 0 / 25%));
z-index: 4000;
transition: all .2s;
}
.app-off {
right: -400px;
}
.st0{fill:#FFFFFF;}
.st1{fill:#578C16;}
.st2{fill:#BFC9AD;}
.st3{fill:#F69D65;}
.st4{fill:#F7F6F2;}
.st5{opacity:0.33;}
@keyframes shake {
0% {transform: translate(22px, 6px) rotateZ(0deg);}
25% {transform: translate(22px, 6px) rotateZ(10deg);}
50% {transform: translate(22px, 6px) rotateZ(0deg);}
75% {transform: translate(22px, 6px) rotateZ(-10deg);}
100% {transform: translate(22px, 6px) rotateZ(0deg);}
}
.app-switch {
display: inline-block;
vertical-align: bottom;
width: 100px;
height: 100px;
transform: translate(22px, 6px);
cursor: pointer;
}
.app-switch:hover {
animation: shake .5s linear;
}
.main-panel {
display: inline-block;
width: 400px;
background-color: white;
}
.panel-header {
display: flex;
height: 40px;
}
.panel-header span {
flex: 1;
text-align: center;
cursor: pointer;
line-height: 40px;
font-size: 14px;
text-decoration: none;
color: rgba(0, 0, 0, 0.7);
}
.panel-header span:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.panel-header .header-active {
border-bottom: 2px rgb(63, 81, 181) solid;
color: rgb(63, 81, 181);
}
.information-panel {
display: flex;
flex-direction: column;
height: 160px;
justify-content: space-evenly;
padding: 0 20px 0 10px;
}
.info-row {
display: flex;
width: 100%;
height: 25px;
}
.info-title {
width: 75px;
font-weight:700;
text-align: right;
font-size: 14px;
line-height: 25px;
}
.info-value {
flex: 1;
font-size: 14px;
line-height: 25px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 10px 0 5px;
}
.info-btn {
font-size: 13px;
width: 50px;
height: 100%;
cursor: pointer;
color: white;
background-color: rgb(255, 64, 129);
border: none;
border-radius: 5px;
position: relative;
}
.info-btn:hover {
background-color: rgb(255, 64, 129, 0.8);
}
.info-btn[tip-text]:hover::after {
content: attr(tip-text);
word-break:keep-all;
white-space:nowrap;
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
border-radius: 6px;
padding: 5px 10px;
position: absolute;
z-index: 1;
bottom: 30px;
right: 0px;
font-size: 10px;
}
.info-btn-disabled {
pointer-events: none;
background-color: lightgray;
}
.connections-panel{
overflow-y: auto;
display: flex;
flex-direction: column;
height: 160px;
}
.connections-panel .conn-row:nth-child(odd){
background-color: rgba(0, 0, 0, 0.04);
}
.connections-panel .conn-row:nth-child(even){
background-color: rgba(0, 0, 0, 0.08);
}
.conn-row {
font-size: 16px;
height: 40px;
line-height: 40px;
text-align: center;
user-select: text;
}
.no-conn-row {
font-size: 16px;
height: 160px;
line-height: 160px;
text-align: center;
}
`
const videoSyncHtml = `
`
let videoSyncStyle = document.createElement('style');
videoSyncStyle.innerText = videoSyncCss;
let videoSyncDoc = new DOMParser().parseFromString(videoSyncHtml, 'text/html');
let videoSyncApp = videoSyncDoc.querySelector('#sync-app');
document.body.appendChild(videoSyncStyle);
document.body.appendChild(videoSyncApp);
/**
* vue.js部分主要代码
*/
var syncApp = new Vue({
el: '#sync-app',
data: {
off: true,
offsetY: 20,
tabIdx: 0,
peerIdStatus: 100,
connectStatus: 100,
videoStatus: 100,
conns: [],
},
computed: {
peerIdStatusText() {
switch(this.peerIdStatus) {
case 100:
return '未获取'
case 101:
return '获取失败'
case 200:
return '获取Peer ID中...'
case 300:
return peer.id
default:
return 'Error Status!'
}
},
conStatusText() {
switch(this.connectStatus) {
case 100:
return '未连接'
case 101:
return '连接失败'
case 102:
return '连接已关闭'
case 103:
return '对方已关闭连接'
case 104:
return '未获取Peer ID'
case 200:
return '连接中...'
case 300:
return '已连接(主机)'
case 301:
return '已连接(客户机)'
default:
return 'Error Status!'
}
},
videoStatusText() {
switch(this.videoStatus) {
case 100:
return '未捕获'
case 101:
return '未找到正在播放的视频'
case 300:
return '已捕获'
default:
return 'Error Status!'
}
}
},
methods: {
setTabIdx(idx) {
this.tabIdx = idx;
},
setPeerIdStatus(value) {
this.peerIdStatus = value;
},
setConnectStatus(value) {
this.connectStatus = value;
},
setVideoStatus(value) {
this.videoStatus = value;
},
setConns(value) {
this.conns = value;
},
addOffsetY(value) {
this.offsetY += value;
if(this.offsetY < 0) {
this.offsetY = 0;
} else if(this.offsetY > 1000) {
this.offsetY = 1000;
}
}
},
mounted() {
window.setPeerIdStatus = this.setPeerIdStatus;
window.setConnectStatus = this.setConnectStatus;
window.setVideoStatus = this.setVideoStatus;
window.setConns = this.setConns;
}
})
var appSwitch = document.getElementsByClassName('app-switch')[0];
var appSwitchDragStart = false;
var appSwitchDrag = false;
appSwitch.addEventListener('mousedown', function(e) {
appSwitchDragStart = true;
e.preventDefault();
})
document.addEventListener('mousemove', function(e) {
if(appSwitchDragStart) {
if (e.movementY != 0) {
appSwitchDrag = true;
}
syncApp.addOffsetY(-e.movementY);
}
})
document.addEventListener('mouseup', function(e) {
appSwitchDragStart = false;
appSwitchDrag = false;
e.preventDefault();
})
appSwitch.addEventListener('mouseup', function(e) {
if(appSwitchDrag) {
appSwitch.click()
}
})
/**
* peer.js部分主要代码
*/
var peer = null;
var conns = [];
var asServer = false;
var lastPeerId = null;
var syncVideo = null;
var sync = true;
new ClipboardJS('#copyBtn', {
text: function(trigger) {
document.getElementById('copyBtn').getAttributeNode('tip-text').value = "已复制";
setTimeout(function() {
document.getElementById('copyBtn').getAttributeNode('tip-text').value = "复制Peer ID";
}, 1000)
return peer.id;
}
});
function peerInit() {
setPeerIdStatus(200) // 获取Peer ID中...
// Create own peer object with connection to shared PeerJS server
peer = new Peer(null, {
debug: 2
});
peer.on('open', function (id) {
// Workaround for peer.reconnect deleting previous id
if (peer.id === null) {
console.log('Received null id from PeerServer');
peer.id = lastPeerId;
} else {
lastPeerId = peer.id;
}
console.log('Peer ID: ' + peer.id);
setPeerIdStatus(300) // 显示Peer ID
});
peer.on('connection', function(c) { readyAsServer(c); });
peer.on('disconnected', function () {
console.log('PeerServer connection lost. Please reconnect');
// Workaround for peer.reconnect deleting previous id
peer.id = lastPeerId;
peer._lastServerId = lastPeerId;
peer.reconnect();
});
peer.on('close', function() {
console.log('PeerServer connection destroyed');
conns = [];
setConns(conns);
});
peer.on('error', function (err) {
console.log('PeerServer error: ', err);
setPeerIdStatus(101) // 获取失败
});
};
function videoInit() {
let videos = document.querySelectorAll('video, bwp-video');
for(v of videos) {
if(!v.paused) {
syncVideo = v;
break;
}
}
// console.log(syncVideo);
sync = true;
if(!syncVideo) {
setVideoStatus(101) // 未找到正在播放的视频
return;
}
setVideoStatus(300) // 已捕获
syncVideo.addEventListener('play', function(event){
// console.log('play', sync, conns.length)
if(sync && conns.length > 0) {
sendData(conns, {
command: 'play',
currentTime: syncVideo.currentTime
});
}
});
syncVideo.addEventListener('pause', function(event){
// console.log('pause', sync, conns.length)
if(sync && conns.length > 0) {
sendData(conns, {
command: 'pause',
currentTime: syncVideo.currentTime
});
}
});
syncVideo.addEventListener('seeked', function(event){
// console.log('seeked', sync, conns.length)
if(sync && conns.length > 0) {
sendData(conns, {
command: 'seeked',
currentTime: syncVideo.currentTime
});
}
});
}
function peerConnect() {
if(!peer || peer.id === null) {
setConnectStatus(104) // 未获取Peer ID
return;
}
if(!asServer) {
let id = prompt('请输入对方ID');
if(id) {
readyAsClient(id);
}
}
}
function peerClose() {
for(let conn of conns) {
conn.close();
}
conns = [];
setConns(conns);
asServer = false;
setConnectStatus(102) // 连接已关闭
}
function sendData(_conns, data) {
if(!Array.isArray(_conns)) {
_conns = [_conns];
}
for(let conn of _conns) {
data.senderId = peer.id;
data.receiverId = conn.peer;
conn.send(data)
// console.log('Send data: ', data)
}
}
function handleData(type, data) {
// console.log('Receive data: ', data);
if(type == 'server') {
let _conns = []
for(let c of conns) {
if(c.peer != data.senderId) {
_conns.push(c);
}
}
sendData(_conns, data);
}
sync = false;
if(data.currentTime) {
syncVideo.currentTime = data.currentTime;
}
switch (data.command) {
case 'play':
syncVideo.play();
break;
case 'pause':
syncVideo.pause();
break;
case 'text':
alert(data.content);
break;
}
}
function readyAsServer(conn) {
conn.on('open', function() {
console.log(asServer, conns.length)
if(!asServer && conns.length > 0) {
sendData(conn, {
command: 'text',
content: 'Target has already connected to another server'
});
setTimeout(function() { conn.close(); }, 500);
return;
}
asServer = true;
conns.push(conn)
setConns(conns);
console.log("A client connected: ", conn.peer);
setConnectStatus(300) // 已连接(主机)
conn.on('data', function(data) {
handleData('server', data)
})
conn.on('close', function (data) {
console.log("Client closed", conn.peer);
conns = conns.filter(c => c.peer != conn.peer)
setConns(conns);
if(conns.length < 1){
asServer = false;
setConnectStatus(103) // 对方已关闭连接
}
});
conn.on('error', function (err) {
console.log("Connect error: ", err);
});
});
}
function readyAsClient(serverId) {
setConnectStatus(200) // 连接中...
var conn = peer.connect(serverId, {
reliable: true
});
conn.on('open', function () {
console.log("Connected to server: ", conn.peer);
setConnectStatus(301) // 已连接(客户机)
});
conn.on('data', function(data) {
handleData('client', data)
})
conn.on('close', function () {
console.log("Server closed");
conns = [];
setConns(conns);
setConnectStatus(103) // 对方已关闭连接
});
conn.on('error', function (err) {
console.log("Connect error: ", err);
setConnectStatus(101) // 连接失败
});
conns.push(conn);
console.log(conn)
setConns(conns);
}
var onKeyDown = function (e) {
sync = true;
}
var onMouseDown = function(e) {
sync = true;
}
document.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onMouseDown);
document.getElementById("peerInitBtn").addEventListener("click", peerInit);
document.getElementById("peerConnectBtn").addEventListener("click", peerConnect);
document.getElementById("peerCloseBtn").addEventListener("click", peerClose);
document.getElementById("videoInitBtn").addEventListener("click", videoInit);