# 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