Files
VibeCoding/WebRTCControllerWeb/src/components/ControlBar.vue
tongtongstudio 0d49c60c12 fix(ControlBar): 修复键盘事件监听范围,改为全局监听window
将键盘事件从输入框的局部监听改为window全局监听,确保页面任意位置按下键盘即可向被控端发送指令。同时保留输入框聚焦逻辑,以兼容移动端软键盘输入场景。
2026-08-03 11:34:50 +08:00

331 lines
10 KiB
Vue
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.
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { store, sendKeyDown, sendKeyUp, sendResolutionChange, toggleRecording } from '../store/controllerStore';
// Android KeyEvent 键值(与被控端 SystemInputUtils 注入一致)
const keys = [
{ label: '主页', code: 3 }, // KEYCODE_HOME
{ label: '返回', code: 4 }, // KEYCODE_BACK
{ label: '多任务', code: 187 }, // KEYCODE_APP_SWITCH
{ label: '菜单', code: 82 }, // KEYCODE_MENU
{ label: '音量 +', code: 24 }, // KEYCODE_VOLUME_UP
{ label: '音量 -', code: 25 }, // KEYCODE_VOLUME_DOWN
{ label: '电源', code: 26 }, // KEYCODE_POWER
];
// 分辨率预设width 为长边像素0 表示被控端原生分辨率;
// height 传 0由被控端按屏幕宽高比自动计算fps 传 0沿用当前帧率
const resolutionPresets = [
{ label: '原始画质', width: 0, height: 0, fps: 0 },
{ label: '1080P', width: 1920, height: 0, fps: 0 },
{ label: '720P', width: 1280, height: 0, fps: 0 },
{ label: '480P', width: 854, height: 0, fps: 0 },
];
// 注意:<option :value="p.width"> 的 value 是数字Vue 的 v-model 会按数字写入,
// 因此这里也必须用数字(不能用字符串 '0'),否则默认选中项与比较逻辑会类型不一致。
const selectedResolution = ref(0);
// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准。
const fpsOptions = ref([15, 24, 30, 60]);
const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率
function pressDown(code) {
if (!store.dataChannelOpen) return;
sendKeyDown(code);
}
function pressUp(code) {
if (!store.dataChannelOpen) return;
sendKeyUp(code);
}
// ----- 键盘捕获输入(真实键盘打字) -----
// 将浏览器 KeyboardEvent.key / .code 映射为 Android KeyEvent 键值。
// 覆盖字母、数字、空格、回车、退格、制表、方向键、修饰键与常用符号。
const KEY_MAP = {
// 字母
a: 29, b: 30, c: 31, d: 32, e: 33, f: 34, g: 35, h: 36, i: 37, j: 38, k: 39,
l: 40, m: 41, n: 42, o: 43, p: 44, q: 45, r: 46, s: 47, t: 48, u: 49, v: 50,
w: 51, x: 52, y: 53, z: 54,
// 数字(主键盘)
'0': 7, '1': 8, '2': 9, '3': 10, '4': 11, '5': 12, '6': 13, '7': 14, '8': 15, '9': 16,
// 控制键
Enter: 66, Backspace: 67, Tab: 61, Space: 62, Delete: 112, Escape: 111,
ArrowLeft: 21, ArrowUp: 19, ArrowRight: 22, ArrowDown: 20,
Home: 3, End: 123, PageUp: 92, PageDown: 93, Insert: 124,
'`': 68, '-': 69, '=': 70, '[': 71, ']': 72, '\\': 73, ';': 74, '\'': 75, ',': 55, '.': 56, '/': 76,
};
// 修饰键(按下保持,直到抬起)
const MODIFIER_MAP = {
Shift: 59, Control: 113, Alt: 57, Meta: 117,
};
const keyInputRef = ref(null);
const keyLog = ref('');
function lookupKeyCode(e) {
// 优先使用 e.code如物理键位置稳定再回退到 e.key。
if (MODIFIER_MAP[e.key]) return { code: MODIFIER_MAP[e.key], modifier: true };
if (KEY_MAP[e.key] !== undefined) return { code: KEY_MAP[e.key], modifier: false };
if (KEY_MAP[e.code]) return { code: KEY_MAP[e.code], modifier: false };
return null;
}
// 固定按键:按下/抬起分离(如电源键需要真实按下-抬起配对)
function onKeyCaptureDown(e) {
if (!store.dataChannelOpen) return;
const m = lookupKeyCode(e);
if (!m) return;
e.preventDefault();
sendKeyDown(m.code);
}
function onKeyCaptureUp(e) {
if (!store.dataChannelOpen) return;
const m = lookupKeyCode(e);
if (!m) return;
e.preventDefault();
sendKeyUp(m.code);
}
function focusKeyInput() {
keyInputRef.value && keyInputRef.value.focus();
}
// 监听整个 window 的键盘事件,保证页面任意位置(不依赖聚焦到某个输入框)
// 按下键盘即可向被控端发送按键指令。移动端软键盘不触发 keydown故保留输入框聚焦。
onMounted(() => {
window.addEventListener('keydown', onKeyCaptureDown);
window.addEventListener('keyup', onKeyCaptureUp);
});
onUnmounted(() => {
window.removeEventListener('keydown', onKeyCaptureDown);
window.removeEventListener('keyup', onKeyCaptureUp);
});
function onResolutionChange() {
if (!store.dataChannelOpen) return;
// 统一按数值匹配v-model 写入的是数字,不能用 String() 再与数字比较。
const preset = resolutionPresets.find((p) => p.width === Number(selectedResolution.value));
if (preset) {
sendResolutionChange(preset.width, preset.height, preset.fps);
}
}
function onFpsChange() {
if (!store.dataChannelOpen) return;
const fps = Number(selectedFps.value);
if (fps <= 0) return;
// 仅切换帧率分辨率沿用被控端最近上报的实际采集尺寸0 表示原生)。
const res = store.currentResolution;
sendResolutionChange(res ? res.width : 0, res ? res.height : 0, fps);
}
// 被控端上报当前实际采集分辨率/帧率时,同步下拉框(避免 UI 与实际不一致)。
watch(() => store.currentResolution, (res) => {
if (!res) return;
const longEdge = Math.max(res.width, res.height);
const preset = resolutionPresets.find((p) => p.width === longEdge);
if (preset) selectedResolution.value = preset.width;
// 帧率档位以被控端上报为准(按屏幕刷新率筛选)
if (Array.isArray(res.supportedFps) && res.supportedFps.length > 0) {
fpsOptions.value = res.supportedFps;
}
if (res.fps > 0 && fpsOptions.value.includes(res.fps)) {
selectedFps.value = res.fps;
}
});
</script>
<template>
<div class="control-bar">
<div class="resolution-row">
<label class="resolution-label">分辨率:</label>
<select
class="resolution-select"
v-model="selectedResolution"
:disabled="!store.dataChannelOpen"
@change="onResolutionChange"
>
<option v-for="p in resolutionPresets" :key="p.label" :value="p.width">
{{ p.label }}
</option>
</select>
<label class="resolution-label">帧率:</label>
<select
class="resolution-select"
v-model="selectedFps"
:disabled="!store.dataChannelOpen"
@change="onFpsChange"
>
<option v-if="selectedFps === 0" :value="0" disabled>--</option>
<option v-for="f in fpsOptions" :key="f" :value="f">{{ f }}fps</option>
</select>
<span class="resolution-current" v-if="store.currentResolution">
当前: {{ store.currentResolution.width }}×{{ store.currentResolution.height }}@{{ store.currentResolution.fps }}fps
</span>
<span class="record-sep"></span>
<!-- 录制按钮与状态包成一组保证换行时二者始终同进同退不被拆散 -->
<div class="record-group">
<button
class="record-btn"
:class="{ recording: store.recording }"
:disabled="!store.rtcConnected"
@click="toggleRecording"
>
{{ store.recording ? '停止录制' : '录制' }}
</button>
<span
class="record-status"
v-if="store.recordStatus"
:title="store.recordStatus"
>{{ store.recordStatus }}</span>
</div>
</div>
<button
v-for="k in keys"
:key="k.code"
class="key-btn"
:disabled="!store.dataChannelOpen"
@pointerdown.prevent="pressDown(k.code)"
@pointerup.prevent="pressUp(k.code)"
@pointerleave.prevent="pressUp(k.code)"
@pointercancel.prevent="pressUp(k.code)"
>
{{ k.label }}
</button>
<div class="keyboard-capture">
<label class="kb-label">键盘输入:</label>
<input
ref="keyInputRef"
class="kb-input"
type="text"
readonly
placeholder="已全局监听,直接在页面上打字即可(手机请点此聚焦)"
:disabled="!store.dataChannelOpen"
@click="focusKeyInput"
@blur="keyLog = ''"
/>
<span class="kb-hint">支持字母/数字/符号/方向键/修饰键组合</span>
</div>
</div>
</template>
<style scoped>
.record-sep {
width: 1px;
height: 22px;
margin: 0 4px;
background: #3a3f4b;
flex-shrink: 0;
}
/* 录制按钮 + 状态作为一个不可拆分的整体参与换行 */
.record-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
min-width: 0;
max-width: 100%;
}
.record-btn {
/* flex-shrink:0 + nowrap 是"永不换行"的关键:
作为 flex 子项默认可被压缩到小于文字宽度,从而把"停止录制"挤成两行。 */
flex: 0 0 auto;
flex-shrink: 0;
white-space: nowrap;
word-break: keep-all;
min-width: fit-content;
padding: 8px 16px;
border: 1px solid #3a3f4b;
border-radius: 6px;
background: #2b6cff;
color: #fff;
font-size: 14px;
line-height: 1.2;
cursor: pointer;
transition: background-color .15s ease, border-color .15s ease;
}
.record-btn:hover:not(:disabled) { background: #1f5ce0; }
.record-btn:disabled {
background: #3a3f4b;
color: #888;
cursor: not-allowed;
}
.record-btn.recording {
background: #e5484d;
border-color: #e5484d;
}
.record-btn.recording:hover:not(:disabled) { background: #cf3b40; }
.record-status {
/* 空间不足时由状态文字收缩并省略,保证按钮文字完整、二者同处一行 */
flex: 0 1 auto;
min-width: 0;
max-width: 180px;
font-size: 13px;
color: #ffb020;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 换行后竖线分隔符失去意义,反而造成视觉噪点 */
@media (max-width: 640px) {
.record-sep { display: none; }
}
/* ----- 键盘捕获输入 ----- */
.keyboard-capture {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.kb-label {
font-size: 14px;
color: #cfd3dc;
white-space: nowrap;
}
.kb-input {
flex: 1 1 240px;
min-width: 200px;
padding: 7px 10px;
border: 1px solid #3a3f4b;
border-radius: 6px;
background: #1b1e24;
color: #fff;
font-size: 14px;
outline: none;
}
.kb-input:focus {
border-color: #2b6cff;
}
.kb-input:disabled {
background: #23262d;
color: #888;
cursor: not-allowed;
}
.kb-hint {
font-size: 12px;
color: #888;
white-space: nowrap;
}
</style>