From d58ca74bddf7bef2767840f93d032532f8dc9ae9 Mon Sep 17 00:00:00 2001 From: xiaosi <2652281683@qq.com> Date: Wed, 2 Sep 2026 18:57:03 +0800 Subject: [PATCH] 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. --- .../plans/2026-09-02-live-stop-phase-gate.md | 235 ++++++++++++++++++ src/api/live.js | 32 +++ src/components/LivePlayer.vue | 11 +- src/components/MonitorDetailPanel.vue | 5 +- src/composables/useMonitorLive.js | 122 ++++++--- src/views/MediaView/MediaView.vue | 8 +- src/views/MonitorView/MonitorView.vue | 7 +- 7 files changed, 383 insertions(+), 37 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-02-live-stop-phase-gate.md diff --git a/docs/superpowers/plans/2026-09-02-live-stop-phase-gate.md b/docs/superpowers/plans/2026-09-02-live-stop-phase-gate.md new file mode 100644 index 0000000..7f8a40c --- /dev/null +++ b/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 diff --git a/src/api/live.js b/src/api/live.js index e72ed3a..5a5c455 100644 --- a/src/api/live.js +++ b/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}`) } diff --git a/src/components/LivePlayer.vue b/src/components/LivePlayer.vue index 22a6c64..e04faa4 100644 --- a/src/components/LivePlayer.vue +++ b/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" > @@ -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 diff --git a/src/components/MonitorDetailPanel.vue b/src/components/MonitorDetailPanel.vue index 959ccf3..ff285fe 100644 --- a/src/components/MonitorDetailPanel.vue +++ b/src/components/MonitorDetailPanel.vue @@ -27,10 +27,11 @@ {{ liveView.phaseText }}