You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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 useUiStoreuseMonitorLive / 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: 静态核对
  1. :src="playUrl"
  2. import('hls.js')
  3. onBeforeUnmount destroy
  4. 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: 静态核对

导出含 stopLiveStreamonPlayError;phase 轮询用 getLiveSessionrefreshPlayURL 自身 catch 不回调 onPlayErrorcloseLive 清三类 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: 手工联调清单(有后端时)
  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
  • 无前端单测文件(仓库惯例)