From 3bb2beb3b766f3af1a2b417e3a2e4a1a8b3f1100 Mon Sep 17 00:00:00 2001 From: xiaosi <2652281683@qq.com> Date: Wed, 2 Sep 2026 16:01:53 +0800 Subject: [PATCH] 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. --- src/components/LivePlayer.vue | 13 ++++++++++--- src/components/MonitorDetailPanel.vue | 7 +++---- src/composables/useMonitorLive.js | 24 ++++++++++++++++++++++-- src/styles/prototype.css | 1 + src/views/MonitorView/MonitorView.vue | 1 + 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/components/LivePlayer.vue b/src/components/LivePlayer.vue index b204590..1da4023 100644 --- a/src/components/LivePlayer.vue +++ b/src/components/LivePlayer.vue @@ -32,6 +32,8 @@ class="play-control" type="button" :aria-label="controlLabel" + :disabled="opening" + :aria-busy="opening ? 'true' : 'false'" @click="onControlClick" > @@ -46,6 +48,7 @@ const props = defineProps({ playUrl: { type: String, default: '' }, phase: { type: String, default: '' }, active: { type: Boolean, default: false }, + opening: { type: Boolean, default: false }, 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 qualityText = computed(() => { + if (props.opening) return '打开中' if (needsUserPlay.value) return '已暂停 · 点击继续' if (playable.value) return 'LIVE · 播放中' if (props.playUrl?.startsWith('fake://')) return '控制面已验证' @@ -71,6 +75,7 @@ const qualityText = computed(() => { }) const statusMessage = computed(() => { + if (props.opening) return '正在打开直播' if (needsUserPlay.value) return '浏览器暂停了画面,点击继续播放' if (playable.value) return '' if (props.phase === 'starting' || props.phase === 'reconnecting') return '正在等待直播流就绪' @@ -81,6 +86,7 @@ const statusMessage = computed(() => { }) const controlLabel = computed(() => { + if (props.opening) return '正在打开直播' if (needsUserPlay.value) return '继续播放' return props.active ? '关闭实时画面' : '打开实时画面' }) @@ -160,6 +166,7 @@ async function resumeFromUserGesture() { } function onControlClick() { + if (props.opening) return if (needsUserPlay.value) { resumeFromUserGesture() return @@ -194,13 +201,13 @@ async function attachPlayer(url) { await video.play() needsUserPlay.value = false } 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') { + console.debug('[live-player] play interrupted', error) + // recoverable pause / autoplay policy — wait for user gesture; do NOT refresh play-url needsUserPlay.value = true return } + console.warn('[live-player] play failed', error) if (seq === attachSeq) emit('error', error) } } diff --git a/src/components/MonitorDetailPanel.vue b/src/components/MonitorDetailPanel.vue index 5cc4475..959ccf3 100644 --- a/src/components/MonitorDetailPanel.vue +++ b/src/components/MonitorDetailPanel.vue @@ -43,9 +43,10 @@ :play-url="liveView.playUrl" :phase="liveView.phase" :active="liveView.active" + :opening="liveView.opening" :camera-label="liveView.cameraLabel" @toggle="$emit('toggle-live')" - @error="$emit('live-error')" + @error="$emit('live-error', $event)" /> @@ -235,14 +236,12 @@ const props = defineProps({ playUrl: '', phase: '', active: false, + opening: false, phaseText: '待机', canFullscreen: false, cameraLabel: '机巢摄像头', }), }, - dockStatus: { type: Object, default: null }, - flightStatus: { type: Object, default: null }, - mission: { type: Object, default: null }, environment: { type: Object, default: () => ({ diff --git a/src/composables/useMonitorLive.js b/src/composables/useMonitorLive.js index bb13648..de18bca 100644 --- a/src/composables/useMonitorLive.js +++ b/src/composables/useMonitorLive.js @@ -33,6 +33,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { const live = reactive({ session: null, dockId: '', + opening: false, playUrl: '', playUrlExpiresAt: 0, heartbeatTimer: null, @@ -42,7 +43,10 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { phasePollStartedAt: 0, }) + let playURLRequest = null + 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://') ? '模拟直播' : '直播中' @@ -66,8 +70,10 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { function resetLocal() { clearTimers() + playURLRequest = null live.session = null live.dockId = '' + live.opening = false live.playUrl = '' live.playUrlExpiresAt = 0 live.playRetryCount = 0 @@ -84,6 +90,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { } function toggleLive() { + if (live.opening) return if (live.session) closeLive() else openLive() } @@ -98,8 +105,11 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { } async function openLive() { + if (live.opening || live.session) return const dockId = getDockId?.() if (!dockId) return + + live.opening = true try { const result = await joinLive(dockId) live.session = result.session @@ -119,6 +129,8 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { } } catch (e) { ui.toast(e.message || '打开直播失败') + } finally { + live.opening = false } } @@ -166,7 +178,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { // 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归) // fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建 - async function refreshPlayURL({ fromRetry = false } = {}) { + async function refreshPlayURLOnce({ fromRetry = false } = {}) { if (!live.dockId || !live.session?.id) return false const sessionId = live.session.id const play = await getLivePlayURL(live.dockId, sessionId) @@ -179,6 +191,14 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { return true } + function refreshPlayURL(options = {}) { + if (playURLRequest) return playURLRequest + playURLRequest = refreshPlayURLOnce(options).finally(() => { + playURLRequest = null + }) + return playURLRequest + } + function schedulePlayRefresh() { window.clearTimeout(live.playRefreshTimer) if (!live.playUrl || live.playUrl.startsWith('fake://') || !live.playUrlExpiresAt) return @@ -268,7 +288,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { } async function stopLiveStream() { - if (!live.session || !live.dockId) return + if (!live.session || !live.dockId || live.opening) return const dockId = live.dockId const session = live.session const ok = await ui.confirm( diff --git a/src/styles/prototype.css b/src/styles/prototype.css index 806c856..8230953 100644 --- a/src/styles/prototype.css +++ b/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; } .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:disabled{ opacity: .45; cursor: not-allowed; pointer-events: none; } .play-control svg{ width: 17px; } .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; } diff --git a/src/views/MonitorView/MonitorView.vue b/src/views/MonitorView/MonitorView.vue index ecfdb87..08ebbfe 100644 --- a/src/views/MonitorView/MonitorView.vue +++ b/src/views/MonitorView/MonitorView.vue @@ -728,6 +728,7 @@ const detailLiveView = computed(() => ({ playUrl: live.playUrl, phase: live.session?.phase, active: !!live.session, + opening: !!live.opening, phaseText: livePhaseText.value, canFullscreen: canFullscreenLive.value, cameraLabel: isDrone.value ? '无人机图传' : '机巢摄像头',