Virtual Media Studio

虚拟摄像头、麦克风和屏幕共享(修复权限事件同步)

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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);
})();