diff --git a/src/views/MediaView/MediaView.vue b/src/views/MediaView/MediaView.vue index f7e59bc..2391f4c 100644 --- a/src/views/MediaView/MediaView.vue +++ b/src/views/MediaView/MediaView.vue @@ -102,13 +102,19 @@ const activeLive = reactive({ dockId: '', sessionId: '', playUrl: '', + playUrlExpiresAt: 0, phase: '', leaseExpiresAt: 0, }) +const PLAY_URL_FRESH_MS = 30_000 +const PLAY_EXPIRE_FALLBACK_SEC = 270 let heartbeatTimer = null let openSeq = 0 let disposed = false let rejoinBusy = false +let playUrlPromise = null +let playUrlSessionId = '' +let opening = false const LEASE_INVALID_CODE = 57006 @@ -144,14 +150,59 @@ function clearHeartbeat() { function resetActiveLive() { clearHeartbeat() + playUrlPromise = null + playUrlSessionId = '' activeLive.id = '' activeLive.dockId = '' activeLive.sessionId = '' activeLive.playUrl = '' + activeLive.playUrlExpiresAt = 0 activeLive.phase = '' activeLive.leaseExpiresAt = 0 } +async function ensurePlayUrl(dockId, sessionId, { force = false } = {}) { + if (!dockId || !sessionId) return null + + if ( + !force && + activeLive.sessionId === sessionId && + activeLive.playUrl && + activeLive.playUrlExpiresAt * 1000 - Date.now() > PLAY_URL_FRESH_MS + ) { + return { + playUrl: activeLive.playUrl, + expiresAt: activeLive.playUrlExpiresAt, + } + } + + if (playUrlPromise && playUrlSessionId === sessionId) { + return playUrlPromise + } + + const seq = openSeq + playUrlSessionId = sessionId + playUrlPromise = getLivePlayURL(dockId, sessionId) + .then((play) => { + if (disposed || seq !== openSeq || activeLive.sessionId !== sessionId) return play + const nowSec = Math.floor(Date.now() / 1000) + const expiresAt = Number(play?.expiresAt) || nowSec + PLAY_EXPIRE_FALLBACK_SEC + if (play?.playUrl) { + activeLive.playUrl = play.playUrl + activeLive.playUrlExpiresAt = expiresAt + } + return play + }) + .finally(() => { + if (playUrlSessionId === sessionId) { + playUrlPromise = null + playUrlSessionId = '' + } + }) + + return playUrlPromise +} + function scheduleHeartbeat(expiresAt) { clearHeartbeat() if (disposed || !activeLive.dockId || !activeLive.sessionId) return @@ -165,6 +216,18 @@ function scheduleHeartbeat(expiresAt) { const result = await heartbeatLive(dockId, sessionId) if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return if (result?.session?.phase) activeLive.phase = result.session.phase + if (result?.session?.phase === 'streaming' && !activeLive.playUrl) { + try { + await ensurePlayUrl(dockId, sessionId) + } catch (playErr) { + if (disposed || activeLive.sessionId !== sessionId) return + if (isLeaseInvalidError(playErr)) { + await rejoinAfterLeaseInvalid(streamId, dockId) + return + } + console.warn('[media] ensure play-url after heartbeat failed', playErr) + } + } scheduleHeartbeat(result.leaseExpiresAt) } catch (e) { if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return @@ -236,24 +299,23 @@ async function load() { } async function openLive(stream) { - if (disposed || !stream?.dockId) return + if (disposed || !stream?.dockId || opening) return if (activeLive.id === stream.id && activeLive.playUrl) { await closeActiveLive() return } - // closeActiveLive 会 bump openSeq,使旧 in-flight join 失效;之后再取本次 seq await closeActiveLive() + if (disposed) return + opening = true const seq = ++openSeq - try { const result = await joinLiveWithRetry(stream.dockId, {}, { - shouldContinue: () => seq === openSeq && !disposed, + shouldContinue: () => seq === openSeq && !disposed && opening, }) const session = result?.session const sessionId = session?.id - // 页面已切走/关闭:不要写入状态,立刻释放刚拿到的 viewer lease if (seq !== openSeq || disposed) { if (sessionId) await safeLeave(stream.dockId, sessionId) return @@ -269,6 +331,7 @@ async function openLive(stream) { activeLive.sessionId = sessionId activeLive.phase = session.phase || '' activeLive.playUrl = '' + activeLive.playUrlExpiresAt = 0 scheduleHeartbeat(result.leaseExpiresAt) if (session.phase !== 'streaming') { @@ -276,11 +339,13 @@ async function openLive(stream) { return } - const play = await getLivePlayURL(stream.dockId, sessionId) + const play = await ensurePlayUrl(stream.dockId, sessionId) if (seq !== openSeq || disposed || activeLive.sessionId !== sessionId) return if (play?.playUrl && !play.playUrl.startsWith('fake://')) { - activeLive.playUrl = play.playUrl - } else { + if (!activeLive.playUrl) { + activeLive.playUrl = play.playUrl + } + } else if (!activeLive.playUrl) { ui.toast('控制面已验证,当前未配置可播放媒体流') } } catch (e) { @@ -291,6 +356,8 @@ async function openLive(stream) { } await closeActiveLive() ui.toast(e.message || '打开直播失败') + } finally { + opening = false } }