|
|
|
@ -2,7 +2,11 @@ |
|
|
|
<div |
|
|
|
ref="frameEl" |
|
|
|
class="video-frame" |
|
|
|
:class="{ streaming: isLiveShell, playing: playable }" |
|
|
|
:class="{ |
|
|
|
streaming: isLiveShell, |
|
|
|
playing: playable, |
|
|
|
paused: needsUserPlay, |
|
|
|
}" |
|
|
|
> |
|
|
|
<video |
|
|
|
v-if="playable" |
|
|
|
@ -27,10 +31,10 @@ |
|
|
|
<button |
|
|
|
class="play-control" |
|
|
|
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> |
|
|
|
</div> |
|
|
|
</template> |
|
|
|
@ -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 |
|
|
|
} |
|
|
|
|
|
|
|
|