1386 lines
51 KiB
HTML
1386 lines
51 KiB
HTML
<!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>
|