Files
One-KVM/web/src/components/VideoConfigPopover.vue
2026-07-26 11:59:58 +08:00

912 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select'
import { Monitor, RefreshCw, Loader2, Zap, Scale, Image } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import {
configApi,
streamApi,
type VideoCodecInfo,
type EncoderBackendInfo,
type BitratePreset,
type StreamConstraintsResponse,
} from '@/api'
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import { useConfigStore } from '@/stores/config'
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
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'update:videoMode', value: VideoMode): void
}>()
const { t } = useI18n()
const configStore = useConfigStore()
// Device list
const devices = ref<VideoDevice[]>([])
const loadingDevices = ref(false)
const codecs = ref<VideoCodecInfo[]>([])
const loadingCodecs = ref(false)
// Backend list
const backends = ref<EncoderBackendInfo[]>([])
const constraints = ref<StreamConstraintsResponse | null>(null)
const currentEncoderBackend = computed(() => configStore.stream?.encoder || 'auto')
const isServiceActive = (status: string | undefined) => status === 'starting' || status === 'running'
const isRtspEnabled = computed(() => isServiceActive(configStore.rtspStatus?.service_status))
const isRustdeskEnabled = computed(() => isServiceActive(configStore.rustdeskStatus?.service_status))
const isRtspCodecLocked = computed(() => isRtspEnabled.value)
const isRustdeskWebrtcLocked = computed(() => !isRtspEnabled.value && isRustdeskEnabled.value)
const codecLockSources = computed(() => {
if (isRtspCodecLocked.value) {
return isRustdeskEnabled.value ? 'RTSP/RustDesk' : 'RTSP'
}
if (isRustdeskWebrtcLocked.value) return 'RustDesk'
return ''
})
const codecLockMessage = computed(() => {
if (!codecLockSources.value) return ''
return t('actionbar.multiSourceCodecLocked', { sources: codecLockSources.value })
})
const videoParamWarningSources = computed(() => {
if (isRtspEnabled.value && isRustdeskEnabled.value) return 'RTSP/RustDesk'
if (isRtspEnabled.value) return 'RTSP'
if (isRustdeskEnabled.value) return 'RustDesk'
return ''
})
const videoParamWarningMessage = computed(() => {
if (!videoParamWarningSources.value) return ''
return t('actionbar.multiSourceVideoParamsWarning', { sources: videoParamWarningSources.value })
})
const isCodecLocked = computed(() => !!codecLockMessage.value)
const isCodecOptionDisabled = (codecId: string): boolean => {
if (!isBrowserSupported(codecId)) return true
if (isRustdeskWebrtcLocked.value && codecId === 'mjpeg') return true
return false
}
// Browser supported codecs (WebRTC receive capabilities)
const browserSupportedCodecs = ref<Set<string>>(new Set())
// Check browser WebRTC codec support
function detectBrowserCodecSupport() {
const supported = new Set<string>()
// MJPEG is always supported (HTTP streaming, no WebRTC)
supported.add('mjpeg')
// Check WebRTC receive capabilities
if (typeof RTCRtpReceiver !== 'undefined' && RTCRtpReceiver.getCapabilities) {
const capabilities = RTCRtpReceiver.getCapabilities('video')
if (capabilities?.codecs) {
for (const codec of capabilities.codecs) {
const mimeType = codec.mimeType.toLowerCase()
if (mimeType.includes('h264') || mimeType.includes('avc')) {
supported.add('h264')
}
if (mimeType.includes('h265') || mimeType.includes('hevc')) {
supported.add('h265')
}
if (mimeType.includes('vp8')) {
supported.add('vp8')
}
if (mimeType.includes('vp9')) {
supported.add('vp9')
}
if (mimeType.includes('av1')) {
supported.add('av1')
}
}
}
} else {
supported.add('h264')
supported.add('vp8')
supported.add('vp9')
}
browserSupportedCodecs.value = supported
console.info('[VideoConfig] Browser supported codecs:', Array.from(supported))
}
// Check if a codec is supported by browser
const isBrowserSupported = (codecId: string): boolean => {
if (codecId === 'mjpeg') return true
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
const translateBackendName = (backend: string | undefined): string => {
if (!backend) return ''
// Translate known backend names
const lowerBackend = backend.toLowerCase()
if (lowerBackend === 'software') {
return t('actionbar.backendSoftware')
}
if (lowerBackend === 'auto') {
return t('actionbar.backendAuto')
}
// Hardware backends (VAAPI, V4L2 M2M, etc.) keep original names
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 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 applying = ref(false)
const applyingBitrate = ref(false)
const currentConfig = computed(() => ({
device: configStore.video?.device || '',
format: configStore.video?.format || '',
width: configStore.video?.width || 1920,
height: configStore.video?.height || 1080,
fps: configStore.video?.fps || 30,
}))
const buttonText = computed(() => t('actionbar.videoConfig'))
// Available codecs for selection (filtered by backend support and enriched with backend info)
const availableCodecs = computed(() => {
const allAvailable = codecs.value.filter(c => c.available)
// Auto mode: show all available with their best (hardware-preferred) backend
if (currentEncoderBackend.value === 'auto') {
return allAvailable
}
// Specific backend: filter by supported formats and override backend info
const backend = backends.value.find(b => b.id === currentEncoderBackend.value)
if (!backend) return allAvailable
const backendFiltered = allAvailable
.filter(codec => {
// MJPEG is always available (doesn't require encoder)
if (codec.id === 'mjpeg') return true
// Check if codec format is supported by the configured backend
return backend.supported_formats.includes(codec.id)
})
.map(codec => {
// For MJPEG, keep original info
if (codec.id === 'mjpeg') return codec
// Override backend info for WebRTC codecs based on selected backend
return {
...codec,
hardware: backend.is_hardware,
backend: backend.name,
}
})
const allowed = constraints.value?.allowed_codecs
if (!allowed || allowed.length === 0) {
return backendFiltered
}
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,
state: getFormatState(format.format),
disabled: isFormatUnsupported(format.format),
}))
})
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
})
// Load devices
async function loadDevices() {
loadingDevices.value = true
try {
const result = await configApi.listDevices()
devices.value = result.video
} catch (e) {
console.info('[VideoConfig] Failed to load devices')
toast.error(t('config.loadDevicesFailed'))
} finally {
loadingDevices.value = false
}
}
// Load available codecs and backends
async function loadCodecs() {
loadingCodecs.value = true
try {
const result = await streamApi.getCodecs()
codecs.value = result.codecs
backends.value = result.backends || []
} catch (e) {
console.info('[VideoConfig] Failed to load codecs')
codecs.value = [
{ id: 'mjpeg', name: 'MJPEG / HTTP', protocol: 'http', hardware: false, backend: 'software', available: true },
{ id: 'h264', name: 'H.264 / WebRTC', protocol: 'webrtc', hardware: false, backend: 'software', available: true },
]
} finally {
loadingCodecs.value = false
}
}
async function loadConstraints() {
try {
constraints.value = await streamApi.getConstraints()
} catch {
constraints.value = null
}
}
function initializeFromCurrent() {
const config = currentConfig.value
selectedDevice.value = config.device
selectedFormat.value = config.format
selectedResolution.value = `${config.width}x${config.height}`
selectedFps.value = config.fps
isDirty.value = false
}
function syncFromCurrentIfChanged() {
const config = currentConfig.value
const nextResolution = `${config.width}x${config.height}`
if (selectedDevice.value === config.device
&& selectedFormat.value === config.format
&& selectedResolution.value === nextResolution
&& selectedFps.value === config.fps) {
return
}
selectedDevice.value = config.device
selectedFormat.value = config.format
selectedResolution.value = nextResolution
selectedFps.value = config.fps
isDirty.value = false
}
function handleVideoModeChange(mode: unknown) {
if (typeof mode !== 'string') return
if (isRtspCodecLocked.value) {
toast.warning(codecLockMessage.value)
return
}
if (isRustdeskWebrtcLocked.value && mode === 'mjpeg') {
toast.warning(codecLockMessage.value)
return
}
if (constraints.value?.allowed_codecs?.length && !constraints.value.allowed_codecs.includes(mode)) {
toast.error(constraints.value.reason || t('actionbar.selectMode'))
return
}
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
function handleDeviceChange(devicePath: unknown) {
if (typeof devicePath !== 'string') return
selectedDevice.value = devicePath
isDirty.value = true
const device = devices.value.find(d => d.path === devicePath)
const format = device ? findFirstSelectableFormat(device.formats) : undefined
if (!format) {
clearFormatSelection()
return
}
selectFormatWithDefaults(format.format)
}
function handleFormatChange(format: unknown) {
if (typeof format !== 'string') return
if (isFormatUnsupported(format)) return
selectFormatWithDefaults(format)
isDirty.value = true
}
function handleResolutionChange(resolution: unknown) {
if (typeof resolution !== 'string') return
selectedResolution.value = resolution
isDirty.value = true
const resolutionData = availableResolutions.value.find(
r => `${r.width}x${r.height}` === resolution
)
if (resolutionData?.fps[0]) {
selectedFps.value = resolutionData.fps[0]
}
}
function handleFpsChange(fps: unknown) {
if (typeof fps !== 'string' && typeof fps !== 'number') return
selectedFps.value = typeof fps === 'string' ? Number(fps) : fps
isDirty.value = true
}
async function applyBitratePreset(preset: 'Speed' | 'Balanced' | 'Quality') {
if (applyingBitrate.value) return
applyingBitrate.value = true
try {
const bitratePreset: BitratePreset = { type: preset }
await streamApi.setBitratePreset(bitratePreset)
} catch (e) {
console.info('[VideoConfig] Failed to apply bitrate preset:', e)
} finally {
applyingBitrate.value = false
}
}
function handleBitratePresetChange(preset: 'Speed' | 'Balanced' | 'Quality') {
selectedBitratePreset.value = preset
if (props.videoMode !== 'mjpeg') {
applyBitratePreset(preset)
}
}
async function applyVideoConfig() {
const [width, height] = selectedResolution.value.split('x').map(Number)
applying.value = true
try {
await configStore.updateVideo({
device: selectedDevice.value,
format: selectedFormat.value,
width,
height,
fps: toConfigFps(selectedFps.value),
})
isDirty.value = false
// Stream state will be updated via WebSocket system.device_info event
} catch (e) {
console.info('[VideoConfig] Failed to apply config:', e)
} finally {
applying.value = false
}
}
watch(() => props.open, (isOpen) => {
if (!isOpen) {
isDirty.value = false
return
}
// Detect browser codec support on first open
if (browserSupportedCodecs.value.size === 0) {
detectBrowserCodecSupport()
}
// Load devices on first open
if (devices.value.length === 0) {
loadDevices()
}
// Load codecs and backends on first open
if (codecs.value.length === 0) {
loadCodecs()
}
loadConstraints()
Promise.all([
configStore.refreshVideo(),
configStore.refreshStream(),
configStore.refreshRtspStatus(),
configStore.refreshRustdeskStatus(),
]).then(() => {
initializeFromCurrent()
}).catch(() => {
initializeFromCurrent()
})
})
// Sync selected values when backend config changes (e.g., auto format switch on mode change)
watch(currentConfig, () => {
if (applying.value) return
if (props.open && isDirty.value) return
syncFromCurrentIfChanged()
}, { 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>
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
<Monitor class="size-3.5 sm:size-4" />
<span class="hidden sm:inline">{{ buttonText }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
<Separator />
<!-- Stream Settings Section -->
<div class="space-y-3">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.streamSettings') }}</h5>
<!-- Mode Selection -->
<div class="space-y-2">
<div class="flex items-center gap-1">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoMode') }}</Label>
<HelpTooltip :content="t('actionbar.videoModeHint')" icon-size="sm" side="right" />
</div>
<Select
:model-value="props.videoMode"
@update:model-value="handleVideoModeChange"
:disabled="loadingCodecs || availableCodecs.length === 0 || isRtspCodecLocked"
>
<SelectTrigger size="sm" class="w-full text-xs">
<div v-if="selectedCodecInfo" class="flex items-center gap-1.5 truncate">
<span class="truncate">{{ selectedCodecInfo.name }}</span>
<span
v-if="selectedCodecInfo.backend && selectedCodecInfo.id !== 'mjpeg'"
class="text-[10px] px-1 py-0.5 rounded shrink-0"
:class="selectedCodecInfo.hardware
? 'bg-info/10 text-info'
: 'bg-warning/10 text-warning'"
>
{{ translateBackendName(selectedCodecInfo.backend) }}
</span>
</div>
<span v-else class="text-muted-foreground">{{ loadingCodecs ? t('common.loading') : t('actionbar.selectMode') }}</span>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="codec in availableCodecs"
:key="codec.id"
:value="codec.id"
:disabled="isCodecOptionDisabled(codec.id)"
:class="['text-xs', { 'opacity-50': isCodecOptionDisabled(codec.id) }]"
>
<div class="flex items-center gap-2">
<span>{{ codec.name }}</span>
<!-- Show backend badge for WebRTC codecs -->
<span
v-if="codec.backend && codec.id !== 'mjpeg'"
class="text-[10px] px-1.5 py-0.5 rounded"
:class="codec.hardware
? 'bg-info/10 text-info'
: 'bg-warning/10 text-warning'"
>
{{ translateBackendName(codec.backend) }}
</span>
<span
v-if="!isBrowserSupported(codec.id)"
class="text-[10px] text-muted-foreground"
>
({{ t('actionbar.browserUnsupported') }})
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<p v-if="isCodecLocked" class="text-xs text-warning">
{{ codecLockMessage }}
</p>
</div>
<!-- Bitrate Preset - Only shown for WebRTC modes -->
<div v-if="props.videoMode !== 'mjpeg'" class="space-y-2">
<div class="flex items-center gap-1">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.bitratePreset') }}</Label>
<HelpTooltip :content="t('help.videoBitratePreset')" icon-size="sm" />
</div>
<div class="grid grid-cols-3 gap-1.5">
<Button
variant="outline"
size="sm"
:class="[
'h-auto py-1.5 px-2 flex flex-col items-center gap-0.5',
selectedBitratePreset === 'Speed' && 'border-primary bg-primary/10'
]"
:disabled="applyingBitrate"
@click="handleBitratePresetChange('Speed')"
>
<Zap class="size-3.5" />
<span class="text-[10px] font-medium">{{ t('actionbar.bitrateSpeed') }}</span>
</Button>
<Button
variant="outline"
size="sm"
:class="[
'h-auto py-1.5 px-2 flex flex-col items-center gap-0.5',
selectedBitratePreset === 'Balanced' && 'border-primary bg-primary/10'
]"
:disabled="applyingBitrate"
@click="handleBitratePresetChange('Balanced')"
>
<Scale class="size-3.5" />
<span class="text-[10px] font-medium">{{ t('actionbar.bitrateBalanced') }}</span>
</Button>
<Button
variant="outline"
size="sm"
:class="[
'h-auto py-1.5 px-2 flex flex-col items-center gap-0.5',
selectedBitratePreset === 'Quality' && 'border-primary bg-primary/10'
]"
:disabled="applyingBitrate"
@click="handleBitratePresetChange('Quality')"
>
<Image class="size-3.5" />
<span class="text-[10px] font-medium">{{ t('actionbar.bitrateQuality') }}</span>
</Button>
</div>
</div>
</div>
<!-- Device Settings Section -->
<Separator />
<div class="space-y-3">
<p v-if="videoParamWarningMessage" class="text-xs text-warning">
{{ videoParamWarningMessage }}
</p>
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
<Button
variant="ghost"
size="icon-xs"
:disabled="loadingDevices"
@click="loadDevices"
>
<RefreshCw :class="['size-3.5', loadingDevices && 'animate-spin']" />
</Button>
</div>
<!-- Device Selection -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoDevice') }}</Label>
<Select
:model-value="selectedDevice"
@update:model-value="handleDeviceChange"
:disabled="loadingDevices || devices.length === 0"
>
<SelectTrigger size="sm" class="w-full text-xs">
<span v-if="selectedDeviceInfo" class="min-w-0 truncate">
{{ formatVideoDeviceLabel(selectedDeviceInfo) }}
</span>
<span v-else class="text-muted-foreground">
{{ loadingDevices ? t('common.loading') : t('actionbar.selectDevice') }}
</span>
</SelectTrigger>
<SelectContent class="max-w-[min(360px,calc(100vw-2rem))]">
<SelectItem
v-for="device in devices"
:key="device.path"
:value="device.path"
class="text-xs"
>
<span class="block min-w-0 truncate" :title="formatVideoDeviceLabel(device)">
{{ formatVideoDeviceLabel(device) }}
</span>
</SelectItem>
</SelectContent>
</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>
<!-- Apply Button -->
<Button
class="w-full h-8 text-xs"
:disabled="applying || !selectedDevice || !selectedFormat"
@click="applyVideoConfig"
>
<Loader2 v-if="applying" class="size-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</template>