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.
544 lines
17 KiB
544 lines
17 KiB
import { computed, reactive } from 'vue'
|
|
import {
|
|
getLivePlayURL,
|
|
getLiveSession,
|
|
heartbeatLive,
|
|
joinLiveWithRetry,
|
|
leaveLive,
|
|
leaveLiveKeepalive,
|
|
stopLive,
|
|
} from '@/api/live'
|
|
|
|
const PHASE_POLL_MS = 2500
|
|
const PHASE_TIMEOUT_MS = 60_000
|
|
const STOP_POLL_MS = 1500
|
|
const STOP_TIMEOUT_MS = 60_000
|
|
const PLAY_RETRY_MAX = 3
|
|
const PLAY_EXPIRE_FALLBACK_SEC = 270
|
|
const PLAY_REFRESH_LEAD_MS = 30_000
|
|
const PLAY_URL_FRESH_MS = PLAY_REFRESH_LEAD_MS
|
|
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 isJoinAbortedError(err) {
|
|
return err?.code === 'ABORTED'
|
|
}
|
|
|
|
function safeLeave(dockId, sessionId) {
|
|
if (!dockId || !sessionId) return Promise.resolve()
|
|
return leaveLive(dockId, sessionId).catch(() => {})
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
}
|
|
|
|
export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
|
|
const live = reactive({
|
|
session: null,
|
|
dockId: '',
|
|
opening: false,
|
|
stopping: false,
|
|
playUrl: '',
|
|
playUrlExpiresAt: 0,
|
|
heartbeatTimer: null,
|
|
phasePollTimer: null,
|
|
playRefreshTimer: null,
|
|
playRetryCount: 0,
|
|
phasePollStartedAt: 0,
|
|
})
|
|
|
|
let playURLRequest = null
|
|
let playURLRequestSessionId = ''
|
|
let disposed = false
|
|
let openGen = 0
|
|
let rejoinBusy = false
|
|
|
|
const livePhaseText = computed(() => {
|
|
if (live.stopping) return '正在停止'
|
|
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 '启动失败'
|
|
if (phase === 'stopped') 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 clearPlaybackOnly() {
|
|
window.clearTimeout(live.heartbeatTimer)
|
|
window.clearTimeout(live.playRefreshTimer)
|
|
live.heartbeatTimer = null
|
|
live.playRefreshTimer = null
|
|
playURLRequest = null
|
|
playURLRequestSessionId = ''
|
|
live.playUrl = ''
|
|
live.playUrlExpiresAt = 0
|
|
live.playRetryCount = 0
|
|
}
|
|
|
|
function resetLocal() {
|
|
clearTimers()
|
|
playURLRequest = null
|
|
playURLRequestSessionId = ''
|
|
live.session = null
|
|
live.dockId = ''
|
|
live.opening = false
|
|
live.stopping = 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 || live.stopping) 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.stopping || live.session) return
|
|
const dockId = getDockId?.()
|
|
if (!dockId) return
|
|
|
|
const gen = ++openGen
|
|
live.opening = true
|
|
try {
|
|
const result = await joinLiveWithRetry(dockId, {}, {
|
|
shouldContinue: () => isCurrentOpen(gen),
|
|
})
|
|
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
|
|
}
|
|
|
|
// 新 session 完全替换旧 id,后续 poll / play-url / heartbeat 都走新 id
|
|
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) || isJoinAbortedError(e)) return
|
|
ui.toast(e.message || '打开直播失败')
|
|
} finally {
|
|
if (gen === openGen) live.opening = false
|
|
}
|
|
}
|
|
|
|
function startPhasePolling() {
|
|
if (disposed || live.stopping) return
|
|
window.clearTimeout(live.phasePollTimer)
|
|
live.phasePollStartedAt = Date.now()
|
|
live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS)
|
|
}
|
|
|
|
async function pollPhaseOnce() {
|
|
if (disposed || live.stopping || !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.stopping || !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.stopping || !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.stopping || !live.session || live.session.id !== sessionId) return
|
|
if (isLeaseInvalidError(e)) {
|
|
await rejoinAfterLeaseInvalid()
|
|
return
|
|
}
|
|
ui.toast(e.message || '查询直播状态失败')
|
|
await closeLive(false)
|
|
return
|
|
}
|
|
if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return
|
|
live.phasePollTimer = window.setTimeout(pollPhaseOnce, PHASE_POLL_MS)
|
|
}
|
|
|
|
// 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归)
|
|
// fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建
|
|
async function refreshPlayURLOnce({ fromRetry = false, force = false } = {}) {
|
|
if (disposed || live.stopping || !live.dockId || !live.session?.id) return false
|
|
const dockId = live.dockId
|
|
const sessionId = live.session.id
|
|
const forceFetch = force || fromRetry
|
|
|
|
if (
|
|
!forceFetch &&
|
|
live.playUrl &&
|
|
live.playUrlExpiresAt * 1000 - Date.now() > PLAY_URL_FRESH_MS
|
|
) {
|
|
return true
|
|
}
|
|
|
|
const play = await getLivePlayURL(dockId, sessionId)
|
|
if (disposed || live.stopping || !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 = {}) {
|
|
const sessionId = live.session?.id
|
|
if (!sessionId) return Promise.resolve(false)
|
|
|
|
const forceFetch = Boolean(options.force || options.fromRetry)
|
|
if (
|
|
!forceFetch &&
|
|
live.playUrl &&
|
|
live.playUrlExpiresAt * 1000 - Date.now() > PLAY_URL_FRESH_MS
|
|
) {
|
|
return Promise.resolve(true)
|
|
}
|
|
|
|
if (playURLRequest && playURLRequestSessionId === sessionId) {
|
|
return playURLRequest
|
|
}
|
|
|
|
playURLRequestSessionId = sessionId
|
|
playURLRequest = refreshPlayURLOnce(options).finally(() => {
|
|
if (playURLRequestSessionId === sessionId) {
|
|
playURLRequest = null
|
|
playURLRequestSessionId = ''
|
|
}
|
|
})
|
|
return playURLRequest
|
|
}
|
|
|
|
function schedulePlayRefresh() {
|
|
if (disposed || live.stopping) 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({ force: true })
|
|
} catch (e) {
|
|
if (disposed || live.stopping) return
|
|
if (isLeaseInvalidError(e)) {
|
|
await rejoinAfterLeaseInvalid()
|
|
return
|
|
}
|
|
await onPlayError(e)
|
|
}
|
|
}, delay)
|
|
}
|
|
|
|
function scheduleLiveHeartbeat(expiresAt) {
|
|
if (disposed || live.stopping) return
|
|
window.clearTimeout(live.heartbeatTimer)
|
|
const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2)
|
|
live.heartbeatTimer = window.setTimeout(async () => {
|
|
if (disposed || live.stopping || !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.stopping || !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.stopping || !live.session || live.session.id !== sessionId) return
|
|
if (isLeaseInvalidError(e)) {
|
|
await rejoinAfterLeaseInvalid()
|
|
return
|
|
}
|
|
await onPlayError(e)
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (disposed || live.stopping || !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 || live.stopping) return
|
|
rejoinBusy = true
|
|
try {
|
|
// 租约失效:停掉旧 session 的 play-url/heartbeat,再重新 join
|
|
await closeLive(false)
|
|
if (disposed || live.stopping) return
|
|
ui.toast('观看租约已失效,正在重新加入直播')
|
|
await openLive()
|
|
} finally {
|
|
rejoinBusy = false
|
|
}
|
|
}
|
|
|
|
// 播放错误策略(稳播):
|
|
// - 启动 404 由 LivePlayer 同 URL 窗口消化,recoverable 不上冒
|
|
// - 57006 → 整段 rejoin;TLS/403 → 致命且禁止刷 play-url
|
|
// - LivePlayer HlsError(含窗口耗尽)→ markPlayFatal;其余错误有限次 refreshPlayURL
|
|
|
|
async function onPlayError(err) {
|
|
if (disposed || live.stopping || !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 || live.stopping) 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 waitUntilStopped(dockId, sessionId, gen) {
|
|
const startedAt = Date.now()
|
|
while (isCurrentOpen(gen) && live.stopping) {
|
|
if (Date.now() - startedAt > STOP_TIMEOUT_MS) {
|
|
throw new Error('停止超时,请稍后重试')
|
|
}
|
|
const session = await getLiveSession(dockId, sessionId)
|
|
if (!isCurrentOpen(gen) || !live.stopping) return false
|
|
live.session = session
|
|
if (session.phase === 'stopped') return true
|
|
await sleep(STOP_POLL_MS)
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function stopLiveStream() {
|
|
if (disposed || !live.session || !live.dockId || live.opening || live.stopping) 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
|
|
|
|
const gen = openGen
|
|
live.stopping = true
|
|
live.opening = false
|
|
clearPlaybackOnly()
|
|
window.clearTimeout(live.phasePollTimer)
|
|
live.phasePollTimer = null
|
|
if (live.session?.phase !== 'stopping') {
|
|
live.session = { ...live.session, phase: 'stopping' }
|
|
}
|
|
|
|
try {
|
|
await stopLive(dockId)
|
|
if (!isCurrentOpen(gen)) return
|
|
await safeLeave(dockId, session.id)
|
|
if (!isCurrentOpen(gen)) return
|
|
|
|
const stopped = await waitUntilStopped(dockId, session.id, gen)
|
|
if (!isCurrentOpen(gen)) return
|
|
if (!stopped) return
|
|
|
|
openGen += 1
|
|
resetLocal()
|
|
ui.toast('直播已停止')
|
|
} catch (e) {
|
|
if (!isCurrentOpen(gen)) return
|
|
// 超时/失败时保持 stopping 闸门,避免立刻重新开播撞上后端 stopping
|
|
live.stopping = true
|
|
ui.toast(e.message || '停止直播失败')
|
|
}
|
|
}
|
|
|
|
return {
|
|
live,
|
|
livePhaseText,
|
|
canFullscreenLive,
|
|
toggleLive,
|
|
fullscreenLive,
|
|
openLive,
|
|
closeLive,
|
|
dispose,
|
|
stopLiveStream,
|
|
onPlayError,
|
|
}
|
|
}
|
|
|