|
|
@@ -5,17 +5,17 @@
|
|
|
class="video-js vjs-big-play-centered"
|
|
|
playsinline
|
|
|
webkit-playsinline
|
|
|
+ x5-playsinline
|
|
|
></video>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script lang="ts">
|
|
|
-import { defineComponent, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
+import { defineComponent, onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
|
|
|
import videojs from 'video.js'
|
|
|
import 'video.js/dist/video-js.css'
|
|
|
-// 使用声明文件后这样引入
|
|
|
-import videojsFlash from 'videojs-flash'
|
|
|
-videojs.registerPlugin('flash', videojsFlash)
|
|
|
+import Hls from 'hls.js'
|
|
|
+import flvjs from 'flv.js'
|
|
|
|
|
|
interface VideoPlayerProps {
|
|
|
url: string
|
|
|
@@ -37,126 +37,252 @@ export default defineComponent({
|
|
|
setup(props: VideoPlayerProps) {
|
|
|
const videoRef = ref<HTMLVideoElement | null>(null)
|
|
|
const player = ref<videojs.Player | null>(null)
|
|
|
+ const hls = ref<Hls | null>(null)
|
|
|
+ const flvPlayer = ref<flvjs.Player | null>(null)
|
|
|
+ const isInitialized = ref(false)
|
|
|
|
|
|
- // 初始化播放器
|
|
|
- const initPlayer = () => {
|
|
|
- if (!videoRef.value) return
|
|
|
+ // 增强的播放器配置
|
|
|
+ const defaultOptions: videojs.PlayerOptions = {
|
|
|
+ autoplay: false,
|
|
|
+ controls: true,
|
|
|
+ fluid: true,
|
|
|
+ preload: 'auto',
|
|
|
+ liveui: true,
|
|
|
+ html5: {
|
|
|
+ vhs: {
|
|
|
+ overrideNative: true,
|
|
|
+ enableLowInitialPlaylist: true,
|
|
|
+ smoothQualityChange: true,
|
|
|
+ experimentalBufferBasedABR: true
|
|
|
+ }
|
|
|
+ },
|
|
|
+ playbackRates: [0.5, 1, 1.5, 2]
|
|
|
+ }
|
|
|
|
|
|
- // 合并默认选项和传入的选项
|
|
|
- const mergedOptions: videojs.PlayerOptions = {
|
|
|
- controls: true,
|
|
|
- fluid: true,
|
|
|
- preload: 'auto',
|
|
|
- techOrder: ['html5', 'flash'], // 优先使用HTML5,回退到Flash
|
|
|
- ...props.options
|
|
|
- }
|
|
|
+ const initPlayer = async () => {
|
|
|
+ if (!videoRef.value || isInitialized.value) return
|
|
|
|
|
|
- // 创建 video.js 播放器实例
|
|
|
- player.value = videojs(videoRef.value, mergedOptions, () => {
|
|
|
- console.log('Player is ready')
|
|
|
- })
|
|
|
+ await nextTick() // 确保DOM完全渲染
|
|
|
+
|
|
|
+ try {
|
|
|
+ const mergedOptions = { ...defaultOptions, ...props.options }
|
|
|
+
|
|
|
+ // 创建播放器实例
|
|
|
+ player.value = videojs(videoRef.value, mergedOptions, function() {
|
|
|
+ console.log('Player ready')
|
|
|
+ this.one('play', () => {
|
|
|
+ console.log('First play event')
|
|
|
+ this.tech_?.on('retryplayback', () => {
|
|
|
+ console.log('Retrying playback')
|
|
|
+ this.play().catch(e => console.warn('Playback retry failed:', e))
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
- // 根据 URL 后缀确定视频类型
|
|
|
- setupSource()
|
|
|
+ // 添加错误监听
|
|
|
+ player.value.on('error', () => {
|
|
|
+ const error = player.value?.error()
|
|
|
+ console.error('VideoJS error:', error)
|
|
|
+ handlePlaybackError()
|
|
|
+ })
|
|
|
+
|
|
|
+ player.value.on('stalled', () => {
|
|
|
+ console.warn('Playback stalled')
|
|
|
+ handlePlaybackError()
|
|
|
+ })
|
|
|
+
|
|
|
+ player.value.on('waiting', () => {
|
|
|
+ console.log('Player waiting for data')
|
|
|
+ })
|
|
|
+
|
|
|
+ isInitialized.value = true
|
|
|
+ setupSource()
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Player initialization failed:', e)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // 设置视频源
|
|
|
const setupSource = () => {
|
|
|
- if (!player.value) return
|
|
|
+ if (!player.value || !videoRef.value) return
|
|
|
|
|
|
+ cleanupSource()
|
|
|
const url = props.url.toLowerCase()
|
|
|
|
|
|
- if (isRTMPStream(url)) {
|
|
|
- // RTMP 流媒体
|
|
|
- setupRTMPPlayer()
|
|
|
+ if (isHLSStream(url)) {
|
|
|
+ setupHLSPlayer()
|
|
|
+ } else if (isFLVStream(url)) {
|
|
|
+ setupFLVPlayer()
|
|
|
} else {
|
|
|
- // 普通视频文件 (MP4, AVI 等)
|
|
|
- player.value.src({
|
|
|
- src: props.url,
|
|
|
- type: getVideoType(props.url)
|
|
|
- })
|
|
|
+ setupStandardSource()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 判断是否是RTMP流
|
|
|
- const isRTMPStream = (url: string): boolean => {
|
|
|
- return url.startsWith('rtmp://') ||
|
|
|
- url.startsWith('rtmps://') ||
|
|
|
- url.includes('rtmp.stream') ||
|
|
|
- url.includes('rtmp/live')
|
|
|
- }
|
|
|
-
|
|
|
- // 设置RTMP播放器
|
|
|
- const setupRTMPPlayer = () => {
|
|
|
+ const setupStandardSource = () => {
|
|
|
if (!player.value) return
|
|
|
|
|
|
- // 使用Flash技术播放RTMP
|
|
|
player.value.src({
|
|
|
src: props.url,
|
|
|
- type: 'rtmp/mp4',
|
|
|
- withCredentials: false
|
|
|
+ type: getVideoType(props.url)
|
|
|
})
|
|
|
|
|
|
- // 备选方案:如果Flash不可用,可以尝试HLS转换
|
|
|
+ // 确保能触发播放
|
|
|
player.value.ready(() => {
|
|
|
- player.value!.on('error', () => {
|
|
|
- console.warn('RTMP playback failed, trying HLS fallback')
|
|
|
- tryHLSFallback()
|
|
|
+ player.value?.play().catch(e => {
|
|
|
+ console.warn('Autoplay prevented:', e)
|
|
|
+ // 显示播放按钮让用户手动触发
|
|
|
+ player.value?.bigPlayButton?.show()
|
|
|
})
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- // 尝试HLS回退方案
|
|
|
- const tryHLSFallback = () => {
|
|
|
- if (!player.value) return
|
|
|
+ const setupHLSPlayer = () => {
|
|
|
+ if (!player.value || !videoRef.value) return
|
|
|
|
|
|
- // 这里假设你的RTMP流有对应的HLS流
|
|
|
- const hlsUrl = props.url
|
|
|
- .replace('rtmp://', 'http://')
|
|
|
- .replace('rtmps://', 'https://')
|
|
|
- .replace('/live/', '/hls/') + '.m3u8'
|
|
|
+ if (Hls.isSupported()) {
|
|
|
+ try {
|
|
|
+ hls.value = new Hls({
|
|
|
+ enableWorker: false,
|
|
|
+ startLevel: -1,
|
|
|
+ maxBufferLength: 30,
|
|
|
+ maxMaxBufferLength: 60,
|
|
|
+ maxBufferSize: 60 * 1000 * 1000,
|
|
|
+ maxBufferHole: 0.5,
|
|
|
+ })
|
|
|
|
|
|
- player.value.src({
|
|
|
- src: hlsUrl,
|
|
|
- type: 'application/x-mpegURL'
|
|
|
- })
|
|
|
+ hls.value.loadSource(props.url)
|
|
|
+ hls.value.attachMedia(videoRef.value)
|
|
|
+
|
|
|
+ hls.value.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
|
+ console.log('Manifest parsed')
|
|
|
+ videoRef.value?.play().catch(e => console.warn('HLS autoplay failed:', e))
|
|
|
+ })
|
|
|
+
|
|
|
+ hls.value.on(Hls.Events.ERROR, (event, data) => {
|
|
|
+ console.error('HLS error:', event, data)
|
|
|
+ if (data.fatal) {
|
|
|
+ handleFatalError()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } catch (e) {
|
|
|
+ console.error('HLS init error:', e)
|
|
|
+ fallbackToNativeHLS()
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ fallbackToNativeHLS()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const setupFLVPlayer = () => {
|
|
|
+ if (!player.value || !videoRef.value) return
|
|
|
+
|
|
|
+ if (flvjs.isSupported()) {
|
|
|
+ try {
|
|
|
+ player.value.pause()
|
|
|
+
|
|
|
+ flvPlayer.value = flvjs.createPlayer({
|
|
|
+ type: 'flv',
|
|
|
+ url: props.url,
|
|
|
+ isLive: props.url.includes('live')
|
|
|
+ }, {
|
|
|
+ enableWorker: false,
|
|
|
+ stashInitialSize: 128,
|
|
|
+ lazyLoad: true,
|
|
|
+ autoCleanupSourceBuffer: true
|
|
|
+ })
|
|
|
+
|
|
|
+ flvPlayer.value.attachMediaElement(videoRef.value)
|
|
|
+ flvPlayer.value.load()
|
|
|
+
|
|
|
+ flvPlayer.value.on(flvjs.Events.METADATA_ARRIVED, () => {
|
|
|
+ console.log('FLV metadata loaded')
|
|
|
+ videoRef.value?.play().catch(e => console.warn('FLV autoplay failed:', e))
|
|
|
+ })
|
|
|
+
|
|
|
+ flvPlayer.value.on(flvjs.Events.ERROR, (errType, errDetail) => {
|
|
|
+ console.error('FLV error:', errType, errDetail)
|
|
|
+ handleFatalError()
|
|
|
+ })
|
|
|
+ } catch (e) {
|
|
|
+ console.error('FLV init error:', e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handlePlaybackError = () => {
|
|
|
+ console.log('Attempting to recover playback...')
|
|
|
+ player.value?.reset()
|
|
|
+ setTimeout(() => setupSource(), 1000)
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleFatalError = () => {
|
|
|
+ console.error('Fatal playback error occurred')
|
|
|
+ cleanupSource()
|
|
|
+ setTimeout(() => setupSource(), 2000)
|
|
|
+ }
|
|
|
+
|
|
|
+ const fallbackToNativeHLS = () => {
|
|
|
+ if (!videoRef.value) return
|
|
|
+ console.warn('Falling back to native HLS')
|
|
|
+ videoRef.value.src = props.url
|
|
|
+ videoRef.value.play().catch(e => console.warn('Native HLS play failed:', e))
|
|
|
+ }
|
|
|
+
|
|
|
+ const cleanupSource = () => {
|
|
|
+ if (hls.value) {
|
|
|
+ hls.value.destroy()
|
|
|
+ hls.value = null
|
|
|
+ }
|
|
|
+
|
|
|
+ if (flvPlayer.value) {
|
|
|
+ flvPlayer.value.pause()
|
|
|
+ flvPlayer.value.unload()
|
|
|
+ flvPlayer.value.detachMediaElement()
|
|
|
+ flvPlayer.value.destroy()
|
|
|
+ flvPlayer.value = null
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const isHLSStream = (url: string): boolean => {
|
|
|
+ return url.endsWith('.m3u8') || url.includes('m3u8')
|
|
|
+ }
|
|
|
+
|
|
|
+ const isFLVStream = (url: string): boolean => {
|
|
|
+ return url.endsWith('.flv') || url.includes('.flv?')
|
|
|
}
|
|
|
|
|
|
- // 根据 URL 获取视频类型
|
|
|
const getVideoType = (url: string): string => {
|
|
|
- const lowerUrl = url.toLowerCase()
|
|
|
- if (lowerUrl.endsWith('.mp4')) {
|
|
|
- return 'video/mp4'
|
|
|
- } else if (lowerUrl.endsWith('.avi')) {
|
|
|
- return 'video/x-msvideo'
|
|
|
+ const ext = url.split('.').pop()?.toLowerCase()
|
|
|
+ const typeMap: Record<string, string> = {
|
|
|
+ mp4: 'video/mp4',
|
|
|
+ webm: 'video/webm',
|
|
|
+ ogg: 'video/ogg',
|
|
|
+ ogv: 'video/ogg',
|
|
|
+ mov: 'video/quicktime',
|
|
|
+ avi: 'video/x-msvideo'
|
|
|
}
|
|
|
- // 默认类型,让浏览器自己判断
|
|
|
- return ''
|
|
|
+ return typeMap[ext || ''] || ''
|
|
|
}
|
|
|
|
|
|
- // 监听 URL 变化
|
|
|
- watch(
|
|
|
- () => props.url,
|
|
|
- () => {
|
|
|
+ watch(() => props.url, (newUrl, oldUrl) => {
|
|
|
+ if (newUrl !== oldUrl && isInitialized.value) {
|
|
|
setupSource()
|
|
|
}
|
|
|
- )
|
|
|
+ })
|
|
|
|
|
|
onMounted(() => {
|
|
|
initPlayer()
|
|
|
})
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
- // 清理 video.js 播放器
|
|
|
+ cleanupSource()
|
|
|
if (player.value) {
|
|
|
player.value.dispose()
|
|
|
player.value = null
|
|
|
}
|
|
|
+ isInitialized.value = false
|
|
|
})
|
|
|
|
|
|
- return {
|
|
|
- videoRef
|
|
|
- }
|
|
|
+ return { videoRef }
|
|
|
}
|
|
|
})
|
|
|
</script>
|
|
|
@@ -165,10 +291,28 @@ export default defineComponent({
|
|
|
.video-player-container {
|
|
|
width: 100%;
|
|
|
height: 100%;
|
|
|
+ background-color: #000;
|
|
|
}
|
|
|
|
|
|
.video-js {
|
|
|
width: 100%;
|
|
|
height: 100%;
|
|
|
}
|
|
|
+
|
|
|
+:deep(.video-js .vjs-big-play-button) {
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ font-size: 3em;
|
|
|
+ border: none;
|
|
|
+ background-color: rgba(0, 0, 0, 0.5);
|
|
|
+ border-radius: 50%;
|
|
|
+ width: 1.5em;
|
|
|
+ height: 1.5em;
|
|
|
+ line-height: 1.5em;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.video-js:hover .vjs-big-play-button) {
|
|
|
+ background-color: rgba(0, 0, 0, 0.7);
|
|
|
+}
|
|
|
</style>
|