@ -44,6 +44,9 @@
< script setup >
< script setup >
import { computed , onBeforeUnmount , ref , watch } from 'vue'
import { computed , onBeforeUnmount , ref , watch } from 'vue'
const STARTUP_RETRY_DELAYS_MS = [ 500 , 1000 , 2000 , 4000 ]
const STARTUP_WINDOW_MS = 15 _000
const props = defineProps ( {
const props = defineProps ( {
playUrl : { type : String , default : '' } ,
playUrl : { type : String , default : '' } ,
phase : { type : String , default : '' } ,
phase : { type : String , default : '' } ,
@ -57,15 +60,22 @@ 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 )
const needsUserPlay = ref ( false )
const waitingForStream = ref ( false )
let hls = null
let hls = null
let attachSeq = 0
let attachSeq = 0
let mediaCleanup = null
let mediaCleanup = null
let startupRetryTimer = null
let startupRetryCount = 0
let startupStartedAt = 0
let activeUrl = ''
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 ( props . opening ) return '打开中'
if ( props . opening ) return '打开中'
if ( waitingForStream . value ) return '启动中 · 等待流就绪'
if ( needsUserPlay . value ) return '已暂停 · 点击继续'
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 '控制面已验证'
@ -76,6 +86,7 @@ const qualityText = computed(() => {
const statusMessage = computed ( ( ) => {
const statusMessage = computed ( ( ) => {
if ( props . opening ) return '正在打开直播'
if ( props . opening ) return '正在打开直播'
if ( waitingForStream . value ) return '正在等待直播流就绪'
if ( needsUserPlay . value ) return '浏览器暂停了画面,点击继续播放'
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 '正在等待直播流就绪'
@ -93,6 +104,18 @@ const controlLabel = computed(() => {
const controlIcon = computed ( ( ) => ( needsUserPlay . value || ! props . active ? '#i-play' : '#i-stop' ) )
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 ( ) {
function clearMediaListeners ( ) {
if ( typeof mediaCleanup === 'function' ) mediaCleanup ( )
if ( typeof mediaCleanup === 'function' ) mediaCleanup ( )
mediaCleanup = null
mediaCleanup = null
@ -121,11 +144,60 @@ function syncPausedState() {
needsUserPlay . value = ! ! video . paused
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 } )
/ / 先 拆 掉 当 前 失 败 实 例 , 稍 后 用 同 一 签 名 地 址 重 建 ; 不 b u m p a t t a c h S e q , 避 免 误 杀 本 次 会 话
destroyPlayer ( )
clearStartupRetry ( )
startupRetryTimer = window . setTimeout ( ( ) => {
if ( seq !== attachSeq || activeUrl !== url ) return
attachPlayer ( url , { seq } ) . catch ( ( ) => { } )
} , delay )
return true
}
function bindMediaListeners ( video , seq ) {
function bindMediaListeners ( video , seq ) {
clearMediaListeners ( )
clearMediaListeners ( )
const onPlay = ( ) => {
const onPlay = ( ) => {
if ( seq !== attachSeq ) return
if ( seq !== attachSeq ) return
needsUserPlay . value = false
needsUserPlay . value = false
resetStartupState ( )
}
}
const onPause = ( ) => {
const onPause = ( ) => {
if ( seq !== attachSeq ) return
if ( seq !== attachSeq ) return
@ -174,8 +246,17 @@ function onControlClick() {
emit ( 'toggle' )
emit ( 'toggle' )
}
}
async function attachPlayer ( url ) {
const seq = ++ attachSeq
async function attachPlayer ( url , { seq : existingSeq } = { } ) {
const seq = existingSeq ? ? ( ++ attachSeq )
if ( existingSeq == null ) {
clearStartupRetry ( )
/ / 新 p l a y U r l 才 重 置 启 动 窗 口 ; 同 地 址 退 避 重 试 保 留 计 数
startupRetryCount = 0
startupStartedAt = Date . now ( )
waitingForStream . value = false
}
activeUrl = url || ''
destroyPlayer ( )
destroyPlayer ( )
if ( ! url || url . startsWith ( 'fake://' ) ) return
if ( ! url || url . startsWith ( 'fake://' ) ) return
@ -190,9 +271,13 @@ async function attachPlayer(url) {
video . addEventListener ( 'error' , ( ) => {
video . addEventListener ( 'error' , ( ) => {
if ( seq !== attachSeq ) return
if ( seq !== attachSeq ) return
const err = new Error ( 'media error' )
err . name = 'HlsError'
emit ( 'error' , err )
/ / S a f a r i 原 生 H L S 难 拿 H T T P c o d e : 启 动 窗 口 内 按 可 恢 复 4 0 4 处 理
if ( scheduleSameUrlRetry ( url , seq , 404 ) ) return
emitFatal ( buildPlayError ( {
message : 'media error' ,
name : 'HlsError' ,
code : 404 ,
} ) )
} , { once : true } )
} , { once : true } )
async function tryPlay ( ) {
async function tryPlay ( ) {
@ -200,6 +285,7 @@ async function attachPlayer(url) {
try {
try {
await video . play ( )
await video . play ( )
needsUserPlay . value = false
needsUserPlay . value = false
resetStartupState ( )
} catch ( error ) {
} catch ( error ) {
if ( error ? . name === 'AbortError' || error ? . name === 'NotAllowedError' ) {
if ( error ? . name === 'AbortError' || error ? . name === 'NotAllowedError' ) {
console . debug ( '[live-player] play interrupted' , error )
console . debug ( '[live-player] play interrupted' , error )
@ -208,7 +294,7 @@ async function attachPlayer(url) {
return
return
}
}
console . warn ( '[live-player] play failed' , error )
console . warn ( '[live-player] play failed' , error )
if ( seq === attachSeq ) emit ( 'error' , error )
if ( seq === attachSeq ) emitFatal ( error )
}
}
}
}
@ -223,40 +309,76 @@ async function attachPlayer(url) {
const { default : Hls } = await import ( 'hls.js' )
const { default : Hls } = await import ( 'hls.js' )
if ( seq !== attachSeq ) return
if ( seq !== attachSeq ) return
if ( ! Hls . isSupported ( ) ) {
if ( ! Hls . isSupported ( ) ) {
emit ( 'error' )
emitFatal ( buildPlayError ( { message : 'hls unsupported' } ) )
return
return
}
}
/ / 关 闭 内 置 长 重 试 , 改 由 启 动 窗 口 退 避 控 制 4 0 4 等 待
hls = new Hls ( {
hls = new Hls ( {
enableWorker : true ,
enableWorker : true ,
lowLatencyMode : true ,
lowLatencyMode : true ,
manifestLoadingMaxRetry : 1 ,
levelLoadingMaxRetry : 1 ,
manifestLoadingMaxRetry : 0 ,
levelLoadingMaxRetry : 0 ,
fragLoadingMaxRetry : 1 ,
fragLoadingMaxRetry : 1 ,
} )
} )
hls . on ( Hls . Events . ERROR , ( _event , data ) => {
hls . on ( Hls . Events . ERROR , ( _event , data ) => {
if ( seq !== attachSeq ) return
if ( seq !== attachSeq ) return
if ( ! data ? . fatal ) return
if ( ! data ? . fatal ) return
const detail = [ data . type , data . details , data . response ? . code , data . error ? . message ]
const httpCode = extractHttpCode ( data )
const detail = [ data . type , data . details , httpCode || '' , data . error ? . message ]
. filter ( Boolean )
. filter ( Boolean )
. join ( ' ' )
. join ( ' ' )
const err = new Error ( detail || 'hls fatal' )
err . name = /ssl|tls|cipher|certificate|cors|mixed content|err_ssl/i . test ( detail )
? 'SecurityError'
: 'HlsError'
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
}
/ / 推 流 刚 上 线 常 见 : 清 单 暂 未 就 绪 。 启 动 窗 口 内 复 用 当 前 签 名 地 址 退 避 重 载 , 不 刷 / p l a y - u r l
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
}
console . warn ( '[live-player] hls fatal' , data )
console . warn ( '[live-player] hls fatal' , data )
destroyPlayer ( )
emit ( 'error' , err )
emitFatal ( buildPlayError ( {
message : detail || 'hls fatal' ,
name : 'HlsError' ,
code : httpCode || 0 ,
} ) )
} )
} )
hls . loadSource ( url )
hls . loadSource ( url )
hls . attachMedia ( video )
hls . attachMedia ( video )
hls . on ( Hls . Events . MANIFEST_PARSED , ( ) => {
hls . on ( Hls . Events . MANIFEST_PARSED , ( ) => {
if ( seq !== attachSeq ) return
if ( seq !== attachSeq ) return
resetStartupState ( )
video . muted = true
video . muted = true
tryPlay ( )
tryPlay ( )
} )
} )
} catch ( _ ) {
} catch ( _ ) {
if ( seq === attachSeq ) emit ( 'error' )
if ( seq === attachSeq ) emitFatal ( buildPlayError ( { message : 'hls init failed' } ) )
}
}
}
}
@ -267,6 +389,8 @@ watch(
attachPlayer ( url ) . catch ( ( ) => { } )
attachPlayer ( url ) . catch ( ( ) => { } )
} else {
} else {
attachSeq += 1
attachSeq += 1
activeUrl = ''
resetStartupState ( )
destroyPlayer ( )
destroyPlayer ( )
}
}
} ,
} ,
@ -275,6 +399,8 @@ watch(
onBeforeUnmount ( ( ) => {
onBeforeUnmount ( ( ) => {
attachSeq += 1
attachSeq += 1
activeUrl = ''
resetStartupState ( )
destroyPlayer ( )
destroyPlayer ( )
} )
} )