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); } }