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'
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 = {}) {
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) {
return request.get(`/v1/live/${dockId}/sessions/${sessionId}`)
}

11
src/components/LivePlayer.vue

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

5
src/components/MonitorDetailPanel.vue

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

122
src/composables/useMonitorLive.js

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

7
src/views/MonitorView/MonitorView.vue

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

Loading…
Cancel
Save