# Live Stop Phase Gate Design Date: 2026-09-02 Status: Approved for implementation planning ## Problem Stopping a live stream currently clears local state immediately after `POST /v1/live/{dockId}/stop`. The UI then allows another `POST /v1/live/{dockId}/sessions` while the previous session may still be `stopping`. That can race the backend and produce join failures (business code `57007`) or stale session polling against an old `streamSessionId`. ## Scope In scope: - Monitor live stop/reopen flow in `useMonitorLive` - Monitor detail panel / LivePlayer button disable + phase text - Join retries for business code `57007` in Monitor and MediaView Out of scope: - Changing backend stop/join contracts - Treating viewer leave (`DELETE .../viewers/me`) as a stop-phase gate - Redesigning MediaView stop-push controls (MediaView has no stop-push button) ## Goals 1. After clicking stop, do not allow re-open until the old session reports `phase=stopped`. 2. Keep the old `streamSessionId` and poll it until stopped. 3. On reopen, always use the newly returned `streamSessionId` for phase poll and play-url. 4. If join returns `57007`, wait ~1.5s and retry a few times instead of immediate failure toast. 5. Keep heartbeat independent of play retries; stop path clears playback and stops heartbeat for the stopping session. ## Non-Goals - Global dock cooldown timers unrelated to an actual stopping session - Auto-stop on viewer leave - Changing HLS 404 startup retry behavior ## Approach Use a local **stopping gate** owned by `useMonitorLive`. Recommended over pure `57007` retries because the UI can disable controls before the user clicks again, and over a dock-wide cooldown because the gate is tied to a concrete previous session. ## State Machine States relevant to this change: | Local condition | UI phase text | Play toggle | Stop button | |---|---|---|---| | stop request in flight / `stopping` | 正在停止 | disabled | disabled | | old session `stopped` (gate cleared) | 已停止 | enabled | hidden | | join in flight (`opening`) | 正在启动 | disabled | hidden | | session `starting` / `reconnecting` | 启动中 / 重连中 | enabled (leave viewer) | enabled | | session `streaming` | 直播中 | enabled (leave viewer) | enabled | ### Stop path (`stopLiveStream`) 1. Confirm dialog unchanged. 2. Set local `stopping=true`, clear `playUrl` and timers/heartbeat. 3. Keep `dockId` + old `session` (`streamSessionId`) for polling. 4. Call `POST /v1/live/{dockId}/stop`. 5. Optionally leave viewer for the old session. 6. Poll `GET /v1/live/{dockId}/sessions/{oldSessionId}` until: - `phase === 'stopped'` → clear stopping gate, allow reopen - timeout / hard failure → toast, keep controls conservative (do not silently reopen) 7. While gate is active, `openLive()` and `toggleLive()` open-path are no-ops / disabled. ### Reopen path (`openLive`) 1. Reject if `disposed`, `opening`, `stopping`, or an active non-stopped session exists. 2. `POST /v1/live/{dockId}/sessions`. 3. On success, replace local session with the **new** `result.session` only. 4. If new session `phase === 'streaming'`, request play-url with the **new** session id. 5. Otherwise start phase polling with the **new** session id. 6. On business code `57007`: - wait 1500ms - retry up to 3 additional attempts (4 total tries) - if still failing, toast and exit opening ### Leave viewer path (`closeLive` / play toggle off) Unchanged: invalidate generation, clear local state, leave viewer. No stop-phase polling. ## API Error Handling | Code | Meaning for frontend | Action | |---|---|---| | `57006` | lease invalid | existing rejoin flow | | `57007` | previous session still stopping / not ready to create | wait 1.5s and retry join a few times | | other | normal failure | toast | `http.js` already attaches business `code` onto rejected errors; reuse that. ## UI Binding Changes - `liveView.opening` true for join-in-flight - add `liveView.stopping` (or derive from session phase + local flag) to disable LivePlayer control and hide/disable stop button - `livePhaseText`: - local stop request / phase `stopping` → `正在停止` - phase `stopped` / idle after gate clear → `已停止` - opening with no session yet → `正在启动` / `打开中` ## MediaView No stop-push gate. Only add the same `57007` join retry helper so a dock still stopping from Monitor does not hard-fail MediaView open. ## Risks - Stop poll timeout leaves user blocked: mitigate with explicit toast and a manual clear/reset on dock switch / dispose. - Accidental reuse of old session id after reopen: mitigate by replacing `live.session` only from join response and scoping all polls/play-url/heartbeat to that id. - Race: user switches selected dock while stopping: gate must be dock/session scoped and disposed with page leave. ## Acceptance Criteria 1. Click stop → play and stop controls disabled; UI shows 正在停止. 2. Frontend keeps polling the old `streamSessionId` until `stopped`. 3. Only after `stopped` can play start a new join. 4. New join uses new `streamSessionId` for subsequent poll and play-url. 5. Join `57007` auto-retries with short delay instead of immediate failure. 6. Viewer leave still clears immediately and does not enter the stopping gate. 7. MediaView join also retries on `57007`. ## Implementation Touchpoints - `src/composables/useMonitorLive.js` - `src/components/MonitorDetailPanel.vue` (button disable bindings) - `src/components/LivePlayer.vue` (disable control while stopping) - `src/views/MonitorView/MonitorView.vue` (pass stopping into liveView) - `src/views/MediaView/MediaView.vue` (`57007` retry on open) - optionally tiny shared helper for join-with-57007-retry near `src/api/live.js`