diff --git a/src/composables/useMonitorLive.js b/src/composables/useMonitorLive.js index 6b5098f..bfd9d37 100644 --- a/src/composables/useMonitorLive.js +++ b/src/composables/useMonitorLive.js @@ -1,8 +1,31 @@ import { computed, reactive } from 'vue' -import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live' +import { + getLivePlayURL, + getLiveSession, + heartbeatLive, + joinLive, + leaveLive, + 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 export function useMonitorLive({ getDockId, ui, getLivePlayer }) { - const live = reactive({ session: null, dockId: '', playUrl: '', heartbeatTimer: null }) + const live = reactive({ + session: null, + dockId: '', + playUrl: '', + playUrlExpiresAt: 0, + heartbeatTimer: null, + phasePollTimer: null, + playRefreshTimer: null, + playRetryCount: 0, + phasePollStartedAt: 0, + }) const livePhaseText = computed(() => { if (!live.session) return '待机' @@ -17,6 +40,25 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { 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() + live.session = null + live.dockId = '' + live.playUrl = '' + live.playUrlExpiresAt = 0 + live.playRetryCount = 0 + live.phasePollStartedAt = 0 + } + function toggleLive() { if (live.session) closeLive() else openLive() @@ -38,28 +80,110 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { const result = await joinLive(dockId) live.session = result.session live.dockId = dockId + live.playUrl = '' + live.playUrlExpiresAt = 0 + live.playRetryCount = 0 scheduleLiveHeartbeat(result.leaseExpiresAt) - if (result.session.phase === 'streaming') await refreshPlayURL() + if (result.session.phase === 'streaming') { + try { + await refreshPlayURL() + } catch (e) { + await onPlayError(e) + } + } else { + startPhasePolling() + } } catch (e) { ui.toast(e.message || '打开直播失败') } } - async function refreshPlayURL() { + function startPhasePolling() { + 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 - const play = await getLivePlayURL(live.dockId, live.session.id) + if (Date.now() - live.phasePollStartedAt > PHASE_TIMEOUT_MS) { + ui.toast('直播启动超时') + await closeLive(false) + return + } + const sessionId = live.session.id + try { + // http 解包后是 LiveSession 本体,不是 { session } + const session = await getLiveSession(live.dockId, sessionId) + if (!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) { + await onPlayError(e) + } + return + } + if (session.phase === 'failed' || session.phase === 'stopped') { + ui.toast(session.phase === 'failed' ? '直播启动失败' : '直播已停止') + await closeLive(false) + return + } + } catch (e) { + ui.toast(e.message || '查询直播状态失败') + await closeLive(false) + return + } + live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS) + } + + // 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归) + async function refreshPlayURL() { + if (!live.dockId || !live.session?.id) return false + const sessionId = live.session.id + const play = await getLivePlayURL(live.dockId, sessionId) + if (!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 + live.playRetryCount = 0 + schedulePlayRefresh() + return true + } + + function schedulePlayRefresh() { + 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) { + await onPlayError(e) + } + }, delay) } function scheduleLiveHeartbeat(expiresAt) { 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 try { const result = await heartbeatLive(live.dockId, live.session.id) + if (!live.session) return live.session = result.session scheduleLiveHeartbeat(result.leaseExpiresAt) - if (result.session.phase === 'streaming' && !live.playUrl) await refreshPlayURL() + if (result.session.phase === 'streaming' && !live.playUrl) { + try { + await refreshPlayURL() + } catch (e) { + await onPlayError(e) + } + } } catch (e) { ui.toast(e.message || '直播观看已结束') await closeLive(false) @@ -67,13 +191,30 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { }, delay) } + async function onPlayError(err) { + if (!live.session) return + if (live.playRetryCount >= PLAY_RETRY_MAX) { + ui.toast(err?.message || '播放失败') + live.playRetryCount = 0 + return + } + live.playRetryCount += 1 + try { + await refreshPlayURL() + } catch (e) { + if (live.playRetryCount >= PLAY_RETRY_MAX) { + ui.toast(e?.message || err?.message || '播放失败') + live.playRetryCount = 0 + } else { + await onPlayError(e) + } + } + } + async function closeLive(sendRequest = true) { - window.clearTimeout(live.heartbeatTimer) const session = live.session const dockId = live.dockId - live.session = null - live.dockId = '' - live.playUrl = '' + resetLocal() if (sendRequest && session && dockId) { try { await leaveLive(dockId, session.id) @@ -81,6 +222,28 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { } } + async function stopLiveStream() { + if (!live.session || !live.dockId) return + const dockId = live.dockId + const session = live.session + const ok = await ui.confirm( + '确认停止直播?', + '停止后所有正在观看的用户都会失去画面。', + ['确认停止推流', '影响当前所有观看者'] + ) + if (!ok) return + try { + await stopLive(dockId) + resetLocal() + try { + await leaveLive(dockId, session.id) + } catch (_) {} + ui.toast('直播已停止') + } catch (e) { + ui.toast(e.message || '停止直播失败') + } + } + return { live, livePhaseText, @@ -89,5 +252,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { fullscreenLive, openLive, closeLive, + stopLiveStream, + onPlayError, } }