Browse Source
Cover hls.js player, phase polling, playUrl renewal, error rebuild, and stop-vs-leave wiring tasks.main
1 changed files with 716 additions and 0 deletions
@ -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…
Reference in new issue