19 KiB
Monitor HLS Playback Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 监控详情页可在 Chrome/Edge 真实播放 HLS,并补齐启动轮询、playUrl 续期、播放错误重建、停止直播与退出观看分离。
Architecture: LivePlayer 只负责媒体(Safari 原生 HLS / 其他动态 import('hls.js'));useMonitorLive 管 join、phase 轮询、heartbeat、playUrl 续期、错误重试、leave/stop;MonitorDetailPanel 标题行加「停止直播」按钮。
Tech Stack: Vue 3、hls.js、现有 axios src/utils/http.js、Pinia useUiStore、useMonitorLive / LivePlayer / MonitorDetailPanel
Spec: docs/superpowers/specs/2026-09-02-monitor-hls-playback-design.md
File map
| 文件 | 职责 |
|---|---|
package.json / package-lock.json |
增加 hls.js |
src/config/urls.js |
LIVE_SESSION / LIVE_STOP |
src/api/live.js |
getLiveSession / stopLive |
src/components/LivePlayer.vue |
HLS 挂载/销毁/错误上报 |
src/composables/useMonitorLive.js |
轮询、续期、重试、stop |
src/components/MonitorDetailPanel.vue |
「停止直播」按钮 + emit |
src/views/MonitorView/MonitorView.vue |
接线 onPlayError / stopLiveStream |
辅助约定:
- 请求:
import request from '@/utils/http'(成功已解包res.data) - Toast / Confirm:
ui.toast(...)/await ui.confirm(title, desc, checks?) GET .../sessions/:id解包后是LiveSession本体(含phase/id),不是{ session }- 本仓库无前端单测惯例:用
npm run build+ 静态核对;不新增测试文件 - 精确
git add;commit 信息简短祈使句
Task 1: 安装 hls.js + URL/API
Files:
-
Modify:
package.json/package-lock.json -
Modify:
src/config/urls.js -
Modify:
src/api/live.js -
Step 1: 安装依赖
npm install hls.js
确认 package.json dependencies 出现 hls.js。
- Step 2: URL 常量
在 src/config/urls.js 现有 live 段落后追加:
export const LIVE_SESSION = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}`
export const LIVE_STOP = (dockId) => `/v1/live/${dockId}/stop`
保留现有:
export const LIVE_SESSIONS = (dockId) => `/v1/live/${dockId}/sessions`
export const LIVE_PLAY_URL = (dockId) => `/v1/live/${dockId}/play-url`
export const LIVE_HEARTBEAT = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}/heartbeat`
export const LIVE_LEAVE = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}/viewers/me`
- Step 3: API 封装
src/api/live.js 最终应为:
import request from '@/utils/http'
export function joinLive(dockId, payload = {}) {
return request.post(`/v1/live/${dockId}/sessions`, payload)
}
export function getLiveSession(dockId, sessionId) {
return request.get(`/v1/live/${dockId}/sessions/${sessionId}`)
}
export function getLivePlayURL(dockId, sessionId) {
return request.get(`/v1/live/${dockId}/play-url`, {
params: { streamSessionId: sessionId }
})
}
export function heartbeatLive(dockId, sessionId) {
return request.post(`/v1/live/${dockId}/sessions/${sessionId}/heartbeat`)
}
export function leaveLive(dockId, sessionId) {
return request.delete(`/v1/live/${dockId}/sessions/${sessionId}/viewers/me`)
}
export function stopLive(dockId) {
return request.post(`/v1/live/${dockId}/stop`)
}
- Step 4: Commit
git add package.json package-lock.json src/config/urls.js src/api/live.js
git commit -m "feat: add hls.js and live session/stop APIs"
Task 2: LivePlayer HLS 适配
Files:
-
Modify:
src/components/LivePlayer.vue -
Step 1: 重写组件(template 去掉 :src 绑定)
完整文件:
<template>
<div
ref="frameEl"
class="video-frame"
:class="{ streaming: isLiveShell, playing: playable }"
>
<video
v-if="playable"
ref="videoEl"
controls
autoplay
playsinline
/>
<div v-else class="camera-scene">
<div class="horizon" />
<div class="dock-model"><span /><span /><i /></div>
</div>
<div class="video-overlay">
<span>{{ cameraLabel }}</span>
<small>{{ qualityText }}</small>
</div>
<div v-if="statusMessage" class="video-status">{{ statusMessage }}</div>
<button
class="play-control"
type="button"
:aria-label="active ? '关闭实时画面' : '打开实时画面'"
@click="$emit('toggle')"
>
<svg><use :href="active ? '#i-stop' : '#i-play'" /></svg>
</button>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps({
playUrl: { type: String, default: '' },
phase: { type: String, default: '' },
active: { type: Boolean, default: false },
cameraLabel: { type: String, default: '机巢摄像头' },
})
const emit = defineEmits(['toggle', 'error'])
const frameEl = ref(null)
const videoEl = ref(null)
let hls = null
let attachSeq = 0
const playable = computed(() => props.playUrl && !props.playUrl.startsWith('fake://'))
const isLiveShell = computed(() => props.active && !playable.value)
const qualityText = computed(() => {
if (playable.value) return 'LIVE · 播放中'
if (props.playUrl?.startsWith('fake://')) return '控制面已验证'
if (props.phase === 'starting' || props.phase === 'reconnecting') return '启动中'
if (props.phase === 'failed') return '启动失败'
return 'H.264 · 720P'
})
const statusMessage = computed(() => {
if (playable.value) return ''
if (props.phase === 'starting' || props.phase === 'reconnecting') return '正在等待直播流就绪'
if (props.phase === 'failed') return '直播启动失败'
if (props.playUrl?.startsWith('fake://')) return '控制面已验证,未配置本地媒体流'
if (props.active) return '暂无实时视频数据'
return ''
})
function destroyPlayer() {
if (hls) {
hls.destroy()
hls = null
}
const video = videoEl.value
if (video) {
video.removeAttribute('src')
video.load()
}
}
async function attachPlayer(url) {
const seq = ++attachSeq
destroyPlayer()
if (!url || url.startsWith('fake://')) return
await Promise.resolve()
const video = videoEl.value
if (!video || seq !== attachSeq) return
video.addEventListener('error', () => {
if (seq !== attachSeq) return
emit('error')
}, { once: true })
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
try { await video.play() } catch (_) {}
return
}
const { default: Hls } = await import('hls.js')
if (seq !== attachSeq) return
if (!Hls.isSupported()) {
emit('error')
return
}
hls = new Hls({ enableWorker: true, lowLatencyMode: true })
hls.on(Hls.Events.ERROR, (_event, data) => {
if (seq !== attachSeq) return
if (data?.fatal) emit('error')
})
hls.loadSource(url)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (seq !== attachSeq) return
video.play().catch(() => {})
})
}
watch(
() => props.playUrl,
(url) => {
if (playable.value) attachPlayer(url)
else destroyPlayer()
},
{ immediate: true }
)
onBeforeUnmount(() => {
attachSeq += 1
destroyPlayer()
})
function requestFullscreen() {
const el = playable.value ? videoEl.value : frameEl.value
if (!el?.requestFullscreen) return false
el.requestFullscreen()
return true
}
defineExpose({ requestFullscreen, frameEl, videoEl })
</script>
若原文件另有 <style>,保留原样式块;上例仅替换 template + script。
- Step 2: 静态核对
- 无
:src="playUrl" - 有
import('hls.js') onBeforeUnmountdestroy- fatal / video error →
emit('error')
- Step 3: Commit
git add src/components/LivePlayer.vue
git commit -m "feat: play HLS in LivePlayer via hls.js"
Task 3: useMonitorLive 轮询 / 续期 / 重试 / stop
Files:
-
Modify:
src/composables/useMonitorLive.js -
Step 1: 整文件替换
import { computed, reactive } from 'vue'
import {
getLivePlayURL,
getLiveSession,
heartbeatLive,
joinLive,
leaveLive,
stopLive,
} from '@/api/live'
const PHASE_POLL_MS = 2500
const PHASE_TIMEOUT_MS = 60_000
const PLAY_RETRY_MAX = 3
const PLAY_EXPIRE_FALLBACK_SEC = 270
const PLAY_REFRESH_LEAD_MS = 30_000
export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
const live = reactive({
session: null,
dockId: '',
playUrl: '',
playUrlExpiresAt: 0,
heartbeatTimer: null,
phasePollTimer: null,
playRefreshTimer: null,
playRetryCount: 0,
phasePollStartedAt: 0,
})
const livePhaseText = computed(() => {
if (!live.session) return '待机'
const phase = live.session.phase
if (phase === 'streaming') return live.playUrl.startsWith('fake://') ? '模拟直播' : '直播中'
if (phase === 'starting') return '启动中'
if (phase === 'reconnecting') return '重连中'
if (phase === 'stopping') return '停止中'
if (phase === 'failed') return '启动失败'
return '已停止'
})
const canFullscreenLive = computed(() => !!live.playUrl && !live.playUrl.startsWith('fake://'))
function clearTimers() {
window.clearTimeout(live.heartbeatTimer)
window.clearTimeout(live.phasePollTimer)
window.clearTimeout(live.playRefreshTimer)
live.heartbeatTimer = null
live.phasePollTimer = null
live.playRefreshTimer = null
}
function resetLocal() {
clearTimers()
live.session = null
live.dockId = ''
live.playUrl = ''
live.playUrlExpiresAt = 0
live.playRetryCount = 0
live.phasePollStartedAt = 0
}
function toggleLive() {
if (live.session) closeLive()
else openLive()
}
function fullscreenLive() {
if (!canFullscreenLive.value) {
ui.toast('当前无可全屏播放的视频流')
return
}
const ok = getLivePlayer()?.requestFullscreen?.()
if (!ok) ui.toast('当前浏览器不支持全屏')
}
async function openLive() {
const dockId = getDockId?.()
if (!dockId) return
try {
const result = await joinLive(dockId)
live.session = result.session
live.dockId = dockId
live.playUrl = ''
live.playUrlExpiresAt = 0
live.playRetryCount = 0
scheduleLiveHeartbeat(result.leaseExpiresAt)
if (result.session.phase === 'streaming') {
try {
await refreshPlayURL()
} catch (e) {
await onPlayError(e)
}
} else {
startPhasePolling()
}
} catch (e) {
ui.toast(e.message || '打开直播失败')
}
}
function startPhasePolling() {
window.clearTimeout(live.phasePollTimer)
live.phasePollStartedAt = Date.now()
live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS)
}
async function pollPhaseOnce() {
if (!live.dockId || !live.session?.id) return
if (Date.now() - live.phasePollStartedAt > PHASE_TIMEOUT_MS) {
ui.toast('直播启动超时')
await closeLive(false)
return
}
const sessionId = live.session.id
try {
// http 解包后是 LiveSession 本体,不是 { session }
const session = await getLiveSession(live.dockId, sessionId)
if (!live.session || live.session.id !== sessionId) return
live.session = session
if (session.phase === 'streaming') {
window.clearTimeout(live.phasePollTimer)
live.phasePollTimer = null
try {
await refreshPlayURL()
} catch (e) {
await onPlayError(e)
}
return
}
if (session.phase === 'failed' || session.phase === 'stopped') {
ui.toast(session.phase === 'failed' ? '直播启动失败' : '直播已停止')
await closeLive(false)
return
}
} catch (e) {
ui.toast(e.message || '查询直播状态失败')
await closeLive(false)
return
}
live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS)
}
// 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归)
async function refreshPlayURL() {
if (!live.dockId || !live.session?.id) return false
const sessionId = live.session.id
const play = await getLivePlayURL(live.dockId, sessionId)
if (!live.session || live.session.id !== sessionId) return false
live.playUrl = play.playUrl || ''
const nowSec = Math.floor(Date.now() / 1000)
live.playUrlExpiresAt = Number(play.expiresAt) || nowSec + PLAY_EXPIRE_FALLBACK_SEC
live.playRetryCount = 0
schedulePlayRefresh()
return true
}
function schedulePlayRefresh() {
window.clearTimeout(live.playRefreshTimer)
if (!live.playUrl || live.playUrl.startsWith('fake://') || !live.playUrlExpiresAt) return
const delay = Math.max(5000, live.playUrlExpiresAt * 1000 - Date.now() - PLAY_REFRESH_LEAD_MS)
live.playRefreshTimer = window.setTimeout(async () => {
try {
await refreshPlayURL()
} catch (e) {
await onPlayError(e)
}
}, delay)
}
function scheduleLiveHeartbeat(expiresAt) {
window.clearTimeout(live.heartbeatTimer)
const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2)
live.heartbeatTimer = window.setTimeout(async () => {
if (!live.dockId || !live.session?.id) return
try {
const result = await heartbeatLive(live.dockId, live.session.id)
if (!live.session) return
live.session = result.session
scheduleLiveHeartbeat(result.leaseExpiresAt)
if (result.session.phase === 'streaming' && !live.playUrl) {
try {
await refreshPlayURL()
} catch (e) {
await onPlayError(e)
}
}
} catch (e) {
ui.toast(e.message || '直播观看已结束')
await closeLive(false)
}
}, delay)
}
async function onPlayError(err) {
if (!live.session) return
if (live.playRetryCount >= PLAY_RETRY_MAX) {
ui.toast(err?.message || '播放失败')
live.playRetryCount = 0
return
}
live.playRetryCount += 1
try {
await refreshPlayURL()
} catch (e) {
if (live.playRetryCount >= PLAY_RETRY_MAX) {
ui.toast(e?.message || err?.message || '播放失败')
live.playRetryCount = 0
} else {
await onPlayError(e)
}
}
}
async function closeLive(sendRequest = true) {
const session = live.session
const dockId = live.dockId
resetLocal()
if (sendRequest && session && dockId) {
try {
await leaveLive(dockId, session.id)
} catch (_) {}
}
}
async function stopLiveStream() {
if (!live.session || !live.dockId) return
const dockId = live.dockId
const session = live.session
const ok = await ui.confirm(
'确认停止直播?',
'停止后所有正在观看的用户都会失去画面。',
['确认停止推流', '影响当前所有观看者']
)
if (!ok) return
try {
await stopLive(dockId)
resetLocal()
try {
await leaveLive(dockId, session.id)
} catch (_) {}
ui.toast('直播已停止')
} catch (e) {
ui.toast(e.message || '停止直播失败')
}
}
return {
live,
livePhaseText,
canFullscreenLive,
toggleLive,
fullscreenLive,
openLive,
closeLive,
stopLiveStream,
onPlayError,
}
}
- Step 2: 静态核对
导出含 stopLiveStream、onPlayError;phase 轮询用 getLiveSession;refreshPlayURL 自身 catch 不回调 onPlayError;closeLive 清三类 timer。
- Step 3: Commit
git add src/composables/useMonitorLive.js
git commit -m "feat: poll live phase and renew HLS play URLs"
Task 4: 详情面板停止按钮 + Monitor 接线
Files:
-
Modify:
src/components/MonitorDetailPanel.vue -
Modify:
src/views/MonitorView/MonitorView.vue -
Step 1: 详情面板标题行加按钮
实时画面标题操作区改为:
<div class="section-title">
<h3>实时画面</h3>
<div>
<span class="live-dot" :class="{ active: liveView.active }" />
<b>{{ liveView.phaseText }}</b>
<button
v-if="liveView.active"
class="text-button"
type="button"
title="停止直播推流"
@click="$emit('stop-live')"
>停止直播</button>
<button class="icon-button" type="button" title="全屏播放" :disabled="!liveView.canFullscreen" @click="$emit('fullscreen-live')">
<svg><use href="#i-maximize" /></svg>
</button>
</div>
</div>
defineEmits 增加 'stop-live':
const emit = defineEmits([
'close',
'open-device-detail',
'select-parent',
'toggle-live',
'fullscreen-live',
'live-error',
'stop-live',
'open-dock-status',
'open-mission-detail',
'update:controlExpanded',
'command',
])
- Step 2: MonitorView 解构与事件
const {
live,
livePhaseText,
canFullscreenLive,
toggleLive,
fullscreenLive,
closeLive,
stopLiveStream,
onPlayError,
} = useMonitorLive({
getDockId: () => selectedDock.value?.dockId,
ui,
getLivePlayer: () => detailPanelRef.value?.livePlayerRef,
})
面板事件:
@toggle-live="toggleLive"
@fullscreen-live="fullscreenLive"
@live-error="onPlayError"
@stop-live="stopLiveStream"
替换原来的 @live-error="ui.toast('视频播放失败')"。
- Step 3: Commit
git add src/components/MonitorDetailPanel.vue src/views/MonitorView/MonitorView.vue
git commit -m "feat: wire stop-live and play error rebuild"
Task 5: 构建验收
Files: 无新文件
- Step 1: 构建
npm run build
Expected: exit 0(chunk 变大警告可忽略)。
- Step 2: 静态字符串核对
rg -n "hls\\.js|getLiveSession|stopLive|stopLiveStream|onPlayError|playUrlExpiresAt|停止直播" \
src/components/LivePlayer.vue \
src/composables/useMonitorLive.js \
src/components/MonitorDetailPanel.vue \
src/views/MonitorView/MonitorView.vue \
src/api/live.js \
src/config/urls.js
Expected: 均有命中;LivePlayer.vue 无 :src="playUrl"。
- Step 3: 手工联调清单(有后端时)
- Chrome:监控选机巢 → 播放 → 数秒内出画
- 启动中:Network 可见 ~2.5s 一次
GET .../sessions/:id - 中心关闭:只
DELETE .../viewers/me,无/stop - 「停止直播」:确认后
POST .../stop,画面清空 - 可选:失效 playUrl → 自动重拉 ≤3 次
- Step 4: 无额外改动则结束;有修复则补 commit
Spec coverage
| Spec 项 | Task |
|---|---|
| hls.js + Safari 原生 | Task 1 + 2 |
| 2.5s phase 轮询 / 60s 超时 | Task 3 |
playUrl expiresAt 提前 30s 刷新 |
Task 3 |
| 播放错误重建 ≤3 | Task 3 + 4 |
| 退出观看 vs 停止直播 | Task 3 + 4 |
| Media 页不动 | 无对应 task(刻意) |
| 动态 import hls.js | Task 2 |
GetLiveSession 返回本体 |
Task 3 |
Self-review notes
- 无 TBD/占位
- API:
stopLive;composable:stopLiveStream— 贯穿 Task 3/4 refreshPlayURL失败抛错,由调用方onPlayError;二者不在 catch 里无界递归- Monitor
@live-error改接onPlayError,不再直接 toast - 无前端单测文件(仓库惯例)