diff --git a/src/api/live.js b/src/api/live.js index fbb8490..e72ed3a 100644 --- a/src/api/live.js +++ b/src/api/live.js @@ -22,6 +22,22 @@ export function leaveLive(dockId, sessionId) { return request.delete(`/v1/live/${dockId}/sessions/${sessionId}/viewers/me`) } +/** pagehide/unload: best-effort leave that outlives the page */ +export function leaveLiveKeepalive(dockId, sessionId) { + if (!dockId || !sessionId || typeof fetch !== 'function') return + const token = localStorage.getItem('token') || '' + const headers = { 'Content-Type': 'application/json' } + if (token) headers.Authorization = `Bearer ${token}` + try { + fetch(`/api/v1/live/${encodeURIComponent(dockId)}/sessions/${encodeURIComponent(sessionId)}/viewers/me`, { + method: 'DELETE', + headers, + keepalive: true, + credentials: 'same-origin', + }).catch(() => {}) + } catch (_) {} +} + export function stopLive(dockId) { return request.post(`/v1/live/${dockId}/stop`) } diff --git a/src/composables/useMonitorLive.js b/src/composables/useMonitorLive.js index de18bca..55f19eb 100644 --- a/src/composables/useMonitorLive.js +++ b/src/composables/useMonitorLive.js @@ -5,6 +5,7 @@ import { heartbeatLive, joinLive, leaveLive, + leaveLiveKeepalive, stopLive, } from '@/api/live' @@ -13,6 +14,7 @@ const PHASE_TIMEOUT_MS = 60_000 const PLAY_RETRY_MAX = 3 const PLAY_EXPIRE_FALLBACK_SEC = 270 const PLAY_REFRESH_LEAD_MS = 30_000 +const LEASE_INVALID_CODE = 57006 function isFatalPlayMediaError(err) { const msg = String(err?.message || err || '').toLowerCase() @@ -29,6 +31,15 @@ function isFatalPlayMediaError(err) { ) } +function isLeaseInvalidError(err) { + return Number(err?.code) === LEASE_INVALID_CODE +} + +function safeLeave(dockId, sessionId) { + if (!dockId || !sessionId) return Promise.resolve() + return leaveLive(dockId, sessionId).catch(() => {}) +} + export function useMonitorLive({ getDockId, ui, getLivePlayer }) { const live = reactive({ session: null, @@ -44,6 +55,9 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { }) let playURLRequest = null + let disposed = false + let openGen = 0 + let rejoinBusy = false const livePhaseText = computed(() => { if (live.opening && !live.session) return '打开中' @@ -80,6 +94,10 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { live.phasePollStartedAt = 0 } + function isCurrentOpen(gen) { + return !disposed && gen === openGen + } + function markPlayFatal(message) { window.clearTimeout(live.playRefreshTimer) live.playRefreshTimer = null @@ -90,7 +108,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { } function toggleLive() { - if (live.opening) return + if (disposed || live.opening) return if (live.session) closeLive() else openLive() } @@ -105,53 +123,72 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { } async function openLive() { - if (live.opening || live.session) return + if (disposed || live.opening || live.session) return const dockId = getDockId?.() if (!dockId) return + const gen = ++openGen live.opening = true try { const result = await joinLive(dockId) - live.session = result.session + const session = result?.session + const sessionId = session?.id + + // 页面已切走/关闭:不要写入状态,立刻释放刚拿到的 viewer lease + if (!isCurrentOpen(gen)) { + if (sessionId) await safeLeave(dockId, sessionId) + return + } + + if (!sessionId) { + ui.toast('直播会话无效') + return + } + + live.session = session live.dockId = dockId live.playUrl = '' live.playUrlExpiresAt = 0 live.playRetryCount = 0 scheduleLiveHeartbeat(result.leaseExpiresAt) - if (result.session.phase === 'streaming') { + if (session.phase === 'streaming') { try { await refreshPlayURL() } catch (e) { + if (!isCurrentOpen(gen)) return await onPlayError(e) } } else { startPhasePolling() } } catch (e) { + if (!isCurrentOpen(gen)) return ui.toast(e.message || '打开直播失败') } finally { - live.opening = false + if (gen === openGen) live.opening = false } } function startPhasePolling() { + if (disposed) return window.clearTimeout(live.phasePollTimer) live.phasePollStartedAt = Date.now() live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS) } async function pollPhaseOnce() { - if (!live.dockId || !live.session?.id) return + if (disposed || !live.dockId || !live.session?.id) return if (Date.now() - live.phasePollStartedAt > PHASE_TIMEOUT_MS) { ui.toast('直播启动超时') await closeLive(false) return } + const dockId = live.dockId const sessionId = live.session.id try { // http 解包后是 LiveSession 本体,不是 { session } - const session = await getLiveSession(live.dockId, sessionId) - if (!live.session || live.session.id !== sessionId) return + const session = await getLiveSession(dockId, sessionId) + if (disposed || !live.session || live.session.id !== sessionId) return live.session = session if (session.phase === 'streaming') { window.clearTimeout(live.phasePollTimer) @@ -159,6 +196,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { try { await refreshPlayURL() } catch (e) { + if (disposed || !live.session || live.session.id !== sessionId) return await onPlayError(e) } return @@ -169,20 +207,27 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { return } } catch (e) { + if (disposed || !live.session || live.session.id !== sessionId) return + if (isLeaseInvalidError(e)) { + await rejoinAfterLeaseInvalid() + return + } ui.toast(e.message || '查询直播状态失败') await closeLive(false) return } + if (disposed || !live.session || live.session.id !== sessionId) return live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS) } // 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归) // fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建 async function refreshPlayURLOnce({ fromRetry = false } = {}) { - if (!live.dockId || !live.session?.id) return false + if (disposed || !live.dockId || !live.session?.id) return false + const dockId = live.dockId const sessionId = live.session.id - const play = await getLivePlayURL(live.dockId, sessionId) - if (!live.session || live.session.id !== sessionId) return false + const play = await getLivePlayURL(dockId, sessionId) + if (disposed || !live.session || live.session.id !== sessionId) return false live.playUrl = play.playUrl || '' const nowSec = Math.floor(Date.now() / 1000) live.playUrlExpiresAt = Number(play.expiresAt) || nowSec + PLAY_EXPIRE_FALLBACK_SEC @@ -200,6 +245,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { } function schedulePlayRefresh() { + if (disposed) return window.clearTimeout(live.playRefreshTimer) if (!live.playUrl || live.playUrl.startsWith('fake://') || !live.playUrlExpiresAt) return const delay = Math.max(5000, live.playUrlExpiresAt * 1000 - Date.now() - PLAY_REFRESH_LEAD_MS) @@ -207,44 +253,78 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { try { await refreshPlayURL() } catch (e) { + if (disposed) return + if (isLeaseInvalidError(e)) { + await rejoinAfterLeaseInvalid() + return + } await onPlayError(e) } }, delay) } function scheduleLiveHeartbeat(expiresAt) { + if (disposed) return window.clearTimeout(live.heartbeatTimer) const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2) live.heartbeatTimer = window.setTimeout(async () => { - if (!live.dockId || !live.session?.id) return + if (disposed || !live.dockId || !live.session?.id) return const dockId = live.dockId const sessionId = live.session.id try { const result = await heartbeatLive(dockId, sessionId) - if (!live.session || live.session.id !== sessionId || live.dockId !== dockId) return + if (disposed || !live.session || live.session.id !== sessionId || live.dockId !== dockId) return live.session = result.session scheduleLiveHeartbeat(result.leaseExpiresAt) if (result.session.phase === 'streaming' && !live.playUrl) { try { await refreshPlayURL() } catch (e) { + if (disposed || !live.session || live.session.id !== sessionId) return + if (isLeaseInvalidError(e)) { + await rejoinAfterLeaseInvalid() + return + } await onPlayError(e) } } } catch (e) { - if (!live.session || live.session.id !== sessionId || live.dockId !== dockId) return + if (disposed || !live.session || live.session.id !== sessionId || live.dockId !== dockId) return + if (isLeaseInvalidError(e)) { + await rejoinAfterLeaseInvalid() + return + } ui.toast(e.message || '直播观看已结束') await closeLive(false) } }, delay) } + async function rejoinAfterLeaseInvalid() { + if (disposed || rejoinBusy) return + rejoinBusy = true + try { + // 租约失效:停掉旧 session 的 play-url/heartbeat,再重新 join + await closeLive(false) + if (disposed) return + ui.toast('观看租约已失效,正在重新加入直播') + await openLive() + } finally { + rejoinBusy = false + } + } + async function onPlayError(err) { - if (!live.session) return + if (disposed || !live.session) return // AbortError/NotAllowedError: browser pause / autoplay policy. Keep current playUrl. if (err?.name === 'AbortError' || err?.name === 'NotAllowedError') return + if (isLeaseInvalidError(err)) { + await rejoinAfterLeaseInvalid() + return + } + // 播放器媒体面失败(TLS/CORS/HLS network):换 auth_key 没用,禁止刷 play-url if (isFatalPlayMediaError(err) || err?.name === 'HlsError') { markPlayFatal( @@ -264,6 +344,11 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { try { await refreshPlayURL({ fromRetry: true }) } catch (e) { + if (disposed) return + if (isLeaseInvalidError(e)) { + await rejoinAfterLeaseInvalid() + return + } if (isFatalPlayMediaError(e) || e?.name === 'HlsError' || live.playRetryCount >= PLAY_RETRY_MAX) { markPlayFatal( isFatalPlayMediaError(e) @@ -277,18 +362,32 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { } async function closeLive(sendRequest = true) { + // 使 in-flight openLive/join 失效;迟到的 join 结果会走 orphan leave + openGen += 1 const session = live.session const dockId = live.dockId resetLocal() if (sendRequest && session && dockId) { - try { - await leaveLive(dockId, session.id) - } catch (_) {} + await safeLeave(dockId, session.id) + } + } + + /** 页面卸载/刷新:立即失效 generation,并用 keepalive 释放租约 */ + function dispose({ keepalive = false } = {}) { + if (disposed) return + disposed = true + openGen += 1 + const session = live.session + const dockId = live.dockId + resetLocal() + if (session && dockId) { + if (keepalive) leaveLiveKeepalive(dockId, session.id) + else safeLeave(dockId, session.id) } } async function stopLiveStream() { - if (!live.session || !live.dockId || live.opening) return + if (disposed || !live.session || !live.dockId || live.opening) return const dockId = live.dockId const session = live.session const ok = await ui.confirm( @@ -298,13 +397,12 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { ) if (!ok) return // confirm 期间可能已关闭/切换会话,避免 stop 错 dock 或 wipe 新会话 - if (!live.session || live.dockId !== dockId || live.session.id !== session.id) return + if (disposed || !live.session || live.dockId !== dockId || live.session.id !== session.id) return try { await stopLive(dockId) + openGen += 1 resetLocal() - try { - await leaveLive(dockId, session.id) - } catch (_) {} + await safeLeave(dockId, session.id) ui.toast('直播已停止') } catch (e) { ui.toast(e.message || '停止直播失败') @@ -319,6 +417,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { fullscreenLive, openLive, closeLive, + dispose, stopLiveStream, onPlayError, } diff --git a/src/utils/http.js b/src/utils/http.js index 31dacb5..7b2f3c9 100644 --- a/src/utils/http.js +++ b/src/utils/http.js @@ -28,7 +28,10 @@ request.interceptors.response.use( if (!response.config.skipErrorToast) { useUiStore().toast(res?.msg || '请求失败') } - return Promise.reject(new Error(res?.msg || '请求失败')) + const err = new Error(res?.msg || '请求失败') + err.code = res?.code + err.response = response + return Promise.reject(err) }, (error) => { const status = error.response?.status @@ -47,6 +50,7 @@ request.interceptors.response.use( } const err = new Error(message) err.status = status + err.code = data?.code err.response = error.response return Promise.reject(err) } diff --git a/src/views/MediaView/MediaView.vue b/src/views/MediaView/MediaView.vue index 58e970a..05d005a 100644 --- a/src/views/MediaView/MediaView.vue +++ b/src/views/MediaView/MediaView.vue @@ -76,7 +76,7 @@ import { useRoute, useRouter } from 'vue-router' import { useUiStore } from '@/stores/modules/uiStore' import request from '@/utils/http' import * as urls from '@/config/urls' -import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live' +import { getLivePlayURL, heartbeatLive, joinLive, leaveLive, leaveLiveKeepalive } from '@/api/live' import MediaCard from '@/components/MediaCard.vue' const route = useRoute() @@ -107,7 +107,19 @@ const activeLive = reactive({ }) let heartbeatTimer = null let openSeq = 0 +let disposed = false +let rejoinBusy = false +const LEASE_INVALID_CODE = 57006 + +function isLeaseInvalidError(err) { + return Number(err?.code) === LEASE_INVALID_CODE +} + +function safeLeave(dockId, sessionId) { + if (!dockId || !sessionId) return Promise.resolve() + return leaveLive(dockId, sessionId).catch(() => {}) +} const liveTabLabel = computed(() => `直播中心 ${liveStreams.value.length}`) const videosTabLabel = computed(() => `原始视频 ${videos.value.length}`) @@ -142,19 +154,24 @@ function resetActiveLive() { function scheduleHeartbeat(expiresAt) { clearHeartbeat() - if (!activeLive.dockId || !activeLive.sessionId) return + if (disposed || !activeLive.dockId || !activeLive.sessionId) return const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2) heartbeatTimer = window.setTimeout(async () => { - if (!activeLive.dockId || !activeLive.sessionId) return + if (disposed || !activeLive.dockId || !activeLive.sessionId) return const dockId = activeLive.dockId const sessionId = activeLive.sessionId + const streamId = activeLive.id try { const result = await heartbeatLive(dockId, sessionId) - if (activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return + if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return if (result?.session?.phase) activeLive.phase = result.session.phase scheduleHeartbeat(result.leaseExpiresAt) } catch (e) { - if (activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return + if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return + if (isLeaseInvalidError(e)) { + await rejoinAfterLeaseInvalid(streamId, dockId) + return + } ui.toast(e.message || '直播观看已结束') resetActiveLive() } @@ -162,13 +179,26 @@ function scheduleHeartbeat(expiresAt) { } async function closeActiveLive({ sendLeave = true } = {}) { + openSeq += 1 const dockId = activeLive.dockId const sessionId = activeLive.sessionId resetActiveLive() if (sendLeave && dockId && sessionId) { - try { - await leaveLive(dockId, sessionId) - } catch (_) {} + await safeLeave(dockId, sessionId) + } +} + +async function rejoinAfterLeaseInvalid(streamId, dockId) { + if (disposed || rejoinBusy) return + rejoinBusy = true + try { + await closeActiveLive({ sendLeave: false }) + if (disposed) return + ui.toast('观看租约已失效,正在重新加入直播') + const stream = liveStreams.value.find((s) => s.id === streamId) || { id: streamId, dockId } + await openLive(stream) + } finally { + rejoinBusy = false } } @@ -206,27 +236,35 @@ async function load() { } async function openLive(stream) { - if (!stream?.dockId) return + if (disposed || !stream?.dockId) return if (activeLive.id === stream.id && activeLive.playUrl) { await closeActiveLive() return } - const seq = ++openSeq + // closeActiveLive 会 bump openSeq,使旧 in-flight join 失效;之后再取本次 seq await closeActiveLive() + const seq = ++openSeq try { const result = await joinLive(stream.dockId) - if (seq !== openSeq) return const session = result?.session - if (!session?.id) { + const sessionId = session?.id + + // 页面已切走/关闭:不要写入状态,立刻释放刚拿到的 viewer lease + if (seq !== openSeq || disposed) { + if (sessionId) await safeLeave(stream.dockId, sessionId) + return + } + + if (!sessionId) { ui.toast('直播会话无效') return } activeLive.id = stream.id activeLive.dockId = stream.dockId - activeLive.sessionId = session.id + activeLive.sessionId = sessionId activeLive.phase = session.phase || '' activeLive.playUrl = '' scheduleHeartbeat(result.leaseExpiresAt) @@ -236,15 +274,19 @@ async function openLive(stream) { return } - const play = await getLivePlayURL(stream.dockId, session.id) - if (seq !== openSeq || activeLive.sessionId !== session.id) return + const play = await getLivePlayURL(stream.dockId, sessionId) + if (seq !== openSeq || disposed || activeLive.sessionId !== sessionId) return if (play?.playUrl && !play.playUrl.startsWith('fake://')) { activeLive.playUrl = play.playUrl } else { ui.toast('控制面已验证,当前未配置可播放媒体流') } } catch (e) { - if (seq !== openSeq) return + if (seq !== openSeq || disposed) return + if (isLeaseInvalidError(e)) { + await rejoinAfterLeaseInvalid(stream.id, stream.dockId) + return + } await closeActiveLive() ui.toast(e.message || '打开直播失败') } @@ -285,8 +327,23 @@ watch(view, async (next, prev) => { await load() }) -onMounted(load) +function onPageHide() { + disposed = true + openSeq += 1 + const dockId = activeLive.dockId + const sessionId = activeLive.sessionId + resetActiveLive() + if (dockId && sessionId) leaveLiveKeepalive(dockId, sessionId) +} + +onMounted(() => { + window.addEventListener('pagehide', onPageHide) + load() +}) + onBeforeUnmount(() => { + window.removeEventListener('pagehide', onPageHide) + disposed = true openSeq += 1 closeActiveLive() }) diff --git a/src/views/MonitorView/MonitorView.vue b/src/views/MonitorView/MonitorView.vue index 08ebbfe..015b39b 100644 --- a/src/views/MonitorView/MonitorView.vue +++ b/src/views/MonitorView/MonitorView.vue @@ -127,6 +127,7 @@ const { toggleLive, fullscreenLive, closeLive, + dispose: disposeLive, stopLiveStream, onPlayError, } = useMonitorLive({ @@ -135,6 +136,10 @@ const { getLivePlayer: () => detailPanelRef.value?.livePlayerRef, }) +function onPageHide() { + disposeLive({ keepalive: true }) +} + const filter = ref('all') const search = ref('') const controlExpanded = ref(false) @@ -655,6 +660,7 @@ function teardownMap() { } onMounted(async () => { + window.addEventListener('pagehide', onPageHide) window.addEventListener('keydown', onGlobalKeydown) try { await devices.load() @@ -672,9 +678,10 @@ onMounted(async () => { }) onUnmounted(() => { + window.removeEventListener('pagehide', onPageHide) window.removeEventListener('keydown', onGlobalKeydown) closeCommandProgress() - closeLive() + disposeLive() teardownMap() })