mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
912 lines
30 KiB
Vue
912 lines
30 KiB
Vue
<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>
|