mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
fix:改进atx usb 继电器适配;修复 webrtc 无法建立连接问题;网页样式优化
This commit is contained in:
@@ -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