Greasy Fork

Greasy Fork is available in English.

Virtual Media Studio

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

当前为 2026-02-10 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Virtual Media Studio
// @namespace    https://virtual.media/
// @version      3.0.0
// @description  虚拟摄像头、麦克风和屏幕共享
// @author       Assistant
// @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';

    // ==================== 配置 ====================
    const CONFIG_KEY = 'vms_config_v3';

    const defaultConfig = {
        videoType: 'test',
        videoUrl: '',
        audioType: 'test',
        audioUrl: '',
        screenType: 'test',
        screenUrl: '',
        enabled: true,
        testPattern: 'colorBars'
    };

    const getConfig = () => {
        try {
            return Object.assign({}, defaultConfig, JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}'));
        } catch (e) {
            return Object.assign({}, defaultConfig);
        }
    };

    const saveConfig = (cfg) => {
        localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
    };

    let config = getConfig();

    // ==================== 虚拟设备 ID ====================
    const VIRTUAL_CAM_ID = 'virtual-camera-vms-001';
    const VIRTUAL_MIC_ID = 'virtual-mic-vms-001';
    const VIRTUAL_GROUP = 'virtual-media-studio';

    // ==================== IndexedDB ====================
    const dbPromise = new Promise((resolve, reject) => {
        const req = indexedDB.open('VMStudioDB', 1);
        req.onupgradeneeded = (e) => {
            const db = e.target.result;
            if (!db.objectStoreNames.contains('files')) {
                db.createObjectStore('files');
            }
        };
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
    });

    const loadBlob = async (key) => {
        try {
            const db = await dbPromise;
            return new Promise((resolve) => {
                const tx = db.transaction('files', 'readonly');
                const req = tx.objectStore('files').get(key);
                req.onsuccess = () => resolve(req.result || null);
                req.onerror = () => resolve(null);
            });
        } catch (e) {
            return null;
        }
    };

    const saveBlob = async (key, blob) => {
        const db = await dbPromise;
        return new Promise((resolve, reject) => {
            const tx = db.transaction('files', 'readwrite');
            tx.objectStore('files').put(blob, key);
            tx.oncomplete = () => resolve();
            tx.onerror = () => reject(tx.error);
        });
    };

    // ==================== 测试画布 ====================
    class TestCanvas {
        constructor(w, h) {
            this.canvas = document.createElement('canvas');
            this.canvas.width = w;
            this.canvas.height = h;
            this.ctx = this.canvas.getContext('2d');
            this.frame = 0;
            this.startTime = Date.now();
        }

        drawColorBars() {
            const { ctx, canvas } = this;
            const w = canvas.width;
            const h = canvas.height;
            const colors = ['#ffffff', '#ffff00', '#00ffff', '#00ff00', '#ff00ff', '#ff0000', '#0000ff', '#000000'];
            const barW = w / colors.length;

            // 彩条
            colors.forEach((c, i) => {
                ctx.fillStyle = c;
                ctx.fillRect(i * barW, 0, barW, h * 0.7);
            });

            // 灰度条
            for (let i = 0; i < 8; i++) {
                const gray = Math.floor(255 * i / 7);
                ctx.fillStyle = `rgb(${gray},${gray},${gray})`;
                ctx.fillRect(i * barW, h * 0.7, barW, h * 0.1);
            }

            // 信息区
            ctx.fillStyle = '#111';
            ctx.fillRect(0, h * 0.8, w, h * 0.2);

            const now = new Date();
            ctx.fillStyle = '#0f0';
            ctx.font = `bold ${Math.floor(h * 0.08)}px Consolas, monospace`;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText(now.toLocaleTimeString(), w / 2, h * 0.9);

            // 帧数
            ctx.font = `${Math.floor(h * 0.03)}px Consolas, monospace`;
            ctx.textAlign = 'left';
            ctx.fillStyle = '#ff0';
            ctx.fillText(`Frame: ${this.frame}`, 10, h * 0.85);

            ctx.textAlign = 'right';
            const sec = ((Date.now() - this.startTime) / 1000).toFixed(1);
            ctx.fillText(`${sec}s`, w - 10, h * 0.85);
        }

        drawGradient() {
            const { ctx, canvas } = this;
            const w = canvas.width;
            const h = canvas.height;
            const t = this.frame * 0.02;

            // 动态渐变背景
            const hue1 = (this.frame * 2) % 360;
            const hue2 = (hue1 + 120) % 360;
            const grad = ctx.createLinearGradient(
                w / 2 + Math.cos(t) * w / 2, 0,
                w / 2 - Math.cos(t) * w / 2, h
            );
            grad.addColorStop(0, `hsl(${hue1}, 80%, 50%)`);
            grad.addColorStop(1, `hsl(${hue2}, 80%, 50%)`);
            ctx.fillStyle = grad;
            ctx.fillRect(0, 0, w, h);

            // 浮动圆
            for (let i = 0; i < 6; i++) {
                const x = w / 2 + Math.cos(t + i) * w * 0.3;
                const y = h / 2 + Math.sin(t * 1.3 + i) * h * 0.3;
                const r = 20 + Math.sin(t * 2 + i) * 15;
                ctx.beginPath();
                ctx.arc(x, y, r, 0, Math.PI * 2);
                ctx.fillStyle = 'rgba(255,255,255,0.5)';
                ctx.fill();
            }

            // 中央文字
            ctx.fillStyle = '#fff';
            ctx.font = `bold ${Math.floor(h * 0.07)}px Arial`;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.shadowColor = 'rgba(0,0,0,0.5)';
            ctx.shadowBlur = 10;
            ctx.fillText('Virtual Camera', w / 2, h / 2 - 20);
            ctx.font = `${Math.floor(h * 0.04)}px Arial`;
            ctx.fillText(new Date().toLocaleTimeString(), w / 2, h / 2 + 25);
            ctx.shadowBlur = 0;
        }

        drawClock() {
            const { ctx, canvas } = this;
            const w = canvas.width;
            const h = canvas.height;
            const cx = w / 2;
            const cy = h / 2;
            const r = Math.min(w, h) * 0.35;

            // 背景
            ctx.fillStyle = '#1a1a2e';
            ctx.fillRect(0, 0, w, h);

            // 表盘
            ctx.beginPath();
            ctx.arc(cx, cy, r, 0, Math.PI * 2);
            ctx.fillStyle = '#16213e';
            ctx.fill();
            ctx.strokeStyle = '#667eea';
            ctx.lineWidth = 3;
            ctx.stroke();

            // 刻度
            for (let i = 0; i < 12; i++) {
                const ang = (i * 30 - 90) * Math.PI / 180;
                const len = i % 3 === 0 ? 0.15 : 0.08;
                ctx.beginPath();
                ctx.moveTo(cx + Math.cos(ang) * r * (1 - len), cy + Math.sin(ang) * r * (1 - len));
                ctx.lineTo(cx + Math.cos(ang) * r * 0.95, cy + Math.sin(ang) * r * 0.95);
                ctx.strokeStyle = '#fff';
                ctx.lineWidth = i % 3 === 0 ? 3 : 1;
                ctx.stroke();
            }

            const now = new Date();
            const hr = now.getHours() % 12;
            const mn = now.getMinutes();
            const sc = now.getSeconds();
            const ms = now.getMilliseconds();

            // 时针
            const hAng = ((hr + mn / 60) * 30 - 90) * Math.PI / 180;
            ctx.beginPath();
            ctx.moveTo(cx, cy);
            ctx.lineTo(cx + Math.cos(hAng) * r * 0.5, cy + Math.sin(hAng) * r * 0.5);
            ctx.strokeStyle = '#fff';
            ctx.lineWidth = 5;
            ctx.lineCap = 'round';
            ctx.stroke();

            // 分针
            const mAng = ((mn + sc / 60) * 6 - 90) * Math.PI / 180;
            ctx.beginPath();
            ctx.moveTo(cx, cy);
            ctx.lineTo(cx + Math.cos(mAng) * r * 0.7, cy + Math.sin(mAng) * r * 0.7);
            ctx.strokeStyle = '#ccc';
            ctx.lineWidth = 3;
            ctx.stroke();

            // 秒针(平滑)
            const sAng = ((sc + ms / 1000) * 6 - 90) * Math.PI / 180;
            ctx.beginPath();
            ctx.moveTo(cx, cy);
            ctx.lineTo(cx + Math.cos(sAng) * r * 0.85, cy + Math.sin(sAng) * r * 0.85);
            ctx.strokeStyle = '#f64f59';
            ctx.lineWidth = 2;
            ctx.stroke();

            // 中心点
            ctx.beginPath();
            ctx.arc(cx, cy, 6, 0, Math.PI * 2);
            ctx.fillStyle = '#f64f59';
            ctx.fill();

            // 数字时间
            ctx.fillStyle = '#fff';
            ctx.font = `bold ${Math.floor(h * 0.05)}px Consolas`;
            ctx.textAlign = 'center';
            ctx.fillText(now.toLocaleTimeString(), cx, cy + r + 40);
        }

        drawNoise() {
            const { ctx, canvas } = this;
            const w = canvas.width;
            const h = canvas.height;
            const imgData = ctx.createImageData(w, h);
            const d = imgData.data;

            for (let i = 0; i < d.length; i += 4) {
                const v = Math.random() * 255 | 0;
                d[i] = d[i + 1] = d[i + 2] = v;
                d[i + 3] = 255;
            }
            ctx.putImageData(imgData, 0, 0);

            // 叠加文字
            ctx.fillStyle = 'rgba(0,0,0,0.7)';
            ctx.fillRect(w * 0.2, h * 0.4, w * 0.6, h * 0.2);
            ctx.fillStyle = '#0f0';
            ctx.font = `bold ${Math.floor(h * 0.08)}px Consolas`;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText('NO SIGNAL', w / 2, h / 2);
        }

        render(pattern) {
            this.frame++;
            switch (pattern) {
                case 'gradient': this.drawGradient(); break;
                case 'clock': this.drawClock(); break;
                case 'noise': this.drawNoise(); break;
                default: this.drawColorBars();
            }
            return this.canvas;
        }
    }

    // ==================== 虚拟流工厂 ====================
    class VirtualStream {
        static createVideoTrack(source, width, height, pattern) {
            const canvas = document.createElement('canvas');
            canvas.width = width;
            canvas.height = height;
            const ctx = canvas.getContext('2d');
            const testCanvas = new TestCanvas(width, height);
            let stopped = false;

            const draw = () => {
                if (stopped) return;

                if (source === 'test') {
                    testCanvas.render(pattern);
                    ctx.drawImage(testCanvas.canvas, 0, 0);
                } else if (source instanceof HTMLVideoElement && source.readyState >= 2) {
                    // 视频源
                    const vw = source.videoWidth;
                    const vh = source.videoHeight;
                    const scale = Math.min(width / vw, height / vh);
                    const sw = vw * scale;
                    const sh = vh * scale;
                    ctx.fillStyle = '#000';
                    ctx.fillRect(0, 0, width, height);
                    ctx.drawImage(source, (width - sw) / 2, (height - sh) / 2, sw, sh);
                } else {
                    // 加载中
                    ctx.fillStyle = '#1a1a2e';
                    ctx.fillRect(0, 0, width, height);
                    ctx.fillStyle = '#888';
                    ctx.font = `${height * 0.05}px Arial`;
                    ctx.textAlign = 'center';
                    ctx.textBaseline = 'middle';
                    ctx.fillText('Loading...', width / 2, height / 2);
                }

                requestAnimationFrame(draw);
            };

            draw();

            const stream = canvas.captureStream(30);
            const track = stream.getVideoTracks()[0];

            if (track) {
                const origStop = track.stop.bind(track);
                track.stop = function() {
                    stopped = true;
                    origStop();
                };
            }

            return track;
        }

        static createAudioTrack(type) {
            const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
            const dest = audioCtx.createMediaStreamDestination();

            if (type === 'test') {
                // 440Hz 测试音
                const osc = audioCtx.createOscillator();
                const gain = audioCtx.createGain();
                osc.frequency.value = 440;
                gain.gain.value = 0.03; // 低音量
                osc.connect(gain);
                gain.connect(dest);
                osc.start();
            } else {
                // 静音
                const osc = audioCtx.createOscillator();
                const gain = audioCtx.createGain();
                gain.gain.value = 0;
                osc.connect(gain);
                gain.connect(dest);
                osc.start();
            }

            return dest.stream.getAudioTracks()[0];
        }

        static async createStream(options) {
            const tracks = [];

            if (options.video) {
                let source = 'test';
                const w = 1280, h = 720;

                if (config.videoType === 'url' && config.videoUrl) {
                    const video = document.createElement('video');
                    video.src = config.videoUrl;
                    video.loop = true;
                    video.muted = true;
                    video.crossOrigin = 'anonymous';
                    video.play().catch(() => {});
                    source = video;
                } else if (config.videoType === 'local') {
                    const blob = await loadBlob('camera_video');
                    if (blob) {
                        const video = document.createElement('video');
                        video.src = URL.createObjectURL(blob);
                        video.loop = true;
                        video.muted = true;
                        video.play().catch(() => {});
                        source = video;
                    }
                }

                tracks.push(this.createVideoTrack(source, w, h, config.testPattern));
            }

            if (options.audio) {
                tracks.push(this.createAudioTrack(config.audioType));
            }

            return new MediaStream(tracks);
        }

        static async createScreenStream(options) {
            const tracks = [];
            let source = 'test';
            const w = 1920, h = 1080;

            if (config.screenType === 'url' && config.screenUrl) {
                const video = document.createElement('video');
                video.src = config.screenUrl;
                video.loop = true;
                video.muted = true;
                video.crossOrigin = 'anonymous';
                video.play().catch(() => {});
                source = video;
            } else if (config.screenType === 'local') {
                const blob = await loadBlob('screen_video');
                if (blob) {
                    const video = document.createElement('video');
                    video.src = URL.createObjectURL(blob);
                    video.loop = true;
                    video.muted = true;
                    video.play().catch(() => {});
                    source = video;
                }
            }

            tracks.push(this.createVideoTrack(source, w, h, config.testPattern));

            if (options.audio) {
                tracks.push(this.createAudioTrack('silent'));
            }

            return new MediaStream(tracks);
        }
    }

    // ==================== 工具函数 ====================
    function extractDeviceId(constraint) {
        if (!constraint || typeof constraint !== 'object') return null;
        const 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 isVirtualDevice(id) {
        return id === VIRTUAL_CAM_ID || id === VIRTUAL_MIC_ID;
    }

    // ==================== 原型链劫持 ====================
    const origGetUserMedia = MediaDevices.prototype.getUserMedia;
    const origEnumerateDevices = MediaDevices.prototype.enumerateDevices;
    const origGetDisplayMedia = MediaDevices.prototype.getDisplayMedia;

    // 劫持 enumerateDevices - 只添加虚拟设备
    MediaDevices.prototype.enumerateDevices = async function() {
        const devices = await origEnumerateDevices.call(this);

        if (!config.enabled) {
            return devices;
        }

        // 创建虚拟设备信息对象
        const virtualCam = Object.create(MediaDeviceInfo.prototype, {
            deviceId: { get: function() { return VIRTUAL_CAM_ID; }, enumerable: true },
            kind: { get: function() { return 'videoinput'; }, enumerable: true },
            label: { get: function() { return '🎥 Virtual Camera (VMS)'; }, enumerable: true },
            groupId: { get: function() { return VIRTUAL_GROUP; }, enumerable: true },
            toJSON: { value: function() {
                return { deviceId: VIRTUAL_CAM_ID, kind: 'videoinput', label: '🎥 Virtual Camera (VMS)', groupId: VIRTUAL_GROUP };
            }}
        });

        const virtualMic = Object.create(MediaDeviceInfo.prototype, {
            deviceId: { get: function() { return VIRTUAL_MIC_ID; }, enumerable: true },
            kind: { get: function() { return 'audioinput'; }, enumerable: true },
            label: { get: function() { return '🎤 Virtual Microphone (VMS)'; }, enumerable: true },
            groupId: { get: function() { return VIRTUAL_GROUP; }, enumerable: true },
            toJSON: { value: function() {
                return { deviceId: VIRTUAL_MIC_ID, kind: 'audioinput', label: '🎤 Virtual Microphone (VMS)', groupId: VIRTUAL_GROUP };
            }}
        });

        console.log('[VMS] enumerateDevices: 添加虚拟设备');
        return [...devices, virtualCam, virtualMic];
    };

    // 劫持 getUserMedia - 只处理虚拟设备请求
    MediaDevices.prototype.getUserMedia = async function(constraints) {
        if (!config.enabled || !constraints) {
            return origGetUserMedia.call(this, constraints);
        }

        const videoId = extractDeviceId(constraints.video);
        const audioId = extractDeviceId(constraints.audio);

        const wantVirtualVideo = videoId === VIRTUAL_CAM_ID;
        const wantVirtualAudio = audioId === VIRTUAL_MIC_ID;

        console.log('[VMS] getUserMedia:', { videoId, audioId, wantVirtualVideo, wantVirtualAudio });

        // 如果没有请求任何虚拟设备,直接调用原始方法
        if (!wantVirtualVideo && !wantVirtualAudio) {
            return origGetUserMedia.call(this, constraints);
        }

        // 构建虚拟流
        const virtualTracks = [];
        const realConstraints = {};
        let needRealStream = false;

        // 处理视频
        if (constraints.video) {
            if (wantVirtualVideo) {
                const vStream = await VirtualStream.createStream({ video: true, audio: false });
                virtualTracks.push(...vStream.getVideoTracks());
            } else {
                realConstraints.video = constraints.video;
                needRealStream = true;
            }
        }

        // 处理音频
        if (constraints.audio) {
            if (wantVirtualAudio) {
                const aStream = await VirtualStream.createStream({ video: false, audio: true });
                virtualTracks.push(...aStream.getAudioTracks());
            } else {
                realConstraints.audio = constraints.audio;
                needRealStream = true;
            }
        }

        // 如果还需要真实设备
        if (needRealStream && (realConstraints.video || realConstraints.audio)) {
            try {
                const realStream = await origGetUserMedia.call(this, realConstraints);
                realStream.getTracks().forEach(t => virtualTracks.push(t));
            } catch (e) {
                console.warn('[VMS] 获取真实设备失败:', e);
            }
        }

        return new MediaStream(virtualTracks);
    };

    // 劫持 getDisplayMedia
    if (origGetDisplayMedia) {
        MediaDevices.prototype.getDisplayMedia = async function(constraints) {
            if (!config.enabled) {
                return origGetDisplayMedia.call(this, constraints);
            }

            // 检查是否配置了屏幕源
            const hasSource = config.screenType === 'test' ||
                (config.screenType === 'url' && config.screenUrl) ||
                config.screenType === 'local';

            if (!hasSource) {
                return origGetDisplayMedia.call(this, constraints);
            }

            console.log('[VMS] getDisplayMedia: 返回虚拟屏幕');
            return VirtualStream.createScreenStream({
                audio: constraints && constraints.audio
            });
        };
    }

    // ==================== UI ====================
    const createUI = () => {
        GM_addStyle(`
            #vms-panel {
                position: fixed;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                width: 400px;
                max-height: 85vh;
                overflow-y: auto;
                background: #1a1a2e;
                z-index: 2147483647;
                border-radius: 16px;
                box-shadow: 0 20px 50px rgba(0,0,0,0.8);
                font-family: system-ui, -apple-system, sans-serif;
                display: none;
                color: #e0e0e0;
                border: 1px solid #333;
            }
            #vms-panel * { box-sizing: border-box; margin: 0; padding: 0; }

            .vms-hd {
                background: linear-gradient(135deg, #667eea, #764ba2);
                padding: 18px 20px;
                display: flex;
                justify-content: space-between;
                align-items: center;
                border-radius: 16px 16px 0 0;
            }
            .vms-hd h2 { font-size: 16px; font-weight: 600; color: #fff; }
            .vms-hd button {
                background: rgba(255,255,255,0.2);
                border: none;
                color: #fff;
                width: 28px;
                height: 28px;
                border-radius: 50%;
                cursor: pointer;
                font-size: 16px;
            }
            .vms-hd button:hover { background: rgba(255,255,255,0.3); }

            .vms-body { padding: 16px; }

            .vms-tabs {
                display: flex;
                gap: 6px;
                margin-bottom: 16px;
            }
            .vms-tabs button {
                flex: 1;
                padding: 8px;
                background: rgba(255,255,255,0.05);
                border: none;
                color: #888;
                border-radius: 8px;
                cursor: pointer;
                font-size: 12px;
            }
            .vms-tabs button.active {
                background: #667eea;
                color: #fff;
            }
            .vms-tabs button:hover:not(.active) { background: rgba(255,255,255,0.1); }

            .vms-tab { display: none; }
            .vms-tab.active { display: block; }

            .vms-sec {
                background: rgba(255,255,255,0.03);
                border: 1px solid rgba(255,255,255,0.05);
                border-radius: 12px;
                padding: 14px;
                margin-bottom: 12px;
            }
            .vms-sec-title {
                font-size: 12px;
                font-weight: 600;
                color: #a78bfa;
                margin-bottom: 12px;
            }

            .vms-row { margin-bottom: 12px; }
            .vms-row:last-child { margin-bottom: 0; }
            .vms-lbl {
                display: block;
                font-size: 11px;
                color: #888;
                margin-bottom: 5px;
            }
            .vms-sel, .vms-inp {
                width: 100%;
                padding: 10px;
                background: rgba(0,0,0,0.3);
                border: 1px solid rgba(255,255,255,0.1);
                border-radius: 8px;
                color: #fff;
                font-size: 13px;
            }
            .vms-sel:focus, .vms-inp:focus {
                outline: none;
                border-color: #667eea;
            }
            .vms-sel option { background: #1a1a2e; }

            .vms-file-lbl {
                display: block;
                padding: 16px;
                border: 2px dashed rgba(255,255,255,0.15);
                border-radius: 8px;
                text-align: center;
                cursor: pointer;
                color: #666;
                font-size: 13px;
            }
            .vms-file-lbl:hover { border-color: #667eea; color: #667eea; }
            .vms-file-lbl.ok { border-color: #4ade80; color: #4ade80; border-style: solid; }
            .vms-file-inp { display: none; }

            .vms-tog {
                display: flex;
                align-items: center;
                justify-content: space-between;
                padding: 10px 0;
            }
            .vms-tog-lbl { font-size: 13px; color: #ddd; }
            .vms-sw {
                position: relative;
                width: 44px;
                height: 24px;
            }
            .vms-sw input { display: none; }
            .vms-sw span {
                position: absolute;
                inset: 0;
                background: rgba(255,255,255,0.1);
                border-radius: 24px;
                cursor: pointer;
                transition: 0.2s;
            }
            .vms-sw span::before {
                content: '';
                position: absolute;
                width: 18px;
                height: 18px;
                left: 3px;
                top: 3px;
                background: #fff;
                border-radius: 50%;
                transition: 0.2s;
            }
            .vms-sw input:checked + span { background: #667eea; }
            .vms-sw input:checked + span::before { transform: translateX(20px); }

            .vms-btn {
                width: 100%;
                padding: 12px;
                border: none;
                border-radius: 10px;
                font-size: 13px;
                font-weight: 600;
                cursor: pointer;
                margin-top: 8px;
            }
            .vms-btn-p { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; }
            .vms-btn-p:hover { opacity: 0.9; }
            .vms-btn-s { background: rgba(255,255,255,0.05); color: #aaa; }
            .vms-btn-s:hover { background: rgba(255,255,255,0.1); }

            .vms-preview {
                background: #000;
                border-radius: 8px;
                aspect-ratio: 16/9;
                overflow: hidden;
            }
            .vms-preview canvas { width: 100%; height: 100%; }

            .vms-status {
                display: flex;
                gap: 8px;
                margin-top: 12px;
            }
            .vms-status > div {
                flex: 1;
                background: rgba(0,0,0,0.2);
                border-radius: 8px;
                padding: 10px;
                text-align: center;
            }
            .vms-status .on { color: #4ade80; }
            .vms-status .off { color: #f87171; }
        `);

        const p = document.createElement('div');
        p.id = 'vms-panel';
        p.innerHTML = `
            <div class="vms-hd">
                <h2>🎬 Virtual Media Studio</h2>
                <button id="vms-x">×</button>
            </div>
            <div class="vms-body">
                <div class="vms-tabs">
                    <button class="active" data-t="cam">📷 摄像头</button>
                    <button data-t="scr">🖥️ 屏幕</button>
                    <button data-t="pre">👁️ 预览</button>
                    <button data-t="set">⚙️</button>
                </div>

                <div class="vms-tab active" id="t-cam">
                    <div class="vms-sec">
                        <div class="vms-sec-title">📹 视频源</div>
                        <div class="vms-row">
                            <label class="vms-lbl">类型</label>
                            <select class="vms-sel" id="v-type">
                                <option value="test" ${config.videoType==='test'?'selected':''}>🎨 测试画面</option>
                                <option value="local" ${config.videoType==='local'?'selected':''}>📁 本地文件</option>
                                <option value="url" ${config.videoType==='url'?'selected':''}>🔗 URL</option>
                            </select>
                        </div>
                        <div class="vms-row" id="v-pattern-row" style="display:${config.videoType==='test'?'block':'none'}">
                            <label class="vms-lbl">测试图案</label>
                            <select class="vms-sel" id="v-pattern">
                                <option value="colorBars" ${config.testPattern==='colorBars'?'selected':''}>📊 彩条</option>
                                <option value="gradient" ${config.testPattern==='gradient'?'selected':''}>🌈 渐变</option>
                                <option value="clock" ${config.testPattern==='clock'?'selected':''}>🕐 时钟</option>
                                <option value="noise" ${config.testPattern==='noise'?'selected':''}>📺 噪点</option>
                            </select>
                        </div>
                        <div class="vms-row" id="v-url-row" style="display:${config.videoType==='url'?'block':'none'}">
                            <label class="vms-lbl">视频 URL</label>
                            <input class="vms-inp" id="v-url" placeholder="https://..." value="${config.videoUrl||''}">
                        </div>
                        <div class="vms-row" id="v-file-row" style="display:${config.videoType==='local'?'block':'none'}">
                            <label class="vms-file-lbl" for="v-file" id="v-file-lbl">📁 选择视频文件</label>
                            <input type="file" class="vms-file-inp" id="v-file" accept="video/*">
                        </div>
                    </div>
                    <div class="vms-sec">
                        <div class="vms-sec-title">🎤 音频源</div>
                        <div class="vms-row">
                            <label class="vms-lbl">类型</label>
                            <select class="vms-sel" id="a-type">
                                <option value="test" ${config.audioType==='test'?'selected':''}>🔊 测试音 440Hz</option>
                                <option value="silent" ${config.audioType==='silent'?'selected':''}>🔇 静音</option>
                                <option value="url" ${config.audioType==='url'?'selected':''}>🔗 URL</option>
                            </select>
                        </div>
                        <div class="vms-row" id="a-url-row" style="display:${config.audioType==='url'?'block':'none'}">
                            <label class="vms-lbl">音频 URL</label>
                            <input class="vms-inp" id="a-url" placeholder="https://..." value="${config.audioUrl||''}">
                        </div>
                    </div>
                </div>

                <div class="vms-tab" id="t-scr">
                    <div class="vms-sec">
                        <div class="vms-sec-title">🖥️ 屏幕视频源</div>
                        <div class="vms-row">
                            <label class="vms-lbl">类型</label>
                            <select class="vms-sel" id="s-type">
                                <option value="test" ${config.screenType==='test'?'selected':''}>🎨 测试画面</option>
                                <option value="local" ${config.screenType==='local'?'selected':''}>📁 本地文件</option>
                                <option value="url" ${config.screenType==='url'?'selected':''}>🔗 URL</option>
                            </select>
                        </div>
                        <div class="vms-row" id="s-url-row" style="display:${config.screenType==='url'?'block':'none'}">
                            <label class="vms-lbl">屏幕视频 URL</label>
                            <input class="vms-inp" id="s-url" placeholder="https://..." value="${config.screenUrl||''}">
                        </div>
                        <div class="vms-row" id="s-file-row" style="display:${config.screenType==='local'?'block':'none'}">
                            <label class="vms-file-lbl" for="s-file" id="s-file-lbl">📁 选择屏幕录像</label>
                            <input type="file" class="vms-file-inp" id="s-file" accept="video/*">
                        </div>
                    </div>
                </div>

                <div class="vms-tab" id="t-pre">
                    <div class="vms-sec">
                        <div class="vms-sec-title">👁️ 实时预览</div>
                        <div class="vms-preview"><canvas id="pre-cv" width="640" height="360"></canvas></div>
                    </div>
                    <button class="vms-btn vms-btn-s" id="pre-btn">▶️ 开始预览</button>
                </div>

                <div class="vms-tab" id="t-set">
                    <div class="vms-sec">
                        <div class="vms-sec-title">🎛️ 总开关</div>
                        <div class="vms-tog">
                            <span class="vms-tog-lbl">启用虚拟设备</span>
                            <label class="vms-sw">
                                <input type="checkbox" id="cfg-enabled" ${config.enabled?'checked':''}>
                                <span></span>
                            </label>
                        </div>
                    </div>
                </div>

                <div class="vms-status">
                    <div>📷 <span class="${config.enabled?'on':'off'}">${config.enabled?'ON':'OFF'}</span></div>
                    <div>🎤 <span class="${config.enabled?'on':'off'}">${config.enabled?'ON':'OFF'}</span></div>
                    <div>🖥️ <span class="${config.enabled?'on':'off'}">${config.enabled?'ON':'OFF'}</span></div>
                </div>

                <button class="vms-btn vms-btn-p" id="vms-save">💾 保存并刷新</button>
                <button class="vms-btn vms-btn-s" id="vms-save2">保存(不刷新)</button>
            </div>
        `;

        document.documentElement.appendChild(p);

        const $ = id => document.getElementById(id);

        // 关闭
        $('vms-x').onclick = () => p.style.display = 'none';

        // 选项卡
        p.querySelectorAll('.vms-tabs button').forEach(btn => {
            btn.onclick = () => {
                p.querySelectorAll('.vms-tabs button').forEach(b => b.classList.remove('active'));
                p.querySelectorAll('.vms-tab').forEach(t => t.classList.remove('active'));
                btn.classList.add('active');
                $('t-' + btn.dataset.t).classList.add('active');
            };
        });

        // 视频类型切换
        $('v-type').onchange = e => {
            $('v-pattern-row').style.display = e.target.value === 'test' ? 'block' : 'none';
            $('v-url-row').style.display = e.target.value === 'url' ? 'block' : 'none';
            $('v-file-row').style.display = e.target.value === 'local' ? 'block' : 'none';
        };

        $('a-type').onchange = e => {
            $('a-url-row').style.display = e.target.value === 'url' ? 'block' : 'none';
        };

        $('s-type').onchange = e => {
            $('s-url-row').style.display = e.target.value === 'url' ? 'block' : 'none';
            $('s-file-row').style.display = e.target.value === 'local' ? 'block' : 'none';
        };

        // 文件选择
        $('v-file').onchange = e => {
            if (e.target.files[0]) {
                $('v-file-lbl').textContent = '✅ ' + e.target.files[0].name;
                $('v-file-lbl').classList.add('ok');
            }
        };
        $('s-file').onchange = e => {
            if (e.target.files[0]) {
                $('s-file-lbl').textContent = '✅ ' + e.target.files[0].name;
                $('s-file-lbl').classList.add('ok');
            }
        };

        // 预览
        let previewOn = false;
        let previewId = null;
        $('pre-btn').onclick = () => {
            if (previewOn) {
                previewOn = false;
                if (previewId) cancelAnimationFrame(previewId);
                $('pre-btn').textContent = '▶️ 开始预览';
            } else {
                previewOn = true;
                $('pre-btn').textContent = '⏹️ 停止';
                const cv = $('pre-cv');
                const ctx = cv.getContext('2d');
                const tc = new TestCanvas(640, 360);
                const pattern = $('v-pattern').value;
                const loop = () => {
                    if (!previewOn) return;
                    tc.render(pattern);
                    ctx.drawImage(tc.canvas, 0, 0);
                    previewId = requestAnimationFrame(loop);
                };
                loop();
            }
        };

        // 保存
        const collect = () => ({
            videoType: $('v-type').value,
            videoUrl: $('v-url').value,
            audioType: $('a-type').value,
            audioUrl: $('a-url').value,
            screenType: $('s-type').value,
            screenUrl: $('s-url').value,
            enabled: $('cfg-enabled').checked,
            testPattern: $('v-pattern').value
        });

        const doSave = async (reload) => {
            const cfg = collect();
            saveConfig(cfg);

            const vf = $('v-file').files[0];
            const sf = $('s-file').files[0];
            if (vf) await saveBlob('camera_video', vf);
            if (sf) await saveBlob('screen_video', sf);

            if (reload) {
                location.reload();
            } else {
                config = cfg;
                alert('已保存!刷新页面或新标签页生效。');
            }
        };

        $('vms-save').onclick = () => doSave(true);
        $('vms-save2').onclick = () => doSave(false);

        // 菜单
        GM_registerMenuCommand('🎬 打开控制面板', () => p.style.display = 'block');
        GM_registerMenuCommand('🔄 切换启用状态', () => {
            config.enabled = !config.enabled;
            saveConfig(config);
            alert('虚拟设备: ' + (config.enabled ? 'ON' : 'OFF') + ' (刷新生效)');
        });
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', createUI);
    } else {
        setTimeout(createUI, 0);
    }

    console.log('[VMS] Virtual Media Studio v3.0 已加载');
    console.log('[VMS] 启用状态:', config.enabled ? '✅' : '❌');

})();