You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

440 lines
12 KiB

<template>
<div
ref="frameEl"
class="video-frame"
:class="{
streaming: isLiveShell,
playing: playable,
paused: needsUserPlay,
}"
>
<video
v-if="playable"
ref="videoEl"
controls
autoplay
muted
playsinline
/>
<div v-else class="camera-scene">
<div class="horizon" />
<div class="dock-model"><span /><span /><i /></div>
</div>
<div class="video-overlay">
<span>{{ cameraLabel }}</span>
<small>{{ qualityText }}</small>
</div>
<div v-if="statusMessage" class="video-status">{{ statusMessage }}</div>
<button
class="play-control"
type="button"
:aria-label="controlLabel"
:disabled="opening || stopping"
:aria-busy="opening || stopping ? 'true' : 'false'"
@click="onControlClick"
>
<svg><use :href="controlIcon" /></svg>
</button>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
const STARTUP_RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 6000, 8000]
const STARTUP_WINDOW_MS = 30_000
const props = defineProps({
playUrl: { type: String, default: '' },
phase: { type: String, default: '' },
active: { type: Boolean, default: false },
opening: { type: Boolean, default: false },
stopping: { type: Boolean, default: false },
cameraLabel: { type: String, default: '机巢摄像头' },
})
const emit = defineEmits(['toggle', 'error'])
const frameEl = ref(null)
const videoEl = ref(null)
const needsUserPlay = ref(false)
const waitingForStream = ref(false)
let hls = null
let attachSeq = 0
let mediaCleanup = null
let startupRetryTimer = null
let startupRetryCount = 0
let startupStartedAt = 0
let activeUrl = ''
let mediaRecoverAttempted = false
const playable = computed(() => props.playUrl && !props.playUrl.startsWith('fake://'))
const isLiveShell = computed(() => props.active && !playable.value)
const qualityText = computed(() => {
if (props.opening) return '打开中'
if (props.stopping) return '正在停止'
if (waitingForStream.value) return '启动中 · 等待流就绪'
if (needsUserPlay.value) return '已暂停 · 点击继续'
if (playable.value) return 'LIVE · 播放中'
if (props.playUrl?.startsWith('fake://')) return '控制面已验证'
if (props.phase === 'starting' || props.phase === 'reconnecting') return '启动中'
if (props.phase === 'stopping') return '正在停止'
if (props.phase === 'failed') return '启动失败'
return 'H.264 · 720P'
})
const statusMessage = computed(() => {
if (props.opening) return '正在打开直播'
if (props.stopping || props.phase === 'stopping') return '正在停止直播'
if (waitingForStream.value) return '正在等待直播流就绪'
if (needsUserPlay.value) return '浏览器暂停了画面,点击继续播放'
if (playable.value) return ''
if (props.phase === 'starting' || props.phase === 'reconnecting') return '正在等待直播流就绪'
if (props.phase === 'failed') return '直播启动失败'
if (props.playUrl?.startsWith('fake://')) return '控制面已验证,未配置本地媒体流'
if (props.active) return '暂无实时视频数据'
return ''
})
const controlLabel = computed(() => {
if (props.opening) return '正在打开直播'
if (props.stopping) return '正在停止直播'
if (needsUserPlay.value) return '继续播放'
return props.active ? '关闭实时画面' : '打开实时画面'
})
const controlIcon = computed(() => (needsUserPlay.value || !props.active ? '#i-play' : '#i-stop'))
function clearStartupRetry() {
window.clearTimeout(startupRetryTimer)
startupRetryTimer = null
}
function resetStartupState() {
clearStartupRetry()
startupRetryCount = 0
startupStartedAt = 0
waitingForStream.value = false
}
function clearMediaListeners() {
if (typeof mediaCleanup === 'function') mediaCleanup()
mediaCleanup = null
}
function destroyPlayer() {
clearMediaListeners()
mediaRecoverAttempted = false
needsUserPlay.value = false
if (hls) {
hls.destroy()
hls = null
}
const video = videoEl.value
if (video) {
video.removeAttribute('src')
video.load()
}
}
function syncPausedState() {
const video = videoEl.value
if (!video || !playable.value) {
needsUserPlay.value = false
return
}
needsUserPlay.value = !!video.paused
}
function extractHttpCode(data) {
const code = Number(data?.response?.code ?? data?.response?.status ?? data?.error?.code)
return Number.isFinite(code) && code > 0 ? code : 0
}
function isSecurityFailure(detail = '') {
return /ssl|tls|cipher|certificate|cors|mixed content|err_ssl/i.test(String(detail))
}
function buildPlayError({ message, name = 'HlsError', code = 0, recoverable = false }) {
const err = new Error(message || 'hls error')
err.name = name
if (code) err.code = code
err.recoverable = recoverable
return err
}
function emitFatal(err) {
resetStartupState()
destroyPlayer()
emit('error', err)
}
function canRetryStartup() {
if (!startupStartedAt) startupStartedAt = Date.now()
if (Date.now() - startupStartedAt > STARTUP_WINDOW_MS) return false
return startupRetryCount < STARTUP_RETRY_DELAYS_MS.length
}
function scheduleSameUrlRetry(url, seq, httpCode = 404) {
if (seq !== attachSeq || !url) return false
if (!canRetryStartup()) return false
const delay = STARTUP_RETRY_DELAYS_MS[startupRetryCount]
startupRetryCount += 1
waitingForStream.value = true
console.debug('[live-player] startup retry', { httpCode, delay, attempt: startupRetryCount })
// 先拆掉当前失败实例,稍后用同一签名地址重建;不 bump attachSeq,避免误杀本次会话
destroyPlayer()
clearStartupRetry()
startupRetryTimer = window.setTimeout(() => {
if (seq !== attachSeq || activeUrl !== url) return
attachPlayer(url, { seq }).catch(() => {})
}, delay)
return true
}
function bindMediaListeners(video, seq) {
clearMediaListeners()
const onPlay = () => {
if (seq !== attachSeq) return
needsUserPlay.value = false
resetStartupState()
}
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 (props.opening || props.stopping) return
if (needsUserPlay.value) {
resumeFromUserGesture()
return
}
emit('toggle')
}
async function attachPlayer(url, { seq: existingSeq } = {}) {
const seq = existingSeq ?? (++attachSeq)
if (existingSeq == null) {
clearStartupRetry()
// 新 playUrl 才重置启动窗口;同地址退避重试保留计数
startupRetryCount = 0
startupStartedAt = Date.now()
waitingForStream.value = false
mediaRecoverAttempted = false
}
activeUrl = url || ''
destroyPlayer()
if (!url || url.startsWith('fake://')) return
await Promise.resolve()
const video = videoEl.value
if (!video || seq !== attachSeq) return
video.muted = true
video.defaultMuted = true
video.setAttribute('muted', '')
bindMediaListeners(video, seq)
video.addEventListener('error', () => {
if (seq !== attachSeq) return
// Safari 原生 HLS 难拿 HTTP code:启动窗口内按可恢复 404 处理
if (scheduleSameUrlRetry(url, seq, 404)) return
emitFatal(buildPlayError({
message: 'media error',
name: 'HlsError',
code: 404,
}))
}, { once: true })
async function tryPlay() {
if (seq !== attachSeq) return
try {
await video.play()
needsUserPlay.value = false
resetStartupState()
} catch (error) {
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) emitFatal(error)
}
}
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
await tryPlay()
syncPausedState()
return
}
try {
const { default: Hls } = await import('hls.js')
if (seq !== attachSeq) return
if (!Hls.isSupported()) {
emitFatal(buildPlayError({ message: 'hls unsupported' }))
return
}
// 现网为普通 6–12s HLS,不是 LL-HLS;放宽分片重试,启动 404 仍由同 URL 窗口控制
hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
manifestLoadingMaxRetry: 2,
manifestLoadingRetryDelay: 1000,
levelLoadingMaxRetry: 2,
levelLoadingRetryDelay: 1000,
fragLoadingMaxRetry: 4,
fragLoadingRetryDelay: 1000,
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (seq !== attachSeq) return
if (!data?.fatal) return
const httpCode = extractHttpCode(data)
const detail = [data.type, data.details, httpCode || '', data.error?.message]
.filter(Boolean)
.join(' ')
if (isSecurityFailure(detail)) {
console.warn('[live-player] hls security fatal', data)
emitFatal(buildPlayError({
message: detail || 'hls security fatal',
name: 'SecurityError',
code: httpCode || 0,
}))
return
}
if (httpCode === 403) {
console.warn('[live-player] hls auth fatal', data)
emitFatal(buildPlayError({
message: detail || 'hls 403',
name: 'HlsError',
code: 403,
}))
return
}
// 推流刚上线常见:清单暂未就绪。启动窗口内复用当前签名地址退避重载,不刷 /play-url
if (httpCode === 404 || data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR) {
if (scheduleSameUrlRetry(url, seq, httpCode || 404)) return
console.warn('[live-player] hls startup 404 exhausted', data)
emitFatal(buildPlayError({
message: detail || 'hls 404',
name: 'HlsError',
code: 404,
}))
return
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
if (!mediaRecoverAttempted) {
mediaRecoverAttempted = true
try {
console.warn('[live-player] hls media fatal, recover once', data)
hls.recoverMediaError()
return
} catch (recoverErr) {
console.warn('[live-player] hls recover failed', recoverErr)
}
}
// already attempted or recover threw → fall through to emitFatal
}
console.warn('[live-player] hls fatal', data)
emitFatal(buildPlayError({
message: detail || 'hls fatal',
name: 'HlsError',
code: httpCode || 0,
}))
})
hls.loadSource(url)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (seq !== attachSeq) return
resetStartupState()
video.muted = true
tryPlay()
})
} catch (_) {
if (seq === attachSeq) emitFatal(buildPlayError({ message: 'hls init failed' }))
}
}
watch(
() => props.playUrl,
(url) => {
if (playable.value) {
attachPlayer(url).catch(() => {})
} else {
attachSeq += 1
activeUrl = ''
resetStartupState()
destroyPlayer()
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
attachSeq += 1
activeUrl = ''
resetStartupState()
destroyPlayer()
})
function requestFullscreen() {
const el = playable.value ? videoEl.value : frameEl.value
if (!el?.requestFullscreen) return false
el.requestFullscreen()
return true
}
defineExpose({ requestFullscreen, frameEl, videoEl })
</script>