fix: 完善 RK3588 HDMI RX 信号检测与自动恢复

- 统一使用 QUERY_DV_TIMINGS 判断 HDMI RX 输入状态
  - 增加 source-following 设备状态 API 与前端只读展示
  - 支持长时间无信号后的 MJPEG/WebRTC 自动恢复
  - 修复模式切换时采集设备尚未释放导致的 EBUSY
  - 取消陈旧 WebRTC 重连并统一采集恢复策略
  - 移除视频输入状态区域的冗余标题
This commit is contained in:
mofeng-git
2026-07-30 19:41:44 +08:00
parent 971c263bf8
commit 9fb23476ac
36 changed files with 1883 additions and 1492 deletions

View File

@@ -115,6 +115,7 @@ const videoError = ref(false)
const videoErrorMessage = ref('')
const videoRestarting = ref(false)
const mjpegFrameReceived = ref(false)
let recoveryEventHandled = false
/** From `stream.state_changed`: ok | no_signal | device_lost | device_busy */
type StreamSignalState = 'ok' | 'no_signal' | 'device_lost' | 'device_busy'
@@ -735,6 +736,8 @@ let webrtcConnectTask: Promise<boolean> | null = null
let webrtcRecoveryTimerId: number | null = null
let webrtcRecoveryAttempts = 0
let webrtcReconnectTimeout: ReturnType<typeof setTimeout> | null = null
let webrtcReconnectFailures = 0
const MAX_WEBRTC_RECOVERY_ATTEMPTS = 8
const WEBRTC_RECOVERY_BASE_DELAY = 2000
@@ -979,6 +982,11 @@ async function waitForWebRTCReadyGate(reason: string, timeoutMs = 3000): Promise
}
async function connectWebRTCSerial(reason: string): Promise<boolean> {
if (videoMode.value === 'mjpeg') {
videoDebugLog('Skipping stale WebRTC connect request in MJPEG mode', { reason })
return false
}
if (webrtcConnectTask) {
videoDebugLog('Reusing serialized WebRTC connect task', {
reason,
@@ -996,6 +1004,10 @@ async function connectWebRTCSerial(reason: string): Promise<boolean> {
})
webrtcConnectTask = (async () => {
await waitForWebRTCReadyGate(reason)
if (videoMode.value === 'mjpeg') {
videoDebugLog('Discarding WebRTC connect after mode changed to MJPEG', { reason })
return false
}
return webrtc.connect()
})()
@@ -1181,13 +1193,36 @@ function cancelWebRTCRecovery() {
webrtcRecoveryAttempts = 0
}
async function stopWebRTCClientActivity() {
cancelWebRTCRecovery()
if (webrtcReconnectTimeout) {
clearTimeout(webrtcReconnectTimeout)
webrtcReconnectTimeout = null
}
await webrtc.disconnect()
}
function handleStreamRecovered(_data: { device: string }) {
videoDebugLog('Stream recovered event', _data)
cancelWebRTCRecovery()
recoveryEventHandled = true
videoError.value = false
videoErrorMessage.value = ''
refreshVideo()
if (videoMode.value === 'mjpeg') {
refreshVideo()
} else if (webrtc.isConnected.value) {
void rebindWebRTCVideo().then(() => {
videoLoading.value = false
})
} else if (!webrtc.isConnecting.value) {
void connectWebRTCSerial('stream recovered').then(async connected => {
if (connected) {
await rebindWebRTCVideo()
videoLoading.value = false
}
})
}
}
async function handleAudioStateChanged(data: { streaming: boolean; device: string | null }) {
@@ -1249,6 +1284,15 @@ async function handleStreamConfigApplied(_data: any) {
})
consecutiveErrors = 0
// A source-following recovery emits `stream.recovered` followed by the
// actual geometry. The recovered handler already reconnected the current
// transport; do not initiate a second mode switch for the bookkeeping event.
if (recoveryEventHandled) {
recoveryEventHandled = false
videoRestarting.value = false
return
}
gracePeriodTimeoutId = window.setTimeout(() => {
gracePeriodTimeoutId = null
consecutiveErrors = 0
@@ -1354,8 +1398,27 @@ function handleStreamStateChanged(data: any) {
} else if (state === 'no_signal' && videoMode.value !== 'mjpeg') {
cancelWebRTCRecovery()
videoRestarting.value = false
videoLoading.value = false
videoError.value = false
videoErrorMessage.value = ''
systemStore.setStreamOnline(false)
// Remove the stale decoded frame without closing the peer connection.
// The live WebRTC subscription is what keeps a source-following capture
// pipeline probing indefinitely; disconnecting here would drop the final
// subscriber and make recovery impossible without a page refresh.
if (webrtcVideoRef.value) {
webrtcVideoRef.value.pause()
webrtcVideoRef.value.srcObject = null
}
} else if (state === 'no_signal' && videoMode.value === 'mjpeg') {
systemStore.setStreamOnline(false)
videoLoading.value = false
mjpegFrameReceived.value = false
mjpegTimestamp.value = 0
if (videoRef.value) {
videoRef.value.src = ''
videoRef.value.removeAttribute('src')
}
} else if (state === 'device_busy' && videoMode.value !== 'mjpeg') {
cancelWebRTCRecovery()
videoRestarting.value = true
@@ -1378,30 +1441,6 @@ function handleStreamStateChanged(data: any) {
videoError.value = false
videoErrorMessage.value = ''
videoRestarting.value = false
if (
videoMode.value === 'mjpeg'
&& (previous === 'no_signal' || previous === 'device_lost' || previous === 'device_busy')
) {
refreshVideo()
} else if (
videoMode.value !== 'mjpeg'
&& (previous === 'no_signal' || previous === 'device_busy' || previous === 'device_lost')
) {
if (webrtc.isConnected.value && !webrtc.isConnecting.value) {
void rebindWebRTCVideo().then(() => {
videoLoading.value = false
})
} else if (!webrtc.isConnected.value && !webrtc.isConnecting.value) {
void connectWebRTCSerial('stream recovered').then(async (ok) => {
if (ok) {
await rebindWebRTCVideo()
videoLoading.value = false
} else if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
scheduleWebRTCRecovery()
}
})
}
}
}
}
@@ -1829,6 +1868,7 @@ async function switchToMJPEG() {
videoError.value = false
videoErrorMessage.value = ''
pendingWebRTCReadyGate = false
await stopWebRTCClientActivity()
try {
const modeResp = await streamApi.setMode('mjpeg')
@@ -1844,10 +1884,6 @@ async function switchToMJPEG() {
console.error('Failed to switch to MJPEG mode:', e)
}
if (webrtc.isConnected.value || webrtc.sessionId.value) {
await webrtc.disconnect()
}
if (webrtcVideoRef.value) {
webrtcVideoRef.value.srcObject = null
}
@@ -1873,6 +1909,7 @@ function syncToServerMode(mode: VideoMode) {
if (mode !== 'mjpeg') {
connectWebRTCOnly(mode)
} else {
void stopWebRTCClientActivity()
refreshVideo()
}
}
@@ -1976,8 +2013,6 @@ watch(webrtc.stats, (stats) => {
}
}, { deep: true })
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', {

View File

@@ -38,6 +38,7 @@ import {
type UpdateStatusResponse,
type UpdateChannel,
type VideoEncoderSelfCheckResponse,
type DeviceList,
} from '@/api'
import type {
ExtensionsStatus,
@@ -54,16 +55,18 @@ import type {
WatchdogConfigResponse,
} from '@/types/generated'
import { FrpProxyType, FrpcConfigMode } from '@/types/generated'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import { toConfigFps } from '@/lib/fps'
import { useClipboard } from '@/composables/useClipboard'
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
import { useTheme } from '@/composables/useTheme'
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
import { getVideoFormatState } from '@/lib/video-format-support'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import AppLayout from '@/components/AppLayout.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import TerminalDialog from '@/components/TerminalDialog.vue'
import TotpSettingsCard from '@/components/TotpSettingsCard.vue'
import VideoInputFields from '@/components/VideoInputFields.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -627,27 +630,7 @@ function openPreviewUrl() {
window.open(previewAccessUrl.value, '_blank', 'noopener,noreferrer')
}
interface DeviceConfig {
video: Array<{
path: string
name: string
driver: string
formats: Array<{
format: string
description: string
resolutions: Array<{
width: number
height: number
fps: number[]
}>
}>
}>
serial: Array<{ path: string; name: string }>
audio: Array<{ name: string; description: string }>
udc: Array<{ name: string }>
}
const devices = ref<DeviceConfig>({
const devices = ref<Pick<DeviceList, 'video' | 'serial' | 'audio' | 'udc'>>({
video: [],
serial: [],
audio: [],
@@ -1235,14 +1218,52 @@ const selectedBackendFormats = computed(() => {
return backend?.supported_formats || []
})
const selectedDevice = computed(() => {
return devices.value.video.find(d => d.path === config.value.video_device)
const videoDeviceSelection = computed({
get: () => config.value.video_device,
set: value => { config.value.video_device = value },
})
const videoFormatSelection = computed({
get: () => config.value.video_format,
set: value => { config.value.video_format = value },
})
const videoResolutionSelection = computed({
get: () => `${config.value.video_width}x${config.value.video_height}`,
set: value => {
const [width, height] = value.split('x').map(Number)
if (width && height) {
config.value.video_width = width
config.value.video_height = height
}
},
})
const videoFpsSelection = computed<number | null>({
get: () => config.value.video_fps,
set: value => { if (value !== null) config.value.video_fps = value },
})
const availableFormats = computed(() => {
if (!selectedDevice.value) return []
return selectedDevice.value.formats
const videoConfiguration = useVideoDeviceConfiguration({
devices: computed(() => devices.value.video),
selection: {
device: videoDeviceSelection,
format: videoFormatSelection,
resolution: videoResolutionSelection,
fps: videoFpsSelection,
},
active: computed(() => activeSection.value === 'video'),
listenForStreamEvents: true,
preferredFormat: device => device.formats.find(format =>
getVideoFormatState(format.format, 'config', config.value.encoder_backend) !== 'unsupported',
)?.format,
})
const {
selectedDevice,
isSourceFollowing,
availableFormats,
availableResolutions,
availableFps,
refreshInputStatus,
refreshingInputStatus,
} = videoConfiguration
const availableFormatOptions = computed(() => {
return availableFormats.value.map(format => {
@@ -1259,36 +1280,6 @@ const selectableFormats = computed(() => {
return availableFormatOptions.value.filter(format => !format.disabled)
})
const selectedFormat = computed(() => {
if (!selectedDevice.value || !config.value.video_format) return null
return selectedDevice.value.formats.find(f => f.format === config.value.video_format)
})
const availableResolutions = computed(() => {
if (!selectedFormat.value) return []
const resMap = new Map<string, { width: number; height: number; fps: number[] }>()
selectedFormat.value.resolutions.forEach(res => {
const key = `${res.width}x${res.height}`
if (!resMap.has(key)) {
resMap.set(key, { ...res })
} else {
const existing = resMap.get(key)!
const allFps = [...new Set([...existing.fps, ...res.fps])].sort((a, b) => b - a)
existing.fps = allFps
}
})
return Array.from(resMap.values()).sort((a, b) => (b.width * b.height) - (a.width * a.height))
})
const availableFps = computed(() => {
const currentRes = availableResolutions.value.find(
r => r.width === config.value.video_width && r.height === config.value.video_height
)
return currentRes ? currentRes.fps : []
})
watch(
selectableFormats,
() => {
@@ -1305,34 +1296,6 @@ watch(
{ deep: true },
)
watch(() => config.value.video_format, () => {
if (availableResolutions.value.length > 0) {
const isValid = availableResolutions.value.some(
r => r.width === config.value.video_width && r.height === config.value.video_height
)
if (!isValid) {
const best = availableResolutions.value[0]
if (best) {
config.value.video_width = best.width
config.value.video_height = best.height
if (best.fps?.[0]) config.value.video_fps = best.fps[0]
}
}
}
})
watch(() => [config.value.video_width, config.value.video_height], () => {
const fpsList = availableFps.value
if (fpsList.length > 0) {
if (!fpsList.includes(config.value.video_fps)) {
const firstFps = fpsList[0]
if (typeof firstFps === 'number') {
config.value.video_fps = firstFps
}
}
}
})
watch(() => authStore.user, (value) => {
if (value) {
usernameInput.value = value
@@ -1439,13 +1402,15 @@ async function saveConfig() {
turn_username: config.value.turn_username.trim(),
turn_password: config.value.turn_password.trim(),
})
await configStore.updateVideo({
device: config.value.video_device || undefined,
format: config.value.video_format || undefined,
width: config.value.video_width,
height: config.value.video_height,
fps: toConfigFps(config.value.video_fps),
})
await configStore.updateVideo(isSourceFollowing.value
? { device: config.value.video_device || undefined }
: {
device: config.value.video_device || undefined,
format: config.value.video_format || undefined,
width: config.value.video_width,
height: config.value.video_height,
fps: toConfigFps(config.value.video_fps),
})
}
if (activeSection.value === 'hid') {
@@ -2457,7 +2422,6 @@ function getRustdeskRendezvousStatusText(status: string | null | undefined): str
if (!status) return '-'
switch (status) {
case 'registered': return t('extensions.rustdesk.registered')
case 'connected': return t('extensions.rustdesk.connected')
case 'connecting': return t('extensions.rustdesk.connecting')
case 'disconnected': return t('extensions.rustdesk.disconnected')
default:
@@ -2469,8 +2433,7 @@ function getRustdeskRendezvousStatusText(status: string | null | undefined): str
function getRustdeskStatusClass(status: string | null | undefined): string {
switch (status) {
case 'running':
case 'registered':
case 'connected': return 'bg-status-active'
case 'registered': return 'bg-status-active'
case 'starting':
case 'connecting': return 'bg-warning'
case 'stopped':
@@ -2957,48 +2920,21 @@ watch(isWindows, () => {
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="video-format">{{ t('settings.videoFormat') }}</Label>
<Select
:model-value="config.video_format"
:disabled="!config.video_device"
@update:model-value="value => config.video_format = value === EMPTY_SELECT_VALUE ? '' : String(value)"
>
<SelectTrigger id="video-format" class="w-full"><SelectValue :placeholder="t('settings.selectFormat')" /></SelectTrigger>
<SelectContent>
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('settings.selectFormat') }}</SelectItem>
<SelectItem
v-for="fmt in availableFormatOptions"
:key="fmt.format"
:value="fmt.format"
:disabled="fmt.disabled"
>
{{ fmt.format }} - {{ fmt.description }}{{ fmt.disabled ? t('common.notSupportedYet') : '' }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="video-resolution">{{ t('settings.resolution') }}</Label>
<Select :model-value="`${config.video_width}x${config.video_height}`" :disabled="!config.video_format" @update:model-value="value => { const parts = String(value).split('x').map(Number); if (parts[0] && parts[1]) { config.video_width = parts[0]; config.video_height = parts[1]; } }">
<SelectTrigger id="video-resolution" class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="res in availableResolutions" :key="`${res.width}x${res.height}`" :value="`${res.width}x${res.height}`">{{ res.width }}x{{ res.height }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="video-fps">{{ t('settings.frameRate') }}</Label>
<Select :model-value="config.video_fps" :disabled="!config.video_format" @update:model-value="value => config.video_fps = Number(value)">
<SelectTrigger id="video-fps" class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="fps in availableFps" :key="fps" :value="fps">{{ formatFpsLabel(fps) }}</SelectItem>
<SelectItem v-if="!availableFps.includes(config.video_fps)" :value="config.video_fps">{{ formatFpsLabel(config.video_fps) }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<VideoInputFields
v-if="selectedDevice"
:device="selectedDevice"
:formats="availableFormatOptions"
:resolutions="availableResolutions"
:fps-options="availableFps"
:format="config.video_format"
:resolution="videoResolutionSelection"
:fps="config.video_fps"
:refreshing="refreshingInputStatus"
@update:format="config.video_format = $event"
@update:resolution="videoResolutionSelection = $event"
@update:fps="config.video_fps = $event"
@refresh="refreshInputStatus"
/>
</CardContent>
</Card>

View File

@@ -3,9 +3,11 @@ import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { configApi, streamApi, type EncoderBackendInfo, type PlatformCapabilities } from '@/api'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import { configApi, streamApi, type DeviceList, type EncoderBackendInfo, type PlatformCapabilities } from '@/api'
import { toConfigFps } from '@/lib/fps'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
import VideoInputFields from '@/components/VideoInputFields.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
import { Button } from '@/components/ui/button'
@@ -38,7 +40,6 @@ import {
Check,
HelpCircle,
Puzzle,
RefreshCw,
AlertTriangle,
} from 'lucide-vue-next'
@@ -92,48 +93,14 @@ const encoderBackend = ref('auto')
const availableBackends = ref<EncoderBackendInfo[]>([])
const showAdvancedEncoder = ref(false)
// Device info from API
interface VideoDeviceInfo {
path: string
name: string
driver: string
formats: Array<{
format: string
description: string
resolutions: Array<{
width: number
height: number
fps: number[]
}>
}>
usb_bus: string | null
has_signal: boolean
}
interface AudioDeviceInfo {
name: string
description: string
is_hdmi: boolean
usb_bus: string | null
}
interface DeviceInfo {
video: VideoDeviceInfo[]
serial: Array<{ path: string; name: string }>
audio: AudioDeviceInfo[]
udc: Array<{ name: string }>
extensions: {
ttyd_available: boolean
}
}
const devices = ref<DeviceInfo>({
const devices = ref<DeviceList>({
video: [],
serial: [],
audio: [],
udc: [],
extensions: {
ttyd_available: false,
rustdesk_available: false,
},
})
@@ -165,46 +132,27 @@ const passwordStrengthColor = computed(() => {
return colors[passwordStrength.value] || colors[0]
})
// Whether the selected video device currently has an HDMI signal
const selectedDeviceHasSignal = computed(() => {
const device = devices.value.video.find((d) => d.path === videoDevice.value)
return device?.has_signal ?? true
})
const refreshingDevices = ref(false)
async function refreshDeviceList() {
refreshingDevices.value = true
try {
const result = await configApi.listDevices()
devices.value = result
if (result.extensions) {
ttydAvailable.value = result.extensions.ttyd_available
}
} catch {
} finally {
refreshingDevices.value = false
}
}
// Computed: available formats for selected video device
const availableFormats = computed(() => {
const device = devices.value.video.find((d) => d.path === videoDevice.value)
return device?.formats || []
})
const availableResolutions = computed(() => {
const format = availableFormats.value.find((f) => f.format === videoFormat.value)
return format?.resolutions || []
})
const availableFps = computed(() => {
const [width, height] = (videoResolution.value || '').split('x').map(Number)
const resolution = availableResolutions.value.find(
(r) => r.width === width && r.height === height
)
return resolution?.fps || []
const videoConfiguration = useVideoDeviceConfiguration({
devices: computed(() => devices.value.video),
selection: {
device: videoDevice,
format: videoFormat,
resolution: videoResolution,
fps: videoFps,
},
active: computed(() => step.value === 2),
preferredFormat: device =>
device.formats.find(format => format.format.toUpperCase().includes('MJPEG'))?.format,
})
const {
selectedDevice,
isSourceFollowing,
availableFormats,
availableResolutions,
availableFps,
refreshInputStatus,
refreshingInputStatus,
} = videoConfiguration
function applyOtgDefaults() {
if (hidBackend.value !== 'otg') return
@@ -258,17 +206,8 @@ function validateConfirmPassword() {
}
}
// Watch video device change to auto-select first format and matching audio device
// Match audio to the selected capture device's USB bus.
watch(videoDevice, (newDevice) => {
videoFormat.value = ''
videoResolution.value = ''
videoFps.value = null
if (availableFormats.value.length > 0) {
// Prefer MJPEG if available
const mjpeg = availableFormats.value.find((f) => f.format.toUpperCase().includes('MJPEG'))
videoFormat.value = mjpeg?.format || availableFormats.value[0]?.format || ''
}
// Auto-select matching audio device based on USB bus
if (newDevice && audioEnabled.value && audioSupported.value) {
const video = devices.value.video.find((d) => d.path === newDevice)
@@ -292,26 +231,6 @@ watch(videoDevice, (newDevice) => {
}
})
watch(videoFormat, () => {
videoResolution.value = ''
videoFps.value = null
if (availableResolutions.value.length > 0) {
const r1080 = availableResolutions.value.find((r) => r.width === 1920 && r.height === 1080)
const r720 = availableResolutions.value.find((r) => r.width === 1280 && r.height === 720)
const best = r1080 || r720 || availableResolutions.value[0]
if (best) {
videoResolution.value = `${best.width}x${best.height}`
}
}
})
watch(videoResolution, () => {
videoFps.value = null
if (availableFps.value.length > 0) {
videoFps.value = availableFps.value.includes(30) ? 30 : availableFps.value[0] || null
}
})
// Watch HID backend change to set defaults
watch(hidBackend, (newBackend) => {
if (newBackend === 'ch9329' && !ch9329Port.value && devices.value.serial.length > 0) {
@@ -429,7 +348,7 @@ function validateStep1(): boolean {
function validateStep2(): boolean {
// Video settings are optional, but if device is selected, format should be too
if (videoDevice.value && !videoFormat.value) {
if (videoDevice.value && !isSourceFollowing.value && !videoFormat.value) {
error.value = t('setup.selectFormat')
return false
}
@@ -485,14 +404,14 @@ async function handleSetup() {
if (videoDevice.value) {
setupData.video_device = videoDevice.value
}
if (videoFormat.value) {
if (!isSourceFollowing.value && videoFormat.value) {
setupData.video_format = videoFormat.value
}
if (width && height) {
if (!isSourceFollowing.value && width && height) {
setupData.video_width = width
setupData.video_height = height
}
if (videoFps.value) {
if (!isSourceFollowing.value && videoFps.value) {
setupData.video_fps = toConfigFps(videoFps.value)
}
@@ -714,85 +633,21 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</Select>
</div>
<Alert v-if="videoDevice && !selectedDeviceHasSignal" variant="warning">
<AlertTriangle />
<AlertDescription class="flex items-center gap-3">
<p class="flex-1">{{ t('setup.noSignalDetected') }}</p>
<Button variant="outline" size="sm" :disabled="refreshingDevices" @click="refreshDeviceList">
<RefreshCw class="w-4 h-4 mr-1" :class="{ 'animate-spin': refreshingDevices }" />
{{ t('setup.refreshDevices') }}
</Button>
</AlertDescription>
</Alert>
<div v-if="videoDevice" class="space-y-2">
<div class="flex items-center gap-2">
<Label for="videoFormat">{{ t('setup.videoFormat') }}</Label>
<HoverCard>
<HoverCardTrigger as-child>
<Button type="button" variant="ghost" size="icon-xs" class="text-muted-foreground" :aria-label="t('common.info')">
<HelpCircle class="w-4 h-4" />
</Button>
</HoverCardTrigger>
<HoverCardContent class="w-64 text-sm">
{{ t('setup.videoFormatHelp') }}
</HoverCardContent>
</HoverCard>
</div>
<Select
:model-value="videoFormat"
@update:model-value="value => videoFormat = value === EMPTY_SELECT_VALUE ? '' : String(value)"
>
<SelectTrigger id="videoFormat" class="w-full">
<SelectValue :placeholder="t('setup.selectFormat')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('setup.selectFormat') }}</SelectItem>
<SelectItem v-for="fmt in availableFormats" :key="fmt.format" :value="fmt.format">
{{ fmt.format }} - {{ fmt.description }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="videoFormat" class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="videoResolution">{{ t('setup.resolution') }}</Label>
<Select
:model-value="videoResolution"
@update:model-value="value => videoResolution = value === EMPTY_SELECT_VALUE ? '' : String(value)"
>
<SelectTrigger id="videoResolution" class="w-full">
<SelectValue :placeholder="t('setup.selectResolution')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('setup.selectResolution') }}</SelectItem>
<SelectItem
v-for="res in availableResolutions"
:key="`${res.width}x${res.height}`"
:value="`${res.width}x${res.height}`"
>
{{ res.width }}x{{ res.height }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="videoFps">{{ t('setup.fps') }}</Label>
<Select :model-value="videoFps" @update:model-value="value => videoFps = value === EMPTY_SELECT_VALUE ? null : Number(value)">
<SelectTrigger id="videoFps" class="w-full">
<SelectValue :placeholder="t('setup.selectFps')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('setup.selectFps') }}</SelectItem>
<SelectItem v-for="fps in availableFps" :key="fps" :value="fps">
{{ formatFpsLabel(fps) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<VideoInputFields
v-if="selectedDevice"
:device="selectedDevice"
:formats="availableFormats"
:resolutions="availableResolutions"
:fps-options="availableFps"
:format="videoFormat"
:resolution="videoResolution"
:fps="videoFps"
:refreshing="refreshingInputStatus"
@update:format="videoFormat = $event"
@update:resolution="videoResolution = $event"
@update:fps="videoFps = $event"
@refresh="refreshInputStatus"
/>
<p v-if="!devices.video.length" class="text-sm text-muted-foreground text-center py-4">
{{ t('setup.noVideoDevices') }}