将键盘事件从输入框的局部监听改为window全局监听,确保页面任意位置按下键盘即可向被控端发送指令。同时保留输入框聚焦逻辑,以兼容移动端软键盘输入场景。
331 lines
10 KiB
Vue
331 lines
10 KiB
Vue
<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>
|