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 }}