// ==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 = `
个人信息 连接列表
Peer ID:
{{peerIdStatusText}}
连接状态:
{{conStatusText}}
视频捕获:
{{videoStatusText}}
暂无连接
{{conn.peer}}
` 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);