diff --git a/src/components/LivePlayer.vue b/src/components/LivePlayer.vue index cf4d2b5..b204590 100644 --- a/src/components/LivePlayer.vue +++ b/src/components/LivePlayer.vue @@ -2,7 +2,11 @@
@@ -49,13 +53,16 @@ const emit = defineEmits(['toggle', 'error']) const frameEl = ref(null) const videoEl = ref(null) +const needsUserPlay = ref(false) let hls = null let attachSeq = 0 +let mediaCleanup = null const playable = computed(() => props.playUrl && !props.playUrl.startsWith('fake://')) const isLiveShell = computed(() => props.active && !playable.value) const qualityText = computed(() => { + if (needsUserPlay.value) return '已暂停 · 点击继续' if (playable.value) return 'LIVE · 播放中' if (props.playUrl?.startsWith('fake://')) return '控制面已验证' if (props.phase === 'starting' || props.phase === 'reconnecting') return '启动中' @@ -64,6 +71,7 @@ const qualityText = computed(() => { }) const statusMessage = computed(() => { + if (needsUserPlay.value) return '浏览器暂停了画面,点击继续播放' if (playable.value) return '' if (props.phase === 'starting' || props.phase === 'reconnecting') return '正在等待直播流就绪' if (props.phase === 'failed') return '直播启动失败' @@ -72,7 +80,21 @@ const statusMessage = computed(() => { return '' }) +const controlLabel = computed(() => { + if (needsUserPlay.value) return '继续播放' + return props.active ? '关闭实时画面' : '打开实时画面' +}) + +const controlIcon = computed(() => (needsUserPlay.value || !props.active ? '#i-play' : '#i-stop')) + +function clearMediaListeners() { + if (typeof mediaCleanup === 'function') mediaCleanup() + mediaCleanup = null +} + function destroyPlayer() { + clearMediaListeners() + needsUserPlay.value = false if (hls) { hls.destroy() hls = null @@ -84,6 +106,67 @@ function destroyPlayer() { } } +function syncPausedState() { + const video = videoEl.value + if (!video || !playable.value) { + needsUserPlay.value = false + return + } + needsUserPlay.value = !!video.paused +} + +function bindMediaListeners(video, seq) { + clearMediaListeners() + const onPlay = () => { + if (seq !== attachSeq) return + needsUserPlay.value = false + } + const onPause = () => { + if (seq !== attachSeq) return + // Chrome 节能/失焦暂停:显示手动恢复,不要重建 play-url + needsUserPlay.value = true + } + video.addEventListener('play', onPlay) + video.addEventListener('playing', onPlay) + video.addEventListener('pause', onPause) + mediaCleanup = () => { + video.removeEventListener('play', onPlay) + video.removeEventListener('playing', onPlay) + video.removeEventListener('pause', onPause) + } +} + +async function resumeFromUserGesture() { + const video = videoEl.value + if (!video) return + try { + // 用户点击恢复时可开声;失败则退回静音再试一次 + video.muted = false + video.removeAttribute('muted') + await video.play() + needsUserPlay.value = false + } catch (error) { + console.warn('[live-player] resume failed', error) + try { + video.muted = true + video.setAttribute('muted', '') + await video.play() + needsUserPlay.value = false + } catch (retryError) { + console.warn('[live-player] muted resume failed', retryError) + needsUserPlay.value = true + } + } +} + +function onControlClick() { + if (needsUserPlay.value) { + resumeFromUserGesture() + return + } + emit('toggle') +} + async function attachPlayer(url) { const seq = ++attachSeq destroyPlayer() @@ -96,6 +179,7 @@ async function attachPlayer(url) { video.muted = true video.defaultMuted = true video.setAttribute('muted', '') + bindMediaListeners(video, seq) video.addEventListener('error', () => { if (seq !== attachSeq) return @@ -108,16 +192,23 @@ async function attachPlayer(url) { if (seq !== attachSeq) return try { await video.play() + needsUserPlay.value = false } catch (error) { - console.warn('[live-player] autoplay blocked', error) - // NotAllowedError: autoplay policy. Do not storm play-url rebuilds. - if (error?.name !== 'NotAllowedError' && seq === attachSeq) emit('error', 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') { + needsUserPlay.value = true + return + } + if (seq === attachSeq) emit('error', error) } } if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url await tryPlay() + syncPausedState() return } diff --git a/src/composables/useMonitorLive.js b/src/composables/useMonitorLive.js index 91decff..bb13648 100644 --- a/src/composables/useMonitorLive.js +++ b/src/composables/useMonitorLive.js @@ -222,6 +222,9 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) { async function onPlayError(err) { if (!live.session) return + // AbortError/NotAllowedError: browser pause / autoplay policy. Keep current playUrl. + if (err?.name === 'AbortError' || err?.name === 'NotAllowedError') return + // 播放器媒体面失败(TLS/CORS/HLS network):换 auth_key 没用,禁止刷 play-url if (isFatalPlayMediaError(err) || err?.name === 'HlsError') { markPlayFatal( diff --git a/src/styles/prototype.css b/src/styles/prototype.css index 748f955..806c856 100644 --- a/src/styles/prototype.css +++ b/src/styles/prototype.css @@ -140,6 +140,7 @@ svg:not(.t-icon){ width: 18px; height: 18px; fill: none; stroke: currentColor; s .video-frame.playing .camera-scene{ display: none; } .video-frame.playing .play-control{ opacity: 0; pointer-events: none; transition: opacity .15s ease; } .video-frame.playing:hover .play-control{ opacity: 1; pointer-events: auto; } +.video-frame.playing.paused .play-control{ opacity: 1; pointer-events: auto; } .video-status{ position: absolute; z-index: 5; left: 50%; bottom: 12px; max-width: calc(100% - 24px); padding: 5px 9px; border-radius: 4px; color: #fff; background: rgba(13,28,35,.72); font-size: 11px; text-align: center; transform: translateX(-50%); pointer-events: none; } .section-title .icon-button:disabled{ opacity: .35; cursor: not-allowed; } @keyframes camera-live{ to { transform: scale(1.035) translateX(-2px); } }