Browse Source
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
7 changed files with 383 additions and 37 deletions
@ -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 |
||||
Loading…
Reference in new issue