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