Greasy Fork is available in English.
虚拟摄像头、麦克风和屏幕共享
当前为
// ==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 ? '✅' : '❌');
})();