// ==UserScript== // @name Virtual Media Studio // @namespace https://virtual.media/ // @version 3.2.2 // @description 虚拟摄像头、麦克风和屏幕共享(修复权限事件同步) // @author Assisstant // @match *://*/* // @run-at document-start // @grant GM_registerMenuCommand // @grant GM_addStyle // @license GPL-lv3-or-later // @icon https://obsproject.com/favicon.ico // @downloadURL https://update.greasyfork.icu/scripts/565780/Virtual%20Media%20Studio.user.js // @updateURL https://update.greasyfork.icu/scripts/565780/Virtual%20Media%20Studio.meta.js // ==/UserScript== (function () { 'use strict'; /* ============ 配置 ============ */ var CONFIG_KEY = 'vms_cfg_v3'; var PERM_KEY = 'vms_perms_v2'; var VCAM_ID = 'virtual-camera-vms'; var VMIC_ID = 'virtual-mic-vms'; var VGROUP = 'vms-group-' + Math.random().toString(36).substr(2, 8); var defaults = { videoType: 'test', videoUrl: '', audioType: 'test', audioUrl: '', screenType: 'test', screenUrl: '', enabled: true, enableCamera: true, enableMic: true, enableScreen: true, testPattern: 'colorBars', mirror: false }; function loadConfig() { try { var s = JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}'); var r = {}; for (var k in defaults) r[k] = s[k] !== undefined ? s[k] : defaults[k]; return r; } catch (e) { console.error('[VMS] 配置加载失败:', e); return JSON.parse(JSON.stringify(defaults)); } } function saveConfigFn(c) { try { localStorage.setItem(CONFIG_KEY, JSON.stringify(c)); console.log('[VMS] 配置已保存'); } catch (e) { console.error('[VMS] 配置保存失败:', e); } } var cfg = loadConfig(); /* ============ 权限存储(修复:同步 PermissionStatus)============ */ // ✅ 新增:跟踪所有创建的 PermissionStatus 实例 var activePermissionStatus = new Map(); // Map> function permGet(type) { try { var a = JSON.parse(localStorage.getItem(PERM_KEY) || '{}'); var h = location.hostname || 'unknown'; var result = (a[h] && a[h][type]) || 'prompt'; console.log('[VMS] 权限查询:', type, '=', result, 'host:', h); return result; } catch (e) { console.error('[VMS] 权限查询失败:', e); return 'prompt'; } } function permSet(type, state) { try { var a = JSON.parse(localStorage.getItem(PERM_KEY) || '{}'); var h = location.hostname || 'unknown'; if (!a[h]) a[h] = {}; a[h][type] = state; localStorage.setItem(PERM_KEY, JSON.stringify(a)); console.log('[VMS] 权限设置:', type, '=', state, 'host:', h); // ✅ 关键修复:同步更新所有 PermissionStatus 实例 var statuses = activePermissionStatus.get(type); if (statuses) { console.log('[VMS] 同步更新', statuses.size, '个 PermissionStatus'); statuses.forEach(function (status) { try { status.state = state; // 触发 change 事件 } catch (e) { console.warn('[VMS] 更新 PermissionStatus 失败:', e); } }); } } catch (e) { console.error('[VMS] 权限设置失败:', e); } } function permResetSite() { try { var a = JSON.parse(localStorage.getItem(PERM_KEY) || '{}'); var h = location.hostname; delete a[h]; localStorage.setItem(PERM_KEY, JSON.stringify(a)); console.log('[VMS] 清除站点权限:', h); } catch (e) { } } function permResetAll() { localStorage.removeItem(PERM_KEY); console.log('[VMS] 清除所有权限'); } /* ============ 保存原始 API ============ */ var origEnum = null, origGUM = null, origGDM = null; if (navigator.mediaDevices) { if (navigator.mediaDevices.enumerateDevices) origEnum = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices); if (navigator.mediaDevices.getUserMedia) origGUM = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); if (navigator.mediaDevices.getDisplayMedia) origGDM = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); } console.log('[VMS] 原始API:', { enumerateDevices: !!origEnum, getUserMedia: !!origGUM, getDisplayMedia: !!origGDM }); /* ============ ✅ 修复: permissions.query 双向同步 + 事件跟踪 ============ */ if (navigator.permissions && typeof navigator.permissions.query === 'function') { var origQuery = navigator.permissions.query.bind(navigator.permissions); var globalListeners = new Map(); function createPermissionStatus(name, state, type) { var target = { _name: name, _state: state, onchange: null, _listeners: new Map(), _type: type // ✅ 记录权限类型(camera/microphone) }; var handler = { get: function (obj, prop) { if (prop === 'name') return obj._name; if (prop === 'state') return obj._state; if (prop === 'addEventListener') { return function (event, handler) { if (event === 'change' && typeof handler === 'function') { var listeners = obj._listeners.get(event) || new Set(); listeners.add(handler); obj._listeners.set(event, listeners); var globalSet = globalListeners.get(obj._name) || new Set(); globalSet.add(handler); globalListeners.set(obj._name, globalSet); } }; } if (prop === 'removeEventListener') { return function (event, handler) { if (event === 'change' && typeof handler === 'function') { var listeners = obj._listeners.get(event); if (listeners) listeners.delete(handler); var globalSet = globalListeners.get(obj._name); if (globalSet) globalSet.delete(handler); } }; } if (prop === 'dispatchEvent') { return function (event) { if (event.type === 'change') { var listeners = obj._listeners.get('change'); if (listeners) { listeners.forEach(function (handler) { try { handler.call(obj, event); } catch (e) { console.error('[VMS] Event handler error:', e); } }); } var globalSet = globalListeners.get(obj._name); if (globalSet) { globalSet.forEach(function (handler) { try { handler.call(obj, event); } catch (e) { console.error('[VMS] Global event handler error:', e); } }); } return true; } return obj[prop]; }; } return obj[prop]; }, set: function (obj, prop, value) { if (prop === 'name') { console.warn('[VMS] PermissionStatus.name is read-only'); return false; } if (prop === 'onchange') { obj.onchange = value; if (typeof value === 'function') { obj._listeners.set('change', new Set([value])); } return true; } if (prop === 'state') { var oldState = obj._state; obj._state = value; if (oldState !== value) { queueMicrotask(function () { var event = new Event('change', { bubbles: false, cancelable: false }); Object.defineProperty(event, 'target', { value: obj, writable: false }); var listeners = obj._listeners.get('change'); if (listeners) { listeners.forEach(function (handler) { try { handler.call(obj, event); } catch (e) { console.error('[VMS] Event handler error:', e); } }); } var globalSet = globalListeners.get(obj._name); if (globalSet) { globalSet.forEach(function (handler) { try { handler.call(obj, event); } catch (e) { console.error('[VMS] Global event handler error:', e); } }); } }); } return true; } obj[prop] = value; return true; } }; return new Proxy(target, handler); } // ✅ 核心修复:双向同步 + 事件跟踪 Object.defineProperty(navigator.permissions, 'query', { value: function (permissionDesc) { console.log('[VMS] permissions.query 调用:', permissionDesc); if (permissionDesc && permissionDesc.name) { var name = permissionDesc.name; var nameLower = name.toLowerCase(); var type = null; var cameraNames = new Set(['camera', 'video', 'videoinput']); var micNames = new Set(['microphone', 'audio', 'audioinput']); if (cameraNames.has(name) || cameraNames.has(nameLower)) type = 'camera'; else if (micNames.has(name) || micNames.has(nameLower)) type = 'microphone'; if (type) { // ✅ 第一步:转发到原生 API return origQuery.apply(this, arguments).then(function (nativeStatus) { var nativeState = nativeStatus.state; console.log('[VMS] 原生权限状态:', type, '=', nativeState); var scriptState = permGet(type); console.log('[VMS] 脚本权限状态:', type, '=', scriptState); // ✅ 决策逻辑 var finalState; if (nativeState === 'denied') { finalState = 'denied'; if (scriptState !== 'denied') permSet(type, 'denied'); } else if (nativeState === 'granted') { finalState = 'granted'; if (scriptState !== 'granted') permSet(type, 'granted'); } else { finalState = scriptState; } console.log('[VMS] 最终权限状态:', type, '=', finalState); // ✅ 第二步:创建 PermissionStatus 并跟踪 var status = createPermissionStatus(name, finalState, type); // ✅ 关键:将实例添加到跟踪列表 if (!activePermissionStatus.has(type)) { activePermissionStatus.set(type, new Set()); } activePermissionStatus.get(type).add(status); console.log('[VMS] 跟踪 PermissionStatus:', type, '总数:', activePermissionStatus.get(type).size); // ✅ 第三步:监听原生状态变化并同步 try { nativeStatus.addEventListener('change', function (e) { console.log('[VMS] 原生权限变化:', type, e.target.state); if (e.target.state === 'denied') { permSet(type, 'denied'); } else if (e.target.state === 'granted') { permSet(type, 'granted'); } }); } catch (syncErr) { console.warn('[VMS] 无法监听原生权限变化:', syncErr); } return status; }).catch(function (queryErr) { // ✅ 原生查询失败(无设备)→ 降级使用脚本权限 console.warn('[VMS] 原生权限查询失败,使用脚本权限:', queryErr); var scriptState = permGet(type); var status = createPermissionStatus(name, scriptState, type); // ✅ 关键:将实例添加到跟踪列表 if (!activePermissionStatus.has(type)) { activePermissionStatus.set(type, new Set()); } activePermissionStatus.get(type).add(status); console.log('[VMS] 跟踪 PermissionStatus (降级):', type, '总数:', activePermissionStatus.get(type).size); return status; }); } } return origQuery.apply(this, arguments); }, writable: true, configurable: true, enumerable: true }); console.log('[VMS] permissions.query 劫持成功(✅ 双向同步 + ✅ 事件跟踪)'); } /* ============ 设备检测 ============ */ var realHasCamera = null, realHasMic = null; var detectPromise = null; function detectDevices() { if (detectPromise) return detectPromise; detectPromise = new Promise(function (resolve) { if (!origEnum) { realHasCamera = false; realHasMic = false; console.log('[VMS] 无enumerateDevices API'); resolve(); return; } origEnum().then(function (devs) { realHasCamera = false; realHasMic = false; console.log('[VMS] 设备列表:', devs.map(d => ({ kind: d.kind, label: d.label, deviceId: d.deviceId }))); for (var i = 0; i < devs.length; i++) { if (devs[i].kind === 'videoinput') realHasCamera = true; if (devs[i].kind === 'audioinput') realHasMic = true; } console.log('[VMS] 检测结果: 真实摄像头=' + realHasCamera + ' 真实麦克风=' + realHasMic + ' 原生录屏=' + !!origGDM); resolve(); }).catch(function (err) { console.error('[VMS] enumerateDevices 失败:', err); realHasCamera = false; realHasMic = false; resolve(); }); }); return detectPromise; } detectDevices(); /* ============ 弹窗系统 ============ */ var activeDialog = null; function removeDialog() { if (activeDialog) { try { if (activeDialog.parentNode) activeDialog.parentNode.removeChild(activeDialog); } catch (e) { console.warn('[VMS] removeDialog error:', e); } activeDialog = null; } } function createEl(tag, styles, text) { var el = document.createElement(tag); if (styles) el.style.cssText = styles; if (text) el.textContent = text; return el; } function waitBody() { return new Promise(function (resolve) { if (document.body) { resolve(); return; } var check = function () { if (document.body) resolve(); else requestAnimationFrame(check); }; check(); }); } function showPermDialog(type) { console.log('[VMS] 显示权限弹窗:', type); return new Promise(function (resolve, reject) { var saved = permGet(type); if (saved === 'granted') { resolve(); return; } if (saved === 'denied') { reject(new DOMException('Permission denied', 'NotAllowedError')); return; } var icons = { camera: '📷', microphone: '🎤' }; var titles = { camera: '使用摄像头', microphone: '使用麦克风' }; var descs = { camera: '此网站请求使用虚拟摄像头。\n您的真实摄像头不会被访问。', microphone: '此网站请求使用虚拟麦克风。\n您的真实麦克风不会被访问。' }; waitBody().then(function () { removeDialog(); var overlay = createEl('div', 'position:fixed;top:0;left:0;right:0;bottom:0;' + 'background:rgba(0,0,0,0.75);' + 'z-index:2147483647;' + 'display:flex;align-items:center;justify-content:center;' + 'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;' + 'pointer-events:auto;' ); var box = createEl('div', 'background:#1e1e2e;border-radius:24px;width:360px;max-width:90vw;' + 'padding:32px;text-align:center;color:#cdd6f4;' + 'box-shadow:0 25px 80px rgba(0,0,0,0.7);border:1px solid rgba(255,255,255,0.1);' ); box.appendChild(createEl('div', 'font-size:64px;margin-bottom:20px;', icons[type])); box.appendChild(createEl('span', 'display:inline-block;background:linear-gradient(135deg,#667eea,#764ba2);' + 'color:#fff;font-size:11px;padding:5px 14px;border-radius:20px;margin-bottom:16px;', '🎬 Virtual Media Studio' )); box.appendChild(createEl('div', 'font-size:20px;font-weight:700;color:#fff;margin-bottom:12px;', titles[type])); var site = createEl('div', 'font-size:15px;color:#a6adc8;margin-bottom:8px;'); site.innerHTML = '' + (location.hostname || 'unknown') + ''; box.appendChild(site); box.appendChild(createEl('div', 'font-size:13px;color:#6c7086;margin-bottom:28px;line-height:1.6;white-space:pre-line;', descs[type] )); var btns = createEl('div', 'display:flex;gap:12px;margin-bottom:16px;'); var denyBtn = createEl('button', 'flex:1;padding:14px;border:none;border-radius:14px;font-size:15px;font-weight:600;' + 'cursor:pointer;background:rgba(255,255,255,0.1);color:#a6adc8;font-family:inherit;' ); denyBtn.textContent = '拒绝'; var allowBtn = createEl('button', 'flex:1;padding:14px;border:none;border-radius:14px;font-size:15px;font-weight:600;' + 'cursor:pointer;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;font-family:inherit;' ); allowBtn.textContent = '允许'; btns.appendChild(denyBtn); btns.appendChild(allowBtn); box.appendChild(btns); var remLabel = createEl('label', 'display:flex;align-items:center;justify-content:center;gap:8px;font-size:13px;color:#6c7086;cursor:pointer;' ); var remCheck = document.createElement('input'); remCheck.type = 'checkbox'; remCheck.checked = true; remCheck.style.cssText = 'width:18px;height:18px;cursor:pointer;margin:0;'; remLabel.appendChild(remCheck); remLabel.appendChild(document.createTextNode('记住此网站的选择')); box.appendChild(remLabel); overlay.appendChild(box); activeDialog = overlay; document.body.appendChild(overlay); var settled = false; function onAllow(e) { e.preventDefault(); e.stopPropagation(); if (settled) return; settled = true; if (remCheck.checked) permSet(type, 'granted'); // ✅ 触发 PermissionStatus 更新 removeDialog(); resolve(); } function onDeny(e) { e.preventDefault(); e.stopPropagation(); if (settled) return; settled = true; if (remCheck.checked) permSet(type, 'denied'); // ✅ 触发 PermissionStatus 更新 removeDialog(); reject(new DOMException('Permission denied', 'NotAllowedError')); } allowBtn.addEventListener('click', onAllow, false); allowBtn.addEventListener('touchend', onAllow, false); denyBtn.addEventListener('click', onDeny, false); denyBtn.addEventListener('touchend', onDeny, false); overlay.addEventListener('click', function (e) { if (e.target === overlay) { e.preventDefault(); e.stopPropagation(); if (!settled) { settled = true; removeDialog(); reject(new DOMException('Permission denied', 'NotAllowedError')); } } }, false); }); }); } function showScreenDialog() { console.log('[VMS] 显示屏幕共享弹窗'); return new Promise(function (resolve, reject) { waitBody().then(function () { removeDialog(); var hasNative = !!origGDM; var overlay = createEl('div', 'position:fixed;top:0;left:0;right:0;bottom:0;' + 'background:rgba(0,0,0,0.75);' + 'z-index:2147483647;' + 'display:flex;align-items:center;justify-content:center;' + 'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;' + 'pointer-events:auto;' ); var box = createEl('div', 'background:#1e1e2e;border-radius:24px;width:380px;max-width:90vw;' + 'padding:32px;text-align:center;color:#cdd6f4;' + 'box-shadow:0 25px 80px rgba(0,0,0,0.7);border:1px solid rgba(255,255,255,0.1);' ); box.appendChild(createEl('div', 'font-size:64px;margin-bottom:20px;', '🖥️')); box.appendChild(createEl('span', 'display:inline-block;background:linear-gradient(135deg,#667eea,#764ba2);' + 'color:#fff;font-size:11px;padding:5px 14px;border-radius:20px;margin-bottom:16px;', '🎬 Virtual Media Studio' )); box.appendChild(createEl('div', 'font-size:20px;font-weight:700;color:#fff;margin-bottom:8px;', '选择共享内容')); var siteDv = createEl('div', 'font-size:14px;color:#a6adc8;margin-bottom:24px;'); siteDv.innerHTML = '' + location.hostname + ' 请求屏幕共享'; box.appendChild(siteDv); var btns = createEl('div', 'display:flex;flex-direction:column;gap:12px;'); if (hasNative) { var realBtn = createEl('button', 'padding:16px;border:none;border-radius:14px;font-size:16px;font-weight:600;' + 'cursor:pointer;background:rgba(255,255,255,0.1);color:#89b4fa;font-family:inherit;' + 'display:flex;align-items:center;justify-content:center;gap:12px;text-align:left;' + 'transition:all 0.2s;' ); realBtn.innerHTML = '🖥️真实屏幕'; realBtn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); removeDialog(); resolve('real'); }, false); realBtn.addEventListener('touchend', function (e) { e.preventDefault(); e.stopPropagation(); removeDialog(); resolve('real'); }, false); btns.appendChild(realBtn); } var virtualBtn = createEl('button', 'padding:16px;border:none;border-radius:14px;font-size:16px;font-weight:600;' + 'cursor:pointer;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;font-family:inherit;' + 'display:flex;align-items:center;justify-content:center;gap:12px;text-align:left;' + 'transition:all 0.2s;box-shadow:0 4px 15px rgba(102,126,234,0.4);' ); virtualBtn.innerHTML = '🎬虚拟屏幕'; virtualBtn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); removeDialog(); resolve('virtual'); }, false); virtualBtn.addEventListener('touchend', function (e) { e.preventDefault(); e.stopPropagation(); removeDialog(); resolve('virtual'); }, false); btns.appendChild(virtualBtn); var denyBtn = createEl('button', 'padding:16px;border:none;border-radius:14px;font-size:16px;font-weight:600;' + 'cursor:pointer;background:rgba(255,255,255,0.05);color:#a6adc8;font-family:inherit;' + 'display:flex;align-items:center;justify-content:center;gap:12px;text-align:left;' + 'transition:all 0.2s;border:1px solid rgba(255,255,255,0.1);' ); denyBtn.innerHTML = '拒绝'; denyBtn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); removeDialog(); reject(new DOMException('Permission denied', 'NotAllowedError')); }, false); denyBtn.addEventListener('touchend', function (e) { e.preventDefault(); e.stopPropagation(); removeDialog(); reject(new DOMException('Permission denied', 'NotAllowedError')); }, false); btns.appendChild(denyBtn); box.appendChild(btns); overlay.appendChild(box); activeDialog = overlay; document.body.appendChild(overlay); overlay.addEventListener('click', function (e) { if (e.target === overlay) { e.preventDefault(); e.stopPropagation(); removeDialog(); reject(new DOMException('Permission denied', 'NotAllowedError')); } }, false); }); }); } /* ============ IndexedDB(保持 v3.1.4 原始逻辑)============ */ var dbInst = null; function getDB() { return new Promise(function (res, rej) { if (dbInst) { res(dbInst); return; } var r = indexedDB.open('VMStudioDB', 1); r.onupgradeneeded = function (e) { if (!e.target.result.objectStoreNames.contains('files')) e.target.result.createObjectStore('files'); }; r.onsuccess = function () { dbInst = r.result; res(dbInst); }; r.onerror = function () { console.error('[VMS] DB打开失败:', r.error); rej(r.error); }; }); } function loadBlob(key) { console.log('[VMS] 加载Blob:', key); return getDB().then(function (db) { return new Promise(function (res) { var t = db.transaction('files', 'readonly'); var r = t.objectStore('files').get(key); r.onsuccess = function () { res(r.result || null); }; r.onerror = function () { res(null); }; }); }).catch(function (err) { console.error('[VMS] loadBlob错误:', err); return null; }); } function saveBlob(key, blob) { console.log('[VMS] 保存Blob:', key, blob ? blob.size : null); return getDB().then(function (db) { return new Promise(function (res, rej) { var t = db.transaction('files', 'readwrite'); t.objectStore('files').put(blob, key); t.oncomplete = function () { res(); }; t.onerror = function () { console.error('[VMS] Blob保存失败:', t.error); rej(t.error); }; }); }); } /* ============ 测试画布(✅ v3.2.1 支持镜像)============ */ function TestCanvas(w, h, mirror) { // ✅ 新增 mirror 参数 this.c = document.createElement('canvas'); this.c.width = w; this.c.height = h; this.x = this.c.getContext('2d'); this.f = 0; this.t0 = Date.now(); this.mirror = mirror; // ✅ 保存镜像状态 } TestCanvas.prototype.bars = function () { var x = this.x, w = this.c.width, h = this.c.height; var cc = ['#fff', '#ff0', '#0ff', '#0f0', '#f0f', '#f00', '#00f', '#000']; var bw = w / 8; if (this.mirror) { x.save(); x.scale(-1, 1); x.translate(-w, 0); } // ✅ 镜像变换 for (var i = 0; i < 8; i++) { x.fillStyle = cc[i]; x.fillRect(i * bw, 0, bw, h * 0.7); } for (var j = 0; j < 8; j++) { var g = Math.floor(255 * j / 7); x.fillStyle = 'rgb(' + g + ',' + g + ',' + g + ')'; x.fillRect(j * bw, h * 0.7, bw, h * 0.1); } x.fillStyle = '#111'; x.fillRect(0, h * 0.8, w, h * 0.2); x.fillStyle = '#0f0'; x.font = 'bold ' + Math.floor(h * 0.08) + 'px Consolas,monospace'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(new Date().toLocaleTimeString(), w / 2, h * 0.9); x.font = Math.floor(h * 0.03) + 'px Consolas,monospace'; x.textAlign = 'left'; x.fillStyle = '#ff0'; x.fillText('Frame: ' + this.f, 10, h * 0.85); x.textAlign = 'right'; x.fillText(((Date.now() - this.t0) / 1000).toFixed(1) + 's', w - 10, h * 0.85); if (this.mirror) x.restore(); // ✅ 恢复上下文 }; TestCanvas.prototype.grad = function () { var x = this.x, w = this.c.width, h = this.c.height, t = this.f * 0.02; var h1 = (this.f * 2) % 360, h2 = (h1 + 120) % 360; if (this.mirror) { x.save(); x.scale(-1, 1); x.translate(-w, 0); } // ✅ 镜像变换 var g = x.createLinearGradient(w / 2 + Math.cos(t) * w / 2, 0, w / 2 - Math.cos(t) * w / 2, h); g.addColorStop(0, 'hsl(' + h1 + ',80%,50%)'); g.addColorStop(1, 'hsl(' + h2 + ',80%,50%)'); x.fillStyle = g; x.fillRect(0, 0, w, h); for (var i = 0; i < 6; i++) { x.beginPath(); x.arc(w / 2 + Math.cos(t + i) * w * 0.3, h / 2 + Math.sin(t * 1.3 + i) * h * 0.3, 20 + Math.sin(t * 2 + i) * 15, 0, Math.PI * 2); x.fillStyle = 'rgba(255,255,255,0.5)'; x.fill(); } x.fillStyle = '#fff'; x.font = 'bold ' + Math.floor(h * 0.07) + 'px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.shadowColor = 'rgba(0,0,0,0.5)'; x.shadowBlur = 10; x.fillText('Virtual Camera', w / 2, h / 2 - 20); x.font = Math.floor(h * 0.04) + 'px Arial'; x.fillText(new Date().toLocaleTimeString(), w / 2, h / 2 + 25); x.shadowBlur = 0; if (this.mirror) x.restore(); // ✅ 恢复上下文 }; TestCanvas.prototype.clock = function () { var x = this.x, w = this.c.width, h = this.c.height; var cx = w / 2, cy = h / 2, r = Math.min(w, h) * 0.35; if (this.mirror) { x.save(); x.scale(-1, 1); x.translate(-w, 0); } // ✅ 镜像变换 x.fillStyle = '#1a1a2e'; x.fillRect(0, 0, w, h); x.beginPath(); x.arc(cx, cy, r, 0, Math.PI * 2); x.fillStyle = '#16213e'; x.fill(); x.strokeStyle = '#667eea'; x.lineWidth = 3; x.stroke(); for (var i = 0; i < 12; i++) { var a = (i * 30 - 90) * Math.PI / 180, l = i % 3 === 0 ? 0.15 : 0.08; x.beginPath(); x.moveTo(cx + Math.cos(a) * r * (1 - l), cy + Math.sin(a) * r * (1 - l)); x.lineTo(cx + Math.cos(a) * r * 0.95, cy + Math.sin(a) * r * 0.95); x.strokeStyle = '#fff'; x.lineWidth = i % 3 === 0 ? 3 : 1; x.stroke(); } var n = new Date(), hr = n.getHours() % 12, mn = n.getMinutes(), sc = n.getSeconds(), ms = n.getMilliseconds(); x.lineCap = 'round'; var ha = ((hr + mn / 60) * 30 - 90) * Math.PI / 180; x.beginPath(); x.moveTo(cx, cy); x.lineTo(cx + Math.cos(ha) * r * 0.5, cy + Math.sin(ha) * r * 0.5); x.strokeStyle = '#fff'; x.lineWidth = 5; x.stroke(); var ma = ((mn + sc / 60) * 6 - 90) * Math.PI / 180; x.beginPath(); x.moveTo(cx, cy); x.lineTo(cx + Math.cos(ma) * r * 0.7, cy + Math.sin(ma) * r * 0.7); x.strokeStyle = '#ccc'; x.lineWidth = 3; x.stroke(); var sa = ((sc + ms / 1000) * 6 - 90) * Math.PI / 180; x.beginPath(); x.moveTo(cx, cy); x.lineTo(cx + Math.cos(sa) * r * 0.85, cy + Math.sin(sa) * r * 0.85); x.strokeStyle = '#f64f59'; x.lineWidth = 2; x.stroke(); x.beginPath(); x.arc(cx, cy, 6, 0, Math.PI * 2); x.fillStyle = '#f64f59'; x.fill(); x.fillStyle = '#fff'; x.font = 'bold ' + Math.floor(h * 0.05) + 'px Consolas'; x.textAlign = 'center'; x.fillText(n.toLocaleTimeString(), cx, cy + r + 40); if (this.mirror) x.restore(); // ✅ 恢复上下文 }; TestCanvas.prototype.noise = function () { var x = this.x, w = this.c.width, h = this.c.height; if (this.mirror) { x.save(); x.scale(-1, 1); x.translate(-w, 0); } // ✅ 镜像变换 var d = x.createImageData(w, h), p = d.data; for (var i = 0; i < p.length; i += 4) { var v = Math.random() * 255 | 0; p[i] = p[i + 1] = p[i + 2] = v; p[i + 3] = 255; } x.putImageData(d, 0, 0); x.fillStyle = 'rgba(0,0,0,0.7)'; x.fillRect(w * 0.2, h * 0.4, w * 0.6, h * 0.2); x.fillStyle = '#0f0'; x.font = 'bold ' + Math.floor(h * 0.08) + 'px Consolas'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('NO SIGNAL', w / 2, h / 2); if (this.mirror) x.restore(); // ✅ 恢复上下文 }; TestCanvas.prototype.render = function (p) { this.f++; switch (p) { case 'gradient': this.grad(); break; case 'clock': this.clock(); break; case 'noise': this.noise(); break; default: this.bars(); } }; /* ============ 虚拟流(✅ v3.2.1 支持镜像)============ */ function makeVideoTrack(src, w, h, pat, deviceId) { console.log('[VMS] 创建视频轨道:', src, w, h, pat, 'deviceId:', deviceId, 'mirror:', cfg.mirror); var cv = document.createElement('canvas'); cv.width = w; cv.height = h; var cx = cv.getContext('2d'); var tc = new TestCanvas(w, h, cfg.mirror); // ✅ 传递镜像参数 var stop = false; function draw() { if (stop) return; // ✅ 全局镜像处理(仅虚拟设备) if (cfg.mirror) { cx.save(); cx.scale(-1, 1); cx.translate(-w, 0); } if (src === 'test') { tc.render(pat); cx.drawImage(tc.c, 0, 0); } else if (src instanceof HTMLVideoElement && src.readyState >= 2) { var vw = src.videoWidth || w, vh = src.videoHeight || h; var s = Math.min(w / vw, h / vh), sw = vw * s, sh = vh * s; cx.fillStyle = '#000'; cx.fillRect(0, 0, w, h); cx.drawImage(src, (w - sw) / 2, (h - sh) / 2, sw, sh); } else { cx.fillStyle = '#1a1a2e'; cx.fillRect(0, 0, w, h); cx.fillStyle = '#888'; cx.font = (h * 0.05) + 'px Arial'; cx.textAlign = 'center'; cx.textBaseline = 'middle'; cx.fillText('Loading...', w / 2, h / 2); } if (cfg.mirror) cx.restore(); // ✅ 恢复上下文 requestAnimationFrame(draw); } draw(); var st = cv.captureStream(30); var tk = st.getVideoTracks()[0]; if (tk) { try { Object.defineProperty(tk, 'label', { value: 'Integrated Camera', writable: false, configurable: true }); Object.defineProperty(tk, 'deviceId', { value: deviceId || VCAM_ID, writable: false, configurable: true }); Object.defineProperty(tk, 'enabled', { value: true, writable: true, configurable: true }); Object.defineProperty(tk, 'muted', { value: false, writable: true, configurable: true }); Object.defineProperty(tk, 'readyState', { value: 'live', writable: false, configurable: true }); Object.defineProperty(tk, 'contentHint', { value: 'detail', writable: true, configurable: true }); Object.defineProperty(tk, 'kind', { value: 'video', writable: false, configurable: true }); } catch (e) { console.warn('[VMS] 无法定义 track 属性:', e); tk.label = 'Integrated Camera'; tk.deviceId = deviceId || VCAM_ID; tk.enabled = true; tk.muted = false; } var os = tk.stop.bind(tk); tk.stop = function () { stop = true; os(); }; } console.log('[VMS] 视频轨道创建成功:', !!tk, 'mirror:', cfg.mirror); return tk; } function makeAudioTrack(type, deviceId) { console.log('[VMS] 创建音频轨道:', type, 'deviceId:', deviceId); var ac = new (window.AudioContext || window.webkitAudioContext)(); var d = ac.createMediaStreamDestination(); var o = ac.createOscillator(), g = ac.createGain(); if (type === 'test') { o.frequency.value = 440; g.gain.value = 0.03; } else { g.gain.value = 0; } o.connect(g); g.connect(d); o.start(); var track = d.stream.getAudioTracks()[0]; if (track) { try { Object.defineProperty(track, 'label', { value: 'Microphone Array', writable: false, configurable: true }); Object.defineProperty(track, 'deviceId', { value: deviceId || VMIC_ID, writable: false, configurable: true }); Object.defineProperty(track, 'enabled', { value: true, writable: true, configurable: true }); Object.defineProperty(track, 'muted', { value: false, writable: true, configurable: true }); Object.defineProperty(track, 'readyState', { value: 'live', writable: false, configurable: true }); Object.defineProperty(track, 'kind', { value: 'audio', writable: false, configurable: true }); } catch (e) { console.warn('[VMS] 无法定义 track 属性:', e); track.label = 'Microphone Array'; track.deviceId = deviceId || VMIC_ID; track.enabled = true; track.muted = false; } } console.log('[VMS] 音频轨道创建成功:', !!track); return track; } function makeVideoSource(type, url, dbKey) { console.log('[VMS] 创建视频源:', type, url, dbKey); if (type === 'url' && url) { var v = document.createElement('video'); v.src = url; v.loop = true; v.muted = true; v.crossOrigin = 'anonymous'; v.setAttribute('playsinline', ''); v.play().catch(function (err) { console.warn('[VMS] 视频播放失败:', err); }); return Promise.resolve(v); } if (type === 'local') { return loadBlob(dbKey).then(function (blob) { if (!blob) return 'test'; var v = document.createElement('video'); v.src = URL.createObjectURL(blob); v.loop = true; v.muted = true; v.setAttribute('playsinline', ''); v.play().catch(function (err) { console.warn('[VMS] 本地视频播放失败:', err); }); return v; }); } return Promise.resolve('test'); } function makeCameraStream(wantV, wantA, videoDeviceId, audioDeviceId) { console.log('[VMS] 创建摄像头流:', wantV, wantA, 'videoDeviceId:', videoDeviceId, 'audioDeviceId:', audioDeviceId); var tracks = []; var p = wantV ? makeVideoSource(cfg.videoType, cfg.videoUrl, 'camera_video') : Promise.resolve(null); return p.then(function (src) { if (wantV) tracks.push(makeVideoTrack(src, 1280, 720, cfg.testPattern, videoDeviceId)); if (wantA) tracks.push(makeAudioTrack(cfg.audioType, audioDeviceId)); var stream = new MediaStream(tracks); try { Object.defineProperty(stream, 'active', { value: true, writable: false, configurable: true }); } catch (e) { } console.log('[VMS] 摄像头流创建成功:', stream.getTracks().length, 'tracks'); return stream; }).catch(function (err) { console.error('[VMS] 摄像头流创建失败:', err); throw err; }); } function makeScreenStream(wantA) { console.log('[VMS] 创建屏幕流:', wantA); return makeVideoSource(cfg.screenType, cfg.screenUrl, 'screen_video').then(function (src) { var tracks = []; tracks.push(makeVideoTrack(src, 1920, 1080, cfg.testPattern)); if (wantA) tracks.push(makeAudioTrack('silent')); var stream = new MediaStream(tracks); try { Object.defineProperty(stream, 'active', { value: true, writable: false, configurable: true }); } catch (e) { } console.log('[VMS] 屏幕流创建成功:', stream.getTracks().length, 'tracks, active:', stream.active); return stream; }).catch(function (err) { console.error('[VMS] 屏幕流创建失败:', err); throw err; }); } /* ============ 工具函数 ============ */ function getDeviceId(constraint) { if (!constraint || typeof constraint !== 'object') return null; var id = constraint.deviceId; if (!id) return null; if (typeof id === 'string') return id; if (typeof id === 'object') return id.exact || id.ideal || null; return null; } /* ============ 创建虚拟设备信息 ============ */ function mkDevice(id, kind, label) { var realLabel = label; if (label === '🎥 Virtual Camera') realLabel = 'Integrated Camera'; else if (label === '🎤 Virtual Microphone') realLabel = 'Microphone Array'; var d = Object.create(MediaDeviceInfo.prototype, { deviceId: { get: function () { return id; }, enumerable: true, configurable: true }, kind: { get: function () { return kind; }, enumerable: true, configurable: true }, label: { get: function () { return realLabel; }, enumerable: true, configurable: true }, groupId: { get: function () { return VGROUP; }, enumerable: true, configurable: true } }); d.toJSON = function () { return { deviceId: id, kind: kind, label: realLabel, groupId: VGROUP }; }; return d; } /* ============ 劫持 API(保持 v3.1.4 共存逻辑)============ */ if (navigator.mediaDevices) { navigator.mediaDevices.enumerateDevices = function () { var en = origEnum || function () { return Promise.resolve([]); }; return en().then(function (devs) { if (!cfg.enabled) return devs; var vd = []; if (cfg.enableCamera) vd.push(mkDevice(VCAM_ID, 'videoinput', '🎥 Virtual Camera')); if (cfg.enableMic) vd.push(mkDevice(VMIC_ID, 'audioinput', '🎤 Virtual Microphone')); console.log('[VMS] enumerateDevices: 原始', devs.length, ' + 虚拟', vd.length, '=', devs.length + vd.length); return devs.concat(vd); }); }; } if (navigator.mediaDevices) { navigator.mediaDevices.getUserMedia = function (constraints) { console.log('[VMS] getUserMedia 调用:', JSON.stringify(constraints, null, 2)); if (!cfg.enabled || !constraints) { console.log('[VMS] 功能未启用或约束为空,直通'); if (origGUM) return origGUM(constraints); return Promise.reject(new DOMException('Not supported', 'NotSupportedError')); } var vc = constraints.video, ac = constraints.audio; var vid = getDeviceId(vc), aid = getDeviceId(ac); var wV = !!vc, wA = !!ac; var reqVV = vid === VCAM_ID; var reqVA = aid === VMIC_ID; return detectDevices().then(function () { console.log('[VMS] getUserMedia 分析:', { wV, wA, vid, aid, reqVV, reqVA, realCam: realHasCamera, realMic: realHasMic }); var useVV = false, useVA = false; var useVDeviceId = null, useADeviceId = null; // ✅ v3.1.4 原始共存逻辑(未修改) if (wV) { if (reqVV) { useVV = true; useVDeviceId = VCAM_ID; } else if (vid) { useVV = false; } else { useVV = !realHasCamera && cfg.enableCamera; if (useVV) useVDeviceId = VCAM_ID; } } if (wA) { if (reqVA) { useVA = true; useADeviceId = VMIC_ID; } else if (aid) { useVA = false; } else { useVA = !realHasMic && cfg.enableMic; if (useVA) useADeviceId = VMIC_ID; } } console.log('[VMS] 决策: useVV=' + useVV + ' useVA=' + useVA, 'useVDeviceId=' + useVDeviceId, 'useADeviceId=' + useADeviceId); if (!useVV && !useVA) { console.log('[VMS] 全部交给浏览器处理'); if (origGUM) return origGUM(constraints); return Promise.reject(new DOMException('No devices', 'NotFoundError')); } // ✅ 仅当需要虚拟设备且权限为 prompt 时弹窗 var perms = []; if (useVV && permGet('camera') === 'prompt') perms.push(showPermDialog('camera')); if (useVA && permGet('microphone') === 'prompt') perms.push(showPermDialog('microphone')); return Promise.all(perms).then(function () { console.log('[VMS] 权限已获取,开始创建流'); return makeCameraStream(useVV, useVA, useVDeviceId, useADeviceId); }); }); }; } if (navigator.mediaDevices) { navigator.mediaDevices.getDisplayMedia = function (constraints) { console.log('[VMS] getDisplayMedia 调用:', JSON.stringify(constraints, null, 2)); if (!cfg.enabled || !cfg.enableScreen) { console.log('[VMS] 屏幕共享未启用,直通'); if (origGDM) return origGDM(constraints); return Promise.reject(new DOMException('Not supported', 'NotSupportedError')); } return detectDevices().then(function () { console.log('[VMS] 显示屏幕共享选择弹窗'); return showScreenDialog(); }).then(function (choice) { console.log('[VMS] 屏幕选择结果:', choice); if (choice === 'real' && origGDM) { console.log('[VMS] 使用原生屏幕共享'); return origGDM(constraints); } var wa = constraints && constraints.audio; console.log('[VMS] 使用虚拟屏幕流'); return makeScreenStream(wa); }).catch(function (err) { console.error('[VMS] getDisplayMedia 失败:', err); throw err; }); }; } /* ============ UI 面板(✅ v3.2.1 新增镜像开关)============ */ function initUI() { GM_addStyle( '#vms-ov{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:2147483640;display:none}' + '#vms-pn{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:92%;max-width:540px;max-height:88vh;overflow:hidden;background:linear-gradient(180deg,#1e1e2e,#181825);z-index:2147483641;border-radius:24px;box-shadow:0 25px 80px rgba(0,0,0,.7);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;display:none;color:#cdd6f4;border:1px solid rgba(255,255,255,.1)}' + '#vms-pn *{box-sizing:border-box;margin:0;padding:0}' + '.vh{background:linear-gradient(135deg,#667eea,#764ba2);padding:24px 28px;display:flex;justify-content:space-between;align-items:center}' + '.vh h2{font-size:22px;font-weight:700;color:#fff;margin-bottom:6px}.vh p{font-size:14px;color:rgba(255,255,255,.85)}' + '.vx{background:rgba(255,255,255,.2);border:none;color:#fff;width:40px;height:40px;border-radius:50%;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center}' + '.vb{padding:24px 28px;overflow-y:auto;max-height:calc(88vh - 100px)}' + '.vtabs{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:24px;background:rgba(0,0,0,.3);padding:8px;border-radius:16px}' + '.vtab{padding:14px 8px;background:0;border:none;color:#6c7086;border-radius:12px;cursor:pointer;font-size:13px;font-weight:500;display:flex;flex-direction:column;align-items:center;gap:6px}' + '.vtab .vi{font-size:24px}.vtab:hover{color:#cdd6f4;background:rgba(255,255,255,.05)}' + '.vtab.on{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}' + '.vtp{display:none}.vtp.on{display:block}' + '.vc{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.06);border-radius:18px;padding:22px;margin-bottom:18px}' + '.vct{font-size:16px;font-weight:600;color:#cba6f7;margin-bottom:18px;display:flex;align-items:center;gap:12px}.vct span{font-size:22px}' + '.vr{margin-bottom:18px}.vr:last-child{margin-bottom:0}' + '.vl{display:block;font-size:14px;color:#a6adc8;margin-bottom:10px;font-weight:500}' + '.vs,.vinp{width:100%;padding:16px 18px;background:rgba(0,0,0,.4);border:2px solid rgba(255,255,255,.08);border-radius:14px;color:#fff;font-size:16px}' + '.vs:focus,.vinp:focus{outline:none;border-color:#667eea}.vs option{background:#1e1e2e}' + '.vfl{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px 20px;border:2px dashed rgba(255,255,255,.15);border-radius:14px;cursor:pointer;color:#6c7086;font-size:15px;gap:10px}' + '.vfl:hover{border-color:#667eea;color:#667eea}.vfl.ok{border-color:#a6e3a1;color:#a6e3a1;border-style:solid}' + '.vfl .fi{font-size:36px}.vfi{display:none}' + '.vsr{display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid rgba(255,255,255,.06)}' + '.vsr:last-child{border-bottom:none}.vsi{flex:1;padding-right:20px}' + '.vst{font-size:16px;color:#cdd6f4;font-weight:500;margin-bottom:4px}.vsd{font-size:13px;color:#6c7086}' + '.vsw{position:relative;width:56px;height:32px;flex-shrink:0}.vsw input{display:none}' + '.vsk{position:absolute;inset:0;background:rgba(255,255,255,.1);border-radius:32px;cursor:pointer;transition:.3s}' + '.vsk::before{content:"";position:absolute;width:26px;height:26px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.3s}' + '.vsw input:checked+.vsk{background:linear-gradient(135deg,#667eea,#764ba2)}' + '.vsw input:checked+.vsk::before{transform:translateX(24px)}' + '.vbt{width:100%;padding:18px;border:none;border-radius:16px;font-size:16px;font-weight:600;cursor:pointer;margin-top:12px}' + '.vbp{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}' + '.vbs{background:rgba(255,255,255,.05);color:#a6adc8;border:1px solid rgba(255,255,255,.1)}' + '.vbd{background:rgba(244,63,94,.2);color:#f43f5e}' + '.vpv{background:#000;border-radius:14px;aspect-ratio:16/9;overflow:hidden;margin-bottom:18px}' + '.vpv canvas{width:100%;height:100%;display:block}' + '.vsg{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:24px 0}' + '.vsi2{background:rgba(0,0,0,.3);border-radius:16px;padding:18px 14px;text-align:center}' + '.vsi2 .ic{font-size:32px;margin-bottom:10px}.vsi2 .lb{font-size:13px;color:#6c7086;margin-bottom:6px}' + '.vsi2 .vl2{font-size:15px;font-weight:600}.von .vl2{color:#a6e3a1}.voff .vl2{color:#f38ba8}' + '.vib{background:rgba(102,126,234,.1);border:1px solid rgba(102,126,234,.3);border-radius:12px;padding:14px;font-size:13px;color:#a6adc8;margin-bottom:18px}' + '.vib strong{color:#cba6f7}' + '.vver{text-align:center;font-size:12px;color:#6c7086;margin-top:16px}' + '@media(max-width:500px){#vms-pn{width:96%;max-height:92vh}.vb{padding:20px}.vh{padding:20px}.vtab{padding:12px 6px;font-size:11px}.vtab .vi{font-size:20px}}' ); var ov = document.createElement('div'); ov.id = 'vms-ov'; document.documentElement.appendChild(ov); var pn = document.createElement('div'); pn.id = 'vms-pn'; var cO = cfg.enabled && cfg.enableCamera, mO = cfg.enabled && cfg.enableMic, sO = cfg.enabled && cfg.enableScreen; pn.innerHTML = [ '

🎬 Virtual Media Studio

虚拟摄像头 · 麦克风 · 屏幕共享

', '
', '
', '', '', '', '', '
', '
', '
💡 工作原理:有真实设备时由浏览器处理权限,无真实设备时脚本提供虚拟设备。
', '
📹视频源
', '
', '
', '
', // ✅ v3.2.1 镜像开关 '
', '
', '
', '
🎤音频源
', '
', '
', '
', '
', '
📱 屏幕共享:弹窗选择使用真实屏幕或虚拟内容。移动端可使用虚拟屏幕。
', '
🖥️虚拟屏幕源
', '
', '
', '
', '
', '
👁️预览
', '
', '
', '
', '
🎛️开关
', '
总开关
启用/禁用所有功能
', '
摄像头
无真实摄像头时启用
', '
麦克风
无真实麦克风时启用
', '
屏幕共享
提供虚拟屏幕选项
', '
', '
🔐权限
', '', '
', '
', '
📷
摄像头
' + (cO ? 'ON' : 'OFF') + '
', '
🎤
麦克风
' + (mO ? 'ON' : 'OFF') + '
', '
🖥️
屏幕
' + (sO ? 'ON' : 'OFF') + '
', '
', '', '', '
v3.2.1 • 修复权限同步 + 新增镜像功能
', '
' ].join(''); document.documentElement.appendChild(pn); var $ = function (i) { return document.getElementById(i); }; function show() { ov.style.display = 'block'; pn.style.display = 'block'; } function hide() { ov.style.display = 'none'; pn.style.display = 'none'; } $('vms-x').onclick = hide; ov.onclick = function (e) { if (e.target === ov) hide(); }; var tabs = pn.querySelectorAll('.vtab'); for (var i = 0; i < tabs.length; i++) { (function (b) { b.onclick = function () { for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('on'); b.classList.add('on'); var ps = pn.querySelectorAll('.vtp'); for (var k = 0; k < ps.length; k++) ps[k].classList.remove('on'); $('p-' + b.getAttribute('data-t')).classList.add('on'); }; })(tabs[i]); } $('c-vt').onchange = function (e) { $('r-pat').style.display = e.target.value === 'test' ? 'block' : 'none'; $('r-vu').style.display = e.target.value === 'url' ? 'block' : 'none'; $('r-vf').style.display = e.target.value === 'local' ? 'block' : 'none'; }; $('c-at').onchange = function (e) { $('r-au').style.display = e.target.value === 'url' ? 'block' : 'none'; }; $('c-st').onchange = function (e) { $('r-su').style.display = e.target.value === 'url' ? 'block' : 'none'; $('r-sf').style.display = e.target.value === 'local' ? 'block' : 'none'; }; $('c-vf').onchange = function (e) { if (e.target.files[0]) { $('l-vf').classList.add('ok'); $('l-vf').innerHTML = '' + e.target.files[0].name + ''; } }; $('c-sf').onchange = function (e) { if (e.target.files[0]) { $('l-sf').classList.add('ok'); $('l-sf').innerHTML = '' + e.target.files[0].name + ''; } }; // ✅ v3.2.1 镜像开关事件 $('c-mirror').onchange = function () { cfg.mirror = this.checked; saveConfigFn(cfg); console.log('[VMS] 镜像开关:', cfg.mirror); }; var pvOn = false, pvId = null; $('b-pv').onclick = function () { if (pvOn) { pvOn = false; if (pvId) cancelAnimationFrame(pvId); $('b-pv').textContent = '▶️ 开始预览'; } else { pvOn = true; $('b-pv').textContent = '⏹️ 停止'; var tc = new TestCanvas(640, 360, cfg.mirror), p = $('c-pat').value, cv = $('pv-cv'), cx = cv.getContext('2d'); (function loop() { if (!pvOn) return; tc.render(p); cx.drawImage(tc.c, 0, 0); pvId = requestAnimationFrame(loop); })(); } }; $('b-rp').onclick = function () { if (confirm('清除当前站点权限?')) { permResetSite(); alert('已清除'); } }; $('b-ra').onclick = function () { if (confirm('清除所有权限?')) { permResetAll(); alert('已清除'); } }; function collect() { return { videoType: $('c-vt').value, videoUrl: $('c-vu').value, audioType: $('c-at').value, audioUrl: $('c-au').value, screenType: $('c-st').value, screenUrl: $('c-su').value, enabled: $('c-en').checked, enableCamera: $('c-cam').checked, enableMic: $('c-mic').checked, enableScreen: $('c-scr').checked, testPattern: $('c-pat').value, mirror: $('c-mirror').checked // ✅ v3.2.1 保存镜像配置 }; } function doSave(r) { var c = collect(); saveConfigFn(c); var ps = []; if ($('c-vf').files[0]) ps.push(saveBlob('camera_video', $('c-vf').files[0])); if ($('c-sf').files[0]) ps.push(saveBlob('screen_video', $('c-sf').files[0])); Promise.all(ps).then(function () { if (r) location.reload(); else { cfg = c; alert('已保存!刷新生效。'); } }).catch(function () { if (r) location.reload(); else alert('保存失败'); }); } $('b-sv').onclick = function () { doSave(true); }; $('b-sv2').onclick = function () { doSave(false); }; GM_registerMenuCommand('🎬 Virtual Media Studio', show); GM_registerMenuCommand('🔄 总开关', function () { cfg.enabled = !cfg.enabled; saveConfigFn(cfg); alert((cfg.enabled ? 'ON' : 'OFF') + ' 刷新生效'); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initUI); else setTimeout(initUI, 0); console.log('%c[VMS] v3.2.2 已加载(✅ 权限双向同步 + ✅ 镜像功能 + ✅ 设备共存)', 'color:#667eea;font-weight:bold;font-size:14px;'); console.log('[VMS] 配置:', cfg); })();