mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-04-29 17:36:35 +08:00
feat: 深入适配 RK628D CSI 采集卡的设备识别、参数读取、自恢复和音频采集
This commit is contained in:
@@ -213,7 +213,18 @@ export interface VideoEncoderSelfCheckResponse {
|
||||
export const streamApi = {
|
||||
status: () =>
|
||||
request<{
|
||||
state: 'uninitialized' | 'ready' | 'streaming' | 'no_signal' | 'error'
|
||||
state:
|
||||
| 'uninitialized'
|
||||
| 'ready'
|
||||
| 'streaming'
|
||||
| 'no_signal'
|
||||
| 'no_cable'
|
||||
| 'no_sync'
|
||||
| 'out_of_range'
|
||||
| 'device_lost'
|
||||
| 'recovering'
|
||||
| 'device_busy'
|
||||
| 'error'
|
||||
device: string | null
|
||||
format: string | null
|
||||
resolution: [number, number] | null
|
||||
@@ -649,6 +660,7 @@ export const configApi = {
|
||||
}>
|
||||
}>
|
||||
usb_bus: string | null
|
||||
has_signal: boolean
|
||||
}>
|
||||
serial: Array<{ path: string; name: string }>
|
||||
audio: Array<{
|
||||
|
||||
@@ -47,6 +47,7 @@ interface VideoDevice {
|
||||
fps: number[]
|
||||
}[]
|
||||
}[]
|
||||
has_signal?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -14,7 +14,14 @@ export interface ConsoleEventHandlers {
|
||||
onStreamModeSwitching?: (data: { transition_id: string; to_mode: string; from_mode: string }) => void
|
||||
onStreamModeReady?: (data: { transition_id: string; mode: string }) => void
|
||||
onWebRTCReady?: (data: { codec: string; hardware: boolean; transition_id?: string }) => void
|
||||
onStreamStateChanged?: (data: { state: string; device?: string | null }) => void
|
||||
onStreamStateChanged?: (data: {
|
||||
state: string
|
||||
device?: string | null
|
||||
/** Optional fine-grained diagnostic tag (e.g. `no_cable`, `out_of_range`, `recovering`). */
|
||||
reason?: string | null
|
||||
/** Optional countdown (ms) until the next backend self-recovery attempt. */
|
||||
next_retry_ms?: number | null
|
||||
}) => void
|
||||
onStreamDeviceLost?: (data: { device: string; reason: string }) => void
|
||||
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
|
||||
onStreamRecovered?: (data: { device: string }) => void
|
||||
|
||||
@@ -240,6 +240,8 @@ export default {
|
||||
fps: 'Frame Rate',
|
||||
selectFps: 'Select FPS',
|
||||
noVideoDevices: 'No video devices detected',
|
||||
noSignalDetected: 'No HDMI signal detected. Please connect an HDMI cable and refresh.',
|
||||
refreshDevices: 'Refresh Devices',
|
||||
// Audio
|
||||
audioDevice: 'Audio Device',
|
||||
selectAudioDevice: 'Select audio capture device',
|
||||
@@ -310,6 +312,33 @@ export default {
|
||||
configChanging: 'Applying new configuration...',
|
||||
videoRestarted: 'Video stream updated',
|
||||
streamError: 'Stream error',
|
||||
// Four canonical video states (backend StreamStateChanged: streaming /
|
||||
// no_signal / device_lost / device_busy). `reason` provides optional
|
||||
// fine-grained diagnostic sub-text.
|
||||
signal: {
|
||||
noSignal: {
|
||||
title: 'Waiting for video signal',
|
||||
detail: 'Capture device is ready, waiting for the target to output video',
|
||||
},
|
||||
deviceLost: {
|
||||
title: 'Video device offline',
|
||||
detail: 'Capture card is not responding, attempting to re-detect…',
|
||||
},
|
||||
deviceBusy: {
|
||||
title: 'Video channel busy',
|
||||
detail: 'Applying a new configuration or another component is using the device, please wait…',
|
||||
},
|
||||
reason: {
|
||||
no_cable: 'HDMI cable not detected — check the cable and that the target is powered on',
|
||||
no_sync: 'Unstable signal: timings could not be locked — try a lower resolution or refresh rate',
|
||||
out_of_range: 'Resolution or refresh rate exceeds capture capability — try 1080p60 or below',
|
||||
no_signal: 'Capture card is ready, waiting for a picture…',
|
||||
recovering: 'Reconnecting the video device automatically',
|
||||
device_lost: 'Video node disappeared, waiting for the driver to recover',
|
||||
config_changing: 'Applying new configuration',
|
||||
mode_switching: 'Switching video mode',
|
||||
},
|
||||
},
|
||||
// WebRTC
|
||||
webrtcConnected: 'WebRTC Connected',
|
||||
webrtcConnectedDesc: 'Using low-latency H.264 video stream',
|
||||
|
||||
@@ -240,6 +240,8 @@ export default {
|
||||
fps: '帧率',
|
||||
selectFps: '选择帧率',
|
||||
noVideoDevices: '未检测到视频设备',
|
||||
noSignalDetected: '未检测到 HDMI 信号,请连接 HDMI 线缆后刷新。',
|
||||
refreshDevices: '刷新设备',
|
||||
// Audio
|
||||
audioDevice: '音频设备',
|
||||
selectAudioDevice: '选择音频采集设备',
|
||||
@@ -310,6 +312,32 @@ export default {
|
||||
configChanging: '正在应用新配置...',
|
||||
videoRestarted: '视频流已更新',
|
||||
streamError: '视频流错误',
|
||||
// 四档视频状态(对应后端 StreamStateChanged:streaming / no_signal /
|
||||
// device_lost / device_busy). `reason` 子键可选,用于在副文案中补充细节。
|
||||
signal: {
|
||||
noSignal: {
|
||||
title: '暂无视频信号',
|
||||
detail: '采集卡已就绪,正在等待被控机画面',
|
||||
},
|
||||
deviceLost: {
|
||||
title: '视频设备已断开',
|
||||
detail: '采集卡离线,正在尝试重新识别…',
|
||||
},
|
||||
deviceBusy: {
|
||||
title: '视频通道忙',
|
||||
detail: '正在切换配置或被其他组件占用,请稍候…',
|
||||
},
|
||||
reason: {
|
||||
no_cable: '未检测到 HDMI 线缆,请检查连接或被控机是否已开机',
|
||||
no_sync: '信号不稳定,无法锁定时序,可尝试降低被控机分辨率/刷新率',
|
||||
out_of_range: '分辨率或刷新率超出采集卡能力,建议切换到 1080p60 以内',
|
||||
no_signal: '采集卡已就绪,正在等待画面…',
|
||||
recovering: '正在自动重连视频设备',
|
||||
device_lost: '视频节点丢失,等待驱动恢复',
|
||||
config_changing: '正在应用新配置',
|
||||
mode_switching: '正在切换视频模式',
|
||||
},
|
||||
},
|
||||
// WebRTC
|
||||
webrtcConnected: 'WebRTC 已连接',
|
||||
webrtcConnectedDesc: '正在使用 H.264 低延迟视频流',
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
Loader2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, te } = useI18n()
|
||||
const router = useRouter()
|
||||
const systemStore = useSystemStore()
|
||||
const configStore = useConfigStore()
|
||||
@@ -98,6 +98,12 @@ const videoErrorMessage = ref('')
|
||||
const videoRestarting = ref(false) // Track if video is restarting due to config change
|
||||
const mjpegFrameReceived = ref(false) // Whether MJPEG stream has received at least one frame
|
||||
|
||||
/** From `stream.state_changed`: ok | no_signal | device_lost | device_busy */
|
||||
type StreamSignalState = 'ok' | 'no_signal' | 'device_lost' | 'device_busy'
|
||||
const streamSignalState = ref<StreamSignalState>('ok')
|
||||
const streamSignalReason = ref<string | null>(null)
|
||||
const streamNextRetryMs = ref<number | null>(null)
|
||||
|
||||
// Video aspect ratio (dynamically updated from actual video dimensions)
|
||||
// Using string format "width/height" to let browser handle the ratio calculation
|
||||
const videoAspectRatio = ref<string | null>(null)
|
||||
@@ -644,6 +650,7 @@ function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise
|
||||
})
|
||||
}
|
||||
|
||||
/** For WebRTC watch: skip auto-reconnect when these hold. */
|
||||
function shouldSuppressAutoReconnect(): boolean {
|
||||
return videoMode.value === 'mjpeg'
|
||||
|| !isConsoleActive.value
|
||||
@@ -751,6 +758,17 @@ function handleVideoError() {
|
||||
return
|
||||
}
|
||||
|
||||
// Expected <img> error while overlay shows no_signal / device_* — do not retry.
|
||||
if (streamSignalState.value !== 'ok') {
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
}
|
||||
videoLoading.value = false
|
||||
mjpegFrameReceived.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Count consecutive errors even in grace period
|
||||
consecutiveErrors++
|
||||
|
||||
@@ -993,22 +1011,121 @@ function handleStreamModeSwitching(data: { transition_id: string; to_mode: strin
|
||||
}
|
||||
|
||||
function handleStreamStateChanged(data: any) {
|
||||
if (data.state === 'error') {
|
||||
const state = typeof data?.state === 'string' ? data.state : ''
|
||||
const reason = typeof data?.reason === 'string' && data.reason.length > 0 ? data.reason : null
|
||||
const nextRetry = typeof data?.next_retry_ms === 'number' && data.next_retry_ms > 0
|
||||
? data.next_retry_ms
|
||||
: null
|
||||
|
||||
streamSignalReason.value = reason
|
||||
streamNextRetryMs.value = nextRetry
|
||||
|
||||
const previous = streamSignalState.value
|
||||
|
||||
switch (state) {
|
||||
case 'streaming':
|
||||
case 'ready':
|
||||
case 'uninitialized':
|
||||
streamSignalState.value = 'ok'
|
||||
break
|
||||
case 'no_signal':
|
||||
streamSignalState.value = 'no_signal'
|
||||
break
|
||||
case 'device_lost':
|
||||
streamSignalState.value = 'device_lost'
|
||||
break
|
||||
case 'device_busy':
|
||||
streamSignalState.value = 'device_busy'
|
||||
break
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = t('console.streamError')
|
||||
} else if (data.state === 'recovering' && videoMode.value !== 'mjpeg') {
|
||||
// Backend is in the DeviceLost recovery loop; start WebRTC reconnect if not already scheduled.
|
||||
} else if (state === 'no_signal' && videoMode.value !== 'mjpeg') {
|
||||
cancelWebRTCRecovery()
|
||||
videoRestarting.value = false
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
} else if (state === 'device_busy' && videoMode.value !== 'mjpeg') {
|
||||
cancelWebRTCRecovery()
|
||||
videoRestarting.value = true
|
||||
videoLoading.value = true
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
if (previous !== 'device_busy') {
|
||||
captureFrameOverlay().catch(() => {})
|
||||
}
|
||||
} else if (state === 'device_lost' && videoMode.value !== 'mjpeg') {
|
||||
if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
|
||||
scheduleWebRTCRecovery()
|
||||
}
|
||||
} else if (data.state === 'streaming' || data.state === 'no_signal') {
|
||||
// Backend stream is alive; cancel any pending recovery timers.
|
||||
if (data.state === 'streaming') {
|
||||
cancelWebRTCRecovery()
|
||||
} else if (state === 'streaming') {
|
||||
cancelWebRTCRecovery()
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
videoRestarting.value = false
|
||||
if (
|
||||
videoMode.value === 'mjpeg'
|
||||
&& (previous === 'no_signal' || previous === 'device_lost' || previous === 'device_busy')
|
||||
) {
|
||||
refreshVideo()
|
||||
} else if (
|
||||
videoMode.value !== 'mjpeg'
|
||||
&& (previous === 'no_signal' || previous === 'device_busy' || previous === 'device_lost')
|
||||
) {
|
||||
if (webrtc.isConnected.value && !webrtc.isConnecting.value) {
|
||||
void rebindWebRTCVideo().then(() => {
|
||||
videoLoading.value = false
|
||||
})
|
||||
} else if (!webrtc.isConnected.value && !webrtc.isConnecting.value) {
|
||||
void connectWebRTCSerial('stream recovered').then(async (ok) => {
|
||||
if (ok) {
|
||||
await rebindWebRTCVideo()
|
||||
videoLoading.value = false
|
||||
} else if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
|
||||
scheduleWebRTCRecovery()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showSignalOverlay = computed(() => streamSignalState.value !== 'ok')
|
||||
|
||||
const signalOverlayInfo = computed(() => {
|
||||
const reason = streamSignalReason.value
|
||||
const reasonHintKey = reason ? `console.signal.reason.${reason}` : ''
|
||||
const hint = reasonHintKey && te(reasonHintKey) ? t(reasonHintKey) : ''
|
||||
|
||||
switch (streamSignalState.value) {
|
||||
case 'no_signal':
|
||||
return {
|
||||
title: t('console.signal.noSignal.title'),
|
||||
detail: t('console.signal.noSignal.detail'),
|
||||
hint,
|
||||
tone: 'info' as const,
|
||||
}
|
||||
case 'device_lost':
|
||||
return {
|
||||
title: t('console.signal.deviceLost.title'),
|
||||
detail: t('console.signal.deviceLost.detail'),
|
||||
hint,
|
||||
tone: 'error' as const,
|
||||
}
|
||||
case 'device_busy':
|
||||
return {
|
||||
title: t('console.signal.deviceBusy.title'),
|
||||
detail: t('console.signal.deviceBusy.detail'),
|
||||
hint,
|
||||
tone: 'info' as const,
|
||||
}
|
||||
default:
|
||||
return { title: '', detail: '', hint: '', tone: 'info' as const }
|
||||
}
|
||||
})
|
||||
|
||||
function handleStreamStatsUpdate(data: any) {
|
||||
// Always update clients count in store (for MJPEG mode display)
|
||||
if (typeof data.clients === 'number') {
|
||||
@@ -1177,8 +1294,12 @@ function refreshVideo() {
|
||||
}
|
||||
|
||||
// MJPEG URL with cache-busting timestamp (reactive)
|
||||
// Only return valid URL when in MJPEG mode to prevent unnecessary requests
|
||||
const mjpegTimestamp = ref(0) // Start with 0 to prevent initial load
|
||||
// Only return valid URL when in MJPEG mode and the backend reports a
|
||||
// healthy stream. When the backend goes offline (no_signal / device_lost
|
||||
// / device_busy) we deliberately return an empty string so the `<img>`
|
||||
// tag has no `src` and the 4-state overlay fully owns the video area —
|
||||
// no more fake placeholder JPEG peeking through.
|
||||
const mjpegTimestamp = ref(0)
|
||||
const mjpegUrl = computed(() => {
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
return '' // Don't load MJPEG when in H264 mode
|
||||
@@ -1186,6 +1307,9 @@ const mjpegUrl = computed(() => {
|
||||
if (mjpegTimestamp.value === 0) {
|
||||
return '' // Don't load until refreshVideo() is called
|
||||
}
|
||||
if (streamSignalState.value !== 'ok') {
|
||||
return '' // Backend is offline; let the overlay own the viewport
|
||||
}
|
||||
return `${streamApi.getMjpegUrl(myClientId)}&t=${mjpegTimestamp.value}`
|
||||
})
|
||||
|
||||
@@ -1491,21 +1615,27 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
webrtcReconnectTimeout = null
|
||||
}
|
||||
|
||||
if (shouldSuppressAutoReconnect()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update stream online status based on WebRTC connection state
|
||||
// Run before `shouldSuppressAutoReconnect()` so `device_busy` / `videoRestarting`
|
||||
// never blocks clearing the loading overlay when ICE becomes connected.
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
if (newState === 'connected') {
|
||||
systemStore.setStreamOnline(true)
|
||||
webrtcReconnectFailures = 0
|
||||
if (videoLoading.value) {
|
||||
void rebindWebRTCVideo().then(() => {
|
||||
videoLoading.value = false
|
||||
})
|
||||
}
|
||||
} else if (newState === 'disconnected' || newState === 'failed') {
|
||||
// Don't immediately set offline - wait for potential reconnect
|
||||
// The device_info event will eventually sync the correct state
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSuppressAutoReconnect()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-reconnect when disconnected (but was previously connected)
|
||||
if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') {
|
||||
webrtcReconnectTimeout = setTimeout(async () => {
|
||||
@@ -2584,6 +2714,50 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!--
|
||||
Canonical 4-state signal overlay (no_signal / device_lost /
|
||||
device_busy). Fully covers the video area with a solid dim
|
||||
backdrop so the browser never shows a frozen last frame or a
|
||||
transparent video element peeking through — the MJPEG `<img>`
|
||||
has its `src` cleared the moment the backend goes offline and
|
||||
the WebRTC track is simply obscured. Sits below the loading /
|
||||
error overlays so those take precedence when both apply.
|
||||
-->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showSignalOverlay && !videoLoading && !videoError"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-4 transition-opacity duration-300 pointer-events-none"
|
||||
:class="{
|
||||
'bg-black/80 backdrop-blur-sm': signalOverlayInfo.tone === 'error',
|
||||
'bg-black/70 backdrop-blur-sm': signalOverlayInfo.tone !== 'error',
|
||||
}"
|
||||
>
|
||||
<MonitorOff
|
||||
class="h-10 w-10 sm:h-16 sm:w-16"
|
||||
:class="{
|
||||
'text-slate-200': signalOverlayInfo.tone === 'info',
|
||||
'text-red-300': signalOverlayInfo.tone === 'error',
|
||||
}"
|
||||
/>
|
||||
<div class="text-center max-w-md">
|
||||
<p
|
||||
class="font-semibold text-sm sm:text-lg text-white"
|
||||
>{{ signalOverlayInfo.title }}</p>
|
||||
<p
|
||||
class="text-xs sm:text-sm mt-1 sm:mt-2"
|
||||
:class="{
|
||||
'text-slate-200/80': signalOverlayInfo.tone === 'info',
|
||||
'text-red-100/80': signalOverlayInfo.tone === 'error',
|
||||
}"
|
||||
>{{ signalOverlayInfo.detail }}</p>
|
||||
<p
|
||||
v-if="signalOverlayInfo.hint"
|
||||
class="text-[11px] sm:text-xs mt-2 text-white/50"
|
||||
>{{ signalOverlayInfo.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Error Overlay with smooth transition and detailed info -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
Check,
|
||||
HelpCircle,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -106,6 +107,7 @@ interface VideoDeviceInfo {
|
||||
}>
|
||||
}>
|
||||
usb_bus: string | null
|
||||
has_signal: boolean
|
||||
}
|
||||
|
||||
interface AudioDeviceInfo {
|
||||
@@ -164,6 +166,29 @@ const passwordStrengthColor = computed(() => {
|
||||
return colors[passwordStrength.value] || colors[0]
|
||||
})
|
||||
|
||||
// Whether the selected video device currently has an HDMI signal
|
||||
const selectedDeviceHasSignal = computed(() => {
|
||||
const device = devices.value.video.find((d) => d.path === videoDevice.value)
|
||||
return device?.has_signal ?? true
|
||||
})
|
||||
|
||||
const refreshingDevices = ref(false)
|
||||
|
||||
async function refreshDeviceList() {
|
||||
refreshingDevices.value = true
|
||||
try {
|
||||
const result = await configApi.listDevices()
|
||||
devices.value = result
|
||||
if (result.extensions) {
|
||||
ttydAvailable.value = result.extensions.ttyd_available
|
||||
}
|
||||
} catch {
|
||||
// keep current list
|
||||
} finally {
|
||||
refreshingDevices.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Computed: available formats for selected video device
|
||||
const availableFormats = computed(() => {
|
||||
const device = devices.value.video.find((d) => d.path === videoDevice.value)
|
||||
@@ -735,6 +760,14 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="videoDevice && !selectedDeviceHasSignal" class="flex items-center gap-3 p-3 rounded-lg border border-orange-500/30 bg-orange-500/5 text-sm text-orange-600 dark:text-orange-400">
|
||||
<p class="flex-1">{{ t('setup.noSignalDetected') }}</p>
|
||||
<Button variant="outline" size="sm" :disabled="refreshingDevices" @click="refreshDeviceList">
|
||||
<RefreshCw class="w-4 h-4 mr-1" :class="{ 'animate-spin': refreshingDevices }" />
|
||||
{{ t('setup.refreshDevices') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="videoDevice" class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="videoFormat">{{ t('setup.videoFormat') }}</Label>
|
||||
|
||||
Reference in New Issue
Block a user