Browse Source

fix: treat AbortError as recoverable live pause

Chrome power-saving pause must not refresh play-url;
show a resume button and play from user gesture instead.
main
xiaosi 2 weeks ago
parent
commit
599146f35d
  1. 105
      src/components/LivePlayer.vue
  2. 3
      src/composables/useMonitorLive.js
  3. 1
      src/styles/prototype.css

105
src/components/LivePlayer.vue

@ -2,7 +2,11 @@
<div <div
ref="frameEl" ref="frameEl"
class="video-frame" class="video-frame"
:class="{ streaming: isLiveShell, playing: playable }"
:class="{
streaming: isLiveShell,
playing: playable,
paused: needsUserPlay,
}"
> >
<video <video
v-if="playable" v-if="playable"
@ -27,10 +31,10 @@
<button <button
class="play-control" class="play-control"
type="button" type="button"
:aria-label="active ? '关闭实时画面' : '打开实时画面'"
@click="$emit('toggle')"
:aria-label="controlLabel"
@click="onControlClick"
> >
<svg><use :href="active ? '#i-stop' : '#i-play'" /></svg>
<svg><use :href="controlIcon" /></svg>
</button> </button>
</div> </div>
</template> </template>
@ -49,13 +53,16 @@ const emit = defineEmits(['toggle', 'error'])
const frameEl = ref(null) const frameEl = ref(null)
const videoEl = ref(null) const videoEl = ref(null)
const needsUserPlay = ref(false)
let hls = null let hls = null
let attachSeq = 0 let attachSeq = 0
let mediaCleanup = null
const playable = computed(() => props.playUrl && !props.playUrl.startsWith('fake://')) 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 (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 '控制面已验证'
if (props.phase === 'starting' || props.phase === 'reconnecting') return '启动中' if (props.phase === 'starting' || props.phase === 'reconnecting') return '启动中'
@ -64,6 +71,7 @@ const qualityText = computed(() => {
}) })
const statusMessage = computed(() => { const statusMessage = computed(() => {
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 '正在等待直播流就绪'
if (props.phase === 'failed') return '直播启动失败' if (props.phase === 'failed') return '直播启动失败'
@ -72,7 +80,21 @@ const statusMessage = computed(() => {
return '' 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() { function destroyPlayer() {
clearMediaListeners()
needsUserPlay.value = false
if (hls) { if (hls) {
hls.destroy() hls.destroy()
hls = null 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) { async function attachPlayer(url) {
const seq = ++attachSeq const seq = ++attachSeq
destroyPlayer() destroyPlayer()
@ -96,6 +179,7 @@ async function attachPlayer(url) {
video.muted = true video.muted = true
video.defaultMuted = true video.defaultMuted = true
video.setAttribute('muted', '') video.setAttribute('muted', '')
bindMediaListeners(video, seq)
video.addEventListener('error', () => { video.addEventListener('error', () => {
if (seq !== attachSeq) return if (seq !== attachSeq) return
@ -108,16 +192,23 @@ async function attachPlayer(url) {
if (seq !== attachSeq) return if (seq !== attachSeq) return
try { try {
await video.play() await video.play()
needsUserPlay.value = false
} catch (error) { } 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')) { if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url video.src = url
await tryPlay() await tryPlay()
syncPausedState()
return return
} }

3
src/composables/useMonitorLive.js

@ -222,6 +222,9 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
async function onPlayError(err) { async function onPlayError(err) {
if (!live.session) return 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 // 播放器媒体面失败(TLS/CORS/HLS network):换 auth_key 没用,禁止刷 play-url
if (isFatalPlayMediaError(err) || err?.name === 'HlsError') { if (isFatalPlayMediaError(err) || err?.name === 'HlsError') {
markPlayFatal( markPlayFatal(

1
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 .camera-scene{ display: none; }
.video-frame.playing .play-control{ opacity: 0; pointer-events: none; transition: opacity .15s ease; } .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: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; } .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; } .section-title .icon-button:disabled{ opacity: .35; cursor: not-allowed; }
@keyframes camera-live{ to { transform: scale(1.035) translateX(-2px); } } @keyframes camera-live{ to { transform: scale(1.035) translateX(-2px); } }

Loading…
Cancel
Save