376 lines
8.1 KiB
Vue
376 lines
8.1 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { buildVideoFrameUrl, fetchVideoStatus } from '@/lib/api'
|
||
import type { VideoStatus } from '@/types'
|
||
|
||
const props = defineProps<{
|
||
video: VideoStatus | null
|
||
}>()
|
||
|
||
const STATUS_REFRESH_MS = 300
|
||
|
||
const liveVideo = ref<VideoStatus | null>(props.video)
|
||
const frameUrl = ref(buildVideoFrameUrl(0))
|
||
const displayVideo = computed(() => liveVideo.value ?? props.video)
|
||
const currentFps = computed(() => displayVideo.value?.fps ?? 30)
|
||
const canRequestFrames = computed(() => displayVideo.value?.available === true)
|
||
const modeLabel = computed(() => {
|
||
if (!displayVideo.value) {
|
||
return '正在获取视频状态'
|
||
}
|
||
if (displayVideo.value.source_mode === 'omnisocket-jpeg-live') {
|
||
return `${displayVideo.value.fps} FPS 实时接收`
|
||
}
|
||
if (displayVideo.value.source_mode === 'omnisocket-waiting') {
|
||
return '未实时获取真实值'
|
||
}
|
||
return `${displayVideo.value.fps} FPS`
|
||
})
|
||
const placeholderText = computed(() => {
|
||
if (!displayVideo.value) {
|
||
return '正在获取视频状态...'
|
||
}
|
||
return '未实时获取真实值'
|
||
})
|
||
const latencyLabels = computed(() => {
|
||
const sampleWindowSize = displayVideo.value?.timing?.sample_window_size ?? 10
|
||
const samples = displayVideo.value?.timing?.delta_samples_ms ?? []
|
||
return Array.from({ length: sampleWindowSize }, (_, index) => samples[index] ?? null)
|
||
})
|
||
const timingHeadline = computed(() => {
|
||
const latest = displayVideo.value?.timing?.latest_delta_ms
|
||
if (latest == null) {
|
||
return '等待帧尾时间'
|
||
}
|
||
return `最新 ${latest.toFixed(1)} ms`
|
||
})
|
||
const timingHint = computed(() => {
|
||
const timing = displayVideo.value?.timing
|
||
if (!timing?.available) {
|
||
return '当前还没有从 JPEG 结尾后的尾字节里解析到时间戳,标签会在收到有效帧尾时间后自动填充。'
|
||
}
|
||
|
||
const unitText = timing.timestamp_unit ? `,单位按 ${timing.timestamp_unit}` : ''
|
||
const endiannessText = timing.timestamp_endianness
|
||
? `,字节序按 ${timing.timestamp_endianness}`
|
||
: ''
|
||
return `最近 ${timing.sample_window_size} 个差值样本,面板按 ${STATUS_REFRESH_MS} ms 刷新一组${unitText}${endiannessText}。`
|
||
})
|
||
|
||
let frameTimer: number | null = null
|
||
let statusTimer: number | null = null
|
||
let frameKey = 0
|
||
let statusRequestPending = false
|
||
|
||
async function refreshStatus() {
|
||
if (statusRequestPending) {
|
||
return
|
||
}
|
||
|
||
statusRequestPending = true
|
||
try {
|
||
liveVideo.value = await fetchVideoStatus()
|
||
} catch {
|
||
// 保持当前已显示状态,避免短暂请求失败把面板内容清空。
|
||
} finally {
|
||
statusRequestPending = false
|
||
}
|
||
}
|
||
|
||
function refreshFrame() {
|
||
if (!canRequestFrames.value) {
|
||
return
|
||
}
|
||
frameKey += 1
|
||
frameUrl.value = buildVideoFrameUrl(frameKey)
|
||
}
|
||
|
||
function startFrameLoop() {
|
||
if (frameTimer != null) {
|
||
window.clearInterval(frameTimer)
|
||
frameTimer = null
|
||
}
|
||
|
||
if (!canRequestFrames.value) {
|
||
return
|
||
}
|
||
|
||
refreshFrame()
|
||
const intervalMs = Math.max(33, Math.round(1000 / currentFps.value))
|
||
frameTimer = window.setInterval(() => {
|
||
refreshFrame()
|
||
}, intervalMs)
|
||
}
|
||
|
||
function startStatusLoop() {
|
||
if (statusTimer != null) {
|
||
window.clearInterval(statusTimer)
|
||
statusTimer = null
|
||
}
|
||
|
||
void refreshStatus()
|
||
statusTimer = window.setInterval(() => {
|
||
void refreshStatus()
|
||
}, STATUS_REFRESH_MS)
|
||
}
|
||
|
||
onMounted(() => {
|
||
startStatusLoop()
|
||
startFrameLoop()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (frameTimer != null) {
|
||
window.clearInterval(frameTimer)
|
||
}
|
||
if (statusTimer != null) {
|
||
window.clearInterval(statusTimer)
|
||
}
|
||
})
|
||
|
||
watch(
|
||
() => props.video,
|
||
(nextVideo) => {
|
||
liveVideo.value = nextVideo
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch([currentFps, canRequestFrames], () => {
|
||
startFrameLoop()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section class="panel video-panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<p class="eyebrow">Video</p>
|
||
<h2>JPEG 视频流</h2>
|
||
</div>
|
||
<span class="badge" :class="{ bad: !displayVideo?.available }">
|
||
{{ displayVideo?.source_mode ?? 'loading' }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="video-shell">
|
||
<img
|
||
v-if="canRequestFrames"
|
||
class="video-frame"
|
||
:src="frameUrl"
|
||
alt="Robot jpeg frame stream"
|
||
/>
|
||
<div v-else class="video-placeholder">
|
||
{{ placeholderText }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats">
|
||
<div class="stat-card">
|
||
<span>帧源</span>
|
||
<strong>{{ displayVideo?.frame_count ?? '--' }} 张 JPEG</strong>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span>当前模式</span>
|
||
<strong>{{ modeLabel }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="timing-panel">
|
||
<div class="timing-head">
|
||
<span>帧尾时间差</span>
|
||
<strong>{{ timingHeadline }}</strong>
|
||
</div>
|
||
<div class="timing-grid">
|
||
<span
|
||
v-for="(sample, index) in latencyLabels"
|
||
:key="index"
|
||
class="timing-label"
|
||
:class="{ empty: sample == null }"
|
||
>
|
||
{{ sample == null ? '--' : `${sample.toFixed(1)} ms` }}
|
||
</span>
|
||
</div>
|
||
<p class="hint subtle">
|
||
{{ timingHint }}
|
||
</p>
|
||
</div>
|
||
|
||
<p class="hint">
|
||
这里只有在后端已经收到 OmniSocket 的真实 JPEG 帧时,才会开始逐帧请求并显示画面。
|
||
如果当前没有真实帧,页面会保持占位提示,不再回退测试视频流。
|
||
</p>
|
||
|
||
<p class="hint subtle">
|
||
当前帧源状态:{{ displayVideo?.source_detail ?? '暂无' }}
|
||
</p>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.video-panel {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: start;
|
||
}
|
||
|
||
.eyebrow {
|
||
margin: 0 0 4px;
|
||
color: #5b7aff;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 24px;
|
||
}
|
||
|
||
.badge {
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
background: rgba(40, 199, 111, 0.16);
|
||
color: #63e6a9;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.badge.bad {
|
||
background: rgba(255, 107, 107, 0.18);
|
||
color: #ffb4b4;
|
||
}
|
||
|
||
.video-shell {
|
||
position: relative;
|
||
overflow: hidden;
|
||
border-radius: 20px;
|
||
border: 1px solid rgba(133, 147, 169, 0.28);
|
||
background: linear-gradient(180deg, #09111f 0%, #050812 100%);
|
||
}
|
||
|
||
.video-frame {
|
||
display: block;
|
||
width: 100%;
|
||
aspect-ratio: 16 / 9;
|
||
object-fit: cover;
|
||
background: #02050d;
|
||
}
|
||
|
||
.video-placeholder {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 100%;
|
||
aspect-ratio: 16 / 9;
|
||
padding: 24px;
|
||
color: #a8b4ce;
|
||
text-align: center;
|
||
line-height: 1.7;
|
||
background:
|
||
radial-gradient(circle at top, rgba(91, 122, 255, 0.14), transparent 42%),
|
||
#02050d;
|
||
}
|
||
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.stat-card {
|
||
padding: 14px;
|
||
border-radius: 16px;
|
||
background: rgba(7, 14, 26, 0.78);
|
||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||
}
|
||
|
||
.stat-card span {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: #8d99b3;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.stat-card strong {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.timing-panel {
|
||
display: grid;
|
||
gap: 12px;
|
||
padding: 14px;
|
||
border-radius: 18px;
|
||
background: rgba(7, 14, 26, 0.88);
|
||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||
}
|
||
|
||
.timing-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
color: #cfd7e6;
|
||
}
|
||
|
||
.timing-head span {
|
||
color: #8d99b3;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.timing-head strong {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.timing-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.timing-label {
|
||
display: grid;
|
||
place-items: center;
|
||
min-height: 40px;
|
||
padding: 8px 10px;
|
||
border-radius: 12px;
|
||
background: rgba(91, 122, 255, 0.12);
|
||
border: 1px solid rgba(91, 122, 255, 0.28);
|
||
color: #dce4ff;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.timing-label.empty {
|
||
background: rgba(133, 147, 169, 0.08);
|
||
border-color: rgba(133, 147, 169, 0.18);
|
||
color: #7e8aa5;
|
||
}
|
||
|
||
.hint {
|
||
margin: 0;
|
||
color: #8d99b3;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
.hint.subtle {
|
||
font-size: 13px;
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.stats {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.timing-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|