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
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>
|
|
|