first commit
This commit is contained in:
228
frontend/src/components/VideoPanel.vue
Normal file
228
frontend/src/components/VideoPanel.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { buildVideoFrameUrl } from '@/lib/api'
|
||||
import type { VideoStatus } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
video: VideoStatus | null
|
||||
}>()
|
||||
|
||||
const frameUrl = ref(buildVideoFrameUrl(0))
|
||||
const currentFps = computed(() => props.video?.fps ?? 30)
|
||||
const canRequestFrames = computed(() => props.video == null || props.video.available)
|
||||
const modeLabel = computed(() => {
|
||||
if (!props.video) {
|
||||
return '--'
|
||||
}
|
||||
if (props.video.source_mode === 'omnisocket-jpeg-live') {
|
||||
return `${props.video.fps} FPS 实时接收`
|
||||
}
|
||||
if (props.video.source_mode === 'omnisocket-waiting') {
|
||||
return '等待 OmniSocket 实时帧'
|
||||
}
|
||||
if (props.video.source_mode === 'sample-jpeg-frame-loop') {
|
||||
return `${props.video.fps} FPS 本地演示`
|
||||
}
|
||||
return `${props.video.fps} FPS`
|
||||
})
|
||||
|
||||
let frameTimer: number | null = null
|
||||
let frameKey = 0
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startFrameLoop()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (frameTimer != null) {
|
||||
window.clearInterval(frameTimer)
|
||||
}
|
||||
})
|
||||
|
||||
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: !video?.available }">
|
||||
{{ video?.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">
|
||||
正在等待 OmniSocket 实时 JPEG 帧接入...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<span>帧源</span>
|
||||
<strong>{{ video?.frame_count ?? '--' }} 张 JPEG</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>当前模式</span>
|
||||
<strong>{{ modeLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
这里始终按固定频率逐张请求 Django 返回的单帧 JPEG,不依赖 MJPEG。只要后端已经收到
|
||||
OmniSocket 里的真实 JPEG 帧,这个组件就会直接显示实时画面。
|
||||
</p>
|
||||
|
||||
<p class="hint subtle">
|
||||
当前帧源状态:{{ video?.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #8d99b3;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.hint.subtle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user