fix:改进atx usb 继电器适配;修复 webrtc 无法建立连接问题;网页样式优化

This commit is contained in:
mofeng-git
2026-05-05 00:52:16 +08:00
parent 6723f432a3
commit c27d3a6703
27 changed files with 1388 additions and 709 deletions

View File

@@ -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>

View File

@@ -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>