feat(web): 优化视频格式与虚拟键盘显示

This commit is contained in:
mofeng-git
2026-03-28 21:34:22 +08:00
parent f4283f45a4
commit a2a8b3802d
7 changed files with 248 additions and 41 deletions

View File

@@ -27,6 +27,7 @@ import {
type BitratePreset, type BitratePreset,
type StreamConstraintsResponse, type StreamConstraintsResponse,
} from '@/api' } from '@/api'
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -167,6 +168,12 @@ const isBrowserSupported = (codecId: string): boolean => {
return browserSupportedCodecs.value.has(codecId) return browserSupportedCodecs.value.has(codecId)
} }
const getFormatState = (formatName: string) =>
getVideoFormatState(formatName, props.videoMode, currentEncoderBackend.value)
const isFormatUnsupported = (formatName: string): boolean =>
getFormatState(formatName) === 'unsupported'
// Translate backend name for display // Translate backend name for display
const translateBackendName = (backend: string | undefined): string => { const translateBackendName = (backend: string | undefined): string => {
if (!backend) return '' if (!backend) return ''
@@ -189,6 +196,10 @@ const hasHighFps = (format: { resolutions: { fps: number[] }[] }): boolean => {
// Check if a format is recommended based on video mode // Check if a format is recommended based on video mode
const isFormatRecommended = (formatName: string): boolean => { const isFormatRecommended = (formatName: string): boolean => {
if (!isVideoFormatSelectable(formatName, props.videoMode, currentEncoderBackend.value)) {
return false
}
const formats = availableFormats.value const formats = availableFormats.value
const upperFormat = formatName.toUpperCase() const upperFormat = formatName.toUpperCase()
@@ -225,12 +236,7 @@ const isFormatRecommended = (formatName: string): boolean => {
// Check if a format is not recommended for current video mode // Check if a format is not recommended for current video mode
// In WebRTC mode, compressed formats (MJPEG/JPEG) are not recommended // In WebRTC mode, compressed formats (MJPEG/JPEG) are not recommended
const isFormatNotRecommended = (formatName: string): boolean => { const isFormatNotRecommended = (formatName: string): boolean => {
const upperFormat = formatName.toUpperCase() return getFormatState(formatName) === 'not_recommended'
// WebRTC mode: MJPEG/JPEG are not recommended (require decoding before encoding)
if (props.videoMode !== 'mjpeg') {
return upperFormat === 'MJPEG' || upperFormat === 'JPEG'
}
return false
} }
// Selected values (mode comes from props) // Selected values (mode comes from props)
@@ -303,6 +309,14 @@ const availableFormats = computed(() => {
return device?.formats || [] return device?.formats || []
}) })
const availableFormatOptions = computed(() => {
return availableFormats.value.map(format => ({
...format,
state: getFormatState(format.format),
disabled: isFormatUnsupported(format.format),
}))
})
const availableResolutions = computed(() => { const availableResolutions = computed(() => {
const format = availableFormats.value.find(f => f.format === selectedFormat.value) const format = availableFormats.value.find(f => f.format === selectedFormat.value)
return format?.resolutions || [] return format?.resolutions || []
@@ -317,8 +331,8 @@ const availableFps = computed(() => {
// Get selected format description for display in trigger // Get selected format description for display in trigger
const selectedFormatInfo = computed(() => { const selectedFormatInfo = computed(() => {
const format = availableFormats.value.find(f => f.format === selectedFormat.value) const format = availableFormatOptions.value.find(f => f.format === selectedFormat.value)
return format ? { description: format.description, format: format.format } : null return format
}) })
// Get selected codec info for display in trigger // Get selected codec info for display in trigger
@@ -423,6 +437,37 @@ function handleVideoModeChange(mode: unknown) {
emit('update:videoMode', mode as VideoMode) emit('update:videoMode', mode as VideoMode)
} }
function findFirstSelectableFormat(
formats: VideoDevice['formats'],
): VideoDevice['formats'][number] | undefined {
return formats.find(format =>
isVideoFormatSelectable(format.format, props.videoMode, currentEncoderBackend.value),
)
}
function clearFormatSelection() {
selectedFormat.value = ''
selectedResolution.value = ''
selectedFps.value = 30
}
function selectFormatWithDefaults(format: string) {
if (isFormatUnsupported(format)) return
selectedFormat.value = format
const formatData = availableFormats.value.find(f => f.format === format)
const resolution = formatData?.resolutions[0]
if (!resolution) {
selectedResolution.value = ''
selectedFps.value = 30
return
}
selectedResolution.value = `${resolution.width}x${resolution.height}`
selectedFps.value = resolution.fps[0] || 30
}
// Handle device change // Handle device change
function handleDeviceChange(devicePath: unknown) { function handleDeviceChange(devicePath: unknown) {
if (typeof devicePath !== 'string') return if (typeof devicePath !== 'string') return
@@ -431,31 +476,22 @@ function handleDeviceChange(devicePath: unknown) {
// Auto-select first format // Auto-select first format
const device = devices.value.find(d => d.path === devicePath) const device = devices.value.find(d => d.path === devicePath)
if (device?.formats[0]) { const format = device ? findFirstSelectableFormat(device.formats) : undefined
selectedFormat.value = device.formats[0].format if (!format) {
clearFormatSelection()
// Auto-select first resolution return
const resolution = device.formats[0].resolutions[0]
if (resolution) {
selectedResolution.value = `${resolution.width}x${resolution.height}`
selectedFps.value = resolution.fps[0] || 30
}
} }
selectFormatWithDefaults(format.format)
} }
// Handle format change // Handle format change
function handleFormatChange(format: unknown) { function handleFormatChange(format: unknown) {
if (typeof format !== 'string') return if (typeof format !== 'string') return
selectedFormat.value = format if (isFormatUnsupported(format)) return
isDirty.value = true
// Auto-select first resolution for this format selectFormatWithDefaults(format)
const formatData = availableFormats.value.find(f => f.format === format) isDirty.value = true
if (formatData?.resolutions[0]) {
const resolution = formatData.resolutions[0]
selectedResolution.value = `${resolution.width}x${resolution.height}`
selectedFps.value = resolution.fps[0] || 30
}
} }
// Handle resolution change // Handle resolution change
@@ -567,6 +603,29 @@ watch(currentConfig, () => {
if (props.open && isDirty.value) return if (props.open && isDirty.value) return
syncFromCurrentIfChanged() syncFromCurrentIfChanged()
}, { deep: true }) }, { deep: true })
watch(
[availableFormatOptions, () => props.videoMode, currentEncoderBackend],
() => {
if (!selectedDevice.value) return
const currentFormat = availableFormatOptions.value.find(
format => format.format === selectedFormat.value,
)
if (currentFormat && !currentFormat.disabled) {
return
}
const fallback = availableFormatOptions.value.find(format => !format.disabled)
if (!fallback) {
clearFormatSelection()
return
}
selectFormatWithDefaults(fallback.format)
},
{ deep: true },
)
</script> </script>
<template> <template>
@@ -770,6 +829,12 @@ watch(currentConfig, () => {
<SelectTrigger class="h-8 text-xs"> <SelectTrigger class="h-8 text-xs">
<div v-if="selectedFormatInfo" class="flex items-center gap-1.5 truncate"> <div v-if="selectedFormatInfo" class="flex items-center gap-1.5 truncate">
<span class="truncate">{{ selectedFormatInfo.description }}</span> <span class="truncate">{{ selectedFormatInfo.description }}</span>
<span
v-if="selectedFormatInfo.state === 'unsupported'"
class="shrink-0 text-muted-foreground"
>
{{ t('common.notSupportedYet') }}
</span>
<span <span
v-if="isFormatRecommended(selectedFormatInfo.format)" v-if="isFormatRecommended(selectedFormatInfo.format)"
class="text-[10px] px-1 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 shrink-0" class="text-[10px] px-1 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 shrink-0"
@@ -787,13 +852,20 @@ watch(currentConfig, () => {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
v-for="format in availableFormats" v-for="format in availableFormatOptions"
:key="format.format" :key="format.format"
:value="format.format" :value="format.format"
class="text-xs" :disabled="format.disabled"
:class="['text-xs', { 'opacity-50': format.disabled }]"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>{{ format.description }}</span> <span>{{ format.description }}</span>
<span
v-if="format.state === 'unsupported'"
class="text-muted-foreground"
>
{{ t('common.notSupportedYet') }}
</span>
<span <span
v-if="isFormatRecommended(format.format)" v-if="isFormatRecommended(format.format)"
class="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" class="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"

View File

@@ -26,6 +26,7 @@ const props = defineProps<{
attached?: boolean attached?: boolean
capsLock?: boolean capsLock?: boolean
pressedKeys?: CanonicalKey[] pressedKeys?: CanonicalKey[]
consumerEnabled?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -718,7 +719,7 @@ onUnmounted(() => {
<!-- Keyboard body --> <!-- Keyboard body -->
<div class="vkb-body"> <div class="vkb-body">
<!-- Media keys row --> <!-- Media keys row -->
<div class="vkb-media-row"> <div v-if="props.consumerEnabled !== false" class="vkb-media-row">
<button <button
v-for="key in mediaKeys" v-for="key in mediaKeys"
:key="key" :key="key"

View File

@@ -32,6 +32,7 @@ export default {
menu: 'Menu', menu: 'Menu',
optional: 'Optional', optional: 'Optional',
recommended: 'Recommended', recommended: 'Recommended',
notSupportedYet: ' (Not Yet Supported)',
create: 'Create', create: 'Create',
creating: 'Creating...', creating: 'Creating...',
deleting: 'Deleting...', deleting: 'Deleting...',

View File

@@ -32,6 +32,7 @@ export default {
menu: '菜单', menu: '菜单',
optional: '可选', optional: '可选',
recommended: '推荐', recommended: '推荐',
notSupportedYet: '(尚未支持)',
create: '创建', create: '创建',
creating: '创建中...', creating: '创建中...',
deleting: '删除中...', deleting: '删除中...',

View File

@@ -0,0 +1,97 @@
export type VideoFormatSupportContext = 'config' | 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
export type VideoFormatState = 'supported' | 'not_recommended' | 'unsupported'
const MJPEG_MODE_SUPPORTED_FORMATS = new Set([
'MJPEG',
'JPEG',
'YUYV',
'YVYU',
'NV12',
'RGB24',
'BGR24',
])
const CONFIG_SUPPORTED_FORMATS = new Set([
'MJPEG',
'JPEG',
'YUYV',
'YVYU',
'NV12',
'NV21',
'NV16',
'YUV420',
'RGB24',
'BGR24',
])
const WEBRTC_SUPPORTED_FORMATS = new Set([
'MJPEG',
'JPEG',
'YUYV',
'NV12',
'NV21',
'NV16',
'YUV420',
'RGB24',
'BGR24',
])
function normalizeFormat(formatName: string): string {
return formatName.trim().toUpperCase()
}
function isCompressedFormat(formatName: string): boolean {
return formatName === 'MJPEG' || formatName === 'JPEG'
}
function isRkmppBackend(backendId?: string): boolean {
return backendId?.toLowerCase() === 'rkmpp'
}
export function getVideoFormatState(
formatName: string,
context: VideoFormatSupportContext,
encoderBackend = 'auto',
): VideoFormatState {
const normalizedFormat = normalizeFormat(formatName)
if (context === 'mjpeg') {
return MJPEG_MODE_SUPPORTED_FORMATS.has(normalizedFormat) ? 'supported' : 'unsupported'
}
if (context === 'config') {
if (CONFIG_SUPPORTED_FORMATS.has(normalizedFormat)) {
return 'supported'
}
if (
normalizedFormat === 'NV24'
&& isRkmppBackend(encoderBackend)
) {
return 'supported'
}
return 'unsupported'
}
if (WEBRTC_SUPPORTED_FORMATS.has(normalizedFormat)) {
return isCompressedFormat(normalizedFormat) ? 'not_recommended' : 'supported'
}
if (
normalizedFormat === 'NV24'
&& isRkmppBackend(encoderBackend)
&& (context === 'h264' || context === 'h265')
) {
return 'supported'
}
return 'unsupported'
}
export function isVideoFormatSelectable(
formatName: string,
context: VideoFormatSupportContext,
encoderBackend = 'auto',
): boolean {
return getVideoFormatState(formatName, context, encoderBackend) !== 'unsupported'
}

View File

@@ -12,7 +12,7 @@ import { useWebRTC } from '@/composables/useWebRTC'
import { useVideoSession } from '@/composables/useVideoSession' import { useVideoSession } from '@/composables/useVideoSession'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio' import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { streamApi, hidApi, atxApi, atxConfigApi, authApi } from '@/api' import { streamApi, hidApi, atxApi, atxConfigApi, authApi } from '@/api'
import { CanonicalKey } from '@/types/generated' import { CanonicalKey, HidBackend } from '@/types/generated'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid' import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings' import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -156,6 +156,12 @@ function syncMouseModeFromConfig() {
const virtualKeyboardVisible = ref(false) const virtualKeyboardVisible = ref(false)
const virtualKeyboardAttached = ref(true) const virtualKeyboardAttached = ref(true)
const statsSheetOpen = ref(false) const statsSheetOpen = ref(false)
const virtualKeyboardConsumerEnabled = computed(() => {
const hid = configStore.hid
if (!hid) return true
if (hid.backend !== HidBackend.Otg) return true
return hid.otg_functions?.consumer !== false
})
// Change password dialog state // Change password dialog state
const changePasswordDialogOpen = ref(false) const changePasswordDialogOpen = ref(false)
@@ -2528,6 +2534,7 @@ onUnmounted(() => {
v-model:attached="virtualKeyboardAttached" v-model:attached="virtualKeyboardAttached"
:caps-lock="keyboardLed.capsLock" :caps-lock="keyboardLed.capsLock"
:pressed-keys="pressedKeys" :pressed-keys="pressedKeys"
:consumer-enabled="virtualKeyboardConsumerEnabled"
@key-down="handleVirtualKeyDown" @key-down="handleVirtualKeyDown"
@key-up="handleVirtualKeyUp" @key-up="handleVirtualKeyUp"
/> />

View File

@@ -39,6 +39,7 @@ import type {
} from '@/types/generated' } from '@/types/generated'
import { setLanguage } from '@/i18n' import { setLanguage } from '@/i18n'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { getVideoFormatState } from '@/lib/video-format-support'
import AppLayout from '@/components/AppLayout.vue' import AppLayout from '@/components/AppLayout.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -780,6 +781,21 @@ const availableFormats = computed(() => {
return selectedDevice.value.formats return selectedDevice.value.formats
}) })
const availableFormatOptions = computed(() => {
return availableFormats.value.map(format => {
const state = getVideoFormatState(format.format, 'config', config.value.encoder_backend)
return {
...format,
state,
disabled: state === 'unsupported',
}
})
})
const selectableFormats = computed(() => {
return availableFormatOptions.value.filter(format => !format.disabled)
})
const selectedFormat = computed(() => { const selectedFormat = computed(() => {
if (!selectedDevice.value || !config.value.video_format) return null if (!selectedDevice.value || !config.value.video_format) return null
return selectedDevice.value.formats.find(f => f.format === config.value.video_format) return selectedDevice.value.formats.find(f => f.format === config.value.video_format)
@@ -810,17 +826,22 @@ const availableFps = computed(() => {
return currentRes ? currentRes.fps : [] return currentRes ? currentRes.fps : []
}) })
// Watch for device change to set default format // Keep the selected format aligned with currently selectable formats.
watch(() => config.value.video_device, () => { watch(
if (availableFormats.value.length > 0) { selectableFormats,
const isValid = availableFormats.value.some(f => f.format === config.value.video_format) () => {
if (!isValid) { if (selectableFormats.value.length === 0) {
config.value.video_format = availableFormats.value[0]?.format || '' config.value.video_format = ''
return
} }
} else {
config.value.video_format = '' const isValid = selectableFormats.value.some(f => f.format === config.value.video_format)
} if (!isValid) {
}) config.value.video_format = selectableFormats.value[0]?.format || ''
}
},
{ deep: true },
)
// Watch for format change to set default resolution // Watch for format change to set default resolution
watch(() => config.value.video_format, () => { watch(() => config.value.video_format, () => {
@@ -2091,7 +2112,14 @@ watch(() => route.query.tab, (tab) => {
<Label for="video-format">{{ t('settings.videoFormat') }}</Label> <Label for="video-format">{{ t('settings.videoFormat') }}</Label>
<select id="video-format" v-model="config.video_format" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_device"> <select id="video-format" v-model="config.video_format" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_device">
<option value="">{{ t('settings.selectFormat') }}</option> <option value="">{{ t('settings.selectFormat') }}</option>
<option v-for="fmt in availableFormats" :key="fmt.format" :value="fmt.format">{{ fmt.format }} - {{ fmt.description }}</option> <option
v-for="fmt in availableFormatOptions"
:key="fmt.format"
:value="fmt.format"
:disabled="fmt.disabled"
>
{{ fmt.format }} - {{ fmt.description }}{{ fmt.disabled ? t('common.notSupportedYet') : '' }}
</option>
</select> </select>
</div> </div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">