mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
fix:改进atx usb 继电器适配;修复 webrtc 无法建立连接问题;网页样式优化
This commit is contained in:
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@@ -1368,7 +1368,6 @@
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1783,7 +1782,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2450,7 +2448,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2498,7 +2495,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2791,8 +2787,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
||||
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
@@ -2846,7 +2841,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -2912,7 +2906,6 @@
|
||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -2994,7 +2987,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
|
||||
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.25",
|
||||
"@vue/compiler-sfc": "3.5.25",
|
||||
|
||||
@@ -72,12 +72,12 @@ watch(
|
||||
<Toaster
|
||||
rich-colors
|
||||
close-button
|
||||
expand
|
||||
position="top-center"
|
||||
close-button-position="top-right"
|
||||
theme="system"
|
||||
:duration="4000"
|
||||
:gap="14"
|
||||
:visible-toasts="3"
|
||||
:offset="{ top: '1rem', right: '1rem', left: '1rem', bottom: '1rem' }"
|
||||
:mobile-offset="{ top: 'max(1rem, env(safe-area-inset-top))', bottom: 'max(1rem, env(safe-area-inset-bottom))', left: '1rem', right: '1rem' }"
|
||||
:toast-options="toasterToastOptions"
|
||||
|
||||
@@ -42,8 +42,6 @@ const isAttached = ref(props.attached ?? true)
|
||||
const selectedOs = ref<KeyboardOsType>('windows')
|
||||
|
||||
const mainKeyboard = ref<Keyboard | null>(null)
|
||||
const controlKeyboard = ref<Keyboard | null>(null)
|
||||
const arrowsKeyboard = ref<Keyboard | null>(null)
|
||||
|
||||
const pressedModifiers = ref<number>(0)
|
||||
const keysDown = ref<CanonicalKey[]>([])
|
||||
@@ -81,47 +79,49 @@ const position = ref({ x: 100, y: 100 })
|
||||
|
||||
const keyboardId = ref(`kb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
|
||||
const getBottomRow = () => osBottomRows[selectedOs.value].join(' ')
|
||||
const getBottomRow = () =>
|
||||
[...osBottomRows[selectedOs.value], 'ArrowLeft', 'ArrowDown', 'ArrowRight'].join(' ')
|
||||
|
||||
const keyboardLayout = {
|
||||
main: {
|
||||
default: [
|
||||
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
|
||||
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace',
|
||||
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
|
||||
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause',
|
||||
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace Insert Home PageUp',
|
||||
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash Delete End PageDown',
|
||||
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
|
||||
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight',
|
||||
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
|
||||
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight ArrowUp',
|
||||
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
|
||||
],
|
||||
shift: [
|
||||
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
|
||||
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||
'(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace',
|
||||
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)',
|
||||
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause',
|
||||
'(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace Insert Home PageUp',
|
||||
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash) Delete End PageDown',
|
||||
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
|
||||
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight',
|
||||
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
|
||||
],
|
||||
},
|
||||
control: {
|
||||
default: [
|
||||
'PrintScreen ScrollLock Pause',
|
||||
'Insert Home PageUp',
|
||||
'Delete End PageDown',
|
||||
],
|
||||
},
|
||||
arrows: {
|
||||
default: [
|
||||
'ArrowUp',
|
||||
'ArrowLeft ArrowDown ArrowRight',
|
||||
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight ArrowUp',
|
||||
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const compactMainLayout = {
|
||||
default: keyboardLayout.main.default.slice(2),
|
||||
shift: keyboardLayout.main.shift.slice(2),
|
||||
default: [
|
||||
'Escape Insert Delete Home End PageUp PageDown PrintScreen',
|
||||
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace',
|
||||
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
|
||||
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
|
||||
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight ArrowUp',
|
||||
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
|
||||
],
|
||||
shift: [
|
||||
'Escape Insert Delete Home End PageUp PageDown PrintScreen',
|
||||
'(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace',
|
||||
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)',
|
||||
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
|
||||
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight ArrowUp',
|
||||
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
|
||||
],
|
||||
}
|
||||
|
||||
const isCompactLayout = ref(false)
|
||||
@@ -286,8 +286,6 @@ function updateKeyboardLayout() {
|
||||
],
|
||||
}
|
||||
mainKeyboard.value?.setOptions({ layout: newLayout, display: keyDisplayMap.value })
|
||||
controlKeyboard.value?.setOptions({ display: keyDisplayMap.value })
|
||||
arrowsKeyboard.value?.setOptions({ display: keyDisplayMap.value })
|
||||
updateKeyboardButtonTheme()
|
||||
}
|
||||
|
||||
@@ -431,8 +429,6 @@ function updateKeyboardButtonTheme() {
|
||||
]
|
||||
|
||||
mainKeyboard.value?.setOptions({ buttonTheme })
|
||||
controlKeyboard.value?.setOptions({ buttonTheme })
|
||||
arrowsKeyboard.value?.setOptions({ buttonTheme })
|
||||
}
|
||||
|
||||
watch([layoutName, () => props.capsLock], ([name]) => {
|
||||
@@ -447,10 +443,8 @@ function initKeyboards() {
|
||||
const id = keyboardId.value
|
||||
|
||||
const mainEl = document.querySelector(`#${id}-main`)
|
||||
const controlEl = document.querySelector(`#${id}-control`)
|
||||
const arrowsEl = document.querySelector(`#${id}-arrows`)
|
||||
|
||||
if (!mainEl || !controlEl || !arrowsEl) {
|
||||
if (!mainEl) {
|
||||
console.warn('[VirtualKeyboard] DOM elements not ready, retrying...', id)
|
||||
setTimeout(initKeyboards, 50)
|
||||
return
|
||||
@@ -476,45 +470,13 @@ function initKeyboards() {
|
||||
stopMouseUpPropagation: true,
|
||||
})
|
||||
|
||||
controlKeyboard.value = new Keyboard(controlEl, {
|
||||
layout: keyboardLayout.control,
|
||||
layoutName: 'default',
|
||||
display: keyDisplayMap.value,
|
||||
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
|
||||
onKeyPress: onKeyDown,
|
||||
onKeyReleased: onKeyUp,
|
||||
disableButtonHold: true,
|
||||
preventMouseDownDefault: true,
|
||||
preventMouseUpDefault: true,
|
||||
stopMouseDownPropagation: true,
|
||||
stopMouseUpPropagation: true,
|
||||
})
|
||||
|
||||
arrowsKeyboard.value = new Keyboard(arrowsEl, {
|
||||
layout: keyboardLayout.arrows,
|
||||
layoutName: 'default',
|
||||
display: keyDisplayMap.value,
|
||||
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
|
||||
onKeyPress: onKeyDown,
|
||||
onKeyReleased: onKeyUp,
|
||||
disableButtonHold: true,
|
||||
preventMouseDownDefault: true,
|
||||
preventMouseUpDefault: true,
|
||||
stopMouseDownPropagation: true,
|
||||
stopMouseUpPropagation: true,
|
||||
})
|
||||
|
||||
updateKeyboardLayout()
|
||||
console.log('[VirtualKeyboard] Keyboards initialized:', id)
|
||||
}
|
||||
|
||||
function destroyKeyboards() {
|
||||
mainKeyboard.value?.destroy()
|
||||
controlKeyboard.value?.destroy()
|
||||
arrowsKeyboard.value?.destroy()
|
||||
mainKeyboard.value = null
|
||||
controlKeyboard.value = null
|
||||
arrowsKeyboard.value = null
|
||||
}
|
||||
|
||||
function getClientCoords(e: MouseEvent | TouchEvent): { x: number; y: number } | null {
|
||||
@@ -682,10 +644,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="vkb-keyboards">
|
||||
<div :id="`${keyboardId}-main`" class="kb-main-container"></div>
|
||||
<div class="vkb-side">
|
||||
<div :id="`${keyboardId}-control`" class="kb-control-container"></div>
|
||||
<div :id="`${keyboardId}-arrows`" class="kb-arrows-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -818,10 +776,9 @@ onUnmounted(() => {
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
/* Right Shift - wider */
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
|
||||
flex-grow: 2.75;
|
||||
min-width: 110px;
|
||||
flex-grow: 1.75;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
/* Bottom row modifiers */
|
||||
@@ -852,21 +809,33 @@ onUnmounted(() => {
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
/* Control keyboard */
|
||||
.kb-control-container .hg-button {
|
||||
min-width: 54px !important;
|
||||
justify-content: center;
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ScrollLock"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Pause"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Home"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="End"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PageUp"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PageDown"] {
|
||||
font-size: 11px;
|
||||
flex-grow: 0.95;
|
||||
}
|
||||
|
||||
/* Arrow buttons */
|
||||
.kb-arrows-container .hg-button {
|
||||
min-width: 44px !important;
|
||||
width: 44px !important;
|
||||
justify-content: center;
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"] {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.kb-arrows-container .hg-row {
|
||||
justify-content: center;
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowDown"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowRight"] {
|
||||
font-size: 14px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Dark mode - must be after simple-keyboard CSS import */
|
||||
@@ -1127,31 +1096,7 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vkb-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.kb-control-container,
|
||||
.kb-arrows-container {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.vkb-keyboards {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vkb-side {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vkb-body {
|
||||
padding: 4px;
|
||||
@@ -1159,11 +1104,11 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button {
|
||||
height: 28px;
|
||||
font-size: 10px;
|
||||
padding: 0 3px;
|
||||
height: 30px;
|
||||
font-size: 11px;
|
||||
padding: 0 2px;
|
||||
margin: 0 1px 2px 0;
|
||||
min-width: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button.combination-key {
|
||||
@@ -1172,35 +1117,15 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"] {
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"] {
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"] {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"] {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Space"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
|
||||
@@ -1208,20 +1133,29 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
|
||||
min-width: 46px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
|
||||
min-width: 140px;
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Home"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="End"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PageUp"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PageDown"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] {
|
||||
font-size: 11px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.kb-control-container .hg-button {
|
||||
min-width: 44px !important;
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"] {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.kb-arrows-container .hg-button {
|
||||
min-width: 36px !important;
|
||||
width: 36px !important;
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"] {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.vkb-media-btn {
|
||||
@@ -1233,55 +1167,27 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.vkb .simple-keyboard .hg-button {
|
||||
height: 26px;
|
||||
font-size: 9px;
|
||||
padding: 0 2px;
|
||||
height: 28px;
|
||||
font-size: 10px;
|
||||
padding: 0 1px;
|
||||
margin: 0 1px 2px 0;
|
||||
min-width: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
|
||||
min-width: 34px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button.combination-key {
|
||||
font-size: 8px;
|
||||
height: 22px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.kb-control-container .hg-button {
|
||||
min-width: 34px !important;
|
||||
}
|
||||
|
||||
.kb-arrows-container .hg-button {
|
||||
min-width: 30px !important;
|
||||
width: 30px !important;
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Home"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="End"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PageUp"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PageDown"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.vkb-media-btn {
|
||||
@@ -1325,15 +1231,6 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.vkb--floating :deep(.kb-control-container .hg-button) {
|
||||
min-width: 52px !important;
|
||||
}
|
||||
|
||||
.vkb--floating :deep(.kb-arrows-container .hg-button) {
|
||||
min-width: 42px !important;
|
||||
width: 42px !important;
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
.keyboard-fade-enter-active,
|
||||
.keyboard-fade-leave-active {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import type { StreamDeviceLostEventData } from '@/types/websocket'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
|
||||
|
||||
export interface ConsoleEventHandlers {
|
||||
onStreamConfigChanging?: (data: { reason?: string }) => void
|
||||
@@ -14,12 +16,10 @@ export interface ConsoleEventHandlers {
|
||||
onStreamStateChanged?: (data: {
|
||||
state: string
|
||||
device?: string | null
|
||||
/** Optional fine-grained diagnostic tag (e.g. `no_cable`, `out_of_range`, `recovering`). */
|
||||
reason?: string | null
|
||||
/** Optional countdown (ms) until the next backend self-recovery attempt. */
|
||||
next_retry_ms?: number | null
|
||||
}) => void
|
||||
onStreamDeviceLost?: (data: { device: string; reason: string }) => void
|
||||
onStreamDeviceLost?: (data: StreamDeviceLostEventData) => void
|
||||
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
|
||||
onStreamRecovered?: (data: { device: string }) => void
|
||||
onDeviceInfo?: (data: any) => void
|
||||
@@ -31,12 +31,16 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
const { on, off, connect } = useWebSocket()
|
||||
const noop = () => {}
|
||||
|
||||
function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
||||
if (systemStore.stream) {
|
||||
function handleStreamDeviceLost(data: StreamDeviceLostEventData) {
|
||||
const audioLost = isAudioStreamDeviceLostPayload(data)
|
||||
if (systemStore.stream && !audioLost) {
|
||||
systemStore.stream.online = false
|
||||
}
|
||||
toast.error(t('console.deviceLost'), {
|
||||
description: t('console.deviceLostDesc', { device: data.device, reason: data.reason }),
|
||||
toast.error(t(audioLost ? 'audio.deviceLost' : 'console.deviceLost'), {
|
||||
description: t(audioLost ? 'audio.deviceLostDesc' : 'console.deviceLostDesc', {
|
||||
device: data.device,
|
||||
reason: data.reason,
|
||||
}),
|
||||
duration: 5000,
|
||||
})
|
||||
handlers.onStreamDeviceLost?.(data)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// WebRTC composable for H264 video streaming
|
||||
// Provides low-latency video via WebRTC with DataChannel for HID
|
||||
|
||||
import { ref, onUnmounted, computed, type Ref } from 'vue'
|
||||
import { webrtcApi, type IceCandidate } from '@/api'
|
||||
import {
|
||||
@@ -9,7 +6,7 @@ import {
|
||||
encodeKeyboardEvent,
|
||||
encodeMouseEvent,
|
||||
} from '@/types/hid'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { videoDebugLog } from '@/lib/debugLog'
|
||||
|
||||
export type { HidKeyboardEvent, HidMouseEvent }
|
||||
|
||||
@@ -28,7 +25,6 @@ export type WebRTCConnectStage =
|
||||
| 'disconnected'
|
||||
| 'failed'
|
||||
|
||||
// ICE candidate type: host=P2P local, srflx=P2P STUN, relay=TURN relay
|
||||
export type IceCandidateType = 'host' | 'srflx' | 'prflx' | 'relay' | 'unknown'
|
||||
|
||||
export interface WebRTCStats {
|
||||
@@ -42,26 +38,14 @@ export interface WebRTCStats {
|
||||
framesPerSecond: number
|
||||
jitter: number
|
||||
roundTripTime: number
|
||||
// ICE connection info
|
||||
localCandidateType: IceCandidateType
|
||||
remoteCandidateType: IceCandidateType
|
||||
transportProtocol: string // 'udp' | 'tcp'
|
||||
isRelay: boolean // true if using TURN relay
|
||||
transportProtocol: string
|
||||
isRelay: boolean
|
||||
}
|
||||
|
||||
// Cached ICE servers from backend API
|
||||
let cachedIceServers: RTCIceServer[] | null = null
|
||||
|
||||
interface WebRTCIceCandidateEvent {
|
||||
session_id: string
|
||||
candidate: IceCandidate
|
||||
}
|
||||
|
||||
interface WebRTCIceCompleteEvent {
|
||||
session_id: string
|
||||
}
|
||||
|
||||
// Fetch ICE servers from backend API
|
||||
async function fetchIceServers(): Promise<RTCIceServer[]> {
|
||||
try {
|
||||
const response = await webrtcApi.getIceServers()
|
||||
@@ -84,7 +68,6 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
|
||||
console.warn('[WebRTC] Failed to fetch ICE servers from API, using fallback:', err)
|
||||
}
|
||||
|
||||
// Fallback: for local connections, use no ICE servers (host candidates only)
|
||||
const isLocalConnection = typeof window !== 'undefined' &&
|
||||
(window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
@@ -107,20 +90,16 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
|
||||
let peerConnection: RTCPeerConnection | null = null
|
||||
let dataChannel: RTCDataChannel | null = null
|
||||
let sessionId: string | null = null
|
||||
const sessionIdRef = ref<string | null>(null)
|
||||
let statsInterval: number | null = null
|
||||
let isConnecting = false // Lock to prevent concurrent connect calls
|
||||
let isConnecting = false
|
||||
let connectInFlight: Promise<boolean> | null = null
|
||||
let pendingIceCandidates: RTCIceCandidate[] = [] // Queue for ICE candidates before sessionId is set
|
||||
let pendingRemoteCandidates: WebRTCIceCandidateEvent[] = [] // Queue for server ICE candidates
|
||||
let pendingRemoteIceComplete = new Set<string>() // Session IDs waiting for end-of-candidates
|
||||
let seenRemoteCandidates = new Set<string>() // Deduplicate server ICE candidates
|
||||
let cachedMediaStream: MediaStream | null = null // Cached MediaStream to avoid recreating
|
||||
let pendingIceCandidates: RTCIceCandidate[] = []
|
||||
let seenRemoteCandidates = new Set<string>()
|
||||
let cachedMediaStream: MediaStream | null = null
|
||||
|
||||
let allowMdnsHostCandidates = false
|
||||
|
||||
let wsHandlersRegistered = false
|
||||
const { on: wsOn } = useWebSocket()
|
||||
|
||||
const state = ref<WebRTCState>('disconnected')
|
||||
const videoTrack = ref<MediaStreamTrack | null>(null)
|
||||
const audioTrack = ref<MediaStreamTrack | null>(null)
|
||||
@@ -144,10 +123,44 @@ const error = ref<string | null>(null)
|
||||
const dataChannelReady = ref(false)
|
||||
const connectStage = ref<WebRTCConnectStage>('idle')
|
||||
|
||||
function setConnectStage(stage: WebRTCConnectStage, details?: unknown) {
|
||||
connectStage.value = stage
|
||||
videoDebugLog(`WebRTC stage -> ${stage}`, details)
|
||||
}
|
||||
|
||||
function getIceCandidatePoolSize(): number {
|
||||
if (typeof window === 'undefined') return 0
|
||||
const icePoolParam = new URLSearchParams(window.location.search).get('ice_pool')
|
||||
if (icePoolParam === null) return 0
|
||||
return Math.max(0, Number.parseInt(icePoolParam, 10) || 0)
|
||||
}
|
||||
|
||||
function summarizeIceCandidate(candidate: RTCIceCandidate | IceCandidate | RTCIceCandidateInit | null) {
|
||||
if (!candidate) return null
|
||||
const candidateLine = candidate.candidate ?? ''
|
||||
const parts = candidateLine.trim().split(/\s+/)
|
||||
const typIndex = parts.indexOf('typ')
|
||||
const raddrIndex = parts.indexOf('raddr')
|
||||
const rportIndex = parts.indexOf('rport')
|
||||
|
||||
return {
|
||||
type: typIndex >= 0 ? parts[typIndex + 1] : 'unknown',
|
||||
protocol: parts[2] ?? '',
|
||||
address: parts[4] ?? '',
|
||||
port: parts[5] ?? '',
|
||||
relatedAddress: raddrIndex >= 0 ? parts[raddrIndex + 1] : undefined,
|
||||
relatedPort: rportIndex >= 0 ? parts[rportIndex + 1] : undefined,
|
||||
sdpMid: candidate.sdpMid ?? undefined,
|
||||
sdpMLineIndex: candidate.sdpMLineIndex ?? undefined,
|
||||
usernameFragment: candidate.usernameFragment ?? undefined,
|
||||
raw: candidateLine,
|
||||
}
|
||||
}
|
||||
|
||||
function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
|
||||
const config: RTCConfiguration = {
|
||||
iceServers,
|
||||
iceCandidatePoolSize: 10,
|
||||
iceCandidatePoolSize: getIceCandidatePoolSize(),
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection(config)
|
||||
@@ -159,38 +172,35 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
|
||||
break
|
||||
case 'connected':
|
||||
state.value = 'connected'
|
||||
connectStage.value = 'connected'
|
||||
setConnectStage('connected')
|
||||
error.value = null
|
||||
startStatsCollection()
|
||||
break
|
||||
case 'disconnected':
|
||||
case 'closed':
|
||||
state.value = 'disconnected'
|
||||
connectStage.value = 'disconnected'
|
||||
setConnectStage('disconnected')
|
||||
stopStatsCollection()
|
||||
break
|
||||
case 'failed':
|
||||
state.value = 'failed'
|
||||
connectStage.value = 'failed'
|
||||
setConnectStage('failed')
|
||||
error.value = 'Connection failed'
|
||||
stopStatsCollection()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ICE connection state
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
// ICE state changes handled silently
|
||||
}
|
||||
|
||||
// Handle ICE candidates
|
||||
pc.onicecandidate = async (event) => {
|
||||
if (!event.candidate) return
|
||||
if (shouldSkipLocalCandidate(event.candidate)) return
|
||||
if (!event.candidate) {
|
||||
return
|
||||
}
|
||||
if (shouldSkipLocalCandidate(event.candidate)) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentSessionId = sessionId
|
||||
if (currentSessionId && pc.connectionState !== 'closed') {
|
||||
// Session ready, send immediately
|
||||
try {
|
||||
await webrtcApi.addIceCandidate(currentSessionId, {
|
||||
candidate: event.candidate.candidate,
|
||||
@@ -198,11 +208,14 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
|
||||
sdpMLineIndex: event.candidate.sdpMLineIndex ?? undefined,
|
||||
usernameFragment: event.candidate.usernameFragment ?? undefined,
|
||||
})
|
||||
} catch {
|
||||
// ICE candidate send failures are non-fatal
|
||||
} catch (err) {
|
||||
videoDebugLog('Failed to send local ICE candidate', {
|
||||
sessionId: currentSessionId,
|
||||
candidate: summarizeIceCandidate(event.candidate),
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
} else if (!currentSessionId) {
|
||||
// Queue candidate until sessionId is set
|
||||
pendingIceCandidates.push(event.candidate)
|
||||
}
|
||||
}
|
||||
@@ -235,7 +248,13 @@ function setupDataChannel(channel: RTCDataChannel) {
|
||||
dataChannelReady.value = false
|
||||
}
|
||||
|
||||
channel.onerror = () => {
|
||||
channel.onerror = (event) => {
|
||||
videoDebugLog('WebRTC data channel error', {
|
||||
label: channel.label,
|
||||
readyState: channel.readyState,
|
||||
event,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
channel.onmessage = () => {
|
||||
@@ -251,59 +270,18 @@ function createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
|
||||
return channel
|
||||
}
|
||||
|
||||
function registerWebSocketHandlers() {
|
||||
if (wsHandlersRegistered) return
|
||||
wsHandlersRegistered = true
|
||||
wsOn('webrtc.ice_candidate', handleRemoteIceCandidate)
|
||||
wsOn('webrtc.ice_complete', handleRemoteIceComplete)
|
||||
}
|
||||
|
||||
function shouldSkipLocalCandidate(candidate: RTCIceCandidate): boolean {
|
||||
if (allowMdnsHostCandidates) return false
|
||||
const value = candidate.candidate || ''
|
||||
return value.includes(' typ host') && value.includes('.local')
|
||||
}
|
||||
|
||||
async function handleRemoteIceCandidate(data: WebRTCIceCandidateEvent) {
|
||||
if (!data || !data.candidate) return
|
||||
|
||||
// Queue until session is ready and remote description is set
|
||||
if (!sessionId) {
|
||||
pendingRemoteCandidates.push(data)
|
||||
return
|
||||
async function addRemoteIceCandidate(candidate: IceCandidate): Promise<boolean> {
|
||||
if (!peerConnection) return false
|
||||
if (!candidate.candidate) return false
|
||||
if (seenRemoteCandidates.has(candidate.candidate)) {
|
||||
return false
|
||||
}
|
||||
if (data.session_id !== sessionId) return
|
||||
if (!peerConnection || !peerConnection.remoteDescription) {
|
||||
pendingRemoteCandidates.push(data)
|
||||
return
|
||||
}
|
||||
|
||||
await addRemoteIceCandidate(data.candidate)
|
||||
}
|
||||
|
||||
async function handleRemoteIceComplete(data: WebRTCIceCompleteEvent) {
|
||||
if (!data || !data.session_id) return
|
||||
|
||||
if (!sessionId) {
|
||||
pendingRemoteIceComplete.add(data.session_id)
|
||||
return
|
||||
}
|
||||
if (data.session_id !== sessionId) return
|
||||
if (!peerConnection || !peerConnection.remoteDescription) {
|
||||
pendingRemoteIceComplete.add(data.session_id)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await peerConnection.addIceCandidate(null)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function addRemoteIceCandidate(candidate: IceCandidate) {
|
||||
if (!peerConnection) return
|
||||
if (!candidate.candidate) return
|
||||
if (seenRemoteCandidates.has(candidate.candidate)) return
|
||||
seenRemoteCandidates.add(candidate.candidate)
|
||||
|
||||
const iceCandidate: RTCIceCandidateInit = {
|
||||
@@ -315,33 +293,17 @@ async function addRemoteIceCandidate(candidate: IceCandidate) {
|
||||
|
||||
try {
|
||||
await peerConnection.addIceCandidate(iceCandidate)
|
||||
} catch {
|
||||
// ICE candidate add failures are non-fatal
|
||||
return true
|
||||
} catch (err) {
|
||||
videoDebugLog('Failed to apply remote ICE candidate', {
|
||||
sessionId,
|
||||
candidate: summarizeIceCandidate(candidate),
|
||||
error: err,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPendingRemoteIce() {
|
||||
if (!peerConnection || !sessionId || !peerConnection.remoteDescription) return
|
||||
|
||||
const queued = pendingRemoteCandidates
|
||||
pendingRemoteCandidates = []
|
||||
|
||||
for (const event of queued) {
|
||||
if (event.session_id === sessionId) {
|
||||
await addRemoteIceCandidate(event.candidate)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRemoteIceComplete.has(sessionId)) {
|
||||
pendingRemoteIceComplete.delete(sessionId)
|
||||
try {
|
||||
await peerConnection.addIceCandidate(null)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start collecting stats
|
||||
function startStatsCollection() {
|
||||
if (statsInterval) return
|
||||
|
||||
@@ -369,7 +331,6 @@ function startStatsCollection() {
|
||||
(stat.state === 'succeeded' && stat.selected === true) ||
|
||||
(stat.state === 'in-progress' && !foundActivePair)
|
||||
|
||||
// Also check if this pair has actual data transfer (more reliable indicator)
|
||||
const hasData = (stat.bytesReceived > 0 || stat.bytesSent > 0)
|
||||
|
||||
if ((isActive || (stat.state === 'succeeded' && hasData)) && !foundActivePair) {
|
||||
@@ -382,7 +343,6 @@ function startStatsCollection() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update video stats
|
||||
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
|
||||
stats.value.bytesReceived = stat.bytesReceived || 0
|
||||
stats.value.packetsReceived = stat.packetsReceived || 0
|
||||
@@ -396,7 +356,6 @@ function startStatsCollection() {
|
||||
}
|
||||
})
|
||||
|
||||
// Update ICE connection info from selected pair
|
||||
const localCandidate = selectedPairLocalId ? candidates[selectedPairLocalId] : undefined
|
||||
const remoteCandidate = selectedPairRemoteId ? candidates[selectedPairRemoteId] : undefined
|
||||
|
||||
@@ -410,12 +369,10 @@ function startStatsCollection() {
|
||||
|
||||
stats.value.isRelay = stats.value.localCandidateType === 'relay' || stats.value.remoteCandidateType === 'relay'
|
||||
} catch {
|
||||
// Stats collection errors are non-fatal
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Stop collecting stats
|
||||
function stopStatsCollection() {
|
||||
if (statsInterval) {
|
||||
clearInterval(statsInterval)
|
||||
@@ -423,37 +380,42 @@ function stopStatsCollection() {
|
||||
}
|
||||
}
|
||||
|
||||
// Send queued ICE candidates after sessionId is set
|
||||
async function flushPendingIceCandidates() {
|
||||
if (!sessionId || pendingIceCandidates.length === 0) return
|
||||
|
||||
const currentSessionId = sessionId
|
||||
const candidates = [...pendingIceCandidates]
|
||||
pendingIceCandidates = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (shouldSkipLocalCandidate(candidate)) continue
|
||||
const sendTasks = candidates.map(async (candidate) => {
|
||||
if (shouldSkipLocalCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await webrtcApi.addIceCandidate(sessionId, {
|
||||
await webrtcApi.addIceCandidate(currentSessionId, {
|
||||
candidate: candidate.candidate,
|
||||
sdpMid: candidate.sdpMid ?? undefined,
|
||||
sdpMLineIndex: candidate.sdpMLineIndex ?? undefined,
|
||||
usernameFragment: candidate.usernameFragment ?? undefined,
|
||||
})
|
||||
} catch {
|
||||
// ICE candidate send failures are non-fatal
|
||||
} catch (err) {
|
||||
videoDebugLog('Failed to send queued local ICE candidate', {
|
||||
sessionId: currentSessionId,
|
||||
candidate: summarizeIceCandidate(candidate),
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.allSettled(sendTasks)
|
||||
}
|
||||
|
||||
// Connect to WebRTC server
|
||||
async function connect(): Promise<boolean> {
|
||||
if (connectInFlight) {
|
||||
return connectInFlight
|
||||
}
|
||||
|
||||
connectInFlight = (async () => {
|
||||
registerWebSocketHandlers()
|
||||
|
||||
if (isConnecting) {
|
||||
return state.value === 'connected'
|
||||
}
|
||||
@@ -468,67 +430,85 @@ async function connect(): Promise<boolean> {
|
||||
await disconnect()
|
||||
}
|
||||
|
||||
// Clear pending ICE candidates from previous attempt
|
||||
pendingIceCandidates = []
|
||||
seenRemoteCandidates.clear()
|
||||
|
||||
try {
|
||||
state.value = 'connecting'
|
||||
error.value = null
|
||||
connectStage.value = 'fetching_ice_servers'
|
||||
setConnectStage('fetching_ice_servers')
|
||||
|
||||
// Fetch ICE servers from backend API
|
||||
const iceServers = await fetchIceServers()
|
||||
connectStage.value = 'creating_peer_connection'
|
||||
setConnectStage('creating_peer_connection', { iceServerCount: iceServers.length })
|
||||
|
||||
// Create peer connection with fetched ICE servers
|
||||
peerConnection = createPeerConnection(iceServers)
|
||||
connectStage.value = 'creating_data_channel'
|
||||
|
||||
setConnectStage('creating_data_channel')
|
||||
createDataChannel(peerConnection)
|
||||
|
||||
peerConnection.addTransceiver('video', { direction: 'recvonly' })
|
||||
peerConnection.addTransceiver('audio', { direction: 'recvonly' })
|
||||
connectStage.value = 'creating_offer'
|
||||
setConnectStage('creating_offer')
|
||||
|
||||
const offer = await peerConnection.createOffer()
|
||||
await peerConnection.setLocalDescription(offer)
|
||||
connectStage.value = 'waiting_server_answer'
|
||||
setConnectStage('waiting_server_answer')
|
||||
|
||||
// Do not pass client_id here: each connect creates a fresh session.
|
||||
const response = await webrtcApi.offer(offer.sdp!)
|
||||
sessionId = response.session_id
|
||||
|
||||
// Send any ICE candidates that were queued while waiting for sessionId
|
||||
await flushPendingIceCandidates()
|
||||
sessionIdRef.value = response.session_id
|
||||
|
||||
const answer: RTCSessionDescriptionInit = {
|
||||
type: 'answer',
|
||||
sdp: response.sdp,
|
||||
}
|
||||
connectStage.value = 'setting_remote_description'
|
||||
setConnectStage('setting_remote_description', { sessionId })
|
||||
await peerConnection.setRemoteDescription(answer)
|
||||
|
||||
// Flush any pending server ICE candidates once remote description is set
|
||||
connectStage.value = 'applying_ice_candidates'
|
||||
await flushPendingRemoteIce()
|
||||
|
||||
// Add any ICE candidates from the response
|
||||
if (response.ice_candidates && response.ice_candidates.length > 0) {
|
||||
for (const candidateObj of response.ice_candidates) {
|
||||
await addRemoteIceCandidate(candidateObj)
|
||||
setConnectStage('applying_ice_candidates', {
|
||||
sessionId,
|
||||
answerCandidates: response.ice_candidates?.length ?? 0,
|
||||
})
|
||||
let appliedAnswerCandidates = 0
|
||||
for (const candidate of response.ice_candidates ?? []) {
|
||||
if (await addRemoteIceCandidate(candidate)) {
|
||||
appliedAnswerCandidates += 1
|
||||
}
|
||||
}
|
||||
try {
|
||||
await peerConnection.addIceCandidate(null)
|
||||
} catch (err) {
|
||||
videoDebugLog('Failed to apply remote ICE end-of-candidates from answer response', {
|
||||
sessionId,
|
||||
appliedAnswerCandidates,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
|
||||
void flushPendingIceCandidates()
|
||||
|
||||
// Wait for connection to establish (5s for LAN, sufficient for most scenarios)
|
||||
const connectionTimeout = 5000
|
||||
const iceConnectedTimeout = 12000
|
||||
const pollInterval = 100
|
||||
let waited = 0
|
||||
connectStage.value = 'waiting_connection'
|
||||
setConnectStage('waiting_connection', {
|
||||
sessionId,
|
||||
connectionTimeout,
|
||||
iceConnectedTimeout,
|
||||
pollInterval,
|
||||
})
|
||||
|
||||
while (waited < connectionTimeout && peerConnection) {
|
||||
while (peerConnection) {
|
||||
const pcState = peerConnection.connectionState
|
||||
const iceState = peerConnection.iceConnectionState
|
||||
const timeoutForState = iceState === 'connected' || iceState === 'completed'
|
||||
? iceConnectedTimeout
|
||||
: connectionTimeout
|
||||
if (waited >= timeoutForState) break
|
||||
|
||||
if (pcState === 'connected') {
|
||||
connectStage.value = 'connected'
|
||||
setConnectStage('connected', { sessionId, waited })
|
||||
isConnecting = false
|
||||
return true
|
||||
}
|
||||
@@ -539,10 +519,25 @@ async function connect(): Promise<boolean> {
|
||||
waited += pollInterval
|
||||
}
|
||||
|
||||
videoDebugLog('WebRTC connect timed out waiting for ICE/DTLS', {
|
||||
sessionId,
|
||||
waited,
|
||||
connectionState: peerConnection?.connectionState,
|
||||
iceConnectionState: peerConnection?.iceConnectionState,
|
||||
iceGatheringState: peerConnection?.iceGatheringState,
|
||||
signalingState: peerConnection?.signalingState,
|
||||
})
|
||||
throw new Error('Connection timeout waiting for ICE negotiation')
|
||||
} catch (err) {
|
||||
state.value = 'failed'
|
||||
connectStage.value = 'failed'
|
||||
setConnectStage('failed', {
|
||||
sessionId,
|
||||
error: err,
|
||||
connectionState: peerConnection?.connectionState,
|
||||
iceConnectionState: peerConnection?.iceConnectionState,
|
||||
iceGatheringState: peerConnection?.iceGatheringState,
|
||||
signalingState: peerConnection?.signalingState,
|
||||
})
|
||||
error.value = err instanceof Error ? err.message : 'Connection failed'
|
||||
isConnecting = false
|
||||
await disconnect()
|
||||
@@ -557,17 +552,15 @@ async function connect(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from WebRTC server
|
||||
async function disconnect() {
|
||||
stopStatsCollection()
|
||||
|
||||
// Clear state FIRST to prevent ICE candidates from being sent
|
||||
const oldSessionId = sessionId
|
||||
sessionId = null
|
||||
sessionIdRef.value = null
|
||||
isConnecting = false
|
||||
pendingIceCandidates = []
|
||||
pendingRemoteCandidates = []
|
||||
pendingRemoteIceComplete.clear()
|
||||
seenRemoteCandidates.clear()
|
||||
|
||||
if (dataChannel) {
|
||||
@@ -584,18 +577,21 @@ async function disconnect() {
|
||||
if (oldSessionId) {
|
||||
try {
|
||||
await webrtcApi.close(oldSessionId)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
videoDebugLog('Failed to close backend WebRTC session', {
|
||||
sessionId: oldSessionId,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
videoTrack.value = null
|
||||
audioTrack.value = null
|
||||
cachedMediaStream = null // Clear cached stream on disconnect
|
||||
cachedMediaStream = null
|
||||
state.value = 'disconnected'
|
||||
connectStage.value = 'disconnected'
|
||||
setConnectStage('disconnected', { previousSessionId: oldSessionId })
|
||||
error.value = null
|
||||
|
||||
// Reset stats
|
||||
stats.value = {
|
||||
bytesReceived: 0,
|
||||
packetsReceived: 0,
|
||||
@@ -614,7 +610,6 @@ async function disconnect() {
|
||||
}
|
||||
}
|
||||
|
||||
// Send keyboard event via DataChannel (binary format)
|
||||
function sendKeyboard(event: HidKeyboardEvent): boolean {
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
return false
|
||||
@@ -629,7 +624,6 @@ function sendKeyboard(event: HidKeyboardEvent): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Send mouse event via DataChannel (binary format)
|
||||
function sendMouse(event: HidMouseEvent): boolean {
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
return false
|
||||
@@ -695,7 +689,7 @@ export function useWebRTC() {
|
||||
error,
|
||||
dataChannelReady,
|
||||
connectStage,
|
||||
sessionId: computed(() => sessionId),
|
||||
sessionId: sessionIdRef,
|
||||
|
||||
connect,
|
||||
disconnect,
|
||||
|
||||
@@ -28,17 +28,16 @@ function arraysEqual(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index])
|
||||
}
|
||||
|
||||
function syncSubscriptions() {
|
||||
function syncSubscriptions(force = false) {
|
||||
const topics = getSubscribedTopics()
|
||||
|
||||
if (arraysEqual(topics, subscribedTopics)) {
|
||||
if (!force && arraysEqual(topics, subscribedTopics)) {
|
||||
return
|
||||
}
|
||||
|
||||
subscribedTopics = topics
|
||||
|
||||
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
|
||||
subscribe(topics)
|
||||
subscribedTopics = topics
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +58,7 @@ function connect() {
|
||||
networkErrorMessage.value = null
|
||||
reconnectAttempts.value = 0
|
||||
|
||||
syncSubscriptions()
|
||||
syncSubscriptions(true)
|
||||
}
|
||||
|
||||
wsInstance.onmessage = (e) => {
|
||||
|
||||
@@ -314,6 +314,10 @@ export default {
|
||||
title: 'Video device offline',
|
||||
detail: 'Capture card is not responding, attempting to re-detect…',
|
||||
},
|
||||
audioDeviceLost: {
|
||||
title: 'Audio device offline',
|
||||
detail: 'Reconnecting the audio capture device…',
|
||||
},
|
||||
deviceBusy: {
|
||||
title: 'Video channel busy',
|
||||
detail: 'Applying a new configuration or another component is using the device, please wait…',
|
||||
@@ -335,6 +339,8 @@ export default {
|
||||
device_lost: 'Video node disappeared, waiting for the driver to recover',
|
||||
config_changing: 'Applying new configuration',
|
||||
mode_switching: 'Switching video mode',
|
||||
audio_device_lost: 'Audio capture is unavailable; recovery in progress',
|
||||
audio_reconnecting: 'Retrying audio device connection',
|
||||
uvc_usb_error:
|
||||
'Try another USB port or cable, avoid hubs, or reconnect the device. You can also reset the device from Settings → Environment → USB Devices.',
|
||||
uvc_capture_stall: '',
|
||||
@@ -352,6 +358,10 @@ export default {
|
||||
webrtcPhaseSetRemote: 'Applying remote description...',
|
||||
webrtcPhaseApplyIce: 'Applying ICE candidates...',
|
||||
webrtcPhaseNegotiating: 'Negotiating secure connection...',
|
||||
mjpegPhaseWebsocket: 'Connecting control channel...',
|
||||
mjpegPhaseStream: 'Requesting video stream...',
|
||||
mjpegPhaseFirstFrame: 'Waiting for first frame...',
|
||||
stepProgress: 'Step {current}/{total}',
|
||||
pointerLocked: 'Pointer Locked',
|
||||
pointerLockedDesc: 'Press Escape to release the pointer',
|
||||
pointerLockFailed: 'Failed to lock pointer',
|
||||
@@ -476,10 +486,11 @@ export default {
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
sidebarSubtitle: 'Manage device, network and extensions',
|
||||
basic: 'Basic',
|
||||
general: 'General',
|
||||
appearance: 'Appearance',
|
||||
account: 'User',
|
||||
account: 'Account',
|
||||
access: 'Access',
|
||||
video: 'Video',
|
||||
encoder: 'Encoder',
|
||||
@@ -496,6 +507,19 @@ export default {
|
||||
configured: 'Configured',
|
||||
security: 'Security',
|
||||
about: 'About',
|
||||
appearanceSubtitle: 'Customize interface appearance and language',
|
||||
accountSubtitle: 'Manage credentials and session policy',
|
||||
networkSubtitle: 'Configure web server ports, listen addresses and SSL certificate',
|
||||
videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers',
|
||||
hidSubtitle: 'Configure keyboard and mouse backend with USB gadget descriptors',
|
||||
msdSubtitle: 'Manage Mass Storage Device image directory',
|
||||
atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN',
|
||||
environmentSubtitle: 'System runtime environment and USB device maintenance',
|
||||
aboutSubtitle: 'Online upgrade, version info and hardware overview',
|
||||
extTtydSubtitle: 'Open a host Shell terminal in the browser',
|
||||
extRustdeskSubtitle: 'Remote graphical access via RustDesk',
|
||||
extRtspSubtitle: 'Provide an RTSP video stream for external clients',
|
||||
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
||||
aboutDesc: 'Open and Lightweight IP-KVM Solution',
|
||||
deviceInfo: 'Device Info',
|
||||
deviceInfoDesc: 'Host system information',
|
||||
@@ -512,8 +536,8 @@ export default {
|
||||
changePassword: 'Change Password',
|
||||
currentPassword: 'Current Password',
|
||||
newPassword: 'New Password',
|
||||
usernameDesc: 'Change your login username',
|
||||
passwordDesc: 'Change your login password',
|
||||
usernameDesc: 'Change the console login username',
|
||||
passwordDesc: 'Change the console login password',
|
||||
version: 'Version',
|
||||
buildInfo: 'Build Info',
|
||||
detectDevices: 'Detect Devices',
|
||||
@@ -542,20 +566,25 @@ export default {
|
||||
addBindAddress: 'Add address',
|
||||
bindAddressListEmpty: 'Add at least one IP address.',
|
||||
httpsEnabled: 'Enable HTTPS',
|
||||
httpsEnabledDesc: 'Enable HTTPS encrypted connection (a self-signed certificate is generated if none is specified)',
|
||||
httpsEnabledDesc: 'Serve over an encrypted connection. A self-signed certificate is generated automatically if none is provided.',
|
||||
portConfig: 'Port & Protocol',
|
||||
portConfigDesc: 'The service runs on a single port at a time, determined by the HTTPS toggle',
|
||||
portConfigDesc: 'The service listens on a single port at a time, determined by the HTTPS toggle',
|
||||
httpPortReserved: 'HTTP port (reserved)',
|
||||
httpsPortReserved: 'HTTPS port (reserved)',
|
||||
portActive: 'Active',
|
||||
portReserved: 'Reserved',
|
||||
portReservedHint: 'The reserved port is applied only after switching protocol; you can preconfigure it now.',
|
||||
previewUrl: 'Access URL preview',
|
||||
copyUrl: 'Copy access URL',
|
||||
openInBrowser: 'Open in browser',
|
||||
listenAddress: 'Listen Address',
|
||||
listenAddressDesc: 'Configure which network interfaces the web server listens on',
|
||||
listenAddressDesc: 'Choose which network interfaces the web server binds to',
|
||||
bindModeAllDesc: '0.0.0.0 — Listen on all network interfaces',
|
||||
bindModeLocalDesc: '127.0.0.1 — Allow local access only',
|
||||
bindModeCustomDesc: 'Specify a list of IP addresses',
|
||||
effectiveAddresses: 'Listen address preview',
|
||||
bindModeLocalDesc: '127.0.0.1 — Loopback only (local access)',
|
||||
bindModeCustomDesc: 'Bind to a specific list of IP addresses',
|
||||
effectiveAddresses: 'Effective listen addresses',
|
||||
sslCertificate: 'SSL Certificate',
|
||||
sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one, restart required',
|
||||
sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one. A service restart is required to apply.',
|
||||
sslCertCustom: 'Custom Certificate',
|
||||
sslCertSelfSigned: 'Self-Signed',
|
||||
sslCertActive: 'Custom certificate is active',
|
||||
@@ -568,6 +597,8 @@ export default {
|
||||
sslCertSaved: 'Certificate saved, restart to apply',
|
||||
sslCertCleared: 'Reverted to self-signed certificate, restart to apply',
|
||||
restartRequired: 'Restart Required',
|
||||
restartRequiredHint: 'The service will restart automatically to apply the new configuration.',
|
||||
unsavedChangesHint: 'Click Save to apply changes',
|
||||
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
|
||||
restarting: 'Restarting...',
|
||||
autoRestarting: 'Restarting automatically',
|
||||
@@ -600,10 +631,10 @@ export default {
|
||||
updateMsgInstalling: 'Replacing binary',
|
||||
updateMsgRestarting: 'Restarting service',
|
||||
auth: 'Access',
|
||||
authSettings: 'Access Settings',
|
||||
authSettingsDesc: 'Single-user access and session behavior',
|
||||
allowMultipleSessions: 'Allow multiple web sessions',
|
||||
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
|
||||
authSettings: 'Session Policy',
|
||||
authSettingsDesc: 'Configure single-user login and concurrent session behavior',
|
||||
allowMultipleSessions: 'Allow concurrent web sessions',
|
||||
allowMultipleSessionsDesc: 'When disabled, a new login automatically signs out the previous session',
|
||||
userManagement: 'User Management',
|
||||
userManagementDesc: 'Manage user accounts and permissions',
|
||||
addUser: 'Add User',
|
||||
@@ -665,10 +696,10 @@ export default {
|
||||
atxWolInterface: 'Network Interface',
|
||||
atxWolInterfacePlaceholder: 'e.g. eth0, enp0s3',
|
||||
atxWolInterfaceHint: 'Specify network interface for WOL packets, leave empty for default routing',
|
||||
themeDesc: 'Choose your preferred color scheme',
|
||||
languageDesc: 'Select your preferred language',
|
||||
videoSettings: 'Video Settings',
|
||||
videoSettingsDesc: 'Configure video capture device',
|
||||
themeDesc: 'Choose the interface color scheme',
|
||||
languageDesc: 'Choose the interface display language',
|
||||
videoSettings: 'Video Capture',
|
||||
videoSettingsDesc: 'Configure capture device format, resolution and frame rate',
|
||||
videoDevice: 'Video Device',
|
||||
selectDevice: 'Select device...',
|
||||
videoFormat: 'Video Format',
|
||||
@@ -676,13 +707,13 @@ export default {
|
||||
driver: 'Driver',
|
||||
resolution: 'Resolution',
|
||||
frameRate: 'Frame Rate',
|
||||
encoderBackend: 'Encoder Backend',
|
||||
encoderBackendDesc: 'Select encoder backend for WebRTC streaming',
|
||||
encoderBackend: 'Video Encoder',
|
||||
encoderBackendDesc: 'Select the encoder backend used for WebRTC streaming',
|
||||
backend: 'Backend',
|
||||
autoRecommended: 'Auto (Recommended)',
|
||||
software: 'Software',
|
||||
supportedFormats: 'Supported Formats',
|
||||
encoderHint: 'Hardware encoders provide better performance with lower CPU usage. Software encoders are more compatible but require more CPU resources.',
|
||||
supportedFormats: 'Supported Codecs',
|
||||
encoderHint: 'Hardware encoders deliver lower latency and CPU usage; software encoders offer broader compatibility at a higher resource cost.',
|
||||
hidSettings: 'HID Settings',
|
||||
hidSettingsDesc: 'Configure keyboard and mouse control',
|
||||
hidBackend: 'HID Backend',
|
||||
@@ -824,20 +855,20 @@ export default {
|
||||
resetConfirmDesc: 'This will reset USB device "{device}" by cycling its authorized attribute. All connections to this device will be temporarily interrupted. Continue?',
|
||||
resetAction: 'Reset Device',
|
||||
},
|
||||
webrtcSettings: 'WebRTC Settings',
|
||||
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
|
||||
publicIceServersHint: 'Empty uses Google public STUN, configure your own TURN for production',
|
||||
webrtcSettings: 'WebRTC Signaling',
|
||||
webrtcSettingsDesc: 'Configure STUN/TURN servers to assist NAT traversal',
|
||||
publicIceServersHint: 'Leave empty to use Google\u2019s public STUN servers; TURN must be self-hosted',
|
||||
stunServer: 'STUN Server',
|
||||
stunServerPlaceholder: 'stun:stun.l.google.com:19302',
|
||||
stunServerHint: 'Custom STUN server (leave empty to use Google public server)',
|
||||
stunServerHint: 'Leave empty to use Google\u2019s public STUN servers',
|
||||
turnServer: 'TURN Server',
|
||||
turnServerPlaceholder: 'turn:turn.example.com:3478',
|
||||
turnServerHint: 'Custom TURN relay server (required for production)',
|
||||
turnServerHint: 'TURN relay server. Strongly recommended for public deployments or strict NAT environments.',
|
||||
turnUsername: 'TURN Username',
|
||||
turnPassword: 'TURN Password',
|
||||
turnPasswordConfigured: 'Password already configured. Leave empty to keep current password.',
|
||||
turnCredentialsHint: 'Credentials for TURN server authentication',
|
||||
iceConfigNote: 'Note: Changes require reconnecting the WebRTC session to take effect.',
|
||||
turnPasswordConfigured: 'A password is already saved. Leave empty to keep the current password.',
|
||||
turnCredentialsHint: 'Credentials used for TURN server authentication',
|
||||
iceConfigNote: 'Changes apply to the next WebRTC session',
|
||||
},
|
||||
virtualKeyboard: {
|
||||
title: 'Virtual Keyboard',
|
||||
@@ -872,6 +903,10 @@ export default {
|
||||
format: 'Format',
|
||||
resolution: 'Resolution',
|
||||
fps: 'FPS',
|
||||
fpsTarget: 'Target FPS',
|
||||
fpsActual: 'Actual FPS',
|
||||
fpsStaticHint: 'Frame rate drops automatically while the image is static',
|
||||
paused: 'Paused',
|
||||
clients: 'Clients',
|
||||
backend: 'Backend',
|
||||
mouse: 'Mouse',
|
||||
|
||||
@@ -313,6 +313,10 @@ export default {
|
||||
title: '视频设备已断开',
|
||||
detail: '采集卡离线,正在尝试重新识别…',
|
||||
},
|
||||
audioDeviceLost: {
|
||||
title: '音频设备已断开',
|
||||
detail: '正在尝试重新连接音频采集设备…',
|
||||
},
|
||||
deviceBusy: {
|
||||
title: '视频通道忙',
|
||||
detail: '正在切换配置或被其他组件占用,请稍候…',
|
||||
@@ -334,6 +338,8 @@ export default {
|
||||
device_lost: '视频节点丢失,等待驱动恢复',
|
||||
config_changing: '正在应用新配置',
|
||||
mode_switching: '正在切换视频模式',
|
||||
audio_device_lost: '音频采集不可用,正在自动恢复',
|
||||
audio_reconnecting: '正在重试连接音频设备',
|
||||
uvc_usb_error:
|
||||
'可尝试更换 USB 口或线、避免 HUB、或重新插拔设备;也可在 设置 → 环境 → USB 设备 中复位。',
|
||||
uvc_capture_stall: '',
|
||||
@@ -351,6 +357,10 @@ export default {
|
||||
webrtcPhaseSetRemote: '正在应用远端会话描述...',
|
||||
webrtcPhaseApplyIce: '正在应用 ICE 候选...',
|
||||
webrtcPhaseNegotiating: '正在协商安全连接...',
|
||||
mjpegPhaseWebsocket: '正在连接控制通道...',
|
||||
mjpegPhaseStream: '正在请求视频流...',
|
||||
mjpegPhaseFirstFrame: '正在等待首帧...',
|
||||
stepProgress: '第 {current}/{total} 步',
|
||||
pointerLocked: '鼠标已锁定',
|
||||
pointerLockedDesc: '按 Escape 键释放鼠标',
|
||||
pointerLockFailed: '鼠标锁定失败',
|
||||
@@ -475,10 +485,11 @@ export default {
|
||||
},
|
||||
settings: {
|
||||
title: '系统设置',
|
||||
sidebarSubtitle: '管理设备、网络与扩展',
|
||||
basic: '基础',
|
||||
general: '通用',
|
||||
appearance: '外观',
|
||||
account: '用户',
|
||||
account: '账户',
|
||||
access: '访问',
|
||||
video: '视频',
|
||||
encoder: '编码器',
|
||||
@@ -495,6 +506,19 @@ export default {
|
||||
configured: '已配置',
|
||||
security: '安全',
|
||||
about: '关于',
|
||||
appearanceSubtitle: '自定义界面外观与显示语言',
|
||||
accountSubtitle: '管理登录凭据与会话策略',
|
||||
networkSubtitle: '配置 Web 服务端口、监听地址与 SSL 证书',
|
||||
videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器',
|
||||
hidSubtitle: '配置键盘鼠标后端与 USB Gadget 描述符',
|
||||
msdSubtitle: '管理虚拟存储设备 (MSD) 镜像目录',
|
||||
atxSubtitle: '配置远程电源控制硬件与网络唤醒',
|
||||
environmentSubtitle: '系统级运行环境与 USB 设备维护',
|
||||
aboutSubtitle: '在线升级、版本信息与设备硬件概览',
|
||||
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
|
||||
extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问',
|
||||
extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流',
|
||||
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
||||
aboutDesc: '开放轻量的 IP-KVM 解决方案',
|
||||
deviceInfo: '设备信息',
|
||||
deviceInfoDesc: '主机系统信息',
|
||||
@@ -511,8 +535,8 @@ export default {
|
||||
changePassword: '修改密码',
|
||||
currentPassword: '当前密码',
|
||||
newPassword: '新密码',
|
||||
usernameDesc: '修改登录用户名',
|
||||
passwordDesc: '修改登录密码',
|
||||
usernameDesc: '修改控制台登录用户名',
|
||||
passwordDesc: '修改控制台登录密码',
|
||||
version: '版本',
|
||||
buildInfo: '构建信息',
|
||||
detectDevices: '探测设备',
|
||||
@@ -541,20 +565,25 @@ export default {
|
||||
addBindAddress: '添加地址',
|
||||
bindAddressListEmpty: '请至少填写一个 IP 地址。',
|
||||
httpsEnabled: '启用 HTTPS',
|
||||
httpsEnabledDesc: '启用 HTTPS 加密连接(未指定证书将生成自签证书)',
|
||||
httpsEnabledDesc: '使用加密连接对外提供服务,未配置证书时自动生成自签名证书',
|
||||
portConfig: '端口与协议',
|
||||
portConfigDesc: '服务一次只运行在一个端口上,由 HTTPS 开关决定使用哪个端口',
|
||||
portConfigDesc: '服务一次仅监听一个端口,由 HTTPS 开关决定生效端口',
|
||||
httpPortReserved: 'HTTP 端口(备用)',
|
||||
httpsPortReserved: 'HTTPS 端口(备用)',
|
||||
portActive: '当前生效',
|
||||
portReserved: '备用',
|
||||
portReservedHint: '备用端口仅在切换协议后生效,可提前配置',
|
||||
previewUrl: '访问地址预览',
|
||||
copyUrl: '复制访问地址',
|
||||
openInBrowser: '在浏览器中打开',
|
||||
listenAddress: '监听地址',
|
||||
listenAddressDesc: '配置 Web 服务监听哪些网络接口',
|
||||
bindModeAllDesc: '0.0.0.0 — 监听所有网络接口',
|
||||
bindModeLocalDesc: '127.0.0.1 — 仅允许本机访问',
|
||||
bindModeCustomDesc: '指定一组 IP 地址',
|
||||
effectiveAddresses: '监听地址预览',
|
||||
effectiveAddresses: '生效监听地址',
|
||||
sslCertificate: 'SSL 证书',
|
||||
sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,修改后需要重启生效',
|
||||
sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,保存后需重启服务生效',
|
||||
sslCertCustom: '自定义证书',
|
||||
sslCertSelfSigned: '自签名证书',
|
||||
sslCertActive: '自定义证书已启用',
|
||||
@@ -567,6 +596,8 @@ export default {
|
||||
sslCertSaved: '证书已保存,重启后生效',
|
||||
sslCertCleared: '已恢复自签名证书,重启后生效',
|
||||
restartRequired: '需要重启',
|
||||
restartRequiredHint: '保存后将自动重启服务以应用新配置',
|
||||
unsavedChangesHint: '点击右侧按钮保存当前配置',
|
||||
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
|
||||
restarting: '正在重启...',
|
||||
autoRestarting: '正在自动重启',
|
||||
@@ -599,10 +630,10 @@ export default {
|
||||
updateMsgInstalling: '替换程序中',
|
||||
updateMsgRestarting: '服务重启中',
|
||||
auth: '访问控制',
|
||||
authSettings: '访问设置',
|
||||
authSettingsDesc: '单用户访问与会话策略',
|
||||
allowMultipleSessions: '允许多个 Web 会话',
|
||||
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
|
||||
authSettings: '会话策略',
|
||||
authSettingsDesc: '配置单用户登录与并发会话规则',
|
||||
allowMultipleSessions: '允许多个 Web 会话并存',
|
||||
allowMultipleSessionsDesc: '关闭后,新登录将自动踢出旧会话',
|
||||
userManagement: '用户管理',
|
||||
userManagementDesc: '管理用户账号和权限',
|
||||
addUser: '添加用户',
|
||||
@@ -664,10 +695,10 @@ export default {
|
||||
atxWolInterface: '网络接口',
|
||||
atxWolInterfacePlaceholder: '例如: eth0, enp0s3',
|
||||
atxWolInterfaceHint: '指定发送 WOL 包的网络接口,留空则使用系统默认路由',
|
||||
themeDesc: '选择您喜欢的颜色方案',
|
||||
languageDesc: '选择您的首选语言',
|
||||
videoSettings: '视频设置',
|
||||
videoSettingsDesc: '配置视频采集设备',
|
||||
themeDesc: '选择界面颜色方案',
|
||||
languageDesc: '选择界面显示语言',
|
||||
videoSettings: '视频采集',
|
||||
videoSettingsDesc: '配置视频采集设备的格式、分辨率与帧率',
|
||||
videoDevice: '视频设备',
|
||||
selectDevice: '选择设备...',
|
||||
videoFormat: '视频格式',
|
||||
@@ -675,13 +706,13 @@ export default {
|
||||
driver: '驱动',
|
||||
resolution: '分辨率',
|
||||
frameRate: '帧率',
|
||||
encoderBackend: '编码器后端',
|
||||
encoderBackendDesc: '选择 WebRTC 流的编码器后端',
|
||||
encoderBackend: '视频编码器',
|
||||
encoderBackendDesc: '选择 WebRTC 输出使用的视频编码器后端',
|
||||
backend: '后端',
|
||||
autoRecommended: '自动(推荐)',
|
||||
software: '软件',
|
||||
supportedFormats: '支持的格式',
|
||||
encoderHint: '硬件编码器性能更好,CPU 占用更低。软件编码器兼容性更好,但需要更多 CPU 资源。',
|
||||
supportedFormats: '支持的编码格式',
|
||||
encoderHint: '硬件编码器延迟低、CPU 占用小;软件编码器兼容性更广但占用资源更多。',
|
||||
hidSettings: 'HID 设置',
|
||||
hidSettingsDesc: '配置键盘和鼠标控制',
|
||||
hidBackend: 'HID 后端',
|
||||
@@ -823,20 +854,20 @@ export default {
|
||||
resetConfirmDesc: '将通过 authorized 属性复位 USB 设备「{device}」,该设备上的所有连接将短暂中断。确定继续?',
|
||||
resetAction: '确认复位',
|
||||
},
|
||||
webrtcSettings: 'WebRTC 设置',
|
||||
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
|
||||
publicIceServersHint: '留空将使用 Google 公共 STUN 服务器,TURN 服务器需自行配置',
|
||||
webrtcSettings: 'WebRTC 信令',
|
||||
webrtcSettingsDesc: '配置 STUN/TURN 服务器以辅助 NAT 穿透',
|
||||
publicIceServersHint: '留空将使用 Google 公共 STUN 服务器;TURN 服务器需自行部署',
|
||||
stunServer: 'STUN 服务器',
|
||||
stunServerPlaceholder: 'stun:stun.l.google.com:19302',
|
||||
stunServerHint: '自定义 STUN 服务器(留空则使用 Google 公共服务器)',
|
||||
stunServerHint: '留空将使用 Google 公共 STUN 服务器',
|
||||
turnServer: 'TURN 服务器',
|
||||
turnServerPlaceholder: 'turn:turn.example.com:3478',
|
||||
turnServerHint: '自定义 TURN 中继服务器(生产环境必须配置)',
|
||||
turnServerHint: 'TURN 中继服务器;公网部署或严格 NAT 环境强烈建议配置',
|
||||
turnUsername: 'TURN 用户名',
|
||||
turnPassword: 'TURN 密码',
|
||||
turnPasswordConfigured: '密码已配置。留空则保持当前密码。',
|
||||
turnCredentialsHint: '用于 TURN 服务器认证的凭据',
|
||||
iceConfigNote: '注意:更改后需要重新连接 WebRTC 会话才能生效。',
|
||||
turnPasswordConfigured: '密码已保存。留空则保持当前密码不变。',
|
||||
turnCredentialsHint: '用于 TURN 服务器身份验证的凭据',
|
||||
iceConfigNote: '更改后将在下一次 WebRTC 会话建立时生效',
|
||||
},
|
||||
virtualKeyboard: {
|
||||
title: '虚拟键盘',
|
||||
@@ -871,6 +902,10 @@ export default {
|
||||
format: '格式',
|
||||
resolution: '分辨率',
|
||||
fps: '帧率',
|
||||
fpsTarget: '目标帧率',
|
||||
fpsActual: '实际帧率',
|
||||
fpsStaticHint: '画面静止时会自动降帧',
|
||||
paused: '已暂停',
|
||||
clients: '客户端',
|
||||
backend: '后端',
|
||||
mouse: '鼠标',
|
||||
|
||||
15
web/src/lib/debugLog.ts
Normal file
15
web/src/lib/debugLog.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function isDebugLogEnabled(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return new URLSearchParams(window.location.search).get('log') === 'debug'
|
||||
}
|
||||
|
||||
export function videoDebugLog(message: string, details?: unknown): void {
|
||||
if (!isDebugLogEnabled()) return
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
if (details === undefined) {
|
||||
console.log(`[VideoDebug ${timestamp}] ${message}`)
|
||||
} else {
|
||||
console.log(`[VideoDebug ${timestamp}] ${message}`, details)
|
||||
}
|
||||
}
|
||||
11
web/src/lib/streamSignal.ts
Normal file
11
web/src/lib/streamSignal.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { StreamDeviceLostEventData } from '@/types/websocket'
|
||||
|
||||
const AUDIO_STATE_REASONS = new Set(['audio_device_lost', 'audio_reconnecting'])
|
||||
|
||||
export function isAudioDeviceLostStateReason(reason: string | null | undefined): boolean {
|
||||
return typeof reason === 'string' && AUDIO_STATE_REASONS.has(reason)
|
||||
}
|
||||
|
||||
export function isAudioStreamDeviceLostPayload(data: StreamDeviceLostEventData): boolean {
|
||||
return data.kind === 'audio'
|
||||
}
|
||||
@@ -35,6 +35,14 @@ export function buildWsUrl(path: string): string {
|
||||
/** Default reconnect delay in milliseconds */
|
||||
export const WS_RECONNECT_DELAY = 3000
|
||||
|
||||
export type StreamDeviceLostKind = 'video' | 'audio'
|
||||
|
||||
export interface StreamDeviceLostEventData {
|
||||
kind: StreamDeviceLostKind
|
||||
device: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** WebSocket ready states */
|
||||
export const WS_STATE = {
|
||||
CONNECTING: WebSocket.CONNECTING,
|
||||
|
||||
@@ -16,8 +16,11 @@ import { CanonicalKey, HidBackend } from '@/types/generated'
|
||||
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
|
||||
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { generateUUID } from '@/lib/utils'
|
||||
import { cn, generateUUID } from '@/lib/utils'
|
||||
import { formatFpsValue } from '@/lib/fps'
|
||||
import { videoDebugLog } from '@/lib/debugLog'
|
||||
import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
|
||||
import type { StreamDeviceLostEventData } from '@/types/websocket'
|
||||
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
||||
|
||||
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
|
||||
@@ -127,7 +130,6 @@ const mousePosition = ref({ x: 0, y: 0 })
|
||||
const lastMousePosition = ref({ x: 0, y: 0 })
|
||||
const isPointerLocked = ref(false)
|
||||
|
||||
/** Local overlay crosshair position (px, relative to video container); HID uses mousePosition separately */
|
||||
const localCrosshairPos = ref<{ x: number; y: number } | null>(null)
|
||||
|
||||
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
|
||||
@@ -180,10 +182,6 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
|
||||
if (webrtc.isConnecting.value) return 'connecting'
|
||||
if (webrtc.isConnected.value) return 'connected'
|
||||
}
|
||||
// MJPEG: check if frames have actually arrived (frontend-side detection)
|
||||
// This is more reliable than relying on stream.online from backend,
|
||||
// which can be stale due to the debounce delay in device_info broadcaster.
|
||||
// Also handles browsers that don't fire img.onload for multipart MJPEG streams.
|
||||
if (videoMode.value === 'mjpeg' && mjpegFrameReceived.value) return 'connected'
|
||||
if (systemStore.stream?.online) return 'connected'
|
||||
return 'disconnected'
|
||||
@@ -201,10 +199,18 @@ function getResolutionShortName(width: number, height: number): string {
|
||||
return `${height}p`
|
||||
}
|
||||
|
||||
const isMjpegPaused = computed(() => {
|
||||
if (videoMode.value !== 'mjpeg') return false
|
||||
const stream = systemStore.stream
|
||||
if (!stream) return false
|
||||
return stream.online === false
|
||||
})
|
||||
|
||||
const videoQuickInfo = computed(() => {
|
||||
const stream = systemStore.stream
|
||||
if (!stream?.resolution) return ''
|
||||
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
|
||||
if (isMjpegPaused.value) return `${resShort} ${t('statusCard.paused')}`
|
||||
return `${resShort} ${formatFpsValue(backendFps.value)}fps`
|
||||
})
|
||||
|
||||
@@ -212,20 +218,36 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
||||
const stream = systemStore.stream
|
||||
if (!stream) return []
|
||||
const receivedFps = backendFps.value
|
||||
const paused = isMjpegPaused.value
|
||||
|
||||
const inputFmt = stream.format || 'MJPEG'
|
||||
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
|
||||
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
|
||||
|
||||
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}`
|
||||
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined
|
||||
const targetFpsValue = formatFpsValue(stream.targetFps ?? 0)
|
||||
const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
|
||||
const actualStatus: StatusDetail['status'] = paused
|
||||
? undefined
|
||||
: receivedFps > 5 ? 'ok'
|
||||
: receivedFps > 0 ? 'warning'
|
||||
: 'error'
|
||||
|
||||
return [
|
||||
const details: StatusDetail[] = [
|
||||
{ label: t('statusCard.device'), value: stream.device || '-' },
|
||||
{ label: t('statusCard.format'), value: formatDisplay },
|
||||
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
|
||||
{ label: t('statusCard.fps'), value: fpsDisplay, status: fpsStatus },
|
||||
{ label: t('statusCard.fpsTarget'), value: targetFpsValue },
|
||||
{ label: t('statusCard.fpsActual'), value: actualFpsValue, status: actualStatus },
|
||||
]
|
||||
|
||||
if (videoMode.value === 'mjpeg' && !paused && receivedFps > 0 && receivedFps < (stream.targetFps ?? 0)) {
|
||||
details.push({
|
||||
label: '',
|
||||
value: t('statusCard.fpsStaticHint'),
|
||||
})
|
||||
}
|
||||
|
||||
return details
|
||||
})
|
||||
|
||||
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||
@@ -418,7 +440,7 @@ const audioDetails = computed<StatusDetail[]>(() => {
|
||||
return [
|
||||
{ label: t('statusCard.device'), value: audio.device || t('statusCard.defaultDevice') },
|
||||
{ label: t('statusCard.quality'), value: translateAudioQuality(audio.quality) },
|
||||
{ label: t('statusCard.streaming'), value: audio.streaming ? t('statusCard.yes') : t('statusCard.no'), status: audio.streaming ? 'ok' : undefined },
|
||||
{ label: t('statusCard.streaming'), value: audio.streaming ? t('common.yes') : t('common.no'), status: audio.streaming ? 'ok' : undefined },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -484,9 +506,48 @@ const msdDetails = computed<StatusDetail[]>(() => {
|
||||
return details
|
||||
})
|
||||
|
||||
const WEBRTC_PROGRESS_STAGES = [
|
||||
'fetching_ice_servers',
|
||||
'creating_peer_connection',
|
||||
'creating_data_channel',
|
||||
'creating_offer',
|
||||
'waiting_server_answer',
|
||||
'setting_remote_description',
|
||||
'applying_ice_candidates',
|
||||
'waiting_connection',
|
||||
] as const
|
||||
|
||||
const MJPEG_PROGRESS_STAGES = [
|
||||
'connecting_websocket',
|
||||
'requesting_stream',
|
||||
'waiting_first_frame',
|
||||
] as const
|
||||
|
||||
type MjpegProgressStage = (typeof MJPEG_PROGRESS_STAGES)[number]
|
||||
|
||||
const mjpegConnectStage = computed<MjpegProgressStage | null>(() => {
|
||||
if (videoMode.value !== 'mjpeg') return null
|
||||
if (videoRestarting.value) return null
|
||||
|
||||
if (!wsConnected.value || wsNetworkError.value) return 'connecting_websocket'
|
||||
if (mjpegTimestamp.value === 0) return 'requesting_stream'
|
||||
if (!mjpegFrameReceived.value) return 'waiting_first_frame'
|
||||
return null
|
||||
})
|
||||
|
||||
const webrtcLoadingMessage = computed(() => {
|
||||
if (videoMode.value === 'mjpeg') {
|
||||
return videoRestarting.value ? t('console.videoRestarting') : t('console.connecting')
|
||||
if (videoRestarting.value) return t('console.videoRestarting')
|
||||
switch (mjpegConnectStage.value) {
|
||||
case 'connecting_websocket':
|
||||
return t('console.mjpegPhaseWebsocket')
|
||||
case 'requesting_stream':
|
||||
return t('console.mjpegPhaseStream')
|
||||
case 'waiting_first_frame':
|
||||
return t('console.mjpegPhaseFirstFrame')
|
||||
default:
|
||||
return t('console.connecting')
|
||||
}
|
||||
}
|
||||
|
||||
switch (webrtc.connectStage.value) {
|
||||
@@ -515,6 +576,44 @@ const webrtcLoadingMessage = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const connectProgress = computed<{ current: number; total: number } | null>(() => {
|
||||
if (videoMode.value === 'mjpeg') {
|
||||
const stage = mjpegConnectStage.value
|
||||
if (!stage) return null
|
||||
return {
|
||||
current: MJPEG_PROGRESS_STAGES.indexOf(stage) + 1,
|
||||
total: MJPEG_PROGRESS_STAGES.length,
|
||||
}
|
||||
}
|
||||
|
||||
const stage = webrtc.connectStage.value
|
||||
const idx = WEBRTC_PROGRESS_STAGES.indexOf(stage as (typeof WEBRTC_PROGRESS_STAGES)[number])
|
||||
if (idx < 0) return null
|
||||
|
||||
return {
|
||||
current: idx + 1,
|
||||
total: WEBRTC_PROGRESS_STAGES.length,
|
||||
}
|
||||
})
|
||||
|
||||
const videoContainerStyle = computed(() => {
|
||||
if (!videoAspectRatio.value) {
|
||||
return {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
minHeight: '120px',
|
||||
}
|
||||
}
|
||||
return {
|
||||
aspectRatio: videoAspectRatio.value,
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
minHeight: '120px',
|
||||
}
|
||||
})
|
||||
|
||||
const showMsdStatusCard = computed(() => {
|
||||
return !!(systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329')
|
||||
})
|
||||
@@ -603,7 +702,6 @@ function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise
|
||||
})
|
||||
}
|
||||
|
||||
/** For WebRTC watch: skip auto-reconnect when these hold. */
|
||||
function shouldSuppressAutoReconnect(): boolean {
|
||||
return videoMode.value === 'mjpeg'
|
||||
|| !isConsoleActive.value
|
||||
@@ -613,6 +711,14 @@ function shouldSuppressAutoReconnect(): boolean {
|
||||
}
|
||||
|
||||
function markWebRTCFailure(reason: string, description?: string) {
|
||||
videoDebugLog('Marking WebRTC failure', {
|
||||
reason,
|
||||
description,
|
||||
videoMode: videoMode.value,
|
||||
webrtcState: webrtc.state.value,
|
||||
webrtcStage: webrtc.connectStage.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
pendingWebRTCReadyGate = false
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = reason
|
||||
@@ -627,25 +733,45 @@ function markWebRTCFailure(reason: string, description?: string) {
|
||||
|
||||
async function waitForWebRTCReadyGate(reason: string, timeoutMs = 3000): Promise<void> {
|
||||
if (!pendingWebRTCReadyGate) return
|
||||
videoDebugLog('Waiting for WebRTC backend ready gate', { reason, timeoutMs })
|
||||
const ready = await videoSession.waitForWebRTCReadyAny(timeoutMs)
|
||||
if (!ready) {
|
||||
console.warn(`[WebRTC] Ready gate timeout (${reason}), attempting connection anyway`)
|
||||
}
|
||||
videoDebugLog('WebRTC backend ready gate completed', { reason, ready })
|
||||
pendingWebRTCReadyGate = false
|
||||
}
|
||||
|
||||
async function connectWebRTCSerial(reason: string): Promise<boolean> {
|
||||
if (webrtcConnectTask) {
|
||||
videoDebugLog('Reusing serialized WebRTC connect task', {
|
||||
reason,
|
||||
stage: webrtc.connectStage.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
return webrtcConnectTask
|
||||
}
|
||||
|
||||
videoDebugLog('Starting serialized WebRTC connect task', {
|
||||
reason,
|
||||
videoMode: videoMode.value,
|
||||
stage: webrtc.connectStage.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
webrtcConnectTask = (async () => {
|
||||
await waitForWebRTCReadyGate(reason)
|
||||
return webrtc.connect()
|
||||
})()
|
||||
|
||||
try {
|
||||
return await webrtcConnectTask
|
||||
const result = await webrtcConnectTask
|
||||
videoDebugLog('Serialized WebRTC connect task finished', {
|
||||
reason,
|
||||
result,
|
||||
stage: webrtc.connectStage.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
return result
|
||||
} finally {
|
||||
webrtcConnectTask = null
|
||||
}
|
||||
@@ -740,7 +866,11 @@ function handleVideoError() {
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
||||
function handleStreamDeviceLost(data: StreamDeviceLostEventData) {
|
||||
videoDebugLog('Stream device lost event', data)
|
||||
if (isAudioStreamDeviceLostPayload(data)) {
|
||||
return
|
||||
}
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason })
|
||||
|
||||
@@ -750,6 +880,12 @@ function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
||||
}
|
||||
|
||||
function scheduleWebRTCRecovery() {
|
||||
videoDebugLog('Scheduling WebRTC recovery check', {
|
||||
attempts: webrtcRecoveryAttempts,
|
||||
videoMode: videoMode.value,
|
||||
videoError: videoError.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
if (webrtcRecoveryTimerId !== null) {
|
||||
clearTimeout(webrtcRecoveryTimerId)
|
||||
webrtcRecoveryTimerId = null
|
||||
@@ -798,6 +934,10 @@ function scheduleWebRTCRecovery() {
|
||||
}
|
||||
|
||||
function cancelWebRTCRecovery() {
|
||||
videoDebugLog('Cancelling WebRTC recovery', {
|
||||
attempts: webrtcRecoveryAttempts,
|
||||
hadTimer: webrtcRecoveryTimerId !== null,
|
||||
})
|
||||
if (webrtcRecoveryTimerId !== null) {
|
||||
clearTimeout(webrtcRecoveryTimerId)
|
||||
webrtcRecoveryTimerId = null
|
||||
@@ -806,6 +946,7 @@ function cancelWebRTCRecovery() {
|
||||
}
|
||||
|
||||
function handleStreamRecovered(_data: { device: string }) {
|
||||
videoDebugLog('Stream recovered event', _data)
|
||||
cancelWebRTCRecovery()
|
||||
|
||||
videoError.value = false
|
||||
@@ -836,6 +977,13 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
|
||||
}
|
||||
|
||||
function handleStreamConfigChanging(_data: any) {
|
||||
videoDebugLog('Stream config changing event', {
|
||||
data: _data,
|
||||
videoMode: videoMode.value,
|
||||
webrtcState: webrtc.state.value,
|
||||
webrtcStage: webrtc.connectStage.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
@@ -856,6 +1004,13 @@ function handleStreamConfigChanging(_data: any) {
|
||||
}
|
||||
|
||||
async function handleStreamConfigApplied(_data: any) {
|
||||
videoDebugLog('Stream config applied event', {
|
||||
data: _data,
|
||||
videoMode: videoMode.value,
|
||||
isModeSwitching: isModeSwitching.value,
|
||||
webrtcState: webrtc.state.value,
|
||||
webrtcStage: webrtc.connectStage.value,
|
||||
})
|
||||
consecutiveErrors = 0
|
||||
|
||||
gracePeriodTimeoutId = window.setTimeout(() => {
|
||||
@@ -881,11 +1036,23 @@ async function handleStreamConfigApplied(_data: any) {
|
||||
|
||||
function handleWebRTCReady(data: { codec: string; hardware: boolean; transition_id?: string }) {
|
||||
console.log(`[WebRTCReady] Backend ready: codec=${data.codec}, hardware=${data.hardware}, transition_id=${data.transition_id || '-'}`)
|
||||
videoDebugLog('WebRTC backend ready event', {
|
||||
...data,
|
||||
pendingWebRTCReadyGate,
|
||||
activeTransitionId: videoSession.activeTransitionId.value,
|
||||
expectedTransitionId: videoSession.expectedTransitionId.value,
|
||||
})
|
||||
pendingWebRTCReadyGate = false
|
||||
videoSession.onWebRTCReady(data)
|
||||
}
|
||||
|
||||
function handleStreamModeReady(data: { transition_id: string; mode: string }) {
|
||||
videoDebugLog('Stream mode ready event', {
|
||||
data,
|
||||
videoMode: videoMode.value,
|
||||
localSwitching: videoSession.localSwitching.value,
|
||||
backendSwitching: videoSession.backendSwitching.value,
|
||||
})
|
||||
videoSession.onModeReady(data)
|
||||
if (data.mode === 'mjpeg') {
|
||||
pendingWebRTCReadyGate = false
|
||||
@@ -894,6 +1061,12 @@ function handleStreamModeReady(data: { transition_id: string; mode: string }) {
|
||||
}
|
||||
|
||||
function handleStreamModeSwitching(data: { transition_id: string; to_mode: string; from_mode: string }) {
|
||||
videoDebugLog('Stream mode switching event', {
|
||||
data,
|
||||
videoMode: videoMode.value,
|
||||
localSwitching: videoSession.localSwitching.value,
|
||||
backendSwitching: videoSession.backendSwitching.value,
|
||||
})
|
||||
if (!isModeSwitching.value) {
|
||||
videoRestarting.value = true
|
||||
videoLoading.value = true
|
||||
@@ -904,6 +1077,13 @@ function handleStreamModeSwitching(data: { transition_id: string; to_mode: strin
|
||||
}
|
||||
|
||||
function handleStreamStateChanged(data: any) {
|
||||
videoDebugLog('Stream state changed event', {
|
||||
data,
|
||||
videoMode: videoMode.value,
|
||||
previousSignalState: streamSignalState.value,
|
||||
webrtcState: webrtc.state.value,
|
||||
webrtcStage: webrtc.connectStage.value,
|
||||
})
|
||||
const state = typeof data?.state === 'string' ? data.state : ''
|
||||
const reason = typeof data?.reason === 'string' && data.reason.length > 0 ? data.reason : null
|
||||
const nextRetry = typeof data?.next_retry_ms === 'number' && data.next_retry_ms > 0
|
||||
@@ -950,7 +1130,11 @@ function handleStreamStateChanged(data: any) {
|
||||
captureFrameOverlay().catch(() => {})
|
||||
}
|
||||
} else if (state === 'device_lost' && videoMode.value !== 'mjpeg') {
|
||||
if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
|
||||
if (
|
||||
!isAudioDeviceLostStateReason(reason)
|
||||
&& webrtcRecoveryTimerId === null
|
||||
&& webrtcRecoveryAttempts === 0
|
||||
) {
|
||||
scheduleWebRTCRecovery()
|
||||
}
|
||||
} else if (state === 'streaming') {
|
||||
@@ -1014,6 +1198,14 @@ const signalOverlayInfo = computed(() => {
|
||||
tone: 'info' as const,
|
||||
}
|
||||
case 'device_lost':
|
||||
if (isAudioDeviceLostStateReason(reason)) {
|
||||
return {
|
||||
title: t('console.signal.audioDeviceLost.title'),
|
||||
detail: t('console.signal.audioDeviceLost.detail'),
|
||||
hint,
|
||||
tone: 'error' as const,
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: t('console.signal.deviceLost.title'),
|
||||
detail: t('console.signal.deviceLost.detail'),
|
||||
@@ -1076,6 +1268,10 @@ function normalizeServerMode(mode: string | undefined): VideoMode | null {
|
||||
async function restoreInitialMode(serverMode: VideoMode) {
|
||||
if (initialModeRestoreDone || initialModeRestoreInProgress) return
|
||||
initialModeRestoreInProgress = true
|
||||
videoDebugLog('Restoring initial video mode from backend', {
|
||||
serverMode,
|
||||
currentMode: videoMode.value,
|
||||
})
|
||||
|
||||
try {
|
||||
initialDeviceInfoReceived = true
|
||||
@@ -1097,6 +1293,14 @@ async function restoreInitialMode(serverMode: VideoMode) {
|
||||
}
|
||||
|
||||
function handleDeviceInfo(data: any) {
|
||||
videoDebugLog('Device info event received', {
|
||||
streamMode: data.video?.stream_mode,
|
||||
configChanging: data.video?.config_changing,
|
||||
currentVideoMode: videoMode.value,
|
||||
initialDeviceInfoReceived,
|
||||
initialModeRestoreDone,
|
||||
initialModeRestoreInProgress,
|
||||
})
|
||||
const prevAudioStreaming = systemStore.audio?.streaming ?? false
|
||||
const prevAudioDevice = systemStore.audio?.device ?? null
|
||||
systemStore.updateFromDeviceInfo(data)
|
||||
@@ -1114,7 +1318,6 @@ function handleDeviceInfo(data: any) {
|
||||
})
|
||||
}
|
||||
|
||||
// This prevents false-positive mode changes during config switching
|
||||
if (data.video?.config_changing) {
|
||||
return
|
||||
}
|
||||
@@ -1139,10 +1342,14 @@ function handleDeviceInfo(data: any) {
|
||||
}
|
||||
|
||||
function handleStreamModeChanged(data: { mode: string; previous_mode: string }) {
|
||||
videoDebugLog('Stream mode changed event', {
|
||||
data,
|
||||
currentVideoMode: videoMode.value,
|
||||
localSwitching: isModeSwitching.value,
|
||||
})
|
||||
const newMode = normalizeServerMode(data.mode)
|
||||
if (!newMode) return
|
||||
|
||||
// Ignore this during a local mode switch because it was triggered by our own request
|
||||
if (isModeSwitching.value) {
|
||||
console.log('[StreamModeChanged] Mode switch in progress, ignoring event')
|
||||
return
|
||||
@@ -1161,6 +1368,11 @@ function reloadPage() {
|
||||
}
|
||||
|
||||
function refreshVideo() {
|
||||
videoDebugLog('Refreshing MJPEG video', {
|
||||
videoMode: videoMode.value,
|
||||
previousTimestamp: mjpegTimestamp.value,
|
||||
streamSignalState: streamSignalState.value,
|
||||
})
|
||||
backendFps.value = 0
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
@@ -1178,12 +1390,6 @@ function refreshVideo() {
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// MJPEG URL with cache-busting timestamp (reactive)
|
||||
// Only return valid URL when in MJPEG mode and the backend reports a
|
||||
// healthy stream. When the backend goes offline (no_signal / device_lost
|
||||
// / device_busy) we deliberately return an empty string so the `<img>`
|
||||
// tag has no `src` and the 4-state overlay fully owns the video area —
|
||||
// no more fake placeholder JPEG peeking through.
|
||||
const mjpegTimestamp = ref(0)
|
||||
const mjpegUrl = computed(() => {
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
@@ -1199,6 +1405,12 @@ const mjpegUrl = computed(() => {
|
||||
})
|
||||
|
||||
async function connectWebRTCOnly(codec: VideoMode = 'h264') {
|
||||
videoDebugLog('Connecting WebRTC without mode switch', {
|
||||
codec,
|
||||
currentMode: videoMode.value,
|
||||
webrtcState: webrtc.state.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
@@ -1222,9 +1434,13 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
|
||||
|
||||
try {
|
||||
const success = await connectWebRTCSerial('connectWebRTCOnly')
|
||||
videoDebugLog('WebRTC-only connect result', {
|
||||
codec,
|
||||
success,
|
||||
stage: webrtc.connectStage.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
if (success) {
|
||||
// Force video rebind even when the track already exists
|
||||
// This fixes missing video after returning to the page
|
||||
await rebindWebRTCVideo()
|
||||
|
||||
videoLoading.value = false
|
||||
@@ -1239,6 +1455,12 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
|
||||
}
|
||||
|
||||
async function rebindWebRTCVideo() {
|
||||
videoDebugLog('Rebinding WebRTC video element', {
|
||||
hasVideoElement: Boolean(webrtcVideoRef.value),
|
||||
hasVideoTrack: Boolean(webrtc.videoTrack.value),
|
||||
hasAudioTrack: Boolean(webrtc.audioTrack.value),
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
if (!webrtcVideoRef.value) return
|
||||
|
||||
webrtcVideoRef.value.srcObject = null
|
||||
@@ -1259,6 +1481,12 @@ async function rebindWebRTCVideo() {
|
||||
}
|
||||
|
||||
async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
videoDebugLog('Switching to WebRTC mode', {
|
||||
codec,
|
||||
currentMode: videoMode.value,
|
||||
webrtcState: webrtc.state.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
@@ -1281,18 +1509,27 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
pendingWebRTCReadyGate = true
|
||||
|
||||
try {
|
||||
// Disconnect first so ICE candidates are not sent to stale sessions during backend codec switch.
|
||||
if (webrtc.isConnected.value || webrtc.sessionId.value) {
|
||||
await webrtc.disconnect()
|
||||
}
|
||||
|
||||
const modeResp = await streamApi.setMode(codec)
|
||||
videoDebugLog('Backend setMode response for WebRTC', {
|
||||
codec,
|
||||
response: modeResp,
|
||||
})
|
||||
if (modeResp.transition_id) {
|
||||
videoSession.registerTransition(modeResp.transition_id)
|
||||
const [mode, webrtcReady] = await Promise.all([
|
||||
videoSession.waitForModeReady(modeResp.transition_id, 5000),
|
||||
videoSession.waitForWebRTCReady(modeResp.transition_id, 3000),
|
||||
])
|
||||
videoDebugLog('Backend WebRTC mode transition wait finished', {
|
||||
codec,
|
||||
transitionId: modeResp.transition_id,
|
||||
mode,
|
||||
webrtcReady,
|
||||
})
|
||||
|
||||
if (mode && mode !== codec && mode !== 'webrtc') {
|
||||
console.warn(`[WebRTC] Backend mode_ready returned '${mode}', expected '${codec}', falling back`)
|
||||
@@ -1310,12 +1547,26 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
const RETRY_DELAYS = [200, 800]
|
||||
let success = false
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS && !success; attempt++) {
|
||||
videoDebugLog('WebRTC connect attempt for mode switch', {
|
||||
codec,
|
||||
attempt: attempt + 1,
|
||||
maxAttempts: MAX_ATTEMPTS,
|
||||
stage: webrtc.connectStage.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
if (attempt > 0) {
|
||||
const delay = RETRY_DELAYS[attempt - 1] ?? RETRY_DELAYS[RETRY_DELAYS.length - 1]
|
||||
console.log(`[WebRTC] Connection failed, retrying in ${delay}ms (${MAX_ATTEMPTS - attempt} attempts left)`)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
success = await connectWebRTCSerial('switchToWebRTC')
|
||||
videoDebugLog('WebRTC connect attempt finished for mode switch', {
|
||||
codec,
|
||||
attempt: attempt + 1,
|
||||
success,
|
||||
stage: webrtc.connectStage.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
}
|
||||
if (success) {
|
||||
await rebindWebRTCVideo()
|
||||
@@ -1332,6 +1583,11 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
}
|
||||
|
||||
async function switchToMJPEG() {
|
||||
videoDebugLog('Switching to MJPEG mode', {
|
||||
currentMode: videoMode.value,
|
||||
webrtcState: webrtc.state.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
videoLoading.value = true
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
@@ -1339,6 +1595,7 @@ async function switchToMJPEG() {
|
||||
|
||||
try {
|
||||
const modeResp = await streamApi.setMode('mjpeg')
|
||||
videoDebugLog('Backend setMode response for MJPEG', modeResp)
|
||||
if (modeResp.transition_id) {
|
||||
videoSession.registerTransition(modeResp.transition_id)
|
||||
const mode = await videoSession.waitForModeReady(modeResp.transition_id, 5000)
|
||||
@@ -1364,6 +1621,12 @@ async function switchToMJPEG() {
|
||||
}
|
||||
|
||||
function syncToServerMode(mode: VideoMode) {
|
||||
videoDebugLog('Syncing frontend video mode to backend mode', {
|
||||
mode,
|
||||
currentMode: videoMode.value,
|
||||
localSwitching: videoSession.localSwitching.value,
|
||||
backendSwitching: videoSession.backendSwitching.value,
|
||||
})
|
||||
if (videoSession.localSwitching.value || videoSession.backendSwitching.value) return
|
||||
if (mode === videoMode.value) return
|
||||
|
||||
@@ -1378,6 +1641,12 @@ function syncToServerMode(mode: VideoMode) {
|
||||
}
|
||||
|
||||
async function handleVideoModeChange(mode: VideoMode) {
|
||||
videoDebugLog('User requested video mode change', {
|
||||
requestedMode: mode,
|
||||
currentMode: videoMode.value,
|
||||
webrtcState: webrtc.state.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
if (mode === videoMode.value) return
|
||||
if (!videoSession.tryStartLocalSwitch()) {
|
||||
console.log('[VideoMode] Switch throttled or in progress, ignoring')
|
||||
@@ -1410,12 +1679,26 @@ async function handleVideoModeChange(mode: VideoMode) {
|
||||
}
|
||||
|
||||
watch(() => webrtc.videoTrack.value, async (track) => {
|
||||
videoDebugLog('WebRTC video track ref changed', {
|
||||
hasTrack: Boolean(track),
|
||||
trackId: track?.id,
|
||||
readyState: track?.readyState,
|
||||
muted: track?.muted,
|
||||
videoMode: videoMode.value,
|
||||
})
|
||||
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
|
||||
await rebindWebRTCVideo()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => webrtc.audioTrack.value, async (track) => {
|
||||
videoDebugLog('WebRTC audio track ref changed', {
|
||||
hasTrack: Boolean(track),
|
||||
trackId: track?.id,
|
||||
readyState: track?.readyState,
|
||||
muted: track?.muted,
|
||||
videoMode: videoMode.value,
|
||||
})
|
||||
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
|
||||
const currentStream = webrtcVideoRef.value.srcObject as MediaStream | null
|
||||
if (currentStream && currentStream.getAudioTracks().length === 0) {
|
||||
@@ -1429,6 +1712,19 @@ watch(webrtcVideoRef, (el) => {
|
||||
}, { immediate: true })
|
||||
|
||||
watch(webrtc.stats, (stats) => {
|
||||
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
|
||||
videoDebugLog('WebRTC video stats updated with active frames', {
|
||||
fps: stats.framesPerSecond,
|
||||
frameWidth: stats.frameWidth,
|
||||
frameHeight: stats.frameHeight,
|
||||
packetsReceived: stats.packetsReceived,
|
||||
packetsLost: stats.packetsLost,
|
||||
localCandidateType: stats.localCandidateType,
|
||||
remoteCandidateType: stats.remoteCandidateType,
|
||||
transportProtocol: stats.transportProtocol,
|
||||
isRelay: stats.isRelay,
|
||||
})
|
||||
}
|
||||
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
|
||||
backendFps.value = Math.round(stats.framesPerSecond)
|
||||
systemStore.setStreamOnline(true)
|
||||
@@ -1442,14 +1738,21 @@ let webrtcReconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let webrtcReconnectFailures = 0
|
||||
watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
console.log('[WebRTC] State changed:', oldState, '->', newState)
|
||||
videoDebugLog('WebRTC state watcher observed change', {
|
||||
oldState,
|
||||
newState,
|
||||
stage: webrtc.connectStage.value,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
videoMode: videoMode.value,
|
||||
videoLoading: videoLoading.value,
|
||||
suppressAutoReconnect: shouldSuppressAutoReconnect(),
|
||||
})
|
||||
|
||||
if (webrtcReconnectTimeout) {
|
||||
clearTimeout(webrtcReconnectTimeout)
|
||||
webrtcReconnectTimeout = null
|
||||
}
|
||||
|
||||
// Run before `shouldSuppressAutoReconnect()` so `device_busy` / `videoRestarting`
|
||||
// never blocks clearing the loading overlay when ICE becomes connected.
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
if (newState === 'connected') {
|
||||
systemStore.setStreamOnline(true)
|
||||
@@ -1467,6 +1770,10 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
}
|
||||
|
||||
if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') {
|
||||
videoDebugLog('Scheduling WebRTC auto reconnect after disconnect', {
|
||||
failures: webrtcReconnectFailures,
|
||||
sessionId: webrtc.sessionId.value,
|
||||
})
|
||||
webrtcReconnectTimeout = setTimeout(async () => {
|
||||
if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') {
|
||||
try {
|
||||
@@ -2112,7 +2419,6 @@ async function activateConsoleView() {
|
||||
isConsoleActive.value = true
|
||||
registerInteractionListeners()
|
||||
|
||||
// REST snapshot: returning from Settings (or other routes) may have missed WS device_info
|
||||
void systemStore.fetchAllStates()
|
||||
void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {})
|
||||
|
||||
@@ -2405,14 +2711,8 @@ onUnmounted(() => {
|
||||
<div
|
||||
ref="videoContainerRef"
|
||||
class="relative bg-black overflow-hidden flex items-center justify-center"
|
||||
:style="{
|
||||
aspectRatio: videoAspectRatio ?? '16/9',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
minHeight: '120px',
|
||||
}"
|
||||
:style="videoContainerStyle"
|
||||
:class="{
|
||||
'opacity-60': videoLoading || videoError,
|
||||
'cursor-none': true,
|
||||
}"
|
||||
tabindex="0"
|
||||
@@ -2506,9 +2806,36 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<Spinner class="h-10 w-10 sm:h-16 sm:w-16 text-white mb-2 sm:mb-4" />
|
||||
<p class="text-white/90 text-sm sm:text-lg font-medium text-center px-4">
|
||||
{{ webrtcLoadingMessage }}
|
||||
<p class="text-white/90 text-sm sm:text-lg font-medium text-center px-4 flex items-baseline justify-center gap-2 flex-wrap">
|
||||
<span>{{ webrtcLoadingMessage }}</span>
|
||||
<span
|
||||
v-if="connectProgress"
|
||||
class="text-white/55 text-xs sm:text-sm font-normal tabular-nums"
|
||||
:aria-label="t('console.stepProgress', { current: connectProgress.current, total: connectProgress.total })"
|
||||
>
|
||||
{{ connectProgress.current }}/{{ connectProgress.total }}
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="connectProgress"
|
||||
class="mt-2 sm:mt-3 flex items-center gap-1"
|
||||
role="progressbar"
|
||||
:aria-valuenow="connectProgress.current"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="connectProgress.total"
|
||||
:aria-valuetext="t('console.stepProgress', { current: connectProgress.current, total: connectProgress.total })"
|
||||
>
|
||||
<span
|
||||
v-for="i in connectProgress.total"
|
||||
:key="i"
|
||||
:class="cn(
|
||||
'h-1 w-4 sm:w-6 rounded-full transition-colors duration-300',
|
||||
i <= connectProgress.current
|
||||
? 'bg-primary'
|
||||
: 'bg-white/15',
|
||||
)"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white/50 text-xs sm:text-sm mt-1 sm:mt-2">
|
||||
{{ t('console.pleaseWait') }}
|
||||
</p>
|
||||
|
||||
@@ -44,7 +44,7 @@ import { getVideoFormatState } from '@/lib/video-format-support'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
Radio,
|
||||
Globe,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
@@ -163,6 +164,33 @@ const navGroups = computed(() => [
|
||||
}
|
||||
])
|
||||
|
||||
const sectionMeta = computed(() => {
|
||||
const fallback = { icon: Info, title: t('settings.title'), description: '' }
|
||||
for (const group of navGroups.value) {
|
||||
for (const item of group.items) {
|
||||
if (item.id === activeSection.value) {
|
||||
const subtitleKey = `settings.${sectionSubtitleKey(item.id)}`
|
||||
return {
|
||||
icon: item.icon,
|
||||
title: item.label,
|
||||
description: te(subtitleKey) ? t(subtitleKey) : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
})
|
||||
|
||||
function sectionSubtitleKey(id: string): string {
|
||||
switch (id) {
|
||||
case 'ext-ttyd': return 'extTtydSubtitle'
|
||||
case 'ext-rustdesk': return 'extRustdeskSubtitle'
|
||||
case 'ext-rtsp': return 'extRtspSubtitle'
|
||||
case 'ext-remote-access': return 'extRemoteAccessSubtitle'
|
||||
default: return `${id}Subtitle`
|
||||
}
|
||||
}
|
||||
|
||||
function selectSection(id: string) {
|
||||
activeSection.value = id
|
||||
mobileMenuOpen.value = false
|
||||
@@ -327,6 +355,23 @@ const previewAccessUrl = computed(() => {
|
||||
return `${scheme}://${host}:${port}`
|
||||
})
|
||||
|
||||
const previewUrlCopied = ref(false)
|
||||
let previewUrlCopiedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function copyPreviewUrl() {
|
||||
const ok = await clipboardCopy(previewAccessUrl.value)
|
||||
if (!ok) return
|
||||
previewUrlCopied.value = true
|
||||
if (previewUrlCopiedTimer) clearTimeout(previewUrlCopiedTimer)
|
||||
previewUrlCopiedTimer = setTimeout(() => {
|
||||
previewUrlCopied.value = false
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
function openPreviewUrl() {
|
||||
window.open(previewAccessUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
interface DeviceConfig {
|
||||
video: Array<{
|
||||
path: string
|
||||
@@ -792,14 +837,14 @@ const atxConfig = ref({
|
||||
power: {
|
||||
driver: 'none' as AtxDriverType,
|
||||
device: '',
|
||||
pin: 0,
|
||||
pin: 1,
|
||||
active_level: 'high' as ActiveLevel,
|
||||
baud_rate: 9600,
|
||||
},
|
||||
reset: {
|
||||
driver: 'none' as AtxDriverType,
|
||||
device: '',
|
||||
pin: 0,
|
||||
pin: 1,
|
||||
active_level: 'high' as ActiveLevel,
|
||||
baud_rate: 9600,
|
||||
},
|
||||
@@ -1038,9 +1083,18 @@ async function saveConfig() {
|
||||
saved.value = false
|
||||
|
||||
try {
|
||||
// Sequential awaits: backend ConfigStore uses read-modify-write; parallel PATCH
|
||||
|
||||
if (activeSection.value === 'video') {
|
||||
const turnUrl = config.value.turn_server.trim()
|
||||
await configStore.updateStream({
|
||||
encoder: config.value.encoder_backend as any,
|
||||
stun_server: config.value.stun_server.trim(),
|
||||
turn_server: turnUrl,
|
||||
turn_username: config.value.turn_username.trim(),
|
||||
turn_password:
|
||||
turnUrl === ''
|
||||
? ''
|
||||
: config.value.turn_password || undefined,
|
||||
})
|
||||
await configStore.updateVideo({
|
||||
device: config.value.video_device || undefined,
|
||||
format: config.value.video_format || undefined,
|
||||
@@ -1048,16 +1102,8 @@ async function saveConfig() {
|
||||
height: config.value.video_height,
|
||||
fps: toConfigFps(config.value.video_fps),
|
||||
})
|
||||
await configStore.updateStream({
|
||||
encoder: config.value.encoder_backend as any,
|
||||
stun_server: config.value.stun_server || undefined,
|
||||
turn_server: config.value.turn_server || undefined,
|
||||
turn_username: config.value.turn_username || undefined,
|
||||
turn_password: config.value.turn_password || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// HID config (includes MSD enable — same gadget; must not race with updateHid)
|
||||
if (activeSection.value === 'hid') {
|
||||
if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
|
||||
return
|
||||
@@ -1327,6 +1373,7 @@ async function loadAtxConfig() {
|
||||
wol_interface: config.wol_interface || '',
|
||||
}
|
||||
clearAtxSerialDeviceConflicts()
|
||||
normalizeAtxRelayChannels()
|
||||
syncSharedAtxSerialBaudRate()
|
||||
} catch (e) {
|
||||
console.error('Failed to load ATX config:', e)
|
||||
@@ -1345,6 +1392,7 @@ async function saveAtxConfig() {
|
||||
loading.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
normalizeAtxRelayChannels()
|
||||
syncSharedAtxSerialBaudRate()
|
||||
await configStore.updateAtx({
|
||||
enabled: atxConfig.value.enabled,
|
||||
@@ -1421,6 +1469,14 @@ function syncSharedAtxSerialBaudRate() {
|
||||
atxConfig.value.reset.baud_rate = atxConfig.value.power.baud_rate
|
||||
}
|
||||
|
||||
function normalizeAtxRelayChannels() {
|
||||
for (const key of [atxConfig.value.power, atxConfig.value.reset]) {
|
||||
if (['usbrelay', 'serial'].includes(key.driver) && key.pin < 1) {
|
||||
key.pin = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [config.value.hid_backend, config.value.hid_serial_device],
|
||||
() => {
|
||||
@@ -1428,6 +1484,13 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [atxConfig.value.power.driver, atxConfig.value.reset.driver],
|
||||
() => {
|
||||
normalizeAtxRelayChannels()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
atxConfig.value.power.driver,
|
||||
@@ -2056,7 +2119,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
<AppLayout>
|
||||
<div class="flex h-full overflow-hidden">
|
||||
<!-- Mobile Header -->
|
||||
<div class="lg:hidden fixed top-11 sm:top-14 left-0 right-0 z-20 flex items-center px-3 sm:px-4 py-2 sm:py-3 border-b bg-background">
|
||||
<div class="lg:hidden fixed top-11 sm:top-14 left-0 right-0 z-20 flex items-center px-3 sm:px-4 py-2 sm:py-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/70">
|
||||
<Sheet v-model:open="mobileMenuOpen">
|
||||
<SheetTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="mr-1.5 sm:mr-2 h-8 w-8 sm:h-9 sm:w-9">
|
||||
@@ -2091,16 +2154,22 @@ watch(() => route.query.tab, (tab) => {
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<h1 class="text-base sm:text-lg font-semibold">{{ t('settings.title') }}</h1>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<component :is="sectionMeta.icon" class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h1 class="text-sm sm:text-base font-semibold truncate">{{ sectionMeta.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside class="hidden lg:block w-64 shrink-0 border-r bg-muted/30">
|
||||
<div class="sticky top-0 p-6 space-y-6">
|
||||
<h1 class="text-xl font-semibold">{{ t('settings.title') }}</h1>
|
||||
<nav class="space-y-6">
|
||||
<div class="sticky top-0 p-6 space-y-6 max-h-screen overflow-y-auto">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-xl font-semibold tracking-tight">{{ t('settings.title') }}</h1>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.sidebarSubtitle') }}</p>
|
||||
</div>
|
||||
<nav class="space-y-5">
|
||||
<div v-for="group in navGroups" :key="group.title" class="space-y-1">
|
||||
<h3 class="px-3 text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">{{ group.title }}</h3>
|
||||
<h3 class="px-3 text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-wider mb-1.5">{{ group.title }}</h3>
|
||||
<button
|
||||
type="button"
|
||||
v-for="item in group.items"
|
||||
@@ -2109,13 +2178,13 @@ watch(() => route.query.tab, (tab) => {
|
||||
:class="[
|
||||
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors',
|
||||
activeSection === item.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-foreground/80 hover:text-foreground hover:bg-muted'
|
||||
]"
|
||||
>
|
||||
<component :is="item.icon" class="h-4 w-4" />
|
||||
<span>{{ item.label }}</span>
|
||||
<Badge v-if="item.status" variant="outline" :class="['ml-auto text-xs', activeSection === item.id ? 'border-primary-foreground/50 text-primary-foreground' : '']">{{ item.status }}</Badge>
|
||||
<component :is="item.icon" class="h-4 w-4 shrink-0" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
<Badge v-if="item.status" variant="outline" :class="['ml-auto text-[10px] px-1.5 py-0 h-4', activeSection === item.id ? 'border-primary-foreground/50 text-primary-foreground' : '']">{{ item.status }}</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -2124,7 +2193,18 @@ watch(() => route.query.tab, (tab) => {
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="max-w-2xl mx-auto p-3 sm:p-6 lg:p-8 pt-16 sm:pt-20 lg:pt-8 space-y-4 sm:space-y-6">
|
||||
<div class="mx-auto w-full max-w-3xl px-3 sm:px-6 lg:px-8 pt-16 sm:pt-20 lg:pt-10 pb-10 space-y-6">
|
||||
|
||||
<!-- Section Header -->
|
||||
<header class="space-y-1.5 pb-2 border-b">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<component :is="sectionMeta.icon" class="h-5 w-5 text-muted-foreground" />
|
||||
<h1 class="text-xl sm:text-2xl font-semibold tracking-tight">{{ sectionMeta.title }}</h1>
|
||||
</div>
|
||||
<p v-if="sectionMeta.description" class="text-sm text-muted-foreground">
|
||||
{{ sectionMeta.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<div v-show="activeSection === 'appearance'" class="space-y-6">
|
||||
@@ -2134,14 +2214,14 @@ watch(() => route.query.tab, (tab) => {
|
||||
<CardDescription>{{ t('settings.themeDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" @click="setTheme('light')">
|
||||
<div class="grid grid-cols-3 gap-2 sm:max-w-md">
|
||||
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" class="justify-center" @click="setTheme('light')">
|
||||
<Sun class="h-4 w-4 mr-1.5" />{{ t('settings.lightMode') }}
|
||||
</Button>
|
||||
<Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" @click="setTheme('dark')">
|
||||
<Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" class="justify-center" @click="setTheme('dark')">
|
||||
<Moon class="h-4 w-4 mr-1.5" />{{ t('settings.darkMode') }}
|
||||
</Button>
|
||||
<Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" @click="setTheme('system')">
|
||||
<Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" class="justify-center" @click="setTheme('system')">
|
||||
<Monitor class="h-4 w-4 mr-1.5" />{{ t('settings.systemMode') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2154,9 +2234,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
<CardDescription>{{ t('settings.languageDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex">
|
||||
<LanguageToggleButton variant="outline" size="sm" label-mode="current" />
|
||||
</div>
|
||||
<LanguageToggleButton variant="outline" size="sm" label-mode="current" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -2171,21 +2249,22 @@ watch(() => route.query.tab, (tab) => {
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="account-username">{{ t('settings.username') }}</Label>
|
||||
<Input id="account-username" v-model="usernameInput" />
|
||||
<Input id="account-username" v-model="usernameInput" autocomplete="username" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="account-username-password">{{ t('settings.currentPassword') }}</Label>
|
||||
<Input id="account-username-password" v-model="usernamePassword" type="password" />
|
||||
<Input id="account-username-password" v-model="usernamePassword" type="password" autocomplete="current-password" />
|
||||
</div>
|
||||
<p v-if="usernameError" class="text-xs text-destructive">{{ usernameError }}</p>
|
||||
<p v-else-if="usernameSaved" class="text-xs text-emerald-600">{{ t('common.success') }}</p>
|
||||
<div class="flex justify-end">
|
||||
<Button @click="changeUsername" :disabled="usernameSaving">
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-else-if="usernameSaved" class="text-xs text-emerald-600 flex items-center gap-1.5"><Check class="h-3.5 w-3.5" />{{ t('common.success') }}</p>
|
||||
</CardContent>
|
||||
<CardFooter class="border-t pt-4 justify-end">
|
||||
<Button @click="changeUsername" :disabled="usernameSaving">
|
||||
<Loader2 v-if="usernameSaving" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -2196,25 +2275,26 @@ watch(() => route.query.tab, (tab) => {
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="account-current-password">{{ t('settings.currentPassword') }}</Label>
|
||||
<Input id="account-current-password" v-model="currentPassword" type="password" />
|
||||
<Input id="account-current-password" v-model="currentPassword" type="password" autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="account-new-password">{{ t('settings.newPassword') }}</Label>
|
||||
<Input id="account-new-password" v-model="newPassword" type="password" />
|
||||
<Input id="account-new-password" v-model="newPassword" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="account-confirm-password">{{ t('auth.confirmPassword') }}</Label>
|
||||
<Input id="account-confirm-password" v-model="confirmPassword" type="password" />
|
||||
<Input id="account-confirm-password" v-model="confirmPassword" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<p v-if="passwordError" class="text-xs text-destructive">{{ passwordError }}</p>
|
||||
<p v-else-if="passwordSaved" class="text-xs text-emerald-600">{{ t('common.success') }}</p>
|
||||
<div class="flex justify-end">
|
||||
<Button @click="changePassword" :disabled="passwordSaving">
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-else-if="passwordSaved" class="text-xs text-emerald-600 flex items-center gap-1.5"><Check class="h-3.5 w-3.5" />{{ t('common.success') }}</p>
|
||||
</CardContent>
|
||||
<CardFooter class="border-t pt-4 justify-end">
|
||||
<Button @click="changePassword" :disabled="passwordSaving">
|
||||
<Loader2 v-if="passwordSaving" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -2223,7 +2303,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
|
||||
@@ -2233,13 +2313,14 @@ watch(() => route.query.tab, (tab) => {
|
||||
:disabled="authConfigLoading"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="border-t pt-4 justify-end">
|
||||
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
|
||||
<Loader2 v-if="authConfigLoading" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -2903,24 +2984,27 @@ watch(() => route.query.tab, (tab) => {
|
||||
<CardTitle>{{ t('settings.portConfig') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.portConfigDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<CardContent class="space-y-5">
|
||||
<!-- HTTPS toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.httpsEnabled') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.httpsEnabledDesc') }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.httpsEnabledDesc') }}</p>
|
||||
</div>
|
||||
<Switch v-model="webServerConfig.https_enabled" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Single active-port input, label follows the HTTPS toggle -->
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="space-y-2 flex-1 max-w-[180px]">
|
||||
<Label>
|
||||
{{ webServerConfig.https_enabled ? t('settings.httpsPort') : t('settings.httpPort') }}
|
||||
</Label>
|
||||
<!-- Active port (primary) -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm font-medium">
|
||||
{{ webServerConfig.https_enabled ? t('settings.httpsPort') : t('settings.httpPort') }}
|
||||
</Label>
|
||||
<Badge variant="default" class="h-4 text-[10px] px-1.5">{{ t('settings.portActive') }}</Badge>
|
||||
</div>
|
||||
<Input
|
||||
v-if="webServerConfig.https_enabled"
|
||||
v-model.number="webServerConfig.https_port"
|
||||
@@ -2932,42 +3016,65 @@ watch(() => route.query.tab, (tab) => {
|
||||
type="number" min="1" max="65535"
|
||||
/>
|
||||
</div>
|
||||
<!-- Inactive-port reference (read-only hint) -->
|
||||
<div class="space-y-2 flex-1 max-w-[180px]">
|
||||
<Label class="text-muted-foreground text-xs">
|
||||
{{ webServerConfig.https_enabled ? t('settings.httpPortReserved') : t('settings.httpsPortReserved') }}
|
||||
</Label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm text-muted-foreground">
|
||||
{{ webServerConfig.https_enabled ? t('settings.httpPort') : t('settings.httpsPort') }}
|
||||
</Label>
|
||||
<Badge variant="secondary" class="h-4 text-[10px] px-1.5 font-normal">{{ t('settings.portReserved') }}</Badge>
|
||||
</div>
|
||||
<Input
|
||||
v-if="webServerConfig.https_enabled"
|
||||
v-model.number="webServerConfig.http_port"
|
||||
type="number" min="1" max="65535"
|
||||
class="opacity-50"
|
||||
class="opacity-60"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model.number="webServerConfig.https_port"
|
||||
type="number" min="1" max="65535"
|
||||
class="opacity-50"
|
||||
class="opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground -mt-2">{{ t('settings.portReservedHint') }}</p>
|
||||
|
||||
<!-- Preview URL -->
|
||||
<div class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
||||
<span class="text-muted-foreground shrink-0">{{ t('settings.previewUrl') }}:</span>
|
||||
<span class="font-mono text-xs break-all">{{ previewAccessUrl }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Save row -->
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || autoRestarting">
|
||||
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||
</Button>
|
||||
<div class="rounded-md border bg-muted/40 p-3 space-y-1.5">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{{ t('settings.previewUrl') }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="font-mono text-xs sm:text-sm break-all flex-1 min-w-0">{{ previewAccessUrl }}</code>
|
||||
<Button
|
||||
variant="ghost" size="icon" class="h-7 w-7 shrink-0"
|
||||
:title="t('settings.copyUrl')"
|
||||
:aria-label="t('settings.copyUrl')"
|
||||
@click="copyPreviewUrl"
|
||||
>
|
||||
<Check v-if="previewUrlCopied" class="h-3.5 w-3.5 text-emerald-600" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" class="h-7 w-7 shrink-0"
|
||||
:title="t('settings.openInBrowser')"
|
||||
:aria-label="t('settings.openInBrowser')"
|
||||
@click="openPreviewUrl"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
|
||||
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
|
||||
{{ t('settings.restartRequiredHint') }}
|
||||
</p>
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || autoRestarting">
|
||||
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<!-- Listen Address Card -->
|
||||
@@ -3038,20 +3145,22 @@ watch(() => route.query.tab, (tab) => {
|
||||
</RadioGroup>
|
||||
|
||||
<!-- Effective addresses preview -->
|
||||
<div v-if="effectiveBindAddresses.length > 0" class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
||||
<span class="text-muted-foreground shrink-0">{{ t('settings.effectiveAddresses') }}:</span>
|
||||
<span class="font-mono text-xs break-all">{{ effectiveBindAddresses.join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError || autoRestarting">
|
||||
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||
</Button>
|
||||
<div v-if="effectiveBindAddresses.length > 0" class="rounded-md border bg-muted/40 p-3 space-y-1.5">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{{ t('settings.effectiveAddresses') }}</p>
|
||||
<code class="font-mono text-xs sm:text-sm break-all block">{{ effectiveBindAddresses.join(', ') }}</code>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
|
||||
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
|
||||
{{ t('settings.restartRequiredHint') }}
|
||||
</p>
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError || autoRestarting">
|
||||
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<!-- SSL Certificate Card -->
|
||||
@@ -3112,18 +3221,21 @@ watch(() => route.query.tab, (tab) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||
<Button
|
||||
:disabled="certSaving || autoRestarting || !sslCertPem.trim() || !sslKeyPem.trim()"
|
||||
@click="saveCertificate"
|
||||
>
|
||||
<RefreshCw v-if="certSaving || autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertSave') }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
|
||||
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
|
||||
{{ t('settings.restartRequiredHint') }}
|
||||
</p>
|
||||
<Button
|
||||
:disabled="certSaving || autoRestarting || !sslCertPem.trim() || !sslKeyPem.trim()"
|
||||
@click="saveCertificate"
|
||||
>
|
||||
<RefreshCw v-if="certSaving || autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertSave') }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3229,7 +3341,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
id="power-pin"
|
||||
type="number"
|
||||
v-model.number="atxConfig.power.pin"
|
||||
:min="atxConfig.power.driver === 'serial' ? 1 : 0"
|
||||
:min="['usbrelay', 'serial'].includes(atxConfig.power.driver) ? 1 : 0"
|
||||
:disabled="atxConfig.power.driver === 'none'"
|
||||
/>
|
||||
</div>
|
||||
@@ -3293,7 +3405,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
id="reset-pin"
|
||||
type="number"
|
||||
v-model.number="atxConfig.reset.pin"
|
||||
:min="atxConfig.reset.driver === 'serial' ? 1 : 0"
|
||||
:min="['usbrelay', 'serial'].includes(atxConfig.reset.driver) ? 1 : 0"
|
||||
:disabled="atxConfig.reset.driver === 'none'"
|
||||
/>
|
||||
</div>
|
||||
@@ -4109,12 +4221,14 @@ watch(() => route.query.tab, (tab) => {
|
||||
</div>
|
||||
|
||||
<!-- Save Button (sticky) -->
|
||||
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-2 bg-background border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||
<div class="flex items-center justify-between sm:justify-end gap-2 sm:gap-3">
|
||||
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex-1 min-w-0">
|
||||
{{ t('settings.otgFunctionMinWarning') }}
|
||||
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||
<div class="flex items-center justify-between gap-2 sm:gap-3">
|
||||
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 min-w-0">
|
||||
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ t('settings.otgFunctionMinWarning') }}</span>
|
||||
</p>
|
||||
<Button class="shrink-0" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
||||
<p v-else class="text-xs text-muted-foreground hidden sm:block">{{ t('settings.unsavedChangesHint') }}</p>
|
||||
<Button class="shrink-0 ml-auto" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user