import { computed, reactive } from 'vue' import { getLivePlayURL, getLiveSession, heartbeatLive, joinLive, leaveLive, leaveLiveKeepalive, stopLive, } from '@/api/live' const PHASE_POLL_MS = 2500 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() const name = String(err?.name || '') const code = Number(err?.code) return ( name === 'SecurityError' || code === 403 || msg.includes('ssl') || msg.includes('tls') || msg.includes('cipher') || msg.includes('err_ssl') || msg.includes('mixed content') || msg.includes('cors') || msg.includes('certificate') ) } function isRecoverablePlayError(err) { // LivePlayer 启动窗口内的 404 会自行退避,不冒泡;仅忽略显式 recoverable,避免吞掉窗口耗尽后的 404 return err?.recoverable === true } 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, dockId: '', opening: false, playUrl: '', playUrlExpiresAt: 0, heartbeatTimer: null, phasePollTimer: null, playRefreshTimer: null, playRetryCount: 0, phasePollStartedAt: 0, }) let playURLRequest = null let disposed = false let openGen = 0 let rejoinBusy = false const livePhaseText = computed(() => { if (live.opening && !live.session) return '打开中' if (!live.session) return '待机' const phase = live.session.phase if (phase === 'streaming') return live.playUrl.startsWith('fake://') ? '模拟直播' : '直播中' if (phase === 'starting') return '启动中' if (phase === 'reconnecting') return '重连中' if (phase === 'stopping') return '停止中' if (phase === 'failed') return '启动失败' return '已停止' }) const canFullscreenLive = computed(() => !!live.playUrl && !live.playUrl.startsWith('fake://')) function clearTimers() { window.clearTimeout(live.heartbeatTimer) window.clearTimeout(live.phasePollTimer) window.clearTimeout(live.playRefreshTimer) live.heartbeatTimer = null live.phasePollTimer = null live.playRefreshTimer = null } function resetLocal() { clearTimers() playURLRequest = null live.session = null live.dockId = '' live.opening = false live.playUrl = '' live.playUrlExpiresAt = 0 live.playRetryCount = 0 live.phasePollStartedAt = 0 } function isCurrentOpen(gen) { return !disposed && gen === openGen } function markPlayFatal(message) { window.clearTimeout(live.playRefreshTimer) live.playRefreshTimer = null live.playUrl = '' live.playUrlExpiresAt = 0 live.playRetryCount = PLAY_RETRY_MAX ui.toast(message) } function toggleLive() { if (disposed || live.opening) return if (live.session) closeLive() else openLive() } function fullscreenLive() { if (!canFullscreenLive.value) { ui.toast('当前无可全屏播放的视频流') return } const ok = getLivePlayer()?.requestFullscreen?.() if (!ok) ui.toast('当前浏览器不支持全屏') } async function openLive() { 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) 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 (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 { 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 (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(dockId, sessionId) if (disposed || !live.session || live.session.id !== sessionId) return live.session = session if (session.phase === 'streaming') { window.clearTimeout(live.phasePollTimer) live.phasePollTimer = null try { await refreshPlayURL() } catch (e) { if (disposed || !live.session || live.session.id !== sessionId) return await onPlayError(e) } return } if (session.phase === 'failed' || session.phase === 'stopped') { ui.toast(session.phase === 'failed' ? '直播启动失败' : '直播已停止') await closeLive(false) 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 (disposed || !live.dockId || !live.session?.id) return false const dockId = live.dockId const sessionId = live.session.id 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 if (!fromRetry) live.playRetryCount = 0 schedulePlayRefresh() return true } function refreshPlayURL(options = {}) { if (playURLRequest) return playURLRequest playURLRequest = refreshPlayURLOnce(options).finally(() => { playURLRequest = null }) return playURLRequest } 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) live.playRefreshTimer = window.setTimeout(async () => { 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 (disposed || !live.dockId || !live.session?.id) return const dockId = live.dockId const sessionId = live.session.id try { const result = await heartbeatLive(dockId, sessionId) 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 (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 (disposed || !live.session) return // AbortError/NotAllowedError: browser pause / autoplay policy. Keep current playUrl. if (err?.name === 'AbortError' || err?.name === 'NotAllowedError') return // 启动期 404 由 LivePlayer 同地址退避处理;父层保持 heartbeat,不刷 /play-url if (isRecoverablePlayError(err)) return if (isLeaseInvalidError(err)) { await rejoinAfterLeaseInvalid() return } // TLS/CORS/403:换 auth_key 没用,禁止刷 play-url if (isFatalPlayMediaError(err)) { markPlayFatal( Number(err?.code) === 403 ? '播放鉴权失败(403),请检查签名或播放域名配置' : '播放域名 HTTPS 不可用(TLS/证书异常),请检查阿里云播放域名证书' ) return } // 启动窗口耗尽后的 HlsError(含持续 404)视为不可恢复 if (err?.name === 'HlsError') { markPlayFatal(err?.message || '播放失败') return } if (live.playRetryCount >= PLAY_RETRY_MAX) { markPlayFatal(err?.message || '播放失败') return } live.playRetryCount += 1 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) ? (Number(e?.code) === 403 ? '播放鉴权失败(403),请检查签名或播放域名配置' : '播放域名 HTTPS 不可用(TLS/证书异常),请检查阿里云播放域名证书') : (e?.message || err?.message || '播放失败') ) return } await onPlayError(e) } } 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) { 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 (disposed || !live.session || !live.dockId || live.opening) return const dockId = live.dockId const session = live.session const ok = await ui.confirm( '确认停止直播?', '停止后所有正在观看的用户都会失去画面。', ['确认停止推流', '影响当前所有观看者'] ) if (!ok) return // confirm 期间可能已关闭/切换会话,避免 stop 错 dock 或 wipe 新会话 if (disposed || !live.session || live.dockId !== dockId || live.session.id !== session.id) return try { await stopLive(dockId) openGen += 1 resetLocal() await safeLeave(dockId, session.id) ui.toast('直播已停止') } catch (e) { ui.toast(e.message || '停止直播失败') } } return { live, livePhaseText, canFullscreenLive, toggleLive, fullscreenLive, openLive, closeLive, dispose, stopLiveStream, onPlayError, } }