241 lines
4.9 KiB
Vue
241 lines
4.9 KiB
Vue
<template>
|
||
<div class="player-wrap">
|
||
<div ref="playerRef" class="artplayer-container"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import Artplayer from 'artplayer'
|
||
import Hls from 'hls.js'
|
||
import type { EpisodeData } from '@/api/qishier'
|
||
import { getEpisodeProgress, saveEpisodeProgress } from '@/utils/playHistory'
|
||
|
||
|
||
|
||
type ArtWithHls = Artplayer & {
|
||
hls?: Hls
|
||
}
|
||
|
||
const props = defineProps<{
|
||
episode: EpisodeData | null
|
||
autoNext: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
error: [message: string]
|
||
endedNext: []
|
||
}>()
|
||
|
||
const playerRef = ref<HTMLDivElement | null>(null)
|
||
let art: ArtWithHls | null = null
|
||
let playRetryTimer: number | null = null
|
||
|
||
function clearPlayRetryTimer() {
|
||
if (playRetryTimer !== null) {
|
||
window.clearTimeout(playRetryTimer)
|
||
playRetryTimer = null
|
||
}
|
||
}
|
||
|
||
function destroyPlayer() {
|
||
clearPlayRetryTimer()
|
||
|
||
if (art?.hls) {
|
||
art.hls.destroy()
|
||
art.hls = undefined
|
||
}
|
||
|
||
if (art) {
|
||
art.destroy()
|
||
art = null
|
||
}
|
||
|
||
if (playerRef.value) {
|
||
playerRef.value.innerHTML = ''
|
||
}
|
||
}
|
||
|
||
function safeAutoPlay() {
|
||
clearPlayRetryTimer()
|
||
|
||
playRetryTimer = window.setTimeout(() => {
|
||
void art?.video?.play().catch(() => {
|
||
// 某些浏览器自动播放会被拦截,这里静默处理
|
||
})
|
||
}, 180)
|
||
}
|
||
|
||
function buildPlayer(episode: EpisodeData) {
|
||
if (!playerRef.value) return
|
||
|
||
art = new Artplayer({
|
||
container: playerRef.value,
|
||
url: episode.videoUrl,
|
||
autoplay: true,
|
||
autoSize: true,
|
||
fullscreen: true,
|
||
fullscreenWeb: true,
|
||
pip: true,
|
||
setting: true,
|
||
playbackRate: true,
|
||
hotkey: true,
|
||
mutex: true,
|
||
theme: '#f5c542',
|
||
moreVideoAttr: {
|
||
playsInline: true,
|
||
preload: 'auto'
|
||
} as any,
|
||
customType: {
|
||
m3u8(video: HTMLVideoElement, url: string, artInstance: Artplayer) {
|
||
const artplayer = artInstance as ArtWithHls
|
||
|
||
if (Hls.isSupported()) {
|
||
const hls = new Hls({
|
||
enableWorker: true,
|
||
lowLatencyMode: false,
|
||
backBufferLength: 30,
|
||
maxBufferLength: 30,
|
||
maxMaxBufferLength: 60,
|
||
maxBufferHole: 1,
|
||
highBufferWatchdogPeriod: 2,
|
||
nudgeOffset: 0.1,
|
||
nudgeMaxRetry: 5
|
||
})
|
||
|
||
artplayer.hls = hls
|
||
hls.loadSource(url)
|
||
hls.attachMedia(video)
|
||
|
||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||
console.error('HLS Error:', data)
|
||
|
||
if (!data.fatal) {
|
||
return
|
||
}
|
||
|
||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||
console.warn('HLS NETWORK_ERROR,尝试恢复')
|
||
hls.startLoad()
|
||
emit('error', '网络异常,正在尝试恢复')
|
||
return
|
||
}
|
||
|
||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||
console.warn('HLS MEDIA_ERROR,尝试恢复')
|
||
hls.recoverMediaError()
|
||
emit('error', '播放异常,正在尝试恢复')
|
||
return
|
||
}
|
||
|
||
emit('error', '视频播放失败')
|
||
})
|
||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||
video.src = url
|
||
} else {
|
||
emit('error', '当前浏览器不支持 m3u8 播放')
|
||
}
|
||
}
|
||
}
|
||
}) as ArtWithHls
|
||
|
||
art.type = 'm3u8'
|
||
|
||
const restoreProgress = () => {
|
||
const progress = getEpisodeProgress(episode.id)
|
||
if (progress > 0 && art?.video) {
|
||
try {
|
||
art.video.currentTime = progress
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
safeAutoPlay()
|
||
}
|
||
|
||
art.on('video:loadedmetadata', restoreProgress)
|
||
|
||
art.on('video:canplay', () => {
|
||
// 某些设备 loadedmetadata 后还没准备好,canplay 时再兜底一次
|
||
safeAutoPlay()
|
||
})
|
||
|
||
art.on('video:waiting', () => {
|
||
// 这里不报错,只作为缓冲状态
|
||
console.warn('视频缓冲中...')
|
||
})
|
||
|
||
art.on('video:timeupdate', () => {
|
||
if (art?.video) {
|
||
saveEpisodeProgress(episode.id, art.video.currentTime)
|
||
}
|
||
})
|
||
|
||
art.on('video:ended', () => {
|
||
if (props.autoNext) {
|
||
emit('endedNext')
|
||
}
|
||
})
|
||
|
||
art.on('error', () => {
|
||
emit('error', '视频播放失败')
|
||
})
|
||
}
|
||
|
||
async function initPlayer() {
|
||
destroyPlayer()
|
||
|
||
if (!props.episode) return
|
||
|
||
await nextTick()
|
||
buildPlayer(props.episode)
|
||
}
|
||
|
||
watch(
|
||
() => props.episode?.id,
|
||
async () => {
|
||
await initPlayer()
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.autoNext,
|
||
() => {
|
||
// 保持响应式依赖
|
||
}
|
||
)
|
||
|
||
onMounted(async () => {
|
||
await initPlayer()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
destroyPlayer()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.player-wrap {
|
||
width: 100%;
|
||
background: #000;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.artplayer-container {
|
||
width: 100%;
|
||
aspect-ratio: 16 / 9;
|
||
background: #000;
|
||
}
|
||
|
||
:deep(.artplayer-app) {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
:deep(.art-video-player) {
|
||
background: #000;
|
||
}
|
||
</style>
|