mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
fix: 完善 RK3588 HDMI RX 信号检测与自动恢复
- 统一使用 QUERY_DV_TIMINGS 判断 HDMI RX 输入状态 - 增加 source-following 设备状态 API 与前端只读展示 - 支持长时间无信号后的 MJPEG/WebRTC 自动恢复 - 修复模式切换时采集设备尚未释放导致的 EBUSY - 取消陈旧 WebRTC 重连并统一采集恢复策略 - 移除视频输入状态区域的冗余标题
This commit is contained in:
@@ -374,11 +374,12 @@ export const webrtcApi = {
|
||||
createSession: () =>
|
||||
request<{ session_id: string }>('/webrtc/session', { method: 'POST' }),
|
||||
|
||||
offer: (sdp: string) =>
|
||||
offer: (sdp: string, signal?: AbortSignal) =>
|
||||
request<{ sdp: string; session_id: string; ice_candidates: IceCandidate[] }>('/webrtc/offer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sdp }),
|
||||
}),
|
||||
signal,
|
||||
}, { toastOnError: false }),
|
||||
|
||||
addIceCandidate: (sessionId: string, candidate: IceCandidate) =>
|
||||
request<{ success: boolean }>('/webrtc/ice', {
|
||||
@@ -742,6 +743,56 @@ interface SerialDeviceOption {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type VideoControlMode = 'configurable' | 'source_following'
|
||||
export type VideoInputState = 'locked' | 'no_signal' | 'unavailable'
|
||||
|
||||
export interface VideoInputStatus {
|
||||
state: VideoInputState
|
||||
format: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
fps: number | null
|
||||
}
|
||||
|
||||
export interface VideoResolution {
|
||||
width: number
|
||||
height: number
|
||||
fps: number[]
|
||||
}
|
||||
|
||||
export interface VideoFormat {
|
||||
format: string
|
||||
description: string
|
||||
resolutions: VideoResolution[]
|
||||
}
|
||||
|
||||
export interface VideoDevice {
|
||||
path: string
|
||||
name: string
|
||||
driver: string
|
||||
formats: VideoFormat[]
|
||||
usb_bus: string | null
|
||||
has_signal: boolean
|
||||
control_mode: VideoControlMode
|
||||
input_status: VideoInputStatus
|
||||
}
|
||||
|
||||
export interface DeviceList {
|
||||
video: VideoDevice[]
|
||||
serial: Array<{ path: string; name: string }>
|
||||
audio: Array<{
|
||||
name: string
|
||||
description: string
|
||||
is_hdmi: boolean
|
||||
usb_bus: string | null
|
||||
}>
|
||||
udc: Array<{ name: string }>
|
||||
extensions: {
|
||||
ttyd_available: boolean
|
||||
rustdesk_available: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function encodeDrivePath(path: string): string {
|
||||
if (path === '' || path === '/') {
|
||||
return '/'
|
||||
@@ -774,42 +825,20 @@ function sortSerialDevices(serialDevices: SerialDeviceOption[]): SerialDeviceOpt
|
||||
|
||||
export const configApi = {
|
||||
listDevices: async () => {
|
||||
const result = await request<{
|
||||
video: Array<{
|
||||
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
|
||||
}>
|
||||
serial: Array<{ path: string; name: string }>
|
||||
audio: Array<{
|
||||
name: string
|
||||
description: string
|
||||
is_hdmi: boolean
|
||||
usb_bus: string | null
|
||||
}>
|
||||
udc: Array<{ name: string }>
|
||||
extensions: {
|
||||
ttyd_available: boolean
|
||||
rustdesk_available: boolean
|
||||
}
|
||||
}>('/devices')
|
||||
const result = await request<DeviceList>('/devices')
|
||||
|
||||
return {
|
||||
...result,
|
||||
serial: sortSerialDevices(result.serial),
|
||||
}
|
||||
},
|
||||
|
||||
getVideoInputStatus: (device: string) =>
|
||||
request<VideoInputStatus>(
|
||||
`/video/input-status?device=${encodeURIComponent(device)}`,
|
||||
{},
|
||||
{ toastOnError: false },
|
||||
),
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -25,30 +25,17 @@ import {
|
||||
type EncoderBackendInfo,
|
||||
type BitratePreset,
|
||||
type StreamConstraintsResponse,
|
||||
type VideoDevice,
|
||||
} from '@/api'
|
||||
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
|
||||
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||
import { toConfigFps } from '@/lib/fps'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
|
||||
import VideoInputFields from '@/components/VideoInputFields.vue'
|
||||
|
||||
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||
|
||||
interface VideoDevice {
|
||||
path: string
|
||||
name: string
|
||||
driver: string
|
||||
formats: {
|
||||
format: string
|
||||
description: string
|
||||
resolutions: {
|
||||
width: number
|
||||
height: number
|
||||
fps: number[]
|
||||
}[]
|
||||
}[]
|
||||
has_signal?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
videoMode: VideoMode
|
||||
@@ -177,63 +164,36 @@ const translateBackendName = (backend: string | undefined): string => {
|
||||
return backend
|
||||
}
|
||||
|
||||
const hasHighFps = (format: { resolutions: { fps: number[] }[] }): boolean => {
|
||||
return format.resolutions.some(res => res.fps.some(fps => fps >= 30))
|
||||
}
|
||||
|
||||
const isFormatRecommended = (formatName: string): boolean => {
|
||||
if (!isVideoFormatSelectable(formatName, props.videoMode, currentEncoderBackend.value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const formats = availableFormats.value
|
||||
const upperFormat = formatName.toUpperCase()
|
||||
|
||||
// MJPEG/HTTP mode: recommend MJPEG
|
||||
if (props.videoMode === 'mjpeg') {
|
||||
return upperFormat === 'MJPEG'
|
||||
}
|
||||
|
||||
// WebRTC mode: check NV12 first, then YUYV
|
||||
const currentFormat = formats.find(f => f.format.toUpperCase() === upperFormat)
|
||||
if (!currentFormat) return false
|
||||
|
||||
const nv12Format = formats.find(f => f.format.toUpperCase() === 'NV12')
|
||||
const nv12HasHighFps = nv12Format && hasHighFps(nv12Format)
|
||||
|
||||
const yuyvFormat = formats.find(f => f.format.toUpperCase() === 'YUYV')
|
||||
const yuyvHasHighFps = yuyvFormat && hasHighFps(yuyvFormat)
|
||||
|
||||
if (nv12HasHighFps) {
|
||||
return upperFormat === 'NV12'
|
||||
}
|
||||
|
||||
if (yuyvHasHighFps) {
|
||||
return upperFormat === 'YUYV'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// In WebRTC mode, compressed formats (MJPEG/JPEG) are not recommended
|
||||
const isFormatNotRecommended = (formatName: string): boolean => {
|
||||
return getFormatState(formatName) === 'not_recommended'
|
||||
}
|
||||
|
||||
const selectedDevice = ref<string>('')
|
||||
const selectedFormat = ref<string>('')
|
||||
const selectedResolution = ref<string>('')
|
||||
const selectedFps = ref<number>(30)
|
||||
const selectedFps = ref<number | null>(30)
|
||||
const selectedBitratePreset = ref<'Speed' | 'Balanced' | 'Quality'>('Balanced')
|
||||
const isDirty = ref(false)
|
||||
|
||||
const selectedFormatStatus = computed<'recommended' | 'not_recommended' | 'unsupported' | null>(() => {
|
||||
if (!selectedFormat.value) return null
|
||||
if (isFormatUnsupported(selectedFormat.value)) return 'unsupported'
|
||||
if (isFormatRecommended(selectedFormat.value)) return 'recommended'
|
||||
if (isFormatNotRecommended(selectedFormat.value)) return 'not_recommended'
|
||||
return null
|
||||
const videoConfiguration = useVideoDeviceConfiguration({
|
||||
devices,
|
||||
selection: {
|
||||
device: selectedDevice,
|
||||
format: selectedFormat,
|
||||
resolution: selectedResolution,
|
||||
fps: selectedFps,
|
||||
},
|
||||
active: computed(() => props.open),
|
||||
listenForStreamEvents: true,
|
||||
preferredFormat: device => device.formats.find(format =>
|
||||
isVideoFormatSelectable(format.format, props.videoMode, currentEncoderBackend.value),
|
||||
)?.format,
|
||||
})
|
||||
const {
|
||||
selectedDevice: selectedDeviceInfo,
|
||||
isSourceFollowing,
|
||||
availableFormats,
|
||||
availableResolutions,
|
||||
availableFps,
|
||||
refreshInputStatus,
|
||||
refreshingInputStatus,
|
||||
} = videoConfiguration
|
||||
|
||||
const applying = ref(false)
|
||||
const applyingBitrate = ref(false)
|
||||
@@ -288,11 +248,6 @@ const availableCodecs = computed(() => {
|
||||
return backendFiltered.filter(codec => allowed.includes(codec.id))
|
||||
})
|
||||
|
||||
const availableFormats = computed(() => {
|
||||
const device = devices.value.find(d => d.path === selectedDevice.value)
|
||||
return device?.formats || []
|
||||
})
|
||||
|
||||
const availableFormatOptions = computed(() => {
|
||||
return availableFormats.value.map(format => ({
|
||||
...format,
|
||||
@@ -301,32 +256,6 @@ const availableFormatOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const availableResolutions = computed(() => {
|
||||
const format = availableFormats.value.find(f => f.format === selectedFormat.value)
|
||||
return format?.resolutions || []
|
||||
})
|
||||
|
||||
const availableFps = computed(() => {
|
||||
const resolution = availableResolutions.value.find(
|
||||
r => `${r.width}x${r.height}` === selectedResolution.value
|
||||
)
|
||||
return resolution?.fps || []
|
||||
})
|
||||
|
||||
const selectedFormatInfo = computed(() =>
|
||||
availableFormatOptions.value.find(format => format.format === selectedFormat.value) ?? null
|
||||
)
|
||||
|
||||
const selectedDeviceInfo = computed(() =>
|
||||
devices.value.find(device => device.path === selectedDevice.value) ?? null
|
||||
)
|
||||
|
||||
const selectedResolutionInfo = computed(() =>
|
||||
availableResolutions.value.find(
|
||||
resolution => `${resolution.width}x${resolution.height}` === selectedResolution.value,
|
||||
) ?? null
|
||||
)
|
||||
|
||||
const selectedCodecInfo = computed(() => {
|
||||
const codec = availableCodecs.value.find(c => c.id === props.videoMode)
|
||||
return codec || null
|
||||
@@ -458,6 +387,10 @@ function handleDeviceChange(devicePath: unknown) {
|
||||
isDirty.value = true
|
||||
|
||||
const device = devices.value.find(d => d.path === devicePath)
|
||||
if (device?.control_mode === 'source_following') {
|
||||
clearFormatSelection()
|
||||
return
|
||||
}
|
||||
const format = device ? findFirstSelectableFormat(device.formats) : undefined
|
||||
if (!format) {
|
||||
clearFormatSelection()
|
||||
@@ -519,13 +452,15 @@ async function applyVideoConfig() {
|
||||
|
||||
applying.value = true
|
||||
try {
|
||||
await configStore.updateVideo({
|
||||
device: selectedDevice.value,
|
||||
format: selectedFormat.value,
|
||||
width,
|
||||
height,
|
||||
fps: toConfigFps(selectedFps.value),
|
||||
})
|
||||
await configStore.updateVideo(isSourceFollowing.value
|
||||
? { device: selectedDevice.value }
|
||||
: {
|
||||
device: selectedDevice.value,
|
||||
format: selectedFormat.value,
|
||||
width,
|
||||
height,
|
||||
fps: toConfigFps(selectedFps.value ?? 30),
|
||||
})
|
||||
|
||||
isDirty.value = false
|
||||
// Stream state will be updated via WebSocket system.device_info event
|
||||
@@ -781,124 +716,28 @@ watch(
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Format Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoFormat') }}</Label>
|
||||
<Select
|
||||
:model-value="selectedFormat"
|
||||
@update:model-value="handleFormatChange"
|
||||
:disabled="!selectedDevice || availableFormats.length === 0"
|
||||
>
|
||||
<SelectTrigger size="sm" class="w-full text-xs">
|
||||
<div v-if="selectedFormatInfo" class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="truncate">{{ selectedFormatInfo.description }}</span>
|
||||
<span
|
||||
v-if="selectedFormatStatus === 'recommended'"
|
||||
class="shrink-0 rounded bg-info/10 px-1 py-0.5 text-[10px] text-info"
|
||||
>
|
||||
{{ t('actionbar.recommended') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="selectedFormatStatus === 'not_recommended'"
|
||||
class="shrink-0 rounded bg-warning/10 px-1 py-0.5 text-[10px] text-warning"
|
||||
>
|
||||
{{ t('actionbar.notRecommended') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="selectedFormatStatus === 'unsupported'"
|
||||
class="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ t('common.notSupportedYet') }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">{{ t('actionbar.selectFormat') }}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="format in availableFormatOptions"
|
||||
:key="format.format"
|
||||
:value="format.format"
|
||||
:disabled="format.disabled"
|
||||
class="text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ format.description }}</span>
|
||||
<span
|
||||
v-if="isFormatRecommended(format.format)"
|
||||
class="rounded bg-info/10 px-1.5 py-0.5 text-[10px] text-info"
|
||||
>
|
||||
{{ t('actionbar.recommended') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isFormatNotRecommended(format.format)"
|
||||
class="rounded bg-warning/10 px-1.5 py-0.5 text-[10px] text-warning"
|
||||
>
|
||||
{{ t('actionbar.notRecommended') }}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoResolution') }}</Label>
|
||||
<Select
|
||||
:model-value="selectedResolution"
|
||||
@update:model-value="handleResolutionChange"
|
||||
:disabled="!selectedFormat || availableResolutions.length === 0"
|
||||
>
|
||||
<SelectTrigger size="sm" class="w-full text-xs">
|
||||
<span v-if="selectedResolutionInfo">
|
||||
{{ selectedResolutionInfo.width }} × {{ selectedResolutionInfo.height }}
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground">{{ t('actionbar.selectResolution') }}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="res in availableResolutions"
|
||||
:key="`${res.width}x${res.height}`"
|
||||
:value="`${res.width}x${res.height}`"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ res.width }} × {{ res.height }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- FPS Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoFps') }}</Label>
|
||||
<Select
|
||||
:model-value="String(selectedFps)"
|
||||
@update:model-value="handleFpsChange"
|
||||
:disabled="!selectedResolution || availableFps.length === 0"
|
||||
>
|
||||
<SelectTrigger size="sm" class="w-full text-xs">
|
||||
<span v-if="selectedResolution && availableFps.includes(selectedFps)">
|
||||
{{ formatFpsLabel(selectedFps) }}
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground">{{ t('actionbar.selectFps') }}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="fps in availableFps"
|
||||
:key="fps"
|
||||
:value="String(fps)"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ formatFpsLabel(fps) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<VideoInputFields
|
||||
v-if="selectedDeviceInfo"
|
||||
compact
|
||||
:device="selectedDeviceInfo"
|
||||
:formats="availableFormatOptions"
|
||||
:resolutions="availableResolutions"
|
||||
:fps-options="availableFps"
|
||||
:format="selectedFormat"
|
||||
:resolution="selectedResolution"
|
||||
:fps="selectedFps"
|
||||
:refreshing="refreshingInputStatus"
|
||||
@update:format="handleFormatChange"
|
||||
@update:resolution="handleResolutionChange"
|
||||
@update:fps="handleFpsChange"
|
||||
@refresh="refreshInputStatus"
|
||||
/>
|
||||
|
||||
<!-- Apply Button -->
|
||||
<Button
|
||||
class="w-full h-8 text-xs"
|
||||
:disabled="applying || !selectedDevice || !selectedFormat"
|
||||
size="sm"
|
||||
class="w-full text-xs"
|
||||
:disabled="applying || !selectedDevice || (!isSourceFollowing && !selectedFormat)"
|
||||
@click="applyVideoConfig"
|
||||
>
|
||||
<Loader2 v-if="applying" class="size-3.5 mr-1.5 animate-spin" />
|
||||
|
||||
129
web/src/components/VideoInputFields.vue
Normal file
129
web/src/components/VideoInputFields.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-vue-next'
|
||||
import type { VideoDevice, VideoFormat, VideoResolution } from '@/api'
|
||||
import { formatFpsLabel } from '@/lib/fps'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
const props = defineProps<{
|
||||
device?: VideoDevice
|
||||
formats: Array<VideoFormat & { disabled?: boolean }>
|
||||
resolutions: VideoResolution[]
|
||||
fpsOptions: number[]
|
||||
format: string
|
||||
resolution: string
|
||||
fps: number | null
|
||||
compact?: boolean
|
||||
refreshing?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:format', value: string): void
|
||||
(event: 'update:resolution', value: string): void
|
||||
(event: 'update:fps', value: number): void
|
||||
(event: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="device?.control_mode === 'source_following'">
|
||||
<div v-if="device.input_status.state === 'locked'" class="space-y-2">
|
||||
<dl class="grid grid-cols-3 gap-3" :class="compact ? 'text-xs' : 'text-sm'">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<dt class="text-muted-foreground">{{ t('videoInput.format') }}</dt>
|
||||
<dd class="truncate font-medium" :title="device.input_status.format ?? ''">
|
||||
{{ device.input_status.format ?? '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<dt class="text-muted-foreground">{{ t('videoInput.resolution') }}</dt>
|
||||
<dd class="whitespace-nowrap font-medium">
|
||||
{{ device.input_status.width }}x{{ device.input_status.height }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<dt class="text-muted-foreground">{{ t('videoInput.frameRate') }}</dt>
|
||||
<dd class="whitespace-nowrap font-medium">
|
||||
{{ device.input_status.fps === null ? '—' : formatFpsLabel(device.input_status.fps) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="device.input_status.state === 'no_signal'"
|
||||
class="flex items-center gap-2 text-warning"
|
||||
:class="compact ? 'text-xs' : 'text-sm'"
|
||||
role="status"
|
||||
>
|
||||
<AlertTriangle class="size-4 shrink-0" />
|
||||
<span>{{ t('videoInput.noSignal') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-between gap-3" role="status">
|
||||
<div class="flex min-w-0 items-center gap-2 text-muted-foreground" :class="compact ? 'text-xs' : 'text-sm'">
|
||||
<AlertTriangle class="size-4 shrink-0" />
|
||||
<span>{{ t('videoInput.unavailable') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:size="compact ? 'icon-xs' : 'icon'"
|
||||
:disabled="refreshing"
|
||||
:title="t('videoInput.refresh')"
|
||||
:aria-label="t('videoInput.refresh')"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw :class="['size-4', refreshing && 'animate-spin']" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="device">
|
||||
<div class="space-y-2">
|
||||
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.format') }}</Label>
|
||||
<Select :model-value="format" @update:model-value="value => emit('update:format', String(value))">
|
||||
<SelectTrigger :size="compact ? 'sm' : 'default'" class="w-full" :class="compact ? 'text-xs' : undefined">
|
||||
<SelectValue :placeholder="t('videoInput.selectFormat')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="item in formats" :key="item.format" :value="item.format" :disabled="item.disabled">
|
||||
{{ item.description || item.format }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.resolution') }}</Label>
|
||||
<Select :model-value="resolution" @update:model-value="value => emit('update:resolution', String(value))">
|
||||
<SelectTrigger :size="compact ? 'sm' : 'default'" class="w-full" :class="compact ? 'text-xs' : undefined">
|
||||
<SelectValue :placeholder="t('videoInput.selectResolution')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="item in resolutions" :key="`${item.width}x${item.height}`" :value="`${item.width}x${item.height}`">
|
||||
{{ item.width }}x{{ item.height }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.frameRate') }}</Label>
|
||||
<Select :model-value="fps === null ? '' : String(fps)" @update:model-value="value => emit('update:fps', Number(value))">
|
||||
<SelectTrigger :size="compact ? 'sm' : 'default'" class="w-full" :class="compact ? 'text-xs' : undefined">
|
||||
<SelectValue :placeholder="t('videoInput.selectFps')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="item in fpsOptions" :key="item" :value="String(item)">
|
||||
{{ formatFpsLabel(item) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -57,7 +57,12 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
handlers.onStreamRecovered?.(_data)
|
||||
}
|
||||
|
||||
function handleStreamStateChangedForward(data: { state: string; device?: string | null }) {
|
||||
function handleStreamStateChangedForward(data: {
|
||||
state: string
|
||||
device?: string | null
|
||||
reason?: string | null
|
||||
next_retry_ms?: number | null
|
||||
}) {
|
||||
handlers.onStreamStateChanged?.(data)
|
||||
}
|
||||
|
||||
|
||||
182
web/src/composables/useVideoDeviceConfiguration.ts
Normal file
182
web/src/composables/useVideoDeviceConfiguration.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
import { configApi, type VideoDevice, type VideoInputStatus, type VideoResolution } from '@/api'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
|
||||
interface VideoSelection {
|
||||
device: Ref<string>
|
||||
format: Ref<string>
|
||||
resolution: Ref<string>
|
||||
fps: Ref<number | null>
|
||||
}
|
||||
|
||||
interface Options {
|
||||
devices: Ref<VideoDevice[]>
|
||||
selection: VideoSelection
|
||||
active: ComputedRef<boolean> | Ref<boolean>
|
||||
listenForStreamEvents?: boolean
|
||||
preferredFormat?: (device: VideoDevice) => string | undefined
|
||||
}
|
||||
|
||||
export function useVideoDeviceConfiguration(options: Options) {
|
||||
const selectedDevice = computed(() =>
|
||||
options.devices.value.find(device => device.path === options.selection.device.value),
|
||||
)
|
||||
const isSourceFollowing = computed(() =>
|
||||
selectedDevice.value?.control_mode === 'source_following',
|
||||
)
|
||||
const inputStatus = computed(() => selectedDevice.value?.input_status ?? null)
|
||||
const availableFormats = computed(() => selectedDevice.value?.formats ?? [])
|
||||
const availableResolutions = computed(() => {
|
||||
const resolutions = availableFormats.value.find(
|
||||
format => format.format === options.selection.format.value,
|
||||
)?.resolutions ?? []
|
||||
const merged = new Map<string, VideoResolution>()
|
||||
for (const resolution of resolutions) {
|
||||
const key = `${resolution.width}x${resolution.height}`
|
||||
const current = merged.get(key)
|
||||
if (!current) {
|
||||
merged.set(key, { ...resolution, fps: [...resolution.fps] })
|
||||
} else {
|
||||
current.fps = [...new Set([...current.fps, ...resolution.fps])].sort((a, b) => b - a)
|
||||
}
|
||||
}
|
||||
return [...merged.values()].sort((a, b) => b.width * b.height - a.width * a.height)
|
||||
})
|
||||
const availableFps = computed(() => {
|
||||
const resolution = availableResolutions.value.find(
|
||||
item => `${item.width}x${item.height}` === options.selection.resolution.value,
|
||||
)
|
||||
return resolution?.fps ?? []
|
||||
})
|
||||
|
||||
let requestGeneration = 0
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
const refreshingInputStatus = ref(false)
|
||||
|
||||
function replaceInputStatus(path: string, status: VideoInputStatus) {
|
||||
const device = options.devices.value.find(item => item.path === path)
|
||||
if (!device) return
|
||||
device.input_status = status
|
||||
device.has_signal = status.state === 'locked'
|
||||
}
|
||||
|
||||
async function refreshInputStatus() {
|
||||
const path = options.selection.device.value
|
||||
if (!path || !isSourceFollowing.value || refreshingInputStatus.value) return
|
||||
const generation = ++requestGeneration
|
||||
refreshingInputStatus.value = true
|
||||
try {
|
||||
const status = await configApi.getVideoInputStatus(path)
|
||||
if (generation !== requestGeneration || path !== options.selection.device.value) return
|
||||
replaceInputStatus(path, status)
|
||||
} catch {
|
||||
if (generation !== requestGeneration || path !== options.selection.device.value) return
|
||||
replaceInputStatus(path, {
|
||||
state: 'unavailable',
|
||||
format: null,
|
||||
width: null,
|
||||
height: null,
|
||||
fps: null,
|
||||
})
|
||||
} finally {
|
||||
if (generation === requestGeneration) refreshingInputStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
requestGeneration++
|
||||
refreshingInputStatus.value = false
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
|
||||
function syncPolling() {
|
||||
stopPolling()
|
||||
if (!options.active.value || document.hidden || !isSourceFollowing.value) return
|
||||
void refreshInputStatus()
|
||||
pollTimer = setInterval(() => void refreshInputStatus(), 2_000)
|
||||
}
|
||||
|
||||
function chooseResolution() {
|
||||
if (isSourceFollowing.value) return
|
||||
const current = options.selection.resolution.value
|
||||
if (availableResolutions.value.some(item => `${item.width}x${item.height}` === current)) return
|
||||
const preferred = availableResolutions.value.find(item => item.width === 1920 && item.height === 1080)
|
||||
?? availableResolutions.value.find(item => item.width === 1280 && item.height === 720)
|
||||
?? availableResolutions.value[0]
|
||||
options.selection.resolution.value = preferred ? `${preferred.width}x${preferred.height}` : ''
|
||||
}
|
||||
|
||||
function chooseFps() {
|
||||
if (isSourceFollowing.value) return
|
||||
const current = options.selection.fps.value
|
||||
if (current !== null && availableFps.value.includes(current)) return
|
||||
options.selection.fps.value = availableFps.value.includes(30) ? 30 : availableFps.value[0] ?? null
|
||||
}
|
||||
|
||||
watch(() => options.selection.device.value, () => {
|
||||
requestGeneration++
|
||||
if (isSourceFollowing.value) {
|
||||
options.selection.format.value = ''
|
||||
options.selection.resolution.value = ''
|
||||
options.selection.fps.value = null
|
||||
} else if (selectedDevice.value) {
|
||||
const valid = availableFormats.value.some(item => item.format === options.selection.format.value)
|
||||
if (!valid) {
|
||||
options.selection.format.value = options.preferredFormat?.(selectedDevice.value)
|
||||
?? availableFormats.value[0]?.format
|
||||
?? ''
|
||||
}
|
||||
}
|
||||
syncPolling()
|
||||
})
|
||||
watch(() => options.selection.format.value, chooseResolution)
|
||||
watch(() => options.selection.resolution.value, chooseFps)
|
||||
watch([() => options.active.value, isSourceFollowing], syncPolling)
|
||||
|
||||
const { on, off, connect } = useWebSocket()
|
||||
const refreshFromStreamEvent = () => {
|
||||
if (options.active.value && isSourceFollowing.value) void refreshInputStatus()
|
||||
}
|
||||
const streamEvents = ['stream.config_applied', 'stream.state_changed', 'stream.recovered']
|
||||
|
||||
function handleVisibilityChange() {
|
||||
syncPolling()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
if (options.listenForStreamEvents) {
|
||||
for (const event of streamEvents) on(event, refreshFromStreamEvent)
|
||||
connect()
|
||||
}
|
||||
syncPolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
if (options.listenForStreamEvents) {
|
||||
for (const event of streamEvents) off(event, refreshFromStreamEvent)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
selectedDevice,
|
||||
isSourceFollowing,
|
||||
inputStatus,
|
||||
availableFormats,
|
||||
availableResolutions,
|
||||
availableFps,
|
||||
refreshInputStatus,
|
||||
refreshingInputStatus,
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ const sessionIdRef = ref<string | null>(null)
|
||||
let statsInterval: number | null = null
|
||||
let isConnecting = false
|
||||
let connectInFlight: Promise<boolean> | null = null
|
||||
let connectAbortController: AbortController | null = null
|
||||
let pendingIceCandidates: RTCIceCandidate[] = []
|
||||
let seenRemoteCandidates = new Set<string>()
|
||||
let cachedMediaStream: MediaStream | null = null
|
||||
@@ -422,6 +423,8 @@ async function connect(): Promise<boolean> {
|
||||
|
||||
pendingIceCandidates = []
|
||||
seenRemoteCandidates.clear()
|
||||
const abortController = new AbortController()
|
||||
connectAbortController = abortController
|
||||
|
||||
try {
|
||||
state.value = 'connecting'
|
||||
@@ -429,6 +432,7 @@ async function connect(): Promise<boolean> {
|
||||
setConnectStage('fetching_ice_servers')
|
||||
|
||||
const iceServers = await fetchIceServers()
|
||||
if (abortController.signal.aborted) return false
|
||||
setConnectStage('creating_peer_connection', { iceServerCount: iceServers.length })
|
||||
|
||||
peerConnection = createPeerConnection(iceServers)
|
||||
@@ -441,11 +445,14 @@ async function connect(): Promise<boolean> {
|
||||
setConnectStage('creating_offer')
|
||||
|
||||
const offer = await peerConnection.createOffer()
|
||||
if (abortController.signal.aborted) return false
|
||||
await peerConnection.setLocalDescription(offer)
|
||||
if (abortController.signal.aborted) return false
|
||||
setConnectStage('waiting_server_answer')
|
||||
|
||||
// Do not pass client_id here: each connect creates a fresh session.
|
||||
const response = await webrtcApi.offer(offer.sdp!)
|
||||
const response = await webrtcApi.offer(offer.sdp!, abortController.signal)
|
||||
if (abortController.signal.aborted) return false
|
||||
sessionId = response.session_id
|
||||
sessionIdRef.value = response.session_id
|
||||
|
||||
@@ -519,6 +526,10 @@ async function connect(): Promise<boolean> {
|
||||
})
|
||||
throw new Error('Connection timeout waiting for ICE negotiation')
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) {
|
||||
isConnecting = false
|
||||
return false
|
||||
}
|
||||
state.value = 'failed'
|
||||
setConnectStage('failed', {
|
||||
sessionId,
|
||||
@@ -532,6 +543,10 @@ async function connect(): Promise<boolean> {
|
||||
isConnecting = false
|
||||
await disconnect()
|
||||
return false
|
||||
} finally {
|
||||
if (connectAbortController === abortController) {
|
||||
connectAbortController = null
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -543,6 +558,8 @@ async function connect(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
connectAbortController?.abort()
|
||||
connectAbortController = null
|
||||
stopStatsCollection()
|
||||
|
||||
// Clear state FIRST to prevent ICE candidates from being sent
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
export default {
|
||||
videoInput: {
|
||||
format: 'Input Format',
|
||||
resolution: 'Resolution',
|
||||
frameRate: 'Frame Rate',
|
||||
noSignal: 'No signal',
|
||||
unavailable: 'Unable to read input status',
|
||||
refresh: 'Refresh input status',
|
||||
selectFormat: 'Select format...',
|
||||
selectResolution: 'Select resolution...',
|
||||
selectFps: 'Select FPS...',
|
||||
},
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
save: 'Save',
|
||||
@@ -678,7 +689,7 @@ export default {
|
||||
computerUseAgent: 'Computer Use Agent',
|
||||
pasteText: 'Paste Text',
|
||||
videoSettings: 'Video Capture',
|
||||
videoSettingsDesc: 'Configure capture device format, resolution and frame rate',
|
||||
videoSettingsDesc: 'Select a capture device. Source-following inputs report their active mode automatically.',
|
||||
videoDevice: 'Video Device',
|
||||
selectDevice: 'Select device...',
|
||||
videoFormat: 'Video Format',
|
||||
@@ -1073,7 +1084,6 @@ export default {
|
||||
confirmRegenerateId: 'Are you sure you want to regenerate the device ID? Existing clients will need to reconnect with the new ID.',
|
||||
confirmRegeneratePassword: 'Are you sure you want to regenerate the password? Existing clients will need to reconnect with the new password.',
|
||||
registered: 'Registered',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
connecting: 'Connecting',
|
||||
notConfigured: 'Not Configured',
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
export default {
|
||||
videoInput: {
|
||||
format: '输入格式',
|
||||
resolution: '分辨率',
|
||||
frameRate: '帧率',
|
||||
noSignal: '无信号',
|
||||
unavailable: '无法读取输入状态',
|
||||
refresh: '刷新输入状态',
|
||||
selectFormat: '选择格式...',
|
||||
selectResolution: '选择分辨率...',
|
||||
selectFps: '选择帧率...',
|
||||
},
|
||||
common: {
|
||||
loading: '加载中...',
|
||||
save: '保存',
|
||||
@@ -677,7 +688,7 @@ export default {
|
||||
computerUseAgent: 'Computer Use Agent',
|
||||
pasteText: '粘贴文本',
|
||||
videoSettings: '视频采集',
|
||||
videoSettingsDesc: '配置视频采集设备的格式、分辨率与帧率',
|
||||
videoSettingsDesc: '选择视频采集设备;输入跟随型设备会自动显示当前输入模式',
|
||||
videoDevice: '视频设备',
|
||||
selectDevice: '选择设备...',
|
||||
videoFormat: '视频格式',
|
||||
@@ -1072,7 +1083,6 @@ export default {
|
||||
confirmRegenerateId: '确定要重新生成设备 ID 吗?现有客户端需要使用新 ID 重新连接。',
|
||||
confirmRegeneratePassword: '确定要重新生成设备密码吗?现有客户端需要使用新密码重新连接。',
|
||||
registered: '已注册',
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
connecting: '连接中',
|
||||
notConfigured: '未配置',
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
Reference in New Issue
Block a user