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