Browse Source

docs: add monitor HLS playback implementation plan

Cover hls.js player, phase polling, playUrl renewal,
error rebuild, and stop-vs-leave wiring tasks.
main
xiaosi 2 weeks ago
parent
commit
a9301c4f82
  1. 716
      docs/superpowers/plans/2026-09-02-monitor-hls-playback.md

716
docs/superpowers/plans/2026-09-02-monitor-hls-playback.md

@ -0,0 +1,716 @@
# 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: 安装依赖**
```bash
npm install hls.js
```
确认 `package.json` `dependencies` 出现 `hls.js`
- [ ] **Step 2: URL 常量**
`src/config/urls.js` 现有 live 段落后追加:
```js
export const LIVE_SESSION = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}`
export const LIVE_STOP = (dockId) => `/v1/live/${dockId}/stop`
```
保留现有:
```js
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` 最终应为:
```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**
```bash
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 绑定)**
完整文件:
```vue
<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: 静态核对**
1. 无 `:src="playUrl"`
2. 有 `import('hls.js')`
3. `onBeforeUnmount` destroy
4. fatal / video error → `emit('error')`
- [ ] **Step 3: Commit**
```bash
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: 整文件替换**
```js
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**
```bash
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: 详情面板标题行加按钮**
实时画面标题操作区改为:
```vue
<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'`
```js
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 解构与事件**
```js
const {
live,
livePhaseText,
canFullscreenLive,
toggleLive,
fullscreenLive,
closeLive,
stopLiveStream,
onPlayError,
} = useMonitorLive({
getDockId: () => selectedDock.value?.dockId,
ui,
getLivePlayer: () => detailPanelRef.value?.livePlayerRef,
})
```
面板事件:
```vue
@toggle-live="toggleLive"
@fullscreen-live="fullscreenLive"
@live-error="onPlayError"
@stop-live="stopLiveStream"
```
替换原来的 `@live-error="ui.toast('视频播放失败')"`
- [ ] **Step 3: Commit**
```bash
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: 构建**
```bash
npm run build
```
Expected: exit 0(chunk 变大警告可忽略)。
- [ ] **Step 2: 静态字符串核对**
```bash
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: 手工联调清单(有后端时)**
1. Chrome:监控选机巢 → 播放 → 数秒内出画
2. 启动中:Network 可见 ~2.5s 一次 `GET .../sessions/:id`
3. 中心关闭:只 `DELETE .../viewers/me`,无 `/stop`
4. 「停止直播」:确认后 `POST .../stop`,画面清空
5. 可选:失效 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
- 无前端单测文件(仓库惯例)
Loading…
Cancel
Save