fix: 完善 RK3588 HDMI RX 信号检测与自动恢复

- 统一使用 QUERY_DV_TIMINGS 判断 HDMI RX 输入状态
  - 增加 source-following 设备状态 API 与前端只读展示
  - 支持长时间无信号后的 MJPEG/WebRTC 自动恢复
  - 修复模式切换时采集设备尚未释放导致的 EBUSY
  - 取消陈旧 WebRTC 重连并统一采集恢复策略
  - 移除视频输入状态区域的冗余标题
This commit is contained in:
mofeng-git
2026-07-30 19:41:44 +08:00
parent 971c263bf8
commit 9fb23476ac
36 changed files with 1883 additions and 1492 deletions

View File

@@ -57,7 +57,12 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
handlers.onStreamRecovered?.(_data)
}
function handleStreamStateChangedForward(data: { state: string; device?: string | null }) {
function handleStreamStateChangedForward(data: {
state: string
device?: string | null
reason?: string | null
next_retry_ms?: number | null
}) {
handlers.onStreamStateChanged?.(data)
}

View File

@@ -0,0 +1,182 @@
import {
computed,
onBeforeUnmount,
onMounted,
ref,
watch,
type ComputedRef,
type Ref,
} from 'vue'
import { configApi, type VideoDevice, type VideoInputStatus, type VideoResolution } from '@/api'
import { useWebSocket } from '@/composables/useWebSocket'
interface VideoSelection {
device: Ref<string>
format: Ref<string>
resolution: Ref<string>
fps: Ref<number | null>
}
interface Options {
devices: Ref<VideoDevice[]>
selection: VideoSelection
active: ComputedRef<boolean> | Ref<boolean>
listenForStreamEvents?: boolean
preferredFormat?: (device: VideoDevice) => string | undefined
}
export function useVideoDeviceConfiguration(options: Options) {
const selectedDevice = computed(() =>
options.devices.value.find(device => device.path === options.selection.device.value),
)
const isSourceFollowing = computed(() =>
selectedDevice.value?.control_mode === 'source_following',
)
const inputStatus = computed(() => selectedDevice.value?.input_status ?? null)
const availableFormats = computed(() => selectedDevice.value?.formats ?? [])
const availableResolutions = computed(() => {
const resolutions = availableFormats.value.find(
format => format.format === options.selection.format.value,
)?.resolutions ?? []
const merged = new Map<string, VideoResolution>()
for (const resolution of resolutions) {
const key = `${resolution.width}x${resolution.height}`
const current = merged.get(key)
if (!current) {
merged.set(key, { ...resolution, fps: [...resolution.fps] })
} else {
current.fps = [...new Set([...current.fps, ...resolution.fps])].sort((a, b) => b - a)
}
}
return [...merged.values()].sort((a, b) => b.width * b.height - a.width * a.height)
})
const availableFps = computed(() => {
const resolution = availableResolutions.value.find(
item => `${item.width}x${item.height}` === options.selection.resolution.value,
)
return resolution?.fps ?? []
})
let requestGeneration = 0
let pollTimer: ReturnType<typeof setInterval> | null = null
const refreshingInputStatus = ref(false)
function replaceInputStatus(path: string, status: VideoInputStatus) {
const device = options.devices.value.find(item => item.path === path)
if (!device) return
device.input_status = status
device.has_signal = status.state === 'locked'
}
async function refreshInputStatus() {
const path = options.selection.device.value
if (!path || !isSourceFollowing.value || refreshingInputStatus.value) return
const generation = ++requestGeneration
refreshingInputStatus.value = true
try {
const status = await configApi.getVideoInputStatus(path)
if (generation !== requestGeneration || path !== options.selection.device.value) return
replaceInputStatus(path, status)
} catch {
if (generation !== requestGeneration || path !== options.selection.device.value) return
replaceInputStatus(path, {
state: 'unavailable',
format: null,
width: null,
height: null,
fps: null,
})
} finally {
if (generation === requestGeneration) refreshingInputStatus.value = false
}
}
function stopPolling() {
requestGeneration++
refreshingInputStatus.value = false
if (pollTimer) clearInterval(pollTimer)
pollTimer = null
}
function syncPolling() {
stopPolling()
if (!options.active.value || document.hidden || !isSourceFollowing.value) return
void refreshInputStatus()
pollTimer = setInterval(() => void refreshInputStatus(), 2_000)
}
function chooseResolution() {
if (isSourceFollowing.value) return
const current = options.selection.resolution.value
if (availableResolutions.value.some(item => `${item.width}x${item.height}` === current)) return
const preferred = availableResolutions.value.find(item => item.width === 1920 && item.height === 1080)
?? availableResolutions.value.find(item => item.width === 1280 && item.height === 720)
?? availableResolutions.value[0]
options.selection.resolution.value = preferred ? `${preferred.width}x${preferred.height}` : ''
}
function chooseFps() {
if (isSourceFollowing.value) return
const current = options.selection.fps.value
if (current !== null && availableFps.value.includes(current)) return
options.selection.fps.value = availableFps.value.includes(30) ? 30 : availableFps.value[0] ?? null
}
watch(() => options.selection.device.value, () => {
requestGeneration++
if (isSourceFollowing.value) {
options.selection.format.value = ''
options.selection.resolution.value = ''
options.selection.fps.value = null
} else if (selectedDevice.value) {
const valid = availableFormats.value.some(item => item.format === options.selection.format.value)
if (!valid) {
options.selection.format.value = options.preferredFormat?.(selectedDevice.value)
?? availableFormats.value[0]?.format
?? ''
}
}
syncPolling()
})
watch(() => options.selection.format.value, chooseResolution)
watch(() => options.selection.resolution.value, chooseFps)
watch([() => options.active.value, isSourceFollowing], syncPolling)
const { on, off, connect } = useWebSocket()
const refreshFromStreamEvent = () => {
if (options.active.value && isSourceFollowing.value) void refreshInputStatus()
}
const streamEvents = ['stream.config_applied', 'stream.state_changed', 'stream.recovered']
function handleVisibilityChange() {
syncPolling()
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
if (options.listenForStreamEvents) {
for (const event of streamEvents) on(event, refreshFromStreamEvent)
connect()
}
syncPolling()
})
onBeforeUnmount(() => {
stopPolling()
document.removeEventListener('visibilitychange', handleVisibilityChange)
if (options.listenForStreamEvents) {
for (const event of streamEvents) off(event, refreshFromStreamEvent)
}
})
return {
selectedDevice,
isSourceFollowing,
inputStatus,
availableFormats,
availableResolutions,
availableFps,
refreshInputStatus,
refreshingInputStatus,
}
}

View File

@@ -94,6 +94,7 @@ const sessionIdRef = ref<string | null>(null)
let statsInterval: number | null = null
let isConnecting = false
let connectInFlight: Promise<boolean> | null = null
let connectAbortController: AbortController | null = null
let pendingIceCandidates: RTCIceCandidate[] = []
let seenRemoteCandidates = new Set<string>()
let cachedMediaStream: MediaStream | null = null
@@ -422,6 +423,8 @@ async function connect(): Promise<boolean> {
pendingIceCandidates = []
seenRemoteCandidates.clear()
const abortController = new AbortController()
connectAbortController = abortController
try {
state.value = 'connecting'
@@ -429,6 +432,7 @@ async function connect(): Promise<boolean> {
setConnectStage('fetching_ice_servers')
const iceServers = await fetchIceServers()
if (abortController.signal.aborted) return false
setConnectStage('creating_peer_connection', { iceServerCount: iceServers.length })
peerConnection = createPeerConnection(iceServers)
@@ -441,11 +445,14 @@ async function connect(): Promise<boolean> {
setConnectStage('creating_offer')
const offer = await peerConnection.createOffer()
if (abortController.signal.aborted) return false
await peerConnection.setLocalDescription(offer)
if (abortController.signal.aborted) return false
setConnectStage('waiting_server_answer')
// Do not pass client_id here: each connect creates a fresh session.
const response = await webrtcApi.offer(offer.sdp!)
const response = await webrtcApi.offer(offer.sdp!, abortController.signal)
if (abortController.signal.aborted) return false
sessionId = response.session_id
sessionIdRef.value = response.session_id
@@ -519,6 +526,10 @@ async function connect(): Promise<boolean> {
})
throw new Error('Connection timeout waiting for ICE negotiation')
} catch (err) {
if (abortController.signal.aborted) {
isConnecting = false
return false
}
state.value = 'failed'
setConnectStage('failed', {
sessionId,
@@ -532,6 +543,10 @@ async function connect(): Promise<boolean> {
isConnecting = false
await disconnect()
return false
} finally {
if (connectAbortController === abortController) {
connectAbortController = null
}
}
})()
@@ -543,6 +558,8 @@ async function connect(): Promise<boolean> {
}
async function disconnect() {
connectAbortController?.abort()
connectAbortController = null
stopStatsCollection()
// Clear state FIRST to prevent ICE candidates from being sent