Browse Source

feat: gate live reopen until session stopped

Keep the old streamSessionId after stop, poll until phase=stopped before
allowing join again, disable controls while stopping, and retry join on
57007 for Monitor and MediaView.
main
xiaosi 2 weeks ago
parent
commit
d58ca74bdd
  1. 235
      docs/superpowers/plans/2026-09-02-live-stop-phase-gate.md
  2. 32
      src/api/live.js
  3. 11
      src/components/LivePlayer.vue
  4. 5
      src/components/MonitorDetailPanel.vue
  5. 122
      src/composables/useMonitorLive.js
  6. 8
      src/views/MediaView/MediaView.vue
  7. 7
      src/views/MonitorView/MonitorView.vue

235
docs/superpowers/plans/2026-09-02-live-stop-phase-gate.md

@ -0,0 +1,235 @@
# Live Stop Phase Gate 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:** After stop, keep the old `streamSessionId`, poll until `phase=stopped`, disable reopen until then, and retry join on business code `57007`.
**Architecture:** Add a stopping gate inside `useMonitorLive`: stop clears playback/heartbeat but retains dock+session for stop-phase polling; reopen is blocked until stopped. Shared `joinLiveWithRetry` handles `57007` for Monitor and MediaView. UI gets a `stopping` flag to disable play/stop controls.
**Tech Stack:** Vue 3, Pinia-free composable, axios `/api` live endpoints
**Spec:** `docs/superpowers/specs/2026-09-02-live-stop-phase-gate-design.md`
---
### Task 1: Shared join retry helper
**Files:**
- Modify: `src/api/live.js`
- [ ] **Step 1: Add constants + `joinLiveWithRetry`**
```js
const JOIN_BUSY_CODE = 57007
const JOIN_BUSY_RETRY_DELAY_MS = 1500
const JOIN_BUSY_MAX_ATTEMPTS = 4
function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
export function isJoinBusyError(err) {
return Number(err?.code) === JOIN_BUSY_CODE
}
/** join with short backoff when previous session is still stopping (57007) */
export async function joinLiveWithRetry(dockId, payload = {}, { shouldContinue } = {}) {
let lastError
for (let attempt = 1; attempt <= JOIN_BUSY_MAX_ATTEMPTS; attempt += 1) {
if (shouldContinue && !shouldContinue()) {
const err = new Error('join aborted')
err.code = 'ABORTED'
throw err
}
try {
return await joinLive(dockId, payload)
} catch (e) {
lastError = e
if (!isJoinBusyError(e) || attempt >= JOIN_BUSY_MAX_ATTEMPTS) throw e
await sleep(JOIN_BUSY_RETRY_DELAY_MS)
}
}
throw lastError
}
```
- [ ] **Step 2: Commit**
```bash
git add src/api/live.js
git commit -m "feat: retry live join on 57007"
```
---
### Task 2: Stopping gate in `useMonitorLive`
**Files:**
- Modify: `src/composables/useMonitorLive.js`
- [ ] **Step 1: Extend live reactive state**
Add:
- `live.stopping = false`
- constants: `STOP_POLL_MS = 1500`, `STOP_TIMEOUT_MS = 60_000`
- import/use `joinLiveWithRetry`, `isJoinBusyError`
In `resetLocal()`, also set `live.stopping = false`.
- [ ] **Step 2: Update phase text / guards**
```js
const livePhaseText = computed(() => {
if (live.stopping) return '正在停止'
if (live.opening && !live.session) return '正在启动'
if (!live.session) return '待机'
// existing phase mapping...
})
function toggleLive() {
if (disposed || live.opening || live.stopping) return
if (live.session) closeLive()
else openLive()
}
```
`openLive` must reject when `live.stopping` or active session exists. Replace `joinLive` with `joinLiveWithRetry(..., { shouldContinue: () => isCurrentOpen(gen) })`. Ignore aborted join errors silently.
- [ ] **Step 3: Rewrite `stopLiveStream` to poll until stopped**
Behavior:
1. confirm
2. capture `dockId` + `session`
3. `live.stopping = true`
4. clear playUrl + heartbeat/play timers (keep session/dockId)
5. `await stopLive(dockId)` then `safeLeave`
6. poll `getLiveSession(dockId, session.id)` every 1.5s
7. on `phase === 'stopped'`: `openGen++`, `resetLocal()`, toast `直播已停止`
8. on timeout/error: toast, keep `stopping=true` until dispose/dock leave; do not allow silent reopen
9. while stopping, stop button/open are disabled via guards
Helper sketch:
```js
function clearPlaybackOnly() {
window.clearTimeout(live.heartbeatTimer)
window.clearTimeout(live.playRefreshTimer)
live.heartbeatTimer = null
live.playRefreshTimer = null
playURLRequest = null
live.playUrl = ''
live.playUrlExpiresAt = 0
live.playRetryCount = 0
}
async function waitUntilStopped(dockId, sessionId, gen) {
const startedAt = Date.now()
while (isCurrentOpen(gen) && live.stopping) {
if (Date.now() - startedAt > STOP_TIMEOUT_MS) {
throw new Error('停止超时,请稍后重试')
}
const session = await getLiveSession(dockId, sessionId)
if (!isCurrentOpen(gen)) return false
live.session = session
if (session.phase === 'stopped') return true
await new Promise((r) => window.setTimeout(r, STOP_POLL_MS))
}
return false
}
```
- [ ] **Step 4: Dispose/close clear stopping gate**
`closeLive` / `dispose` must clear stopping and invalidate generation as today.
- [ ] **Step 5: Smoke-check via node syntax + commit**
```bash
node --check src/composables/useMonitorLive.js
git add src/composables/useMonitorLive.js
git commit -m "feat: gate reopen until live session stopped"
```
---
### Task 3: Wire UI disable flags
**Files:**
- Modify: `src/views/MonitorView/MonitorView.vue`
- Modify: `src/components/MonitorDetailPanel.vue`
- Modify: `src/components/LivePlayer.vue`
- [ ] **Step 1: Pass `stopping` in `detailLiveView`**
```js
const detailLiveView = computed(() => ({
playUrl: live.playUrl,
phase: live.session?.phase,
active: !!live.session && !live.stopping,
opening: !!live.opening,
stopping: !!live.stopping,
phaseText: livePhaseText.value,
canFullscreen: canFullscreenLive.value,
cameraLabel: isDrone.value ? '无人机图传' : '机巢摄像头',
}))
```
- [ ] **Step 2: Detail panel stop button**
Show stop only when `liveView.active && !liveView.stopping`; disable when stopping.
- [ ] **Step 3: LivePlayer disable while stopping**
Add prop `stopping: Boolean`, disable control when `opening || stopping`, show status `正在停止`.
- [ ] **Step 4: Commit**
```bash
git add src/views/MonitorView/MonitorView.vue src/components/MonitorDetailPanel.vue src/components/LivePlayer.vue
git commit -m "feat: disable live controls while stopping"
```
---
### Task 4: MediaView `57007` retry
**Files:**
- Modify: `src/views/MediaView/MediaView.vue`
- [ ] **Step 1: Use `joinLiveWithRetry` in `openLive`**
Import `joinLiveWithRetry` and call it with `shouldContinue: () => seq === openSeq && !disposed`.
- [ ] **Step 2: Commit**
```bash
git add src/views/MediaView/MediaView.vue
git commit -m "feat: media join retries when live still stopping"
```
---
### Task 5: Build, push, deploy
- [ ] **Step 1: Build**
```bash
npm run build
```
- [ ] **Step 2: Push + deploy dist**
```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 3: Verify acceptance mentally against spec checklist**
1. stop disables play/stop, shows 正在停止
2. polls old session until stopped
3. reopen blocked until stopped
4. new session id used after join
5. 57007 retries
6. viewer leave still immediate
7. MediaView join retries 57007

32
src/api/live.js

@ -1,9 +1,41 @@
import request from '@/utils/http' import request from '@/utils/http'
const JOIN_BUSY_CODE = 57007
const JOIN_BUSY_RETRY_DELAY_MS = 1500
const JOIN_BUSY_MAX_ATTEMPTS = 4
function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
export function isJoinBusyError(err) {
return Number(err?.code) === JOIN_BUSY_CODE
}
export function joinLive(dockId, payload = {}) { export function joinLive(dockId, payload = {}) {
return request.post(`/v1/live/${dockId}/sessions`, payload) return request.post(`/v1/live/${dockId}/sessions`, payload)
} }
/** join with short backoff when previous session is still stopping (57007) */
export async function joinLiveWithRetry(dockId, payload = {}, { shouldContinue } = {}) {
let lastError
for (let attempt = 1; attempt <= JOIN_BUSY_MAX_ATTEMPTS; attempt += 1) {
if (shouldContinue && !shouldContinue()) {
const err = new Error('join aborted')
err.code = 'ABORTED'
throw err
}
try {
return await joinLive(dockId, payload)
} catch (e) {
lastError = e
if (!isJoinBusyError(e) || attempt >= JOIN_BUSY_MAX_ATTEMPTS) throw e
await sleep(JOIN_BUSY_RETRY_DELAY_MS)
}
}
throw lastError
}
export function getLiveSession(dockId, sessionId) { export function getLiveSession(dockId, sessionId) {
return request.get(`/v1/live/${dockId}/sessions/${sessionId}`) return request.get(`/v1/live/${dockId}/sessions/${sessionId}`)
} }

11
src/components/LivePlayer.vue

@ -32,8 +32,8 @@
class="play-control" class="play-control"
type="button" type="button"
:aria-label="controlLabel" :aria-label="controlLabel"
:disabled="opening"
:aria-busy="opening ? 'true' : 'false'"
:disabled="opening || stopping"
:aria-busy="opening || stopping ? 'true' : 'false'"
@click="onControlClick" @click="onControlClick"
> >
<svg><use :href="controlIcon" /></svg> <svg><use :href="controlIcon" /></svg>
@ -52,6 +52,7 @@ const props = defineProps({
phase: { type: String, default: '' }, phase: { type: String, default: '' },
active: { type: Boolean, default: false }, active: { type: Boolean, default: false },
opening: { type: Boolean, default: false }, opening: { type: Boolean, default: false },
stopping: { type: Boolean, default: false },
cameraLabel: { type: String, default: '机巢摄像头' }, cameraLabel: { type: String, default: '机巢摄像头' },
}) })
@ -75,17 +76,20 @@ const isLiveShell = computed(() => props.active && !playable.value)
const qualityText = computed(() => { const qualityText = computed(() => {
if (props.opening) return '打开中' if (props.opening) return '打开中'
if (props.stopping) return '正在停止'
if (waitingForStream.value) return '启动中 · 等待流就绪' if (waitingForStream.value) return '启动中 · 等待流就绪'
if (needsUserPlay.value) return '已暂停 · 点击继续' if (needsUserPlay.value) return '已暂停 · 点击继续'
if (playable.value) return 'LIVE · 播放中' if (playable.value) return 'LIVE · 播放中'
if (props.playUrl?.startsWith('fake://')) return '控制面已验证' if (props.playUrl?.startsWith('fake://')) return '控制面已验证'
if (props.phase === 'starting' || props.phase === 'reconnecting') return '启动中' if (props.phase === 'starting' || props.phase === 'reconnecting') return '启动中'
if (props.phase === 'stopping') return '正在停止'
if (props.phase === 'failed') return '启动失败' if (props.phase === 'failed') return '启动失败'
return 'H.264 · 720P' return 'H.264 · 720P'
}) })
const statusMessage = computed(() => { const statusMessage = computed(() => {
if (props.opening) return '正在打开直播' if (props.opening) return '正在打开直播'
if (props.stopping || props.phase === 'stopping') return '正在停止直播'
if (waitingForStream.value) return '正在等待直播流就绪' if (waitingForStream.value) return '正在等待直播流就绪'
if (needsUserPlay.value) return '浏览器暂停了画面,点击继续播放' if (needsUserPlay.value) return '浏览器暂停了画面,点击继续播放'
if (playable.value) return '' if (playable.value) return ''
@ -98,6 +102,7 @@ const statusMessage = computed(() => {
const controlLabel = computed(() => { const controlLabel = computed(() => {
if (props.opening) return '正在打开直播' if (props.opening) return '正在打开直播'
if (props.stopping) return '正在停止直播'
if (needsUserPlay.value) return '继续播放' if (needsUserPlay.value) return '继续播放'
return props.active ? '关闭实时画面' : '打开实时画面' return props.active ? '关闭实时画面' : '打开实时画面'
}) })
@ -238,7 +243,7 @@ async function resumeFromUserGesture() {
} }
function onControlClick() { function onControlClick() {
if (props.opening) return
if (props.opening || props.stopping) return
if (needsUserPlay.value) { if (needsUserPlay.value) {
resumeFromUserGesture() resumeFromUserGesture()
return return

5
src/components/MonitorDetailPanel.vue

@ -27,10 +27,11 @@
<span class="live-dot" :class="{ active: liveView.active }" /> <span class="live-dot" :class="{ active: liveView.active }" />
<b>{{ liveView.phaseText }}</b> <b>{{ liveView.phaseText }}</b>
<button <button
v-if="liveView.active"
v-if="liveView.active && !liveView.stopping"
class="text-button" class="text-button"
type="button" type="button"
title="停止直播推流" title="停止直播推流"
:disabled="!!liveView.stopping || !!liveView.opening"
@click="$emit('stop-live')" @click="$emit('stop-live')"
>停止直播</button> >停止直播</button>
<button class="icon-button" type="button" title="全屏播放" :disabled="!liveView.canFullscreen" @click="$emit('fullscreen-live')"> <button class="icon-button" type="button" title="全屏播放" :disabled="!liveView.canFullscreen" @click="$emit('fullscreen-live')">
@ -44,6 +45,7 @@
:phase="liveView.phase" :phase="liveView.phase"
:active="liveView.active" :active="liveView.active"
:opening="liveView.opening" :opening="liveView.opening"
:stopping="!!liveView.stopping"
:camera-label="liveView.cameraLabel" :camera-label="liveView.cameraLabel"
@toggle="$emit('toggle-live')" @toggle="$emit('toggle-live')"
@error="$emit('live-error', $event)" @error="$emit('live-error', $event)"
@ -237,6 +239,7 @@ const props = defineProps({
phase: '', phase: '',
active: false, active: false,
opening: false, opening: false,
stopping: false,
phaseText: '待机', phaseText: '待机',
canFullscreen: false, canFullscreen: false,
cameraLabel: '机巢摄像头', cameraLabel: '机巢摄像头',

122
src/composables/useMonitorLive.js

@ -3,7 +3,7 @@ import {
getLivePlayURL, getLivePlayURL,
getLiveSession, getLiveSession,
heartbeatLive, heartbeatLive,
joinLive,
joinLiveWithRetry,
leaveLive, leaveLive,
leaveLiveKeepalive, leaveLiveKeepalive,
stopLive, stopLive,
@ -11,10 +11,13 @@ import {
const PHASE_POLL_MS = 2500 const PHASE_POLL_MS = 2500
const PHASE_TIMEOUT_MS = 60_000 const PHASE_TIMEOUT_MS = 60_000
const STOP_POLL_MS = 1500
const STOP_TIMEOUT_MS = 60_000
const PLAY_RETRY_MAX = 3 const PLAY_RETRY_MAX = 3
const PLAY_EXPIRE_FALLBACK_SEC = 270 const PLAY_EXPIRE_FALLBACK_SEC = 270
const PLAY_REFRESH_LEAD_MS = 30_000 const PLAY_REFRESH_LEAD_MS = 30_000
const LEASE_INVALID_CODE = 57006 const LEASE_INVALID_CODE = 57006
function isFatalPlayMediaError(err) { function isFatalPlayMediaError(err) {
const msg = String(err?.message || err || '').toLowerCase() const msg = String(err?.message || err || '').toLowerCase()
const name = String(err?.name || '') const name = String(err?.name || '')
@ -31,6 +34,7 @@ function isFatalPlayMediaError(err) {
msg.includes('certificate') msg.includes('certificate')
) )
} }
function isRecoverablePlayError(err) { function isRecoverablePlayError(err) {
// LivePlayer 启动窗口内的 404 会自行退避,不冒泡;仅忽略显式 recoverable,避免吞掉窗口耗尽后的 404 // LivePlayer 启动窗口内的 404 会自行退避,不冒泡;仅忽略显式 recoverable,避免吞掉窗口耗尽后的 404
return err?.recoverable === true return err?.recoverable === true
@ -40,16 +44,25 @@ function isLeaseInvalidError(err) {
return Number(err?.code) === LEASE_INVALID_CODE return Number(err?.code) === LEASE_INVALID_CODE
} }
function isJoinAbortedError(err) {
return err?.code === 'ABORTED'
}
function safeLeave(dockId, sessionId) { function safeLeave(dockId, sessionId) {
if (!dockId || !sessionId) return Promise.resolve() if (!dockId || !sessionId) return Promise.resolve()
return leaveLive(dockId, sessionId).catch(() => {}) return leaveLive(dockId, sessionId).catch(() => {})
} }
function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
export function useMonitorLive({ getDockId, ui, getLivePlayer }) { export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
const live = reactive({ const live = reactive({
session: null, session: null,
dockId: '', dockId: '',
opening: false, opening: false,
stopping: false,
playUrl: '', playUrl: '',
playUrlExpiresAt: 0, playUrlExpiresAt: 0,
heartbeatTimer: null, heartbeatTimer: null,
@ -65,14 +78,16 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
let rejoinBusy = false let rejoinBusy = false
const livePhaseText = computed(() => { const livePhaseText = computed(() => {
if (live.opening && !live.session) return '打开中'
if (live.stopping) return '正在停止'
if (live.opening && !live.session) return '正在启动'
if (!live.session) return '待机' if (!live.session) return '待机'
const phase = live.session.phase const phase = live.session.phase
if (phase === 'streaming') return live.playUrl.startsWith('fake://') ? '模拟直播' : '直播中' if (phase === 'streaming') return live.playUrl.startsWith('fake://') ? '模拟直播' : '直播中'
if (phase === 'starting') return '启动中' if (phase === 'starting') return '启动中'
if (phase === 'reconnecting') return '重连中' if (phase === 'reconnecting') return '重连中'
if (phase === 'stopping') return '停止'
if (phase === 'stopping') return '正在停止'
if (phase === 'failed') return '启动失败' if (phase === 'failed') return '启动失败'
if (phase === 'stopped') return '已停止'
return '已停止' return '已停止'
}) })
@ -87,12 +102,24 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
live.playRefreshTimer = null live.playRefreshTimer = null
} }
function clearPlaybackOnly() {
window.clearTimeout(live.heartbeatTimer)
window.clearTimeout(live.playRefreshTimer)
live.heartbeatTimer = null
live.playRefreshTimer = null
playURLRequest = null
live.playUrl = ''
live.playUrlExpiresAt = 0
live.playRetryCount = 0
}
function resetLocal() { function resetLocal() {
clearTimers() clearTimers()
playURLRequest = null playURLRequest = null
live.session = null live.session = null
live.dockId = '' live.dockId = ''
live.opening = false live.opening = false
live.stopping = false
live.playUrl = '' live.playUrl = ''
live.playUrlExpiresAt = 0 live.playUrlExpiresAt = 0
live.playRetryCount = 0 live.playRetryCount = 0
@ -113,7 +140,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
function toggleLive() { function toggleLive() {
if (disposed || live.opening) return
if (disposed || live.opening || live.stopping) return
if (live.session) closeLive() if (live.session) closeLive()
else openLive() else openLive()
} }
@ -128,14 +155,16 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
async function openLive() { async function openLive() {
if (disposed || live.opening || live.session) return
if (disposed || live.opening || live.stopping || live.session) return
const dockId = getDockId?.() const dockId = getDockId?.()
if (!dockId) return if (!dockId) return
const gen = ++openGen const gen = ++openGen
live.opening = true live.opening = true
try { try {
const result = await joinLive(dockId)
const result = await joinLiveWithRetry(dockId, {}, {
shouldContinue: () => isCurrentOpen(gen),
})
const session = result?.session const session = result?.session
const sessionId = session?.id const sessionId = session?.id
@ -150,6 +179,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
return return
} }
// 新 session 完全替换旧 id,后续 poll / play-url / heartbeat 都走新 id
live.session = session live.session = session
live.dockId = dockId live.dockId = dockId
live.playUrl = '' live.playUrl = ''
@ -167,7 +197,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
startPhasePolling() startPhasePolling()
} }
} catch (e) { } catch (e) {
if (!isCurrentOpen(gen)) return
if (!isCurrentOpen(gen) || isJoinAbortedError(e)) return
ui.toast(e.message || '打开直播失败') ui.toast(e.message || '打开直播失败')
} finally { } finally {
if (gen === openGen) live.opening = false if (gen === openGen) live.opening = false
@ -175,14 +205,14 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
function startPhasePolling() { function startPhasePolling() {
if (disposed) return
if (disposed || live.stopping) return
window.clearTimeout(live.phasePollTimer) window.clearTimeout(live.phasePollTimer)
live.phasePollStartedAt = Date.now() live.phasePollStartedAt = Date.now()
live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS) live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS)
} }
async function pollPhaseOnce() { async function pollPhaseOnce() {
if (disposed || !live.dockId || !live.session?.id) return
if (disposed || live.stopping || !live.dockId || !live.session?.id) return
if (Date.now() - live.phasePollStartedAt > PHASE_TIMEOUT_MS) { if (Date.now() - live.phasePollStartedAt > PHASE_TIMEOUT_MS) {
ui.toast('直播启动超时') ui.toast('直播启动超时')
await closeLive(false) await closeLive(false)
@ -193,7 +223,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
try { try {
// http 解包后是 LiveSession 本体,不是 { session } // http 解包后是 LiveSession 本体,不是 { session }
const session = await getLiveSession(dockId, sessionId) const session = await getLiveSession(dockId, sessionId)
if (disposed || !live.session || live.session.id !== sessionId) return
if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return
live.session = session live.session = session
if (session.phase === 'streaming') { if (session.phase === 'streaming') {
window.clearTimeout(live.phasePollTimer) window.clearTimeout(live.phasePollTimer)
@ -201,7 +231,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
try { try {
await refreshPlayURL() await refreshPlayURL()
} catch (e) { } catch (e) {
if (disposed || !live.session || live.session.id !== sessionId) return
if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return
await onPlayError(e) await onPlayError(e)
} }
return return
@ -212,7 +242,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
return return
} }
} catch (e) { } catch (e) {
if (disposed || !live.session || live.session.id !== sessionId) return
if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return
if (isLeaseInvalidError(e)) { if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid() await rejoinAfterLeaseInvalid()
return return
@ -221,18 +251,18 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
await closeLive(false) await closeLive(false)
return return
} }
if (disposed || !live.session || live.session.id !== sessionId) return
if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return
live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS) live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS)
} }
// 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归) // 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归)
// fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建 // fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建
async function refreshPlayURLOnce({ fromRetry = false } = {}) { async function refreshPlayURLOnce({ fromRetry = false } = {}) {
if (disposed || !live.dockId || !live.session?.id) return false
if (disposed || live.stopping || !live.dockId || !live.session?.id) return false
const dockId = live.dockId const dockId = live.dockId
const sessionId = live.session.id const sessionId = live.session.id
const play = await getLivePlayURL(dockId, sessionId) const play = await getLivePlayURL(dockId, sessionId)
if (disposed || !live.session || live.session.id !== sessionId) return false
if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return false
live.playUrl = play.playUrl || '' live.playUrl = play.playUrl || ''
const nowSec = Math.floor(Date.now() / 1000) const nowSec = Math.floor(Date.now() / 1000)
live.playUrlExpiresAt = Number(play.expiresAt) || nowSec + PLAY_EXPIRE_FALLBACK_SEC live.playUrlExpiresAt = Number(play.expiresAt) || nowSec + PLAY_EXPIRE_FALLBACK_SEC
@ -250,7 +280,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
function schedulePlayRefresh() { function schedulePlayRefresh() {
if (disposed) return
if (disposed || live.stopping) return
window.clearTimeout(live.playRefreshTimer) window.clearTimeout(live.playRefreshTimer)
if (!live.playUrl || live.playUrl.startsWith('fake://') || !live.playUrlExpiresAt) return if (!live.playUrl || live.playUrl.startsWith('fake://') || !live.playUrlExpiresAt) return
const delay = Math.max(5000, live.playUrlExpiresAt * 1000 - Date.now() - PLAY_REFRESH_LEAD_MS) const delay = Math.max(5000, live.playUrlExpiresAt * 1000 - Date.now() - PLAY_REFRESH_LEAD_MS)
@ -258,7 +288,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
try { try {
await refreshPlayURL() await refreshPlayURL()
} catch (e) { } catch (e) {
if (disposed) return
if (disposed || live.stopping) return
if (isLeaseInvalidError(e)) { if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid() await rejoinAfterLeaseInvalid()
return return
@ -269,23 +299,23 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
function scheduleLiveHeartbeat(expiresAt) { function scheduleLiveHeartbeat(expiresAt) {
if (disposed) return
if (disposed || live.stopping) return
window.clearTimeout(live.heartbeatTimer) window.clearTimeout(live.heartbeatTimer)
const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2) const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2)
live.heartbeatTimer = window.setTimeout(async () => { live.heartbeatTimer = window.setTimeout(async () => {
if (disposed || !live.dockId || !live.session?.id) return
if (disposed || live.stopping || !live.dockId || !live.session?.id) return
const dockId = live.dockId const dockId = live.dockId
const sessionId = live.session.id const sessionId = live.session.id
try { try {
const result = await heartbeatLive(dockId, sessionId) const result = await heartbeatLive(dockId, sessionId)
if (disposed || !live.session || live.session.id !== sessionId || live.dockId !== dockId) return
if (disposed || live.stopping || !live.session || live.session.id !== sessionId || live.dockId !== dockId) return
live.session = result.session live.session = result.session
scheduleLiveHeartbeat(result.leaseExpiresAt) scheduleLiveHeartbeat(result.leaseExpiresAt)
if (result.session.phase === 'streaming' && !live.playUrl) { if (result.session.phase === 'streaming' && !live.playUrl) {
try { try {
await refreshPlayURL() await refreshPlayURL()
} catch (e) { } catch (e) {
if (disposed || !live.session || live.session.id !== sessionId) return
if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return
if (isLeaseInvalidError(e)) { if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid() await rejoinAfterLeaseInvalid()
return return
@ -294,7 +324,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
} }
} catch (e) { } catch (e) {
if (disposed || !live.session || live.session.id !== sessionId || live.dockId !== dockId) return
if (disposed || live.stopping || !live.session || live.session.id !== sessionId || live.dockId !== dockId) return
if (isLeaseInvalidError(e)) { if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid() await rejoinAfterLeaseInvalid()
return return
@ -306,12 +336,12 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
async function rejoinAfterLeaseInvalid() { async function rejoinAfterLeaseInvalid() {
if (disposed || rejoinBusy) return
if (disposed || rejoinBusy || live.stopping) return
rejoinBusy = true rejoinBusy = true
try { try {
// 租约失效:停掉旧 session 的 play-url/heartbeat,再重新 join // 租约失效:停掉旧 session 的 play-url/heartbeat,再重新 join
await closeLive(false) await closeLive(false)
if (disposed) return
if (disposed || live.stopping) return
ui.toast('观看租约已失效,正在重新加入直播') ui.toast('观看租约已失效,正在重新加入直播')
await openLive() await openLive()
} finally { } finally {
@ -320,7 +350,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
async function onPlayError(err) { async function onPlayError(err) {
if (disposed || !live.session) return
if (disposed || live.stopping || !live.session) return
// AbortError/NotAllowedError: browser pause / autoplay policy. Keep current playUrl. // AbortError/NotAllowedError: browser pause / autoplay policy. Keep current playUrl.
if (err?.name === 'AbortError' || err?.name === 'NotAllowedError') return if (err?.name === 'AbortError' || err?.name === 'NotAllowedError') return
@ -358,7 +388,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
try { try {
await refreshPlayURL({ fromRetry: true }) await refreshPlayURL({ fromRetry: true })
} catch (e) { } catch (e) {
if (disposed) return
if (disposed || live.stopping) return
if (isLeaseInvalidError(e)) { if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid() await rejoinAfterLeaseInvalid()
return return
@ -402,8 +432,23 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
} }
async function waitUntilStopped(dockId, sessionId, gen) {
const startedAt = Date.now()
while (isCurrentOpen(gen) && live.stopping) {
if (Date.now() - startedAt > STOP_TIMEOUT_MS) {
throw new Error('停止超时,请稍后重试')
}
const session = await getLiveSession(dockId, sessionId)
if (!isCurrentOpen(gen) || !live.stopping) return false
live.session = session
if (session.phase === 'stopped') return true
await sleep(STOP_POLL_MS)
}
return false
}
async function stopLiveStream() { async function stopLiveStream() {
if (disposed || !live.session || !live.dockId || live.opening) return
if (disposed || !live.session || !live.dockId || live.opening || live.stopping) return
const dockId = live.dockId const dockId = live.dockId
const session = live.session const session = live.session
const ok = await ui.confirm( const ok = await ui.confirm(
@ -414,13 +459,34 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
if (!ok) return if (!ok) return
// confirm 期间可能已关闭/切换会话,避免 stop 错 dock 或 wipe 新会话 // confirm 期间可能已关闭/切换会话,避免 stop 错 dock 或 wipe 新会话
if (disposed || !live.session || live.dockId !== dockId || live.session.id !== session.id) return if (disposed || !live.session || live.dockId !== dockId || live.session.id !== session.id) return
const gen = openGen
live.stopping = true
live.opening = false
clearPlaybackOnly()
window.clearTimeout(live.phasePollTimer)
live.phasePollTimer = null
if (live.session?.phase !== 'stopping') {
live.session = { ...live.session, phase: 'stopping' }
}
try { try {
await stopLive(dockId) await stopLive(dockId)
if (!isCurrentOpen(gen)) return
await safeLeave(dockId, session.id)
if (!isCurrentOpen(gen)) return
const stopped = await waitUntilStopped(dockId, session.id, gen)
if (!isCurrentOpen(gen)) return
if (!stopped) return
openGen += 1 openGen += 1
resetLocal() resetLocal()
await safeLeave(dockId, session.id)
ui.toast('直播已停止') ui.toast('直播已停止')
} catch (e) { } catch (e) {
if (!isCurrentOpen(gen)) return
// 超时/失败时保持 stopping 闸门,避免立刻重新开播撞上后端 stopping
live.stopping = true
ui.toast(e.message || '停止直播失败') ui.toast(e.message || '停止直播失败')
} }
} }

8
src/views/MediaView/MediaView.vue

@ -76,7 +76,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls' import * as urls from '@/config/urls'
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive, leaveLiveKeepalive } from '@/api/live'
import { getLivePlayURL, heartbeatLive, joinLiveWithRetry, leaveLive, leaveLiveKeepalive } from '@/api/live'
import MediaCard from '@/components/MediaCard.vue' import MediaCard from '@/components/MediaCard.vue'
const route = useRoute() const route = useRoute()
@ -247,7 +247,9 @@ async function openLive(stream) {
const seq = ++openSeq const seq = ++openSeq
try { try {
const result = await joinLive(stream.dockId)
const result = await joinLiveWithRetry(stream.dockId, {}, {
shouldContinue: () => seq === openSeq && !disposed,
})
const session = result?.session const session = result?.session
const sessionId = session?.id const sessionId = session?.id
@ -282,7 +284,7 @@ async function openLive(stream) {
ui.toast('控制面已验证,当前未配置可播放媒体流') ui.toast('控制面已验证,当前未配置可播放媒体流')
} }
} catch (e) { } catch (e) {
if (seq !== openSeq || disposed) return
if (seq !== openSeq || disposed || e?.code === 'ABORTED') return
if (isLeaseInvalidError(e)) { if (isLeaseInvalidError(e)) {
await rejoinAfterLeaseInvalid(stream.id, stream.dockId) await rejoinAfterLeaseInvalid(stream.id, stream.dockId)
return return

7
src/views/MonitorView/MonitorView.vue

@ -689,10 +689,12 @@ watch(mapMarkers, () => {
syncMarkers() syncMarkers()
}, { deep: true }) }, { deep: true })
watch(selectedId, () => {
watch(selectedId, (next, prev) => {
syncMarkers() syncMarkers()
if (selectedId.value) flyToSelected() if (selectedId.value) flyToSelected()
else resetOverviewCamera() else resetOverviewCamera()
// / dock
if (prev && next !== prev) closeLive()
}) })
const selectedAsset = computed(() => (selectedId.value ? devices.asset(selectedId.value) : null)) const selectedAsset = computed(() => (selectedId.value ? devices.asset(selectedId.value) : null))
@ -734,8 +736,9 @@ const detailRelation = computed(() => {
const detailLiveView = computed(() => ({ const detailLiveView = computed(() => ({
playUrl: live.playUrl, playUrl: live.playUrl,
phase: live.session?.phase, phase: live.session?.phase,
active: !!live.session,
active: !!live.session && !live.stopping,
opening: !!live.opening, opening: !!live.opening,
stopping: !!live.stopping,
phaseText: livePhaseText.value, phaseText: livePhaseText.value,
canFullscreen: canFullscreenLive.value, canFullscreen: canFullscreenLive.value,
cameraLabel: isDrone.value ? '无人机图传' : '机巢摄像头', cameraLabel: isDrone.value ? '无人机图传' : '机巢摄像头',

Loading…
Cancel
Save