Files
qiming/qiming-rcoder/docker/test-page/vnc-test.html
2026-06-01 13:54:52 +08:00

1386 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VNC + 音频 + 输入法 测试页面</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header h1 {
color: #333;
margin-bottom: 20px;
font-size: 24px;
}
.controls {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.input-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.input-group label {
font-size: 12px;
color: #666;
font-weight: 500;
}
.input-group input {
padding: 10px 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s;
width: 200px;
}
.input-group input.full-width {
width: 100%;
}
.mode-toggle {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
}
.mode-toggle label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.mode-toggle input[type="radio"] {
width: auto;
margin: 0;
}
.config-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.config-section.direct-mode {
display: none;
}
.config-section.proxy-mode {
display: grid;
}
.input-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.button {
padding: 10px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
height: 42px;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.button:active {
transform: translateY(0);
}
.button.secondary {
background: #6c757d;
}
.button.secondary:hover {
background: #5a6268;
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.button.audio {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.button.audio:hover {
box-shadow: 0 4px 12px rgba(245, 87, 108, 0.4);
}
.button.ime {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.button.ime:hover {
box-shadow: 0 4px 12px rgba(79, 172, 254, 0.4);
}
.status {
padding: 10px 15px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
}
.status.connected {
background: #d4edda;
color: #155724;
}
.status.disconnected {
background: #f8d7da;
color: #721c24;
}
.status.connecting {
background: #fff3cd;
color: #856404;
}
.vnc-container {
flex: 1;
min-height: 2000px;
height: calc(100vh - 50px);
background: #000;
position: relative;
margin: 5px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.vnc-container iframe {
width: 100%;
height: 100%;
border: none;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #00ff00;
font-family: "Courier New", monospace;
font-size: 18px;
text-align: center;
}
.loading::after {
content: "...";
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%,
20% {
content: ".";
}
40% {
content: "..";
}
60%,
100% {
content: "...";
}
}
.info {
background: rgba(255, 255, 255, 0.9);
padding: 10px 15px;
margin: 10px 20px;
border-radius: 8px;
font-size: 12px;
color: #666;
line-height: 1.4;
max-height: 150px;
overflow-y: auto;
}
.info code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-family: "Courier New", monospace;
color: #e83e8c;
}
/* 音频和输入法控制面板 */
.feature-panel {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 15px 20px;
margin: 10px 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.feature-panel h3 {
font-size: 16px;
color: #333;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.feature-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.volume-control {
display: flex;
align-items: center;
gap: 10px;
}
.volume-control input[type="range"] {
width: 150px;
}
.ime-input {
position: fixed;
top: -9999px;
left: -9999px;
opacity: 0;
pointer-events: none;
}
.ime-input-visible {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background: white;
transition: all 0.3s;
}
.ime-input-visible:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.ime-input-visible:disabled {
background: #f5f5f5;
cursor: not-allowed;
opacity: 0.6;
}
.ime-status {
font-size: 12px;
color: #666;
padding: 5px 10px;
background: #f8f9fa;
border-radius: 4px;
}
.audio-visualizer {
width: 100px;
height: 30px;
background: #f8f9fa;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
padding: 5px;
}
.audio-visualizer span {
width: 3px;
height: 10px;
background: #667eea;
border-radius: 2px;
animation: audioBar 0.8s ease-in-out infinite;
}
.audio-visualizer span:nth-child(2) {
animation-delay: 0.1s;
}
.audio-visualizer span:nth-child(3) {
animation-delay: 0.2s;
}
.audio-visualizer span:nth-child(4) {
animation-delay: 0.3s;
}
.audio-visualizer span:nth-child(5) {
animation-delay: 0.4s;
}
@keyframes audioBar {
0%,
100% {
height: 5px;
}
50% {
height: 20px;
}
}
</style>
<!-- 引入 Opus 解码器库 -->
<!-- 优先加载同目录的本地文件CDN 作为 fallback -->
<script src="./opus-decoder.min.js"></script>
<script src="https://unpkg.com/opus-decoder@0.7.9/dist/opus-decoder.min.js"></script>
<script>
// 延迟到页面加载完成后检查,确保脚本已执行
// opus-decoder 库使用 UMD 模式,导出为 window["opus-decoder"].OpusDecoder
document.addEventListener('DOMContentLoaded', function() {
// 检查两种可能的全局变量位置
const opusDecoderLib = window["opus-decoder"];
window.OpusDecoder = opusDecoderLib ? opusDecoderLib.OpusDecoder : undefined;
window.OpusDecoderLoaded = (typeof window.OpusDecoder !== 'undefined');
if (window.OpusDecoderLoaded) {
console.log('[Audio] ✅ OpusDecoder 加载成功');
console.log('[Audio] OpusDecoder:', window.OpusDecoder);
} else {
console.error('[Audio] ❌ OpusDecoder 加载失败,音频功能可能无法使用');
console.error('[Audio] 请确保 opus-decoder.min.js 文件与 vnc-test.html 在同一目录');
console.error('[Audio] 全局对象调试:', {
"window['opus-decoder']": window["opus-decoder"],
"window.OpusDecoder": window.OpusDecoder
});
}
});
</script>
</head>
<body>
<div class="header">
<h1>🔌 VNC + 🎵 音频 + ⌨️ 输入法 测试</h1>
<!-- 模式切换 -->
<div class="mode-toggle">
<label>
<input
type="radio"
name="connectionMode"
value="proxy"
checked
onchange="toggleMode()"
/>
<span>RCoder 代理模式</span>
</label>
<label>
<input
type="radio"
name="connectionMode"
value="direct"
onchange="toggleMode()"
/>
<span>直接端口模式</span>
</label>
</div>
<!-- RCoder 代理模式配置 -->
<div class="config-section proxy-mode" id="proxyConfig">
<div class="input-group">
<label>RCoder 服务地址</label>
<input
type="text"
id="baseUrlInput"
placeholder="http://127.0.0.1:8088"
value="http://127.0.0.1:8088"
class="full-width"
/>
</div>
<div class="input-group">
<label>User ID</label>
<input
type="text"
id="userIdInput"
placeholder="user_123"
value="user_123"
/>
</div>
<div class="input-group">
<label>Project ID</label>
<input
type="text"
id="projectIdInput"
value=""
class="full-width"
/>
</div>
</div>
<!-- 直接端口模式配置 -->
<div class="config-section direct-mode" id="directConfig">
<div class="input-group">
<label>VNC 端口 (6080 映射)</label>
<input
type="number"
id="portInput"
placeholder="例如: 50001"
value=""
/>
</div>
<div class="input-group">
<label>音频 WebSocket 端口 (6089 映射)</label>
<input
type="number"
id="audioPortInput"
placeholder="例如: 50002"
value=""
/>
</div>
<div class="input-group">
<label>IME 端口 (6091 映射)</label>
<input
type="number"
id="imePortInput"
placeholder="例如: 50003"
value=""
/>
</div>
</div>
<div class="controls">
<div class="input-group">
<label>连接选项</label>
<div style="display: flex; gap: 10px; align-items: center">
<label
style="
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
"
>
<input
type="checkbox"
id="viewOnly"
style="width: auto"
/>
<span style="font-size: 13px">只读模式</span>
</label>
<label
style="
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
"
>
<input
type="checkbox"
id="autoconnect"
checked
style="width: auto"
/>
<span style="font-size: 13px">自动连接</span>
</label>
</div>
</div>
<button class="button" onclick="connectVNC()">连接 VNC</button>
<button class="button secondary" onclick="disconnectVNC()">
断开 VNC
</button>
<div class="status disconnected" id="vncStatus">
<span></span>
<span>VNC 未连接</span>
</div>
</div>
</div>
<!-- 音频控制面板 -->
<div class="feature-panel">
<h3>🎵 音频流</h3>
<div class="feature-controls">
<button
class="button audio"
onclick="toggleAudio()"
id="audioBtn"
>
启动音频
</button>
<div
class="volume-control"
id="volumeControl"
style="display: none"
>
<span>音量:</span>
<input
type="range"
id="volumeSlider"
min="0"
max="100"
value="80"
oninput="setVolume(this.value)"
/>
<span id="volumeValue">80%</span>
</div>
<div
class="audio-visualizer"
id="audioVisualizer"
style="display: none"
>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div class="status disconnected" id="audioStatus">
<span></span>
<span>音频未连接</span>
</div>
</div>
</div>
<!-- 输入法控制面板 -->
<div class="feature-panel">
<h3>⌨️ 输入法透传</h3>
<div class="feature-controls">
<button class="button ime" onclick="toggleIME()" id="imeBtn">
连接 IME 服务
</button>
<div class="ime-status" id="imeStatus">未连接</div>
</div>
<div style="margin-top: 10px;">
<div style="font-size: 12px; color: #666; margin-bottom: 8px;">
<strong>使用方法:</strong><br>
1. 点击"连接 IME 服务"按钮<br>
2. 在下方输入框中切换到中文输入法<br>
3. 输入中文(会显示候选词窗口)<br>
4. 选择中文后自动发送到远程桌面
</div>
<input
type="text"
id="imeInputVisible"
class="ime-input-visible"
placeholder="在此处使用中文输入法输入..."
disabled
/>
<div id="imeInputHint" style="font-size: 11px; color: #999; margin-top: 4px;">
请先连接 IME 服务
</div>
</div>
</div>
<!-- 隐藏的输入框(用于捕获输入法事件) -->
<input type="text" id="imeInput" class="ime-input" />
<div class="info">
<strong>🎯 使用说明:</strong><br />
<strong>1. VNC 连接:</strong><br />
- RCoder 代理模式配置服务地址、User ID、Project ID点击"连接
VNC"<br />
- 直接端口模式:配置各服务端口,点击"连接 VNC"<br /><br />
<strong>2. 音频播放:</strong><br />
- 点击"启动音频"按钮,在远程桌面播放视频/音乐时会自动传输音频<br />
- 支持音量调节和实时音频可视化<br />
- 首次播放需要用户交互激活(浏览器安全策略)<br /><br />
<strong>3. 输入法透传:</strong><br />
- 点击"启动输入法"按钮激活<br />
- 点击 VNC 画面区域获得焦点<br />
- 使用本地输入法(如搜狗、微软拼音)输入中文<br />
- 输入完成后文本会自动发送到远程桌面
</div>
<div class="vnc-container" id="vncContainer">
<div class="loading" id="loading" style="display: none">
正在连接 VNC
</div>
<iframe id="vncFrame" style="display: none"></iframe>
</div>
<!-- 隐藏的输入代理框 -->
<input type="text" id="imeInput" class="ime-input" />
<script>
// =====================================================
// 全局状态
// =====================================================
let currentUrl = null;
let audioContext = null;
let audioWorklet = null;
let audioWs = null;
let audioConnected = false;
let audioGainNode = null;
let audioDecoder = null; // Opus 解码器实例(复用)
let imeWs = null;
let imeConnected = false;
let imeEnabled = false;
// =====================================================
// VNC 连接逻辑
// =====================================================
function updateVNCStatus(status, message) {
const statusEl = document.getElementById("vncStatus");
statusEl.className = `status ${status}`;
statusEl.innerHTML = `<span>●</span><span>${message}</span>`;
}
function updateAudioStatus(status, message) {
const statusEl = document.getElementById("audioStatus");
statusEl.className = `status ${status}`;
statusEl.innerHTML = `<span>●</span><span>${message}</span>`;
}
function toggleMode() {
const mode = document.querySelector(
'input[name="connectionMode"]:checked',
).value;
const proxyConfig = document.getElementById("proxyConfig");
const directConfig = document.getElementById("directConfig");
if (mode === "proxy") {
proxyConfig.classList.remove("direct-mode");
proxyConfig.classList.add("proxy-mode");
directConfig.classList.remove("proxy-mode");
directConfig.classList.add("direct-mode");
} else {
proxyConfig.classList.remove("proxy-mode");
proxyConfig.classList.add("direct-mode");
directConfig.classList.remove("direct-mode");
directConfig.classList.add("proxy-mode");
}
}
function buildVNCUrl() {
const mode = document.querySelector(
'input[name="connectionMode"]:checked',
).value;
const viewOnly = document.getElementById("viewOnly").checked;
const autoconnect =
document.getElementById("autoconnect").checked;
const params = new URLSearchParams();
if (autoconnect) {
params.set("autoconnect", "true");
}
params.set("resize", "scale");
if (viewOnly) {
params.set("view_only", "true");
}
if (mode === "proxy") {
const baseUrl = document
.getElementById("baseUrlInput")
.value.trim();
const userId = document
.getElementById("userIdInput")
.value.trim();
const projectId = document
.getElementById("projectIdInput")
.value.trim();
if (!baseUrl || !userId || !projectId) {
alert("请填写完整的连接信息!");
return null;
}
const cleanBaseUrl = baseUrl.replace(/\/+$/, "");
return `${cleanBaseUrl}/computer/vnc/${userId}/${projectId}/vnc.html?${params.toString()}`;
} else {
const port = document
.getElementById("portInput")
.value.trim();
if (!port) {
alert("请输入 VNC 端口号!");
return null;
}
const portNum = parseInt(port);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
alert("请输入有效的端口号 (1-65535)");
return null;
}
return `http://localhost:${port}/vnc.html?${params.toString()}`;
}
}
function connectVNC() {
const url = buildVNCUrl();
if (!url) {
return;
}
currentUrl = url;
console.log("连接 VNC:", currentUrl);
document.getElementById("loading").style.display = "block";
document.getElementById("vncFrame").style.display = "none";
updateVNCStatus("connecting", "VNC 正在连接...");
const iframe = document.getElementById("vncFrame");
iframe.src = currentUrl;
iframe.onload = function () {
document.getElementById("loading").style.display = "none";
iframe.style.display = "block";
updateVNCStatus("connected", "VNC 已连接");
};
iframe.onerror = function () {
document.getElementById("loading").style.display = "none";
updateVNCStatus("disconnected", "VNC 连接失败");
};
setTimeout(() => {
if (iframe.style.display === "none") {
try {
if (iframe.contentWindow) {
document.getElementById(
"loading",
).style.display = "none";
iframe.style.display = "block";
updateVNCStatus(
"connected",
"VNC 已连接(加载中)",
);
}
} catch (e) {
// 跨域限制
}
}
}, 5000);
}
function disconnectVNC() {
const iframe = document.getElementById("vncFrame");
iframe.src = "about:blank";
iframe.style.display = "none";
document.getElementById("loading").style.display = "none";
updateVNCStatus("disconnected", "VNC 已断开");
currentUrl = null;
}
// =====================================================
// 音频流逻辑
// =====================================================
// 音频队列管理
let audioQueue = [];
let nextPlayTime = 0;
let isPlaying = false;
const BUFFER_DURATION = 0.02; // 20ms 每个音频片
function buildAudioWsUrl() {
const mode = document.querySelector(
'input[name="connectionMode"]:checked',
).value;
if (mode === "proxy") {
const baseUrl = document
.getElementById("baseUrlInput")
.value.trim()
.replace(/^http/, "ws")
.replace(/\/+$/, "");
const userId = document
.getElementById("userIdInput")
.value.trim();
const projectId = document
.getElementById("projectIdInput")
.value.trim();
if (!baseUrl || !userId || !projectId) {
alert("请先配置连接信息!");
return null;
}
return `${baseUrl}/computer/audio/${userId}/${projectId}/ws`;
} else {
const port = document
.getElementById("audioPortInput")
.value.trim();
if (!port) {
alert("请输入音频 WebSocket 端口!");
return null;
}
return `ws://localhost:${port}/ws`;
}
}
async function toggleAudio() {
if (audioConnected) {
disconnectAudio();
} else {
await connectAudio();
}
}
async function connectAudio() {
const wsUrl = buildAudioWsUrl();
if (!wsUrl) {
return;
}
try {
updateAudioStatus("connecting", "音频连接中...");
console.log("连接音频流:", wsUrl);
// 初始化 Web Audio API
if (!audioContext) {
audioContext = new (
window.AudioContext || window.webkitAudioContext
)({ sampleRate: 48000, latencyHint: 'interactive' });
audioGainNode = audioContext.createGain();
audioGainNode.gain.value = 0.8;
audioGainNode.connect(audioContext.destination);
nextPlayTime = audioContext.currentTime + 0.1; // 初始延迟
}
// 恢复 AudioContext浏览器安全策略
if (audioContext.state === "suspended") {
await audioContext.resume();
}
// 连接 WebSocket
audioWs = new WebSocket(wsUrl);
audioWs.binaryType = "arraybuffer";
audioWs.onopen = () => {
console.log("[Audio] WebSocket 已连接");
audioConnected = true;
updateAudioStatus("connected", "音频已连接");
document.getElementById("audioBtn").textContent =
"停止音频";
document.getElementById("volumeControl").style.display =
"flex";
document.getElementById(
"audioVisualizer",
).style.display = "flex";
};
audioWs.onmessage = async (event) => {
try {
const data = new Uint8Array(event.data);
// 检查协议头 (0x01 表示 Opus 音频)
if (data[0] === 0x01) {
const opusData = data.slice(1);
// 这里需要 Opus 解码器
// 简化版:直接播放 PCM假设后端发送 PCM
await playAudioChunk(opusData);
}
} catch (err) {
console.error("[Audio] 处理音频数据失败:", err);
}
};
audioWs.onerror = (err) => {
console.error("[Audio] WebSocket 错误:", err);
updateAudioStatus("disconnected", "音频连接失败");
};
audioWs.onclose = () => {
console.log("[Audio] WebSocket 已关闭");
audioConnected = false;
updateAudioStatus("disconnected", "音频已断开");
document.getElementById("audioBtn").textContent =
"启动音频";
document.getElementById("volumeControl").style.display =
"none";
document.getElementById(
"audioVisualizer",
).style.display = "none";
};
} catch (err) {
console.error("[Audio] 连接失败:", err);
updateAudioStatus("disconnected", "音频启动失败");
alert("音频启动失败: " + err.message);
}
}
function disconnectAudio() {
if (audioWs) {
audioWs.close();
audioWs = null;
}
audioConnected = false;
// 重置播放调度
nextPlayTime = 0;
updateAudioStatus("disconnected", "音频已停止");
document.getElementById("audioBtn").textContent = "启动音频";
document.getElementById("volumeControl").style.display = "none";
document.getElementById("audioVisualizer").style.display =
"none";
}
async function playAudioChunk(opusData) {
try {
// 0. 检查 OpusDecoder 是否已加载
if (!window.OpusDecoderLoaded) {
console.error('[Audio] ❌ OpusDecoder 未加载,无法播放音频');
console.error('[Audio] 请确保 opus-decoder.min.js 文件与 vnc-test.html 在同一目录');
return;
}
// 1. 初始化或复用 Opus 解码器
if (!audioDecoder) {
console.log('[Audio] 初始化 OpusDecoder...');
audioDecoder = new OpusDecoder({
sampleRate: 48000,
channels: 2
});
await audioDecoder.ready;
console.log('[Audio] OpusDecoder 初始化完成');
}
// 2. 解码 Opus 数据
const decoded = audioDecoder.decodeFrame(opusData);
const channels = decoded.channelData;
const samplesDecoded = decoded.samplesDecoded;
if (!channels || channels.length < 2) {
console.error('[Audio] 输出声道数据无效:', channels);
return;
}
const leftChannelData = channels[0];
const rightChannelData = channels[1];
// 3. 创建 AudioBuffer
const audioBuffer = audioContext.createBuffer(
2, samplesDecoded, 48000
);
// 4. 填充声道数据
const leftChannel = audioBuffer.getChannelData(0);
const rightChannel = audioBuffer.getChannelData(1);
leftChannel.set(leftChannelData);
rightChannel.set(rightChannelData);
// 5. 调度播放(关键修复:确保连续性)
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioGainNode);
// 计算播放时间
const currentTime = audioContext.currentTime;
// 如果 nextPlayTime 已经过期,重新开始
if (nextPlayTime < currentTime) {
nextPlayTime = currentTime + 0.05; // 50ms 缓冲
console.log('[Audio] 重置播放时间nextPlayTime =', nextPlayTime);
}
// 调度播放
source.start(nextPlayTime);
// 计算下一个播放时间点(连续播放)
const duration = samplesDecoded / 48000;
nextPlayTime += duration;
// 6. 清理已播放的源
source.onended = () => {
// 自动垃圾回收
};
// 7. 可视化音频(节流,避免频繁更新)
if (Math.random() < 0.1) { // 只更新 10% 的时间
const mergedPcm = new Float32Array(leftChannelData.length + rightChannelData.length);
for (let i = 0; i < leftChannelData.length; i++) {
mergedPcm[i * 2] = leftChannelData[i];
mergedPcm[i * 2 + 1] = rightChannelData[i];
}
updateAudioVisualizer(mergedPcm);
}
} catch (err) {
console.error("[Audio] 解码或播放失败:", err);
// 解码失败时重置解码器实例
if (audioDecoder) {
audioDecoder.free();
audioDecoder = null;
}
}
}
function updateAudioVisualizer(pcmData) {
const visualizer = document.getElementById('audioVisualizer');
if (!visualizer || visualizer.style.display === 'none') return;
// 计算 RMS 音量
let sum = 0;
for (let i = 0; i < pcmData.length; i++) {
sum += pcmData[i] * pcmData[i];
}
const rms = Math.sqrt(sum / pcmData.length);
// 更新可视化条高度
const bars = visualizer.querySelectorAll('span');
bars.forEach((bar, index) => {
const height = 5 + (rms * 20 * (1 + index * 0.2));
bar.style.height = `${Math.min(height, 20)}px`;
});
}
function setVolume(value) {
if (audioGainNode) {
audioGainNode.gain.value = value / 100;
}
document.getElementById("volumeValue").textContent =
value + "%";
}
// =====================================================
// 输入法透传逻辑
// =====================================================
function buildIMEWsUrl() {
const mode = document.querySelector(
'input[name="connectionMode"]:checked',
).value;
if (mode === "proxy") {
const baseUrl = document
.getElementById("baseUrlInput")
.value.trim()
.replace(/^http/, "ws")
.replace(/\/+$/, "");
const userId = document
.getElementById("userIdInput")
.value.trim();
const projectId = document
.getElementById("projectIdInput")
.value.trim();
if (!baseUrl || !userId || !projectId) {
alert("请先配置连接信息!");
return null;
}
return `${baseUrl}/computer/ime/${userId}/${projectId}/connect`;
} else {
const port = document
.getElementById("imePortInput")
.value.trim();
if (!port) {
alert("请输入 IME 端口!");
return null;
}
return `ws://localhost:${port}/`;
}
}
function toggleIME() {
if (imeEnabled) {
disconnectIME();
} else {
connectIME();
}
}
function connectIME() {
const wsUrl = buildIMEWsUrl();
console.log("[IME] 连接 IME 服务:", wsUrl);
if (!wsUrl) {
return;
}
try {
console.log("连接 IME 服务:", wsUrl);
// 连接 WebSocket
imeWs = new WebSocket(wsUrl);
imeWs.onopen = () => {
console.log("[IME] WebSocket 已连接");
imeConnected = true;
imeEnabled = true;
document.getElementById("imeBtn").textContent =
"断开 IME 服务";
document.getElementById("imeStatus").textContent =
"已连接";
document.getElementById("imeStatus").style.background =
"#d4edda";
document.getElementById("imeStatus").style.color =
"#155724";
// 启用可见输入框
updateIMEInputState(true);
};
imeWs.onmessage = (event) => {
try {
const response = JSON.parse(event.data);
if (response.status === "ok") {
console.log("[IME] 文本发送成功");
} else {
console.error(
"[IME] 服务器错误:",
response.message,
);
}
} catch (err) {
console.error("[IME] 响应解析失败:", err);
}
};
imeWs.onerror = (err) => {
console.error("[IME] WebSocket 错误:", err);
alert(
"IME 连接失败!\n\n" +
"可能原因:\n" +
"1. 容器未启动 - 请先发送聊天请求创建容器\n" +
"2. IME 服务未运行 - 请检查容器状态\n" +
"3. 网络连接问题 - 请检查 Pingora 代理是否运行\n\n" +
"调试信息:\n" +
"- WebSocket URL: " + wsUrl + "\n" +
"- 错误详情: " + JSON.stringify(err)
);
};
imeWs.onclose = () => {
console.log("[IME] WebSocket 已关闭");
imeConnected = false;
imeEnabled = false;
document.getElementById("imeBtn").textContent =
"连接 IME 服务";
document.getElementById("imeStatus").textContent =
"未连接";
document.getElementById("imeStatus").style.background =
"#f8f9fa";
document.getElementById("imeStatus").style.color =
"#666";
// 禁用可见输入框
updateIMEInputState(false);
};
} catch (err) {
console.error("[IME] 连接失败:", err);
alert("IME 启动失败: " + err.message);
}
}
function disconnectIME() {
if (imeWs) {
imeWs.close();
imeWs = null;
}
imeEnabled = false;
imeConnected = false;
document.getElementById("imeBtn").textContent = "启动输入法";
document.getElementById("imeStatus").textContent = "未激活";
document.getElementById("imeStatus").style.background =
"#f8f9fa";
document.getElementById("imeStatus").style.color = "#666";
}
function setupIMEListeners() {
const imeInput = document.getElementById("imeInput");
const imeInputVisible = document.getElementById("imeInputVisible");
const imeInputHint = document.getElementById("imeInputHint");
// 监听隐藏输入框的输入法完成事件
imeInput.addEventListener("compositionend", (event) => {
const text = event.data;
if (text && text.length > 0 && imeConnected) {
console.log("[IME] 输入完成:", text);
sendTextToRemote(text);
// 清空输入框
imeInput.value = "";
}
});
// 监听可见输入框的输入法完成事件
imeInputVisible.addEventListener("compositionend", (event) => {
const text = event.data;
if (text && text.length > 0 && imeConnected) {
console.log("[IME] 输入完成:", text);
sendTextToRemote(text);
// 清空输入框
imeInputVisible.value = "";
}
});
// 监听可见输入框的 input 事件(处理非输入法的直接输入)
imeInputVisible.addEventListener("input", (event) => {
// 如果不是输入法输入,直接发送
const text = event.target.value;
if (text && text.length > 0 && imeConnected && !event.isComposing) {
console.log("[IME] 直接输入:", text);
sendTextToRemote(text);
event.target.value = "";
}
});
}
function updateIMEInputState(connected) {
const imeInputVisible = document.getElementById("imeInputVisible");
const imeInputHint = document.getElementById("imeInputHint");
if (connected) {
imeInputVisible.disabled = false;
imeInputHint.textContent = "请切换到中文输入法,然后在此处输入";
imeInputVisible.focus();
console.log("[IME] 输入框已启用");
} else {
imeInputVisible.disabled = true;
imeInputHint.textContent = "请先连接 IME 服务";
imeInputVisible.value = "";
}
}
function sendTextToRemote(text) {
if (!imeConnected || !imeWs) {
console.warn("[IME] 未连接,无法发送文本");
return;
}
const message = JSON.stringify({
type: "text",
text: text,
method: "xdotool", // 默认使用 xdotool也可以改为 'clipboard'
});
imeWs.send(message);
console.log("[IME] 发送文本:", text);
}
// =====================================================
// 页面初始化
// =====================================================
function setupEnterKeyListeners() {
const inputs = [
"baseUrlInput",
"userIdInput",
"projectIdInput",
"portInput",
"audioPortInput",
"imePortInput",
];
inputs.forEach((id) => {
const input = document.getElementById(id);
if (input) {
input.addEventListener("keypress", function (e) {
if (e.key === "Enter") {
connectVNC();
}
});
}
});
}
window.addEventListener("DOMContentLoaded", function () {
setupEnterKeyListeners();
setupIMEListeners(); // 初始化 IME 监听器
const urlParams = new URLSearchParams(window.location.search);
const baseUrl = urlParams.get("baseUrl");
const userId = urlParams.get("userId");
const projectId = urlParams.get("projectId");
const port = urlParams.get("port");
const mode = urlParams.get("mode") || "proxy";
if (baseUrl) {
document.getElementById("baseUrlInput").value = baseUrl;
}
if (userId) {
document.getElementById("userIdInput").value = userId;
}
if (projectId) {
document.getElementById("projectIdInput").value = projectId;
}
if (port) {
document.getElementById("portInput").value = port;
}
const modeRadio = document.querySelector(
`input[name="connectionMode"][value="${mode}"]`,
);
if (modeRadio) {
modeRadio.checked = true;
toggleMode();
}
// 自动连接
if (mode === "proxy" && baseUrl && userId && projectId) {
setTimeout(() => {
connectVNC();
}, 500);
} else if (mode === "direct" && port) {
setTimeout(() => {
connectVNC();
}, 500);
}
});
// 页面卸载时清理资源
window.addEventListener("beforeunload", function () {
if (audioWs) {
audioWs.close();
}
if (imeWs) {
imeWs.close();
}
if (audioContext) {
audioContext.close();
}
});
</script>
</body>
</html>