fix: 修复无信号状态同步与采集管线阻塞

- 使用非阻塞 V4L2 句柄并类型化采集错误
- 完善视频管线生命周期,避免复用停止中的管线
- 区分音视频事件并支持 REST/WebSocket 状态快照
- 修复信号丢失后保留最后一帧及刷新后黑屏问题
- 优化无信号提示、状态展示优先级及相关界面细节
- 降低重复 OTG 错误日志级别
This commit is contained in:
mofeng-git
2026-07-30 22:43:59 +08:00
parent ce1712ff2e
commit 6fdcf5c7c9
24 changed files with 406 additions and 165 deletions

View File

@@ -10,6 +10,7 @@ import type {
ComputerUseStartRequest,
} from '@/types/generated'
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
import type { StreamSignalReason, StreamState } from '@/types/websocket'
const API_BASE = '/api'
@@ -299,18 +300,8 @@ export interface VideoEncoderSelfCheckResponse {
export const streamApi = {
status: () =>
request<{
state:
| 'uninitialized'
| 'ready'
| 'streaming'
| 'no_signal'
| 'no_cable'
| 'no_sync'
| 'out_of_range'
| 'device_lost'
| 'recovering'
| 'device_busy'
| 'error'
state: StreamState
reason?: StreamSignalReason | null
device: string | null
format: string | null
resolution: [number, number] | null

View File

@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Bot,
BrainCircuit,
Brain,
ChevronDown,
ChevronRight,
Image,
@@ -293,7 +293,7 @@ onMounted(loadConfig)
>
<ChevronDown v-if="reasoningOpen(item)" class="size-3.5 shrink-0" />
<ChevronRight v-else class="size-3.5 shrink-0" />
<BrainCircuit class="size-3.5 shrink-0" />
<Brain class="size-3.5 shrink-0" />
<span>
{{ item.failed
? t('computerUse.reasoning.failed')

View File

@@ -32,7 +32,7 @@ const { t } = useI18n()
<template>
<template v-if="device?.control_mode === 'source_following'">
<div v-if="device.input_status.state === 'locked'" class="space-y-2">
<dl class="grid grid-cols-3 gap-3" :class="compact ? 'text-xs' : 'text-sm'">
<dl class="grid grid-cols-3 gap-3 text-center" :class="compact ? 'text-xs' : 'text-sm'">
<div class="min-w-0 space-y-1">
<dt class="text-muted-foreground">{{ t('videoInput.format') }}</dt>
<dd class="truncate font-medium" :title="device.input_status.format ?? ''">

View File

@@ -1,7 +1,7 @@
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system'
import type { StreamDeviceLostEventData } from '@/types/websocket'
import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket'
import { useWebSocket } from '@/composables/useWebSocket'
import { isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
@@ -13,12 +13,7 @@ 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
reason?: string | null
next_retry_ms?: number | null
}) => void
onStreamStateChanged?: (data: StreamStateChangedEventData) => void
onStreamDeviceLost?: (data: StreamDeviceLostEventData) => void
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
onStreamRecovered?: (data: { device: string }) => void
@@ -57,12 +52,7 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
handlers.onStreamRecovered?.(_data)
}
function handleStreamStateChangedForward(data: {
state: string
device?: string | null
reason?: string | null
next_retry_ms?: number | null
}) {
function handleStreamStateChangedForward(data: StreamStateChangedEventData) {
handlers.onStreamStateChanged?.(data)
}

View File

@@ -303,7 +303,7 @@ export default {
signal: {
noSignal: {
title: 'Waiting for video signal',
detail: 'Capture device is ready, waiting for the target to output video',
detail: 'No video signal detected',
},
deviceLost: {
title: 'Video device offline',
@@ -327,7 +327,7 @@ export default {
},
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',
no_sync: 'No signal or unstable signal; unable to lock timing',
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',
@@ -496,8 +496,8 @@ export default {
},
settings: {
title: 'Settings',
sidebarSubtitle: 'Manage device, network and extensions',
appearance: 'Appearance',
accountSubtitle: 'Manage console login credentials and security verification',
account: 'Account',
video: 'Video',
encoder: 'Encoder',
@@ -505,6 +505,7 @@ export default {
msd: 'MSD',
atx: 'ATX',
environment: 'Environment',
networkSubtitle: 'Configure web server ports, listen addresses, and HTTPS',
network: 'Network',
hardware: 'Hardware',
system: 'System',

View File

@@ -302,7 +302,7 @@ export default {
signal: {
noSignal: {
title: '暂无视频信号',
detail: '采集卡已就绪,正在等待被控机画面',
detail: '未检测到视频信号',
},
deviceLost: {
title: '视频设备已断开',
@@ -326,7 +326,7 @@ export default {
},
reason: {
no_cable: '未检测到 HDMI 线缆,请检查连接或被控机是否已开机',
no_sync: '信号不稳定,无法锁定时序,可尝试降低被控机分辨率/刷新率',
no_sync: '无信号或信号不稳定,无法锁定时序',
out_of_range: '分辨率或刷新率超出采集卡能力,建议切换到 1080p60 以内',
no_signal: '采集卡已就绪,正在等待画面…',
recovering: '正在自动重连视频设备',
@@ -495,8 +495,8 @@ export default {
},
settings: {
title: '系统设置',
sidebarSubtitle: '管理设备、网络与扩展',
appearance: '外观',
accountSubtitle: '管理控制台登录账户与安全验证',
account: '账户',
video: '视频',
encoder: '编码器',
@@ -504,6 +504,7 @@ export default {
msd: 'MSD',
atx: 'ATX',
environment: '环境',
networkSubtitle: '配置 Web 服务端口、监听地址与 HTTPS',
network: '网络',
hardware: '硬件',
system: '系统',

View File

@@ -35,14 +35,45 @@ export function buildWsUrl(path: string): string {
/** Default reconnect delay in milliseconds */
export const WS_RECONNECT_DELAY = 3000
export type StreamDeviceLostKind = 'video' | 'audio'
export type StreamKind = 'video' | 'audio'
export interface StreamDeviceLostEventData {
kind: StreamDeviceLostKind
kind: StreamKind
device: string
reason: string
}
export type StreamState =
| 'uninitialized'
| 'ready'
| 'streaming'
| 'no_signal'
| 'device_lost'
| 'device_busy'
| 'error'
export type StreamSignalReason =
| 'no_signal'
| 'no_cable'
| 'no_sync'
| 'out_of_range'
| 'recovering'
| 'device_lost'
| 'config_changing'
| 'mode_switching'
| 'audio_device_lost'
| 'audio_reconnecting'
| 'uvc_usb_error'
| 'uvc_capture_stall'
export interface StreamStateChangedEventData {
kind?: StreamKind
state: StreamState
device?: string | null
reason?: StreamSignalReason | null
next_retry_ms?: number | null
}
/** WebSocket ready states */
export const WS_STATE = {
CONNECTING: WebSocket.CONNECTING,

View File

@@ -27,7 +27,7 @@ import { formatFpsValue } from '@/lib/fps'
import { videoDebugLog } from '@/lib/debugLog'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
import type { StreamDeviceLostEventData } from '@/types/websocket'
import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket'
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
@@ -1356,7 +1356,7 @@ function handleStreamModeSwitching(data: { transition_id: string; to_mode: strin
videoSession.onModeSwitching(data)
}
function handleStreamStateChanged(data: any) {
function handleStreamStateChanged(data: StreamStateChangedEventData) {
videoDebugLog('Stream state changed event', {
data,
videoMode: videoMode.value,
@@ -1468,8 +1468,8 @@ const signalOverlayInfo = computed(() => {
case 'no_signal':
return {
title: t('console.signal.noSignal.title'),
detail: t('console.signal.noSignal.detail'),
hint,
detail: hint || t('console.signal.noSignal.detail'),
hint: '',
tone: 'info' as const,
}
case 'device_lost':
@@ -3083,6 +3083,10 @@ onMounted(async () => {
}, { immediate: true })
await systemStore.startStream().catch(() => {})
const initialStreamStatus = await streamApi.status().catch(() => null)
if (initialStreamStatus) {
handleStreamStateChanged(initialStreamStatus)
}
await systemStore.fetchSystemInfo().catch(() => {})
await systemStore.fetchAllStates()
await configStore.refreshHid().then(() => {
@@ -3398,7 +3402,7 @@ onUnmounted(() => {
</div>
<Transition name="fade">
<div
v-if="videoLoading"
v-if="videoLoading && !showSignalOverlay && !videoError"
class="absolute inset-0 flex flex-col items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300"
>
<div class="absolute inset-0 overflow-hidden pointer-events-none">
@@ -3443,7 +3447,7 @@ onUnmounted(() => {
</Transition>
<Transition name="fade">
<div
v-if="showSignalOverlay && !videoLoading && !videoError"
v-if="showSignalOverlay && !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',

View File

@@ -2717,7 +2717,6 @@ watch(isWindows, () => {
<Sidebar class="top-10 h-[calc(100dvh-2.5rem)] sm:top-14 sm:h-[calc(100dvh-3.5rem)]" collapsible="offcanvas">
<SidebarHeader class="gap-1 px-6 pt-4 pb-2 text-foreground md:pt-6">
<h1 class="text-xl font-semibold">{{ t('settings.title') }}</h1>
<p class="text-xs text-muted-foreground">{{ t('settings.sidebarSubtitle') }}</p>
</SidebarHeader>
<SidebarContent class="px-3 pb-6 md:pb-10">
<SidebarGroup v-for="group in navGroups" :key="group.title" class="px-0 py-1">