You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

440 lines
13 KiB

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,
}
}