虚拟摄像头、麦克风和屏幕共享(修复权限事件同步)
// ==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
// ==/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<type, Set<PermissionStatus>>
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 = '<strong style="color:#cba6f7">' + (location.hostname || 'unknown') + '</strong>';
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 = '<strong style="color:#cba6f7">' + location.hostname + '</strong> 请求屏幕共享';
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 = '<span style="font-size:28px">🖥️</span><span style="flex:1;text-align:left">真实屏幕</span>';
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 = '<span style="font-size:28px">🎬</span><span style="flex:1;text-align:left">虚拟屏幕</span>';
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 = '<span style="font-size:28px">❌</span><span style="flex:1;text-align:left">拒绝</span>';
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 = [
'<div class="vh"><div><h2>🎬 Virtual Media Studio</h2><p>虚拟摄像头 · 麦克风 · 屏幕共享</p></div><button class="vx" id="vms-x">×</button></div>',
'<div class="vb">',
'<div class="vtabs">',
'<button class="vtab on" data-t="cam"><span class="vi">📷</span><span>摄像头</span></button>',
'<button class="vtab" data-t="scr"><span class="vi">🖥️</span><span>屏幕</span></button>',
'<button class="vtab" data-t="pre"><span class="vi">👁️</span><span>预览</span></button>',
'<button class="vtab" data-t="set"><span class="vi">⚙️</span><span>设置</span></button>',
'</div>',
'<div class="vtp on" id="p-cam">',
'<div class="vib"><strong>💡 工作原理:</strong>有真实设备时由浏览器处理权限,无真实设备时脚本提供虚拟设备。</div>',
'<div class="vc"><div class="vct"><span>📹</span>视频源</div>',
'<div class="vr"><label class="vl">类型</label><select class="vs" id="c-vt">',
'<option value="test"' + (cfg.videoType === 'test' ? ' selected' : '') + '>🎨 测试画面</option>',
'<option value="local"' + (cfg.videoType === 'local' ? ' selected' : '') + '>📁 本地文件</option>',
'<option value="url"' + (cfg.videoType === 'url' ? ' selected' : '') + '>🔗 URL</option></select></div>',
'<div class="vr" id="r-pat" style="display:' + (cfg.videoType === 'test' ? ' block' : ' none') + '"><label class="vl">图案</label><select class="vs" id="c-pat">',
'<option value="colorBars"' + (cfg.testPattern === 'colorBars' ? ' selected' : '') + '>📊 彩条</option>',
'<option value="gradient"' + (cfg.testPattern === 'gradient' ? ' selected' : '') + '>🌈 渐变</option>',
'<option value="clock"' + (cfg.testPattern === 'clock' ? ' selected' : '') + '>🕐 时钟</option>',
'<option value="noise"' + (cfg.testPattern === 'noise' ? ' selected' : '') + '>📺 噪点</option></select></div>',
'<div class="vr"><label class="vl">镜像</label><label class="vsw"><input type="checkbox" id="c-mirror"' + (cfg.mirror ? ' checked' : '') + '><span class="vsk"></span></label></div>', // ✅ v3.2.1 镜像开关
'<div class="vr" id="r-vu" style="display:' + (cfg.videoType === 'url' ? ' block' : ' none') + '"><label class="vl">URL</label><input class="vinp" id="c-vu" placeholder="https://..." value="' + (cfg.videoUrl || '') + '"></div>',
'<div class="vr" id="r-vf" style="display:' + (cfg.videoType === 'local' ? ' block' : ' none') + '"><label class="vfl" for="c-vf" id="l-vf"><span class="fi">📁</span><span>选择视频</span></label><input type="file" class="vfi" id="c-vf" accept="video/*"></div>',
'</div>',
'<div class="vc"><div class="vct"><span>🎤</span>音频源</div>',
'<div class="vr"><label class="vl">类型</label><select class="vs" id="c-at">',
'<option value="test"' + (cfg.audioType === 'test' ? ' selected' : '') + '>🔊 440Hz</option>',
'<option value="silent"' + (cfg.audioType === 'silent' ? ' selected' : '') + '>🔇 静音</option>',
'<option value="url"' + (cfg.audioType === 'url' ? ' selected' : '') + '>🔗 URL</option></select></div>',
'<div class="vr" id="r-au" style="display:' + (cfg.audioType === 'url' ? ' block' : ' none') + '"><label class="vl">URL</label><input class="vinp" id="c-au" placeholder="https://..." value="' + (cfg.audioUrl || '') + '"></div>',
'</div></div>',
'<div class="vtp" id="p-scr">',
'<div class="vib"><strong>📱 屏幕共享:</strong>弹窗选择使用真实屏幕或虚拟内容。移动端可使用虚拟屏幕。</div>',
'<div class="vc"><div class="vct"><span>🖥️</span>虚拟屏幕源</div>',
'<div class="vr"><label class="vl">类型</label><select class="vs" id="c-st">',
'<option value="test"' + (cfg.screenType === 'test' ? ' selected' : '') + '>🎨 测试画面</option>',
'<option value="local"' + (cfg.screenType === 'local' ? ' selected' : '') + '>📁 本地文件</option>',
'<option value="url"' + (cfg.screenType === 'url' ? ' selected' : '') + '>🔗 URL</option></select></div>',
'<div class="vr" id="r-su" style="display:' + (cfg.screenType === 'url' ? ' block' : ' none') + '"><label class="vl">URL</label><input class="vinp" id="c-su" placeholder="https://..." value="' + (cfg.screenUrl || '') + '"></div>',
'<div class="vr" id="r-sf" style="display:' + (cfg.screenType === 'local' ? ' block' : ' none') + '"><label class="vfl" for="c-sf" id="l-sf"><span class="fi">📁</span><span>选择录像</span></label><input type="file" class="vfi" id="c-sf" accept="video/*"></div>',
'</div></div>',
'<div class="vtp" id="p-pre"><div class="vc"><div class="vct"><span>👁️</span>预览</div>',
'<div class="vpv"><canvas id="pv-cv" width="640" height="360"></canvas></div>',
'<button class="vbt vbs" id="b-pv">▶️ 开始预览</button></div></div>',
'<div class="vtp" id="p-set">',
'<div class="vc"><div class="vct"><span>🎛️</span>开关</div>',
'<div class="vsr"><div class="vsi"><div class="vst">总开关</div><div class="vsd">启用/禁用所有功能</div></div><label class="vsw"><input type="checkbox" id="c-en"' + (cfg.enabled ? ' checked' : '') + '><span class="vsk"></span></label></div>',
'<div class="vsr"><div class="vsi"><div class="vst">摄像头</div><div class="vsd">无真实摄像头时启用</div></div><label class="vsw"><input type="checkbox" id="c-cam"' + (cfg.enableCamera ? ' checked' : '') + '><span class="vsk"></span></label></div>',
'<div class="vsr"><div class="vsi"><div class="vst">麦克风</div><div class="vsd">无真实麦克风时启用</div></div><label class="vsw"><input type="checkbox" id="c-mic"' + (cfg.enableMic ? ' checked' : '') + '><span class="vsk"></span></label></div>',
'<div class="vsr"><div class="vsi"><div class="vst">屏幕共享</div><div class="vsd">提供虚拟屏幕选项</div></div><label class="vsw"><input type="checkbox" id="c-scr"' + (cfg.enableScreen ? ' checked' : '') + '><span class="vsk"></span></label></div>',
'</div>',
'<div class="vc"><div class="vct"><span>🔐</span>权限</div>',
'<button class="vbt vbd" id="b-rp">🗑️ 清除当前站点权限</button>',
'<button class="vbt vbs" id="b-ra">清除所有权限</button></div></div>',
'<div class="vsg">',
'<div class="vsi2 ' + (cO ? 'von' : 'voff') + '"><div class="ic">📷</div><div class="lb">摄像头</div><div class="vl2">' + (cO ? 'ON' : 'OFF') + '</div></div>',
'<div class="vsi2 ' + (mO ? 'von' : 'voff') + '"><div class="ic">🎤</div><div class="lb">麦克风</div><div class="vl2">' + (mO ? 'ON' : 'OFF') + '</div></div>',
'<div class="vsi2 ' + (sO ? 'von' : 'voff') + '"><div class="ic">🖥️</div><div class="lb">屏幕</div><div class="vl2">' + (sO ? 'ON' : 'OFF') + '</div></div>',
'</div>',
'<button class="vbt vbp" id="b-sv">💾 保存并刷新</button>',
'<button class="vbt vbs" id="b-sv2">保存(不刷新)</button>',
'<div class="vver">v3.2.1 • 修复权限同步 + 新增镜像功能</div>',
'</div>'
].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 = '<span class="fi">✅</span><span>' + e.target.files[0].name + '</span>'; }
};
$('c-sf').onchange = function (e) {
if (e.target.files[0]) { $('l-sf').classList.add('ok'); $('l-sf').innerHTML = '<span class="fi">✅</span><span>' + e.target.files[0].name + '</span>'; }
};
// ✅ 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);
})();