Browse Source

fix: guard live open clicks and coalesce play-url fetches

Prevent double join while opening, share in-flight play-url
requests across poll/heartbeat/error paths, and keep AbortError local.
main
xiaosi 2 weeks ago
parent
commit
3bb2beb3b7
  1. 13
      src/components/LivePlayer.vue
  2. 7
      src/components/MonitorDetailPanel.vue
  3. 24
      src/composables/useMonitorLive.js
  4. 1
      src/styles/prototype.css
  5. 1
      src/views/MonitorView/MonitorView.vue

13
src/components/LivePlayer.vue

@ -32,6 +32,8 @@
class="play-control" class="play-control"
type="button" type="button"
:aria-label="controlLabel" :aria-label="controlLabel"
:disabled="opening"
:aria-busy="opening ? 'true' : 'false'"
@click="onControlClick" @click="onControlClick"
> >
<svg><use :href="controlIcon" /></svg> <svg><use :href="controlIcon" /></svg>
@ -46,6 +48,7 @@ const props = defineProps({
playUrl: { type: String, default: '' }, playUrl: { type: String, default: '' },
phase: { type: String, default: '' }, phase: { type: String, default: '' },
active: { type: Boolean, default: false }, active: { type: Boolean, default: false },
opening: { type: Boolean, default: false },
cameraLabel: { type: String, default: '机巢摄像头' }, cameraLabel: { type: String, default: '机巢摄像头' },
}) })
@ -62,6 +65,7 @@ const playable = computed(() => props.playUrl && !props.playUrl.startsWith('fake
const isLiveShell = computed(() => props.active && !playable.value) const isLiveShell = computed(() => props.active && !playable.value)
const qualityText = computed(() => { const qualityText = computed(() => {
if (props.opening) return '打开中'
if (needsUserPlay.value) return '已暂停 · 点击继续' if (needsUserPlay.value) return '已暂停 · 点击继续'
if (playable.value) return 'LIVE · 播放中' if (playable.value) return 'LIVE · 播放中'
if (props.playUrl?.startsWith('fake://')) return '控制面已验证' if (props.playUrl?.startsWith('fake://')) return '控制面已验证'
@ -71,6 +75,7 @@ const qualityText = computed(() => {
}) })
const statusMessage = computed(() => { const statusMessage = computed(() => {
if (props.opening) return '正在打开直播'
if (needsUserPlay.value) return '浏览器暂停了画面,点击继续播放' if (needsUserPlay.value) return '浏览器暂停了画面,点击继续播放'
if (playable.value) return '' if (playable.value) return ''
if (props.phase === 'starting' || props.phase === 'reconnecting') return '正在等待直播流就绪' if (props.phase === 'starting' || props.phase === 'reconnecting') return '正在等待直播流就绪'
@ -81,6 +86,7 @@ const statusMessage = computed(() => {
}) })
const controlLabel = computed(() => { const controlLabel = computed(() => {
if (props.opening) return '正在打开直播'
if (needsUserPlay.value) return '继续播放' if (needsUserPlay.value) return '继续播放'
return props.active ? '关闭实时画面' : '打开实时画面' return props.active ? '关闭实时画面' : '打开实时画面'
}) })
@ -160,6 +166,7 @@ async function resumeFromUserGesture() {
} }
function onControlClick() { function onControlClick() {
if (props.opening) return
if (needsUserPlay.value) { if (needsUserPlay.value) {
resumeFromUserGesture() resumeFromUserGesture()
return return
@ -194,13 +201,13 @@ async function attachPlayer(url) {
await video.play() await video.play()
needsUserPlay.value = false needsUserPlay.value = false
} catch (error) { } catch (error) {
console.warn('[live-player] play failed', error)
// AbortError/NotAllowedError: recoverable pause / autoplay policy.
// Wait for user gesture; do NOT refresh play-url.
if (error?.name === 'AbortError' || error?.name === 'NotAllowedError') { if (error?.name === 'AbortError' || error?.name === 'NotAllowedError') {
console.debug('[live-player] play interrupted', error)
// recoverable pause / autoplay policy wait for user gesture; do NOT refresh play-url
needsUserPlay.value = true needsUserPlay.value = true
return return
} }
console.warn('[live-player] play failed', error)
if (seq === attachSeq) emit('error', error) if (seq === attachSeq) emit('error', error)
} }
} }

7
src/components/MonitorDetailPanel.vue

@ -43,9 +43,10 @@
:play-url="liveView.playUrl" :play-url="liveView.playUrl"
:phase="liveView.phase" :phase="liveView.phase"
:active="liveView.active" :active="liveView.active"
:opening="liveView.opening"
:camera-label="liveView.cameraLabel" :camera-label="liveView.cameraLabel"
@toggle="$emit('toggle-live')" @toggle="$emit('toggle-live')"
@error="$emit('live-error')"
@error="$emit('live-error', $event)"
/> />
</section> </section>
@ -235,14 +236,12 @@ const props = defineProps({
playUrl: '', playUrl: '',
phase: '', phase: '',
active: false, active: false,
opening: false,
phaseText: '待机', phaseText: '待机',
canFullscreen: false, canFullscreen: false,
cameraLabel: '机巢摄像头', cameraLabel: '机巢摄像头',
}), }),
}, },
dockStatus: { type: Object, default: null },
flightStatus: { type: Object, default: null },
mission: { type: Object, default: null },
environment: { environment: {
type: Object, type: Object,
default: () => ({ default: () => ({

24
src/composables/useMonitorLive.js

@ -33,6 +33,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
const live = reactive({ const live = reactive({
session: null, session: null,
dockId: '', dockId: '',
opening: false,
playUrl: '', playUrl: '',
playUrlExpiresAt: 0, playUrlExpiresAt: 0,
heartbeatTimer: null, heartbeatTimer: null,
@ -42,7 +43,10 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
phasePollStartedAt: 0, phasePollStartedAt: 0,
}) })
let playURLRequest = null
const livePhaseText = computed(() => { const livePhaseText = computed(() => {
if (live.opening && !live.session) return '打开中'
if (!live.session) return '待机' if (!live.session) return '待机'
const phase = live.session.phase const phase = live.session.phase
if (phase === 'streaming') return live.playUrl.startsWith('fake://') ? '模拟直播' : '直播中' if (phase === 'streaming') return live.playUrl.startsWith('fake://') ? '模拟直播' : '直播中'
@ -66,8 +70,10 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
function resetLocal() { function resetLocal() {
clearTimers() clearTimers()
playURLRequest = null
live.session = null live.session = null
live.dockId = '' live.dockId = ''
live.opening = false
live.playUrl = '' live.playUrl = ''
live.playUrlExpiresAt = 0 live.playUrlExpiresAt = 0
live.playRetryCount = 0 live.playRetryCount = 0
@ -84,6 +90,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
function toggleLive() { function toggleLive() {
if (live.opening) return
if (live.session) closeLive() if (live.session) closeLive()
else openLive() else openLive()
} }
@ -98,8 +105,11 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
async function openLive() { async function openLive() {
if (live.opening || live.session) return
const dockId = getDockId?.() const dockId = getDockId?.()
if (!dockId) return if (!dockId) return
live.opening = true
try { try {
const result = await joinLive(dockId) const result = await joinLive(dockId)
live.session = result.session live.session = result.session
@ -119,6 +129,8 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
} catch (e) { } catch (e) {
ui.toast(e.message || '打开直播失败') ui.toast(e.message || '打开直播失败')
} finally {
live.opening = false
} }
} }
@ -166,7 +178,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
// 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归) // 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归)
// fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建 // fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建
async function refreshPlayURL({ fromRetry = false } = {}) {
async function refreshPlayURLOnce({ fromRetry = false } = {}) {
if (!live.dockId || !live.session?.id) return false if (!live.dockId || !live.session?.id) return false
const sessionId = live.session.id const sessionId = live.session.id
const play = await getLivePlayURL(live.dockId, sessionId) const play = await getLivePlayURL(live.dockId, sessionId)
@ -179,6 +191,14 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
return true return true
} }
function refreshPlayURL(options = {}) {
if (playURLRequest) return playURLRequest
playURLRequest = refreshPlayURLOnce(options).finally(() => {
playURLRequest = null
})
return playURLRequest
}
function schedulePlayRefresh() { function schedulePlayRefresh() {
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
@ -268,7 +288,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
async function stopLiveStream() { async function stopLiveStream() {
if (!live.session || !live.dockId) return
if (!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(

1
src/styles/prototype.css

@ -134,6 +134,7 @@ svg:not(.t-icon){ width: 18px; height: 18px; fill: none; stroke: currentColor; s
.video-overlay small{ font-size: 10px; } .video-overlay small{ font-size: 10px; }
.play-control{ position: absolute; z-index: 4; left: 50%; top: 50%; width: 42px; height: 42px; display: grid; place-items: center; transform: translate(-50%, -50%); border: 1px solid rgba(255,255,255,.55); border-radius: 50%; color: #fff; background: rgba(13,28,35,.64); backdrop-filter: blur(4px); } .play-control{ position: absolute; z-index: 4; left: 50%; top: 50%; width: 42px; height: 42px; display: grid; place-items: center; transform: translate(-50%, -50%); border: 1px solid rgba(255,255,255,.55); border-radius: 50%; color: #fff; background: rgba(13,28,35,.64); backdrop-filter: blur(4px); }
.play-control:hover{ transform: translate(-50%, -50%) scale(1.06); } .play-control:hover{ transform: translate(-50%, -50%) scale(1.06); }
.play-control:disabled{ opacity: .45; cursor: not-allowed; pointer-events: none; }
.play-control svg{ width: 17px; } .play-control svg{ width: 17px; }
.video-frame.streaming .camera-scene{ animation: camera-live 4s ease-in-out infinite alternate; } .video-frame.streaming .camera-scene{ animation: camera-live 4s ease-in-out infinite alternate; }
.video-frame > video{ position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; object-fit: cover; background: #000; border: 0; } .video-frame > video{ position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; object-fit: cover; background: #000; border: 0; }

1
src/views/MonitorView/MonitorView.vue

@ -728,6 +728,7 @@ const detailLiveView = computed(() => ({
playUrl: live.playUrl, playUrl: live.playUrl,
phase: live.session?.phase, phase: live.session?.phase,
active: !!live.session, active: !!live.session,
opening: !!live.opening,
phaseText: livePhaseText.value, phaseText: livePhaseText.value,
canFullscreen: canFullscreenLive.value, canFullscreen: canFullscreenLive.value,
cameraLabel: isDrone.value ? '无人机图传' : '机巢摄像头', cameraLabel: isDrone.value ? '无人机图传' : '机巢摄像头',

Loading…
Cancel
Save