feat: 适配 RK 原生 HDMI IN 适配采集

This commit is contained in:
mofeng-git
2026-04-01 21:28:15 +08:00
parent 51d7d8b8be
commit abb319068b
36 changed files with 1382 additions and 406 deletions

View File

@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useSystemStore } from '@/stores/system'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -18,12 +19,10 @@ import {
LogOut,
Sun,
Moon,
Languages,
Menu,
} from 'lucide-vue-next'
import { setLanguage } from '@/i18n'
const { t, locale } = useI18n()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
@@ -40,11 +39,6 @@ function toggleTheme() {
localStorage.setItem('theme', isDark ? 'light' : 'dark')
}
function toggleLanguage() {
const newLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
setLanguage(newLang)
}
async function handleLogout() {
await authStore.logout()
router.push('/login')
@@ -93,10 +87,7 @@ async function handleLogout() {
</Button>
<!-- Language Toggle -->
<Button variant="ghost" size="icon" :aria-label="t('common.toggleLanguage')" @click="toggleLanguage">
<Languages class="h-4 w-4" />
<span class="sr-only">{{ t('common.toggleLanguage') }}</span>
</Button>
<LanguageToggleButton />
<!-- Mobile Menu -->
<DropdownMenu>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/components/ui/button'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { setLanguage } from '@/i18n'
import { Languages } from 'lucide-vue-next'
interface Props {
class?: HTMLAttributes['class']
size?: ButtonVariants['size']
variant?: ButtonVariants['variant']
labelMode?: 'hidden' | 'current' | 'next'
}
const props = withDefaults(defineProps<Props>(), {
size: 'icon',
variant: 'ghost',
labelMode: 'hidden',
})
const { t, locale } = useI18n()
const currentLanguageLabel = computed(() => (locale.value === 'zh-CN' ? '中文' : 'English'))
const nextLanguageLabel = computed(() => (locale.value === 'zh-CN' ? 'English' : '中文'))
const buttonLabel = computed(() => (
props.labelMode === 'current' ? currentLanguageLabel.value : nextLanguageLabel.value
))
function toggleLanguage() {
const newLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
setLanguage(newLang)
}
</script>
<template>
<Button
:variant="variant"
:size="size"
:class="cn(props.labelMode !== 'hidden' && 'gap-2', props.class)"
:aria-label="t('common.toggleLanguage')"
@click="toggleLanguage"
>
<Languages class="h-4 w-4" />
<span v-if="props.labelMode !== 'hidden'">{{ buttonLabel }}</span>
<span class="sr-only">{{ t('common.toggleLanguage') }}</span>
</Button>
</template>

View File

@@ -11,6 +11,7 @@ import {
} from '@/components/ui/sheet'
import { ScrollArea } from '@/components/ui/scroll-area'
import type { WebRTCStats } from '@/composables/useWebRTC'
import { formatFpsValue } from '@/lib/fps'
const { t } = useI18n()
@@ -547,7 +548,7 @@ onUnmounted(() => {
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('stats.frameRate') }}</h4>
<span class="text-xs text-muted-foreground">
{{ currentStats.fps }} fps
{{ formatFpsValue(currentStats.fps) }} fps
</span>
</div>
<p class="text-xs text-muted-foreground">

View File

@@ -28,6 +28,7 @@ import {
type StreamConstraintsResponse,
} from '@/api'
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import { useConfigStore } from '@/stores/config'
import { useRouter } from 'vue-router'
@@ -549,7 +550,7 @@ async function applyVideoConfig() {
format: selectedFormat.value,
width,
height,
fps: selectedFps.value,
fps: toConfigFps(selectedFps.value),
})
toast.success(t('config.applied'))
@@ -926,7 +927,7 @@ watch(
:value="String(fps)"
class="text-xs"
>
{{ fps }} FPS
{{ formatFpsLabel(fps) }}
</SelectItem>
</SelectContent>
</Select>

View File

@@ -268,9 +268,6 @@ export default {
// Help tooltips
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
otgAdvanced: 'Advanced: OTG Preset',
otgProfile: 'Initial HID Preset',
otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.',
otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.',
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',

View File

@@ -268,9 +268,6 @@ export default {
// Help tooltips
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
otgAdvanced: '高级OTG 预设',
otgProfile: '初始 HID 预设',
otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键盘。',
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。',

15
web/src/lib/fps.ts Normal file
View File

@@ -0,0 +1,15 @@
export function formatFpsValue(fps: number): string {
if (!Number.isFinite(fps)) return '0'
const rounded = Math.round(fps * 100) / 100
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2).replace(/\.?0+$/, '')
}
export function formatFpsLabel(fps: number): string {
return `${formatFpsValue(fps)} FPS`
}
export function toConfigFps(fps: number): number {
if (!Number.isFinite(fps)) return 30
return Math.round(fps)
}

View File

@@ -8,6 +8,8 @@ const MJPEG_MODE_SUPPORTED_FORMATS = new Set([
'YUYV',
'YVYU',
'NV12',
'NV16',
'NV24',
'RGB24',
'BGR24',
])
@@ -20,6 +22,7 @@ const CONFIG_SUPPORTED_FORMATS = new Set([
'NV12',
'NV21',
'NV16',
'NV24',
'YUV420',
'RGB24',
'BGR24',
@@ -32,6 +35,7 @@ const WEBRTC_SUPPORTED_FORMATS = new Set([
'NV12',
'NV21',
'NV16',
'NV24',
'YUV420',
'RGB24',
'BGR24',
@@ -45,14 +49,10 @@ 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',
_encoderBackend = 'auto',
): VideoFormatState {
const normalizedFormat = normalizeFormat(formatName)
@@ -64,12 +64,6 @@ export function getVideoFormatState(
if (CONFIG_SUPPORTED_FORMATS.has(normalizedFormat)) {
return 'supported'
}
if (
normalizedFormat === 'NV24'
&& isRkmppBackend(encoderBackend)
) {
return 'supported'
}
return 'unsupported'
}
@@ -77,14 +71,6 @@ export function getVideoFormatState(
return isCompressedFormat(normalizedFormat) ? 'not_recommended' : 'supported'
}
if (
normalizedFormat === 'NV24'
&& isRkmppBackend(encoderBackend)
&& (context === 'h264' || context === 'h265')
) {
return 'supported'
}
return 'unsupported'
}

View File

@@ -17,6 +17,7 @@ import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils'
import { formatFpsValue } from '@/lib/fps'
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
// Components
@@ -25,6 +26,7 @@ import ActionBar from '@/components/ActionBar.vue'
import InfoBar from '@/components/InfoBar.vue'
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
import StatsSheet from '@/components/StatsSheet.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import {
@@ -50,16 +52,14 @@ import {
LogOut,
Sun,
Moon,
Languages,
ChevronDown,
Terminal,
ExternalLink,
KeyRound,
Loader2,
} from 'lucide-vue-next'
import { setLanguage } from '@/i18n'
const { t, locale } = useI18n()
const { t } = useI18n()
const router = useRouter()
const systemStore = useSystemStore()
const configStore = useConfigStore()
@@ -212,7 +212,7 @@ const videoQuickInfo = computed(() => {
const stream = systemStore.stream
if (!stream?.resolution) return ''
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
return `${resShort} ${backendFps.value}fps`
return `${resShort} ${formatFpsValue(backendFps.value)}fps`
})
const videoDetails = computed<StatusDetail[]>(() => {
@@ -227,8 +227,8 @@ const videoDetails = computed<StatusDetail[]>(() => {
{ label: t('statusCard.mode'), value: modeDisplay, status: 'ok' },
{ label: t('statusCard.format'), value: stream.format || 'MJPEG' },
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
{ label: t('statusCard.targetFps'), value: String(stream.targetFps ?? 0) },
{ label: t('statusCard.fps'), value: String(receivedFps), status: receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined },
{ label: t('statusCard.targetFps'), value: formatFpsValue(stream.targetFps ?? 0) },
{ label: t('statusCard.fps'), value: formatFpsValue(receivedFps), status: receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined },
]
// Show network error if WebSocket has network issue
@@ -875,7 +875,7 @@ async function handleStreamConfigApplied(data: any) {
videoRestarting.value = false
toast.success(t('console.videoRestarted'), {
description: `${data.device} - ${data.resolution[0]}x${data.resolution[1]} @ ${data.fps}fps`,
description: `${data.device} - ${data.resolution[0]}x${data.resolution[1]} @ ${formatFpsValue(data.fps)}fps`,
duration: 3000,
})
}
@@ -1458,12 +1458,6 @@ function toggleTheme() {
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
// Language toggle
function toggleLanguage() {
const newLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
setLanguage(newLang)
}
// Logout
async function logout() {
await authStore.logout()
@@ -2306,9 +2300,7 @@ onUnmounted(() => {
</Button>
<!-- Language Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" :aria-label="t('common.toggleLanguage')" @click="toggleLanguage">
<Languages class="h-4 w-4" />
</Button>
<LanguageToggleButton class="h-8 w-8 hidden md:flex" />
<!-- User Menu -->
<DropdownMenu>
@@ -2324,9 +2316,13 @@ onUnmounted(() => {
<Moon v-else class="h-4 w-4 mr-2" />
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
</DropdownMenuItem>
<DropdownMenuItem class="md:hidden" @click="toggleLanguage">
<Languages class="h-4 w-4 mr-2" />
{{ locale === 'zh-CN' ? 'English' : '中文' }}
<DropdownMenuItem as-child class="md:hidden p-0">
<LanguageToggleButton
label-mode="next"
size="sm"
variant="ghost"
class="w-full justify-start rounded-sm px-2 py-1.5 font-normal shadow-none"
/>
</DropdownMenuItem>
<DropdownMenuSeparator class="md:hidden" />
<DropdownMenuItem @click="changePasswordDialogOpen = true">

View File

@@ -3,15 +3,11 @@ import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import {
setLanguage,
getCurrentLanguage,
type SupportedLocale,
} from '@/i18n'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import { Monitor, Lock, Eye, EyeOff, User } from 'lucide-vue-next'
const { t } = useI18n()
@@ -19,18 +15,12 @@ const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const currentLanguage = ref<SupportedLocale>(getCurrentLanguage())
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const loading = ref(false)
const error = ref('')
function handleLanguageChange(lang: SupportedLocale) {
currentLanguage.value = lang
setLanguage(lang)
}
async function handleLogin() {
if (!username.value) {
error.value = t('auth.enterUsername')
@@ -60,21 +50,8 @@ async function handleLogin() {
<template>
<div class="min-h-screen min-h-dvh flex items-center justify-center bg-background p-4">
<Card class="relative w-full max-w-sm">
<div class="absolute top-4 right-4 flex gap-2">
<Button
:variant="currentLanguage === 'zh-CN' ? 'default' : 'outline'"
size="sm"
@click="handleLanguageChange('zh-CN')"
>
中文
</Button>
<Button
:variant="currentLanguage === 'en-US' ? 'default' : 'outline'"
size="sm"
@click="handleLanguageChange('en-US')"
>
English
</Button>
<div class="absolute top-4 right-4">
<LanguageToggleButton />
</div>
<CardHeader class="space-y-2 pt-10 text-center sm:pt-12">

View File

@@ -37,10 +37,11 @@ import type {
OtgHidProfile,
OtgHidFunctions,
} from '@/types/generated'
import { setLanguage } from '@/i18n'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import { useClipboard } from '@/composables/useClipboard'
import { getVideoFormatState } from '@/lib/video-format-support'
import AppLayout from '@/components/AppLayout.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -85,7 +86,7 @@ import {
Radio,
} from 'lucide-vue-next'
const { t, te, locale } = useI18n()
const { t, te } = useI18n()
const route = useRoute()
const systemStore = useSystemStore()
const configStore = useConfigStore()
@@ -902,13 +903,6 @@ function setTheme(newTheme: 'light' | 'dark' | 'system') {
}
}
// Language handling
function handleLanguageChange(lang: string) {
if (lang === 'zh-CN' || lang === 'en-US') {
setLanguage(lang)
}
}
// Account updates
async function changeUsername() {
usernameError.value = ''
@@ -991,7 +985,7 @@ async function saveConfig() {
format: config.value.video_format || undefined,
width: config.value.video_width,
height: config.value.video_height,
fps: config.value.video_fps,
fps: toConfigFps(config.value.video_fps),
})
)
// Save Stream/Encoder and STUN/TURN config together
@@ -2022,9 +2016,8 @@ watch(() => route.query.tab, (tab) => {
<CardDescription>{{ t('settings.languageDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="flex gap-2">
<Button :variant="locale === 'zh-CN' ? 'default' : 'outline'" size="sm" @click="handleLanguageChange('zh-CN')">中文</Button>
<Button :variant="locale === 'en-US' ? 'default' : 'outline'" size="sm" @click="handleLanguageChange('en-US')">English</Button>
<div class="flex">
<LanguageToggleButton variant="outline" size="sm" label-mode="current" />
</div>
</CardContent>
</Card>
@@ -2132,8 +2125,8 @@ watch(() => route.query.tab, (tab) => {
<div class="space-y-2">
<Label for="video-fps">{{ t('settings.frameRate') }}</Label>
<select id="video-fps" v-model.number="config.video_fps" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_format">
<option v-for="fps in availableFps" :key="fps" :value="fps">{{ fps }} FPS</option>
<option v-if="!availableFps.includes(config.video_fps)" :value="config.video_fps">{{ config.video_fps }} FPS</option>
<option v-for="fps in availableFps" :key="fps" :value="fps">{{ formatFpsLabel(fps) }}</option>
<option v-if="!availableFps.includes(config.video_fps)" :value="config.video_fps">{{ formatFpsLabel(config.video_fps) }}</option>
</select>
</div>
</div>

View File

@@ -4,12 +4,8 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { configApi, streamApi, type EncoderBackendInfo } from '@/api'
import {
supportedLanguages,
setLanguage,
getCurrentLanguage,
type SupportedLocale,
} from '@/i18n'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -21,12 +17,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
HoverCard,
HoverCardContent,
@@ -44,7 +34,6 @@ import {
Keyboard,
Check,
HelpCircle,
Languages,
Puzzle,
} from 'lucide-vue-next'
@@ -52,14 +41,6 @@ const { t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
// Language switcher
const currentLanguage = ref<SupportedLocale>(getCurrentLanguage())
function switchLanguage(lang: SupportedLocale) {
currentLanguage.value = lang
setLanguage(lang)
}
// Steps: 1 = Account, 2 = Audio/Video, 3 = HID, 4 = Extensions
const step = ref(1)
const totalSteps = 4
@@ -96,14 +77,10 @@ const hidBackend = ref('ch9329')
const ch9329Port = ref('')
const ch9329Baudrate = ref(9600)
const otgUdc = ref('')
const hidOtgProfile = ref('full')
const hidOtgProfile = ref('full_no_consumer')
const otgMsdEnabled = ref(true)
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
const otgKeyboardLeds = ref(true)
const otgProfileTouched = ref(false)
const otgEndpointBudgetTouched = ref(false)
const otgKeyboardLedsTouched = ref(false)
const showAdvancedOtg = ref(false)
// Extension settings
const ttydEnabled = ref(false)
@@ -237,57 +214,17 @@ const otgRequiredEndpoints = computed(() => {
return endpoints
})
const otgProfileHasKeyboard = computed(() =>
hidOtgProfile.value === 'full'
|| hidOtgProfile.value === 'full_no_consumer'
|| hidOtgProfile.value === 'legacy_keyboard'
)
const isOtgEndpointBudgetValid = computed(() => {
const limit = endpointLimitForBudget(otgEndpointBudget.value)
return limit === null || otgRequiredEndpoints.value <= limit
})
const otgEndpointUsageText = computed(() => {
const limit = endpointLimitForBudget(otgEndpointBudget.value)
if (limit === null) {
return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value })
}
return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit })
})
function applyOtgDefaults() {
if (hidBackend.value !== 'otg') return
const recommendedBudget = defaultOtgEndpointBudgetForUdc(otgUdc.value)
if (!otgEndpointBudgetTouched.value) {
otgEndpointBudget.value = recommendedBudget
}
if (!otgProfileTouched.value) {
hidOtgProfile.value = 'full_no_consumer'
}
if (!otgKeyboardLedsTouched.value) {
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
}
}
function onOtgProfileChange(value: unknown) {
hidOtgProfile.value = typeof value === 'string' ? value : 'full'
otgProfileTouched.value = true
}
function onOtgEndpointBudgetChange(value: unknown) {
otgEndpointBudget.value =
value === 'five' || value === 'six' || value === 'unlimited' ? value : 'six'
otgEndpointBudgetTouched.value = true
if (!otgKeyboardLedsTouched.value) {
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
}
}
function onOtgKeyboardLedsChange(value: boolean) {
otgKeyboardLeds.value = value
otgKeyboardLedsTouched.value = true
otgEndpointBudget.value = defaultOtgEndpointBudgetForUdc(otgUdc.value)
hidOtgProfile.value = 'full_no_consumer'
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
}
// Common baud rates for CH9329
@@ -412,12 +349,6 @@ watch(otgUdc, () => {
applyOtgDefaults()
})
watch(showAdvancedOtg, (open) => {
if (open) {
applyOtgDefaults()
}
})
onMounted(async () => {
try {
const result = await configApi.listDevices()
@@ -585,7 +516,7 @@ async function handleSetup() {
setupData.video_height = height
}
if (videoFps.value) {
setupData.video_fps = videoFps.value
setupData.video_fps = toConfigFps(videoFps.value)
}
// HID settings
@@ -637,27 +568,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<Card class="w-full max-w-lg relative">
<!-- Language Switcher -->
<div class="absolute top-4 right-4">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="gap-2">
<Languages class="w-4 h-4" />
<span class="text-sm">
{{ supportedLanguages.find((l) => l.code === currentLanguage)?.name }}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="lang in supportedLanguages"
:key="lang.code"
:class="{ 'bg-accent': lang.code === currentLanguage }"
@click="switchLanguage(lang.code)"
>
<span class="mr-2">{{ lang.flag }}</span>
{{ lang.name }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LanguageToggleButton />
</div>
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
@@ -879,7 +790,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</SelectTrigger>
<SelectContent>
<SelectItem v-for="fps in availableFps" :key="fps" :value="fps">
{{ fps }} FPS
{{ formatFpsLabel(fps) }}
</SelectItem>
</SelectContent>
</Select>
@@ -1039,78 +950,6 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
{{ t('setup.noUdcDevices') }}
</p>
</div>
<div class="mt-2 border rounded-lg">
<button
type="button"
class="w-full flex items-center justify-between p-3 text-left hover:bg-muted/50 rounded-lg transition-colors"
:aria-label="t('setup.advancedOtg')"
@click="showAdvancedOtg = !showAdvancedOtg"
>
<span class="text-sm font-medium">
{{ t('setup.otgAdvanced') }} ({{ t('common.optional') }})
</span>
<ChevronRight
class="h-4 w-4 transition-transform duration-200"
:class="{ 'rotate-90': showAdvancedOtg }"
/>
</button>
<div v-if="showAdvancedOtg" class="px-3 pb-3 space-y-3">
<p class="text-xs text-muted-foreground">
{{ t('setup.otgProfileDesc') }}
</p>
<div class="space-y-2">
<Label for="otgProfile">{{ t('setup.otgProfile') }}</Label>
<Select :model-value="hidOtgProfile" @update:modelValue="onOtgProfileChange">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem>
<SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem>
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="otgEndpointBudget">{{ t('settings.otgEndpointBudget') }}</Label>
<Select :model-value="otgEndpointBudget" @update:modelValue="onOtgEndpointBudgetChange">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="five">5</SelectItem>
<SelectItem value="six">6</SelectItem>
<SelectItem value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ otgEndpointUsageText }}
</p>
</div>
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
<div>
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
</div>
<Switch :model-value="otgKeyboardLeds" :disabled="!otgProfileHasKeyboard" @update:model-value="onOtgKeyboardLedsChange" />
</div>
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="otgMsdEnabled" />
</div>
<p class="text-xs text-muted-foreground">
{{ t('settings.otgEndpointBudgetHint') }}
</p>
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: otgEndpointBudget === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget === 'five' ? '5' : '6' }) }}
</p>
</div>
</div>
</div>
</div>