Compare commits

...

7 Commits

Author SHA1 Message Date
xiaosi a00b3643dc docs: align onPlayError policy comment with branches 2 weeks ago
xiaosi 3f6e31fa4f docs: clarify live onPlayError stabilize policy 2 weeks ago
xiaosi 0ca23c8a45 chore: align LivePlayer Hls config comment with retries 2 weeks ago
xiaosi 42fd09ee74 fix: guard LivePlayer media recover to one attempt 2 weeks ago
xiaosi 5eda25a2fb fix: stabilize LivePlayer for normal HLS live 2 weeks ago
xiaosi f57d630188 docs: add live playback stabilize plan 2 weeks ago
xiaosi 34061892ef docs: add live playback stabilize design 2 weeks ago
  1. 214
      docs/superpowers/plans/2026-09-03-live-playback-stabilize.md
  2. 111
      docs/superpowers/specs/2026-09-03-live-playback-stabilize-design.md
  3. 34
      src/components/LivePlayer.vue
  4. 5
      src/composables/useMonitorLive.js

214
docs/superpowers/plans/2026-09-03-live-playback-stabilize.md

@ -0,0 +1,214 @@
# Live Playback Stabilize 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:** Reduce dock live stutter / artifacting / full-player reconnects by tuning HLS for normal (non-LL) live and narrowing play-url rebuilds.
**Architecture:** Keep responsibilities: `LivePlayer.vue` owns media attach/retry/recover; `useMonitorLive.js` owns session/lease/play-url. Soften hls.js for 6–12s segments, lengthen startup same-URL 404 window, attempt one media recover before bubbling fatals, leave TLS/403/57006 paths unchanged.
**Tech Stack:** Vue 3, hls.js, existing Monitor live composable
**Spec:** `docs/superpowers/specs/2026-09-03-live-playback-stabilize-design.md`
---
### Task 1: LivePlayer HLS config + startup window
**Files:**
- Modify: `src/components/LivePlayer.vue`
- [ ] **Step 1: Lengthen startup retry constants**
Near top of `<script setup>` replace:
```js
const STARTUP_RETRY_DELAYS_MS = [500, 1000, 2000, 4000]
const STARTUP_WINDOW_MS = 15_000
```
with:
```js
const STARTUP_RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 6000, 8000]
const STARTUP_WINDOW_MS = 30_000
```
- [ ] **Step 2: Switch Hls to normal live + wider load retries**
In `attachPlayer`, replace the `new Hls({...})` block with:
```js
// 现网为普通 6–12s HLS,不是 LL-HLS;放宽分片重试,启动 404 仍由同 URL 窗口控制
hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
manifestLoadingMaxRetry: 2,
manifestLoadingRetryDelay: 1000,
levelLoadingMaxRetry: 2,
levelLoadingRetryDelay: 1000,
fragLoadingMaxRetry: 4,
fragLoadingRetryDelay: 1000,
})
```
- [ ] **Step 3: One-shot media recover before bubbling other fatals**
Still inside the `hls.on(Hls.Events.ERROR, ...)` handler, **after** the startup-404 branch and **before** the final `emitFatal` for generic fatals, insert:
```js
// 播放中解码/媒体缓冲类 fatal:先尝试一次 recover,避免整段重建
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
try {
console.warn('[live-player] hls media fatal, recover once', data)
hls.recoverMediaError()
return
} catch (recoverErr) {
console.warn('[live-player] hls recover failed', recoverErr)
}
}
```
Keep existing branches for:
- `isSecurityFailure``SecurityError` fatal
- `httpCode === 403` → fatal
- startup `404` / `MANIFEST_LOAD_ERROR``scheduleSameUrlRetry` then fatal
Do **not** call `recoverMediaError` for network/manifest fatals already handled above.
- [ ] **Step 4: Static check**
Run:
```bash
rg -n "lowLatencyMode|STARTUP_WINDOW_MS|STARTUP_RETRY_DELAYS_MS|recoverMediaError|fragLoadingMaxRetry" src/components/LivePlayer.vue
```
Expected:
- `lowLatencyMode: false`
- `STARTUP_WINDOW_MS = 30_000`
- delays include `6000, 8000`
- `fragLoadingMaxRetry: 4`
- `recoverMediaError` present once
- [ ] **Step 5: Commit**
```bash
git add src/components/LivePlayer.vue
git commit -m "$(cat <<'EOF'
fix: stabilize LivePlayer for normal HLS live
Disable lowLatencyMode, widen fragment retries, extend startup 404
window to 30s, and recover media fatals once before bubbling.
EOF
)"
```
---
### Task 2: Tighten `onPlayError` comments / branch clarity
**Files:**
- Modify: `src/composables/useMonitorLive.js`
Behavior should already mostly match the spec. This task only adjusts comments / ordering if needed so the policy is explicit; **do not** change heartbeat math, phase poll, stop gate, or 57006 rejoin.
- [ ] **Step 1: Read current `onPlayError`**
Confirm current order is effectively:
1. ignore `AbortError` / `NotAllowedError`
2. ignore `recoverable === true`
3. `57006``rejoinAfterLeaseInvalid`
4. TLS/403 → `markPlayFatal` (no play-url refresh)
5. `HlsError` (startup exhausted etc.) → `markPlayFatal`
6. else limited `refreshPlayURL({ fromRetry: true })` up to `PLAY_RETRY_MAX`
- [ ] **Step 2: If comments are misleading, rewrite the header comment only**
Replace / add above `onPlayError`:
```js
// 播放错误策略(稳播):
// - 启动 404 由 LivePlayer 同 URL 窗口消化,recoverable 不上冒
// - TLS/403 致命,禁止刷 play-url
// - 其它 LivePlayer fatal:有限次 refreshPlayURL;57006 整段 rejoin
```
Only change control flow if Step 1 finds a real divergence from the spec (e.g. TLS path accidentally refreshing). Prefer minimal diff.
- [ ] **Step 3: Commit (skip if no file change)**
```bash
git add src/composables/useMonitorLive.js
git commit -m "docs: clarify live onPlayError stabilize policy"
```
If unchanged:
```bash
git status -sb
```
Expected: clean for that file / nothing to commit.
---
### Task 3: Build + smoke notes + deploy
**Files:**
- None required (verification)
- [ ] **Step 1: Production build**
```bash
npm run build
```
Expected: success (chunk size warnings OK).
- [ ] **Step 2: Manual / API smoke checklist (dock-1)**
Against deployed or local-proxied env:
1. Open live on `dock-1` → may show「正在等待直播流就绪」briefly → picture appears (no instant fail on first 404).
2. Keep playing ~1–2 minutes → no frequent full black-flash reconnect loops.
3. Stop dialog → Cancel still closes only (no `/stop`).
4. Optional: confirm console no longer configures `lowLatencyMode: true`.
If browser automation blocked, API-level checks from the design evidence section remain acceptable for start/play-url; UI reconnect feel still needs a human glance when possible.
- [ ] **Step 3: Deploy (same pipeline as recent frontend ships)**
```bash
git push origin main
tar -C dist -czf - . | ssh -o BatchMode=yes jg-serv1 'rm -rf /usr/share/nginx/laic-frontend/dist/* && tar -C /usr/share/nginx/laic-frontend/dist -xzf - && cat /usr/share/nginx/laic-frontend/dist/index.html'
```
- [ ] **Step 4: Final commit for plan doc if not already committed with earlier tasks**
```bash
git add docs/superpowers/plans/2026-09-03-live-playback-stabilize.md
git commit -m "docs: add live playback stabilize plan"
```
(If this plan is committed before execution, do this step first; then execute Tasks 1–3.)
---
## Spec coverage
| Spec item | Task |
|-----------|------|
| `lowLatencyMode: false` + wider load retries | Task 1 |
| Startup delays + 30s window | Task 1 |
| `recoverMediaError` once for media fatals | Task 1 |
| TLS/403/57006 / recoverable unchanged | Task 1–2 |
| `onPlayError` limited refreshPlayURL | Task 2 (verify) |
| No http→https rewrite / no backend | all (non-goals) |
| build + dock-1 acceptance | Task 3 |
## Out of scope reminders
- Backend lease `57006` root cause
- `vpull` HTTPS certificate
- Forcing https playUrl

111
docs/superpowers/specs/2026-09-03-live-playback-stabilize-design.md

@ -0,0 +1,111 @@
# 监控直播播放稳定(卡顿 / 花屏 / 重连)
日期:2026-09-03
状态:已确认(待写实现计划)
范围:仅前端 `laic-frontend`
## 1. 背景与证据
`dock-1` 现网可开播、可出 HLS 分片,但观感为卡顿 / 花屏 / 经常重连。API + 拉流实测要点:
1. join 后约 8–12s 到 `phase=streaming`,`deviceStreaming=true`,`cloudOnline=true`。
2. `GET /play-url` 现网返回 **`http://vpull.jiagutech.com/...m3u8`**(本地后端源码期望 `https://`)。
3. `https://vpull.jiagutech.com` TLS handshake failure(无 peer certificate);站点本身也是 HTTP。
4. 刚进入 streaming 时 m3u8 常先 **404**,数秒后才有清单;首份清单常见 `#EXT-X-DISCONTINUITY`
5. 分片时长约 `5.9s–12s`,`TARGETDURATION` 9–12 —— 普通 HLS,不是低延迟流。
6. 前端 `LivePlayer` 当前:`lowLatencyMode: true`,`fragLoadingMaxRetry: 1`,启动 404 窗口约 15s。
7. join 后立刻 heartbeat 偶发 `57006`(租约不存在或已过期),前端会 `rejoinAfterLeaseInvalid` → 整段重开。
用户确认:能出画面,但卡顿 / 花屏 / 经常重连。本轮只改前端。
## 2. 目标
1. **启动更稳**:streaming 后短时 404 不直接失败;允许「等待流就绪」后出画。
2. **播放中少重建**:普通分片抖动 / 短暂网络错尽量在播放器内恢复,避免动辄 `refreshPlayURL` → 整实例重建。
3. **保留明确致命路径**:TLS / CORS / 403 仍明确失败,不瞎刷 play-url。
## 3. 非目标
- 不改后端租约实现、stop grace、阿里云证书 / 播流域名。
- 不把 `http://` playUrl 强改 `https://`(现网 https 不可用,改写会更糟)。
- 不改 Media 页、多路同屏、WebRTC / FLV。
- 不改停止确认框、join busy 重试等已合并行为(仅回归验证)。
## 4. 方案(稳播优先)
职责不变:**LivePlayer 管媒体;`useMonitorLive` 管会话 / 租约 / play-url 续期。**
### 4.1 `LivePlayer.vue`
1. `new Hls` 配置改为普通直播友好:
- `lowLatencyMode: false`
- `manifestLoadingMaxRetry: 2`
- `levelLoadingMaxRetry: 2`
- `fragLoadingMaxRetry: 4`
- 为上述重试设置合理 delay(与 hls.js 默认同量级即可,避免 0 间隔打爆 CDN)
2. 启动 404 / manifest 未就绪窗口加长:
- 现:`STARTUP_RETRY_DELAYS_MS = [500,1000,2000,4000]`,`STARTUP_WINDOW_MS = 15_000`
- 改:`[500,1000,2000,4000,6000,8000]`,`STARTUP_WINDOW_MS = 30_000`
- 行为不变:窗口内同 URL 退避重建,不请求 `/play-url`
3. 错误分级:
- TLS / CORS / certificate / mixed content → `SecurityError` fatal(已有)
- HTTP 403 → fatal(已有)
- 启动窗口内 404 / `MANIFEST_LOAD_ERROR` → 同 URL 重试;耗尽再 fatal
- 其它 fatal:若 hls 提供 `recoverMediaError` 且错误类型适合,先尝试一次自愈;仍失败再 `emit('error')`
4. `AbortError` / `NotAllowedError` / 用户手势恢复路径保持不变。
### 4.2 `useMonitorLive.js`
1. `onPlayError` 收敛:
- `AbortError` / `NotAllowedError`:忽略(已有)
- `recoverable === true`:忽略(已有)
- `57006`:仍 `rejoinAfterLeaseInvalid()`
- TLS / 403:`markPlayFatal`,不 `refreshPlayURL`(已有)
- 启动耗尽后的 `HlsError`(含持续 404):`markPlayFatal`(已有语义)
- 其余非致命媒体错:仅在 LivePlayer 明确 fatal 且非 TLS/403 时,有限次 `refreshPlayURL`(保留 `PLAY_RETRY_MAX = 3`,`fromRetry: true` 不重置计数)
2. **不改**本轮:
- 心跳调度公式(仍约 `lease/2`
- phase 轮询间隔 / 超时
- stop / leave / join busy 重试
### 4.3 文件
| 文件 | 变更 |
|------|------|
| `src/components/LivePlayer.vue` | HLS 配置、启动窗口、有限自愈 |
| `src/composables/useMonitorLive.js` | `onPlayError` 注释与分支收紧(行为按上表) |
| 本 spec | 设计文档 |
## 5. 错误处理(汇总)
| 情况 | 处理 |
|------|------|
| 启动期 m3u8 404 | LivePlayer 同 URL 退避;不刷 play-url |
| 播放中 frag 短暂失败 | hls 重试 / recover;不立刻 rebuild |
| playUrl 临期 | 仍按 `expiresAt - 30s` 续期(已有) |
| 57006 | leave 本地态后重新 join |
| TLS / 403 | toast 明确原因;停止刷 URL |
| 启动窗口耗尽仍失败 | toast 播放失败;保留会话心跳与否沿用现逻辑(`markPlayFatal` 清 playUrl,不强制 leave) |
## 6. 验收
`dock-1`(或同等可推流机巢):
1. 打开直播:可出现短暂「正在等待直播流就绪」,最终出画;不因首包 404 直接失败。
2. 连续播放约 1–2 分钟:无明显「整段黑屏闪断式」频繁重建。
3. 停止弹窗点取消:只关弹窗,不 `POST /stop`(回归)。
4. 人为 TLS/403 类错误(若可模拟)仍提示致命信息,不循环刷 play-url。
5. `npm run build` 通过。
## 7. 风险与后续
- **证书 / http playUrl**:站点若日后上 HTTPS,现网 http 拉流会 Mixed Content;需后端改回 https 并修复 `vpull` 证书。不在本轮。
- **57006 根因**:属后端租约时序;前端 rejoin 是兜底,可能仍偶发一次重开。
- **上行抖动**:机巢 / SRT 断续导致的 DISCONTINUITY 无法单靠前端消除,只能降低播放器过激反应。
## 8. 实现顺序
1. 改 `LivePlayer` HLS 配置 + 启动窗口
2. 收紧 `onPlayError` 注释 / 分支(若代码已基本符合则只补缺口)
3. build + 现网 dock-1 冒烟
4. commit / 部署

34
src/components/LivePlayer.vue

@ -44,8 +44,8 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
const STARTUP_RETRY_DELAYS_MS = [500, 1000, 2000, 4000]
const STARTUP_WINDOW_MS = 15_000
const STARTUP_RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 6000, 8000]
const STARTUP_WINDOW_MS = 30_000
const props = defineProps({ const props = defineProps({
playUrl: { type: String, default: '' }, playUrl: { type: String, default: '' },
@ -70,6 +70,7 @@ let startupRetryTimer = null
let startupRetryCount = 0 let startupRetryCount = 0
let startupStartedAt = 0 let startupStartedAt = 0
let activeUrl = '' let activeUrl = ''
let mediaRecoverAttempted = false
const playable = computed(() => props.playUrl && !props.playUrl.startsWith('fake://')) const playable = computed(() => props.playUrl && !props.playUrl.startsWith('fake://'))
const isLiveShell = computed(() => props.active && !playable.value) const isLiveShell = computed(() => props.active && !playable.value)
@ -128,6 +129,7 @@ function clearMediaListeners() {
function destroyPlayer() { function destroyPlayer() {
clearMediaListeners() clearMediaListeners()
mediaRecoverAttempted = false
needsUserPlay.value = false needsUserPlay.value = false
if (hls) { if (hls) {
hls.destroy() hls.destroy()
@ -259,6 +261,7 @@ async function attachPlayer(url, { seq: existingSeq } = {}) {
startupRetryCount = 0 startupRetryCount = 0
startupStartedAt = Date.now() startupStartedAt = Date.now()
waitingForStream.value = false waitingForStream.value = false
mediaRecoverAttempted = false
} }
activeUrl = url || '' activeUrl = url || ''
@ -318,13 +321,16 @@ async function attachPlayer(url, { seq: existingSeq } = {}) {
return return
} }
// 退 404
// 612s HLS LL-HLS 404 URL
hls = new Hls({ hls = new Hls({
enableWorker: true, enableWorker: true,
lowLatencyMode: true,
manifestLoadingMaxRetry: 0,
levelLoadingMaxRetry: 0,
fragLoadingMaxRetry: 1,
lowLatencyMode: false,
manifestLoadingMaxRetry: 2,
manifestLoadingRetryDelay: 1000,
levelLoadingMaxRetry: 2,
levelLoadingRetryDelay: 1000,
fragLoadingMaxRetry: 4,
fragLoadingRetryDelay: 1000,
}) })
hls.on(Hls.Events.ERROR, (_event, data) => { hls.on(Hls.Events.ERROR, (_event, data) => {
if (seq !== attachSeq) return if (seq !== attachSeq) return
@ -367,6 +373,20 @@ async function attachPlayer(url, { seq: existingSeq } = {}) {
return return
} }
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
if (!mediaRecoverAttempted) {
mediaRecoverAttempted = true
try {
console.warn('[live-player] hls media fatal, recover once', data)
hls.recoverMediaError()
return
} catch (recoverErr) {
console.warn('[live-player] hls recover failed', recoverErr)
}
}
// already attempted or recover threw fall through to emitFatal
}
console.warn('[live-player] hls fatal', data) console.warn('[live-player] hls fatal', data)
emitFatal(buildPlayError({ emitFatal(buildPlayError({
message: detail || 'hls fatal', message: detail || 'hls fatal',

5
src/composables/useMonitorLive.js

@ -349,6 +349,11 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
} }
// 播放错误策略(稳播):
// - 启动 404 由 LivePlayer 同 URL 窗口消化,recoverable 不上冒
// - 57006 → 整段 rejoin;TLS/403 → 致命且禁止刷 play-url
// - LivePlayer HlsError(含窗口耗尽)→ markPlayFatal;其余错误有限次 refreshPlayURL
async function onPlayError(err) { async function onPlayError(err) {
if (disposed || live.stopping || !live.session) return if (disposed || live.stopping || !live.session) return

Loading…
Cancel
Save