@ -5,6 +5,7 @@ import {
heartbeatLive ,
heartbeatLive ,
joinLive ,
joinLive ,
leaveLive ,
leaveLive ,
leaveLiveKeepalive ,
stopLive ,
stopLive ,
} from '@/api/live'
} from '@/api/live'
@ -13,6 +14,7 @@ const PHASE_TIMEOUT_MS = 60_000
const PLAY_RETRY_MAX = 3
const PLAY_RETRY_MAX = 3
const PLAY_EXPIRE_FALLBACK_SEC = 270
const PLAY_EXPIRE_FALLBACK_SEC = 270
const PLAY_REFRESH_LEAD_MS = 30_000
const PLAY_REFRESH_LEAD_MS = 30_000
const LEASE_INVALID_CODE = 57006
function isFatalPlayMediaError ( err ) {
function isFatalPlayMediaError ( err ) {
const msg = String ( err ? . message || err || '' ) . toLowerCase ( )
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 } ) {
export function useMonitorLive ( { getDockId , ui , getLivePlayer } ) {
const live = reactive ( {
const live = reactive ( {
session : null ,
session : null ,
@ -44,6 +55,9 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} )
} )
let playURLRequest = null
let playURLRequest = null
let disposed = false
let openGen = 0
let rejoinBusy = false
const livePhaseText = computed ( ( ) => {
const livePhaseText = computed ( ( ) => {
if ( live . opening && ! live . session ) return '打开中'
if ( live . opening && ! live . session ) return '打开中'
@ -80,6 +94,10 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
live . phasePollStartedAt = 0
live . phasePollStartedAt = 0
}
}
function isCurrentOpen ( gen ) {
return ! disposed && gen === openGen
}
function markPlayFatal ( message ) {
function markPlayFatal ( message ) {
window . clearTimeout ( live . playRefreshTimer )
window . clearTimeout ( live . playRefreshTimer )
live . playRefreshTimer = null
live . playRefreshTimer = null
@ -90,7 +108,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
}
}
function toggleLive ( ) {
function toggleLive ( ) {
if ( live . opening ) return
if ( disposed || live . opening ) return
if ( live . session ) closeLive ( )
if ( live . session ) closeLive ( )
else openLive ( )
else openLive ( )
}
}
@ -105,53 +123,72 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
}
}
async function openLive ( ) {
async function openLive ( ) {
if ( live . opening || live . session ) return
if ( disposed || live . opening || live . session ) return
const dockId = getDockId ? . ( )
const dockId = getDockId ? . ( )
if ( ! dockId ) return
if ( ! dockId ) return
const gen = ++ openGen
live . opening = true
live . opening = true
try {
try {
const result = await joinLive ( dockId )
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 . dockId = dockId
live . playUrl = ''
live . playUrl = ''
live . playUrlExpiresAt = 0
live . playUrlExpiresAt = 0
live . playRetryCount = 0
live . playRetryCount = 0
scheduleLiveHeartbeat ( result . leaseExpiresAt )
scheduleLiveHeartbeat ( result . leaseExpiresAt )
if ( result . session . phase === 'streaming' ) {
if ( session . phase === 'streaming' ) {
try {
try {
await refreshPlayURL ( )
await refreshPlayURL ( )
} catch ( e ) {
} catch ( e ) {
if ( ! isCurrentOpen ( gen ) ) return
await onPlayError ( e )
await onPlayError ( e )
}
}
} else {
} else {
startPhasePolling ( )
startPhasePolling ( )
}
}
} catch ( e ) {
} catch ( e ) {
if ( ! isCurrentOpen ( gen ) ) return
ui . toast ( e . message || '打开直播失败' )
ui . toast ( e . message || '打开直播失败' )
} finally {
} finally {
live . opening = false
if ( gen === openGen ) live . opening = false
}
}
}
}
function startPhasePolling ( ) {
function startPhasePolling ( ) {
if ( disposed ) return
window . clearTimeout ( live . phasePollTimer )
window . clearTimeout ( live . phasePollTimer )
live . phasePollStartedAt = Date . now ( )
live . phasePollStartedAt = Date . now ( )
live . phasePollTimer = window . setTimeout ( pollPhaseOnce , PHASE_POLL_MS )
live . phasePollTimer = window . setTimeout ( pollPhaseOnce , PHASE_POLL_MS )
}
}
async function pollPhaseOnce ( ) {
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 ) {
if ( Date . now ( ) - live . phasePollStartedAt > PHASE_TIMEOUT_MS ) {
ui . toast ( '直播启动超时' )
ui . toast ( '直播启动超时' )
await closeLive ( false )
await closeLive ( false )
return
return
}
}
const dockId = live . dockId
const sessionId = live . session . id
const sessionId = live . session . id
try {
try {
// http 解包后是 LiveSession 本体,不是 { session }
// 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
live . session = session
if ( session . phase === 'streaming' ) {
if ( session . phase === 'streaming' ) {
window . clearTimeout ( live . phasePollTimer )
window . clearTimeout ( live . phasePollTimer )
@ -159,6 +196,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
try {
try {
await refreshPlayURL ( )
await refreshPlayURL ( )
} catch ( e ) {
} catch ( e ) {
if ( disposed || ! live . session || live . session . id !== sessionId ) return
await onPlayError ( e )
await onPlayError ( e )
}
}
return
return
@ -169,20 +207,27 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
return
return
}
}
} catch ( e ) {
} catch ( e ) {
if ( disposed || ! live . session || live . session . id !== sessionId ) return
if ( isLeaseInvalidError ( e ) ) {
await rejoinAfterLeaseInvalid ( )
return
}
ui . toast ( e . message || '查询直播状态失败' )
ui . toast ( e . message || '查询直播状态失败' )
await closeLive ( false )
await closeLive ( false )
return
return
}
}
if ( disposed || ! live . session || live . session . id !== sessionId ) return
live . phasePollTimer = window . setTimeout ( pollPhaseOnce , PHASE_POLL_MS )
live . phasePollTimer = window . setTimeout ( pollPhaseOnce , PHASE_POLL_MS )
}
}
// 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归)
// 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归)
// fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建
// fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建
async function refreshPlayURLOnce ( { fromRetry = false } = { } ) {
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 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 || ''
live . playUrl = play . playUrl || ''
const nowSec = Math . floor ( Date . now ( ) / 1000 )
const nowSec = Math . floor ( Date . now ( ) / 1000 )
live . playUrlExpiresAt = Number ( play . expiresAt ) || nowSec + PLAY_EXPIRE_FALLBACK_SEC
live . playUrlExpiresAt = Number ( play . expiresAt ) || nowSec + PLAY_EXPIRE_FALLBACK_SEC
@ -200,6 +245,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
}
}
function schedulePlayRefresh ( ) {
function schedulePlayRefresh ( ) {
if ( disposed ) return
window . clearTimeout ( live . playRefreshTimer )
window . clearTimeout ( live . playRefreshTimer )
if ( ! live . playUrl || live . playUrl . startsWith ( 'fake://' ) || ! live . playUrlExpiresAt ) return
if ( ! live . playUrl || live . playUrl . startsWith ( 'fake://' ) || ! live . playUrlExpiresAt ) return
const delay = Math . max ( 5000 , live . playUrlExpiresAt * 1000 - Date . now ( ) - PLAY_REFRESH_LEAD_MS )
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 {
try {
await refreshPlayURL ( )
await refreshPlayURL ( )
} catch ( e ) {
} catch ( e ) {
if ( disposed ) return
if ( isLeaseInvalidError ( e ) ) {
await rejoinAfterLeaseInvalid ( )
return
}
await onPlayError ( e )
await onPlayError ( e )
}
}
} , delay )
} , delay )
}
}
function scheduleLiveHeartbeat ( expiresAt ) {
function scheduleLiveHeartbeat ( expiresAt ) {
if ( disposed ) return
window . clearTimeout ( live . heartbeatTimer )
window . clearTimeout ( live . heartbeatTimer )
const delay = Math . max ( 5000 , ( Number ( expiresAt ) * 1000 - Date . now ( ) ) / 2 )
const delay = Math . max ( 5000 , ( Number ( expiresAt ) * 1000 - Date . now ( ) ) / 2 )
live . heartbeatTimer = window . setTimeout ( async ( ) => {
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 dockId = live . dockId
const sessionId = live . session . id
const sessionId = live . session . id
try {
try {
const result = await heartbeatLive ( dockId , sessionId )
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
live . session = result . session
scheduleLiveHeartbeat ( result . leaseExpiresAt )
scheduleLiveHeartbeat ( result . leaseExpiresAt )
if ( result . session . phase === 'streaming' && ! live . playUrl ) {
if ( result . session . phase === 'streaming' && ! live . playUrl ) {
try {
try {
await refreshPlayURL ( )
await refreshPlayURL ( )
} catch ( e ) {
} catch ( e ) {
if ( disposed || ! live . session || live . session . id !== sessionId ) return
if ( isLeaseInvalidError ( e ) ) {
await rejoinAfterLeaseInvalid ( )
return
}
await onPlayError ( e )
await onPlayError ( e )
}
}
}
}
} catch ( 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 || '直播观看已结束' )
ui . toast ( e . message || '直播观看已结束' )
await closeLive ( false )
await closeLive ( false )
}
}
} , delay )
} , 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 ) {
async function onPlayError ( err ) {
if ( ! live . session ) return
if ( disposed || ! live . session ) return
// AbortError/NotAllowedError: browser pause / autoplay policy. Keep current playUrl.
// AbortError/NotAllowedError: browser pause / autoplay policy. Keep current playUrl.
if ( err ? . name === 'AbortError' || err ? . name === 'NotAllowedError' ) return
if ( err ? . name === 'AbortError' || err ? . name === 'NotAllowedError' ) return
if ( isLeaseInvalidError ( err ) ) {
await rejoinAfterLeaseInvalid ( )
return
}
// 播放器媒体面失败(TLS/CORS/HLS network):换 auth_key 没用,禁止刷 play-url
// 播放器媒体面失败(TLS/CORS/HLS network):换 auth_key 没用,禁止刷 play-url
if ( isFatalPlayMediaError ( err ) || err ? . name === 'HlsError' ) {
if ( isFatalPlayMediaError ( err ) || err ? . name === 'HlsError' ) {
markPlayFatal (
markPlayFatal (
@ -264,6 +344,11 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
try {
try {
await refreshPlayURL ( { fromRetry : true } )
await refreshPlayURL ( { fromRetry : true } )
} catch ( e ) {
} catch ( e ) {
if ( disposed ) return
if ( isLeaseInvalidError ( e ) ) {
await rejoinAfterLeaseInvalid ( )
return
}
if ( isFatalPlayMediaError ( e ) || e ? . name === 'HlsError' || live . playRetryCount >= PLAY_RETRY_MAX ) {
if ( isFatalPlayMediaError ( e ) || e ? . name === 'HlsError' || live . playRetryCount >= PLAY_RETRY_MAX ) {
markPlayFatal (
markPlayFatal (
isFatalPlayMediaError ( e )
isFatalPlayMediaError ( e )
@ -277,18 +362,32 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
}
}
async function closeLive ( sendRequest = true ) {
async function closeLive ( sendRequest = true ) {
// 使 in-flight openLive/join 失效;迟到的 join 结果会走 orphan leave
openGen += 1
const session = live . session
const session = live . session
const dockId = live . dockId
const dockId = live . dockId
resetLocal ( )
resetLocal ( )
if ( sendRequest && session && dockId ) {
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 ( ) {
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 dockId = live . dockId
const session = live . session
const session = live . session
const ok = await ui . confirm (
const ok = await ui . confirm (
@ -298,13 +397,12 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
)
)
if ( ! ok ) return
if ( ! ok ) return
// confirm 期间可能已关闭/切换会话,避免 stop 错 dock 或 wipe 新会话
// 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 {
try {
await stopLive ( dockId )
await stopLive ( dockId )
openGen += 1
resetLocal ( )
resetLocal ( )
try {
await leaveLive ( dockId , session . id )
} catch ( _ ) { }
await safeLeave ( dockId , session . id )
ui . toast ( '直播已停止' )
ui . toast ( '直播已停止' )
} catch ( e ) {
} catch ( e ) {
ui . toast ( e . message || '停止直播失败' )
ui . toast ( e . message || '停止直播失败' )
@ -319,6 +417,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
fullscreenLive ,
fullscreenLive ,
openLive ,
openLive ,
closeLive ,
closeLive ,
dispose ,
stopLiveStream ,
stopLiveStream ,
onPlayError ,
onPlayError ,
}
}