vue中使用西瓜播放器xgplayer (封装)+xgplayer-hls 播放.m3u8格式视频

发布于:2025-07-17 ⋅ 阅读:(18) ⋅ 点赞:(0)

1.西瓜播放器官网

http://h5player.bytedance.com/guide/

2.安装

# 最新稳定版
$ npm install xgplayer

对于已有项目也可以通过 CDN 引入,代码如下:
<script src="//unpkg.byted-static.com/xgplayer/2.31.2/browser/index.js" type="text/javascript"></script>

3.封装西瓜播放器组件

<template>
	    <div class="video-box">
	        <div ref="playerRef" id="video-player" class="video-player"></div>
	    </div>
	</template>
	<script setup>
	import { ref, onMounted, watch } from 'vue';
	import Player from 'xgplayer';
	import 'xgplayer/dist/index.min.css';
	
	// 定义props
	const props = defineProps({
	    url: {
	        type: String,
	        default: ''
	    },
	    poster: {
	        type: String,
	        default: ""
	    }
	});
	
	// 定义播放器实例和DOM引用
	const playerRef = ref(null);
	const player = ref(null);
	// 定义emits
	const emit = defineEmits(['triggerEvent', 'timeupdate', 'loadingStateChange', 'playEnd', 'videoClick']);
	// 判断是否为Apple设备
	const isAppleDevice = () => {
	    const ua = navigator.userAgent.toLowerCase();
	    return /iphone|ipad|phone|Mac/i.test(ua);
	};
	// 初始化播放器
		const initPlayer = () => {
	
	    if (!props.url) return console.warn('url is not exist');
	    const config = {
	        el: playerRef.value, // 使用ref代替id
	        url: props.url,
	        plugins: [window.HlsPlayer],
	        hls: {
	            retryCount: 3, // 重试 3 次,默认值
	            retryDelay: 1000, // 每次重试间隔 1 秒,默认值
	            loadTimeout: 10000, // 请求超时时间为 10 秒,默认值
	            fetchOptions: {
	                // 该参数会透传给 fetch,默认值为 undefined
	                mode: 'cors'
	            }
	        },
	        fluid: true,
	        // 倍速播放
	        playbackRate: [2],
	        defaultPlaybackRate: 1,
	        volume: 0.7,
	        playsinline: isAppleDevice(), // IOS设备设置
	        'x5-video-player-type': 'h5', // 微信内置浏览器设置
	        'x5-video-orientation': 'portraint',
	        poster: props.poster,
	        // 画中画
	        // pip: true,
	        pipConfig: {
	            bottom: 100,
	            right: 100,
	            width: 320,
	            height: 180
	        },
	        // 初始化首帧
	        // videoInit: true,
	        autoplay: true
	    };
	
	    // 实例化播放器
	    player.value = new Player(config);
	
	    if (player.value) {
	        // 新增:单击事件处理
	        const container = player.value.root;
	        container.addEventListener('click', handleContainerClick);
	
	
	        // 注册事件监听
	        player.value.on('play', () => {
	            emit('triggerEvent', true);
	        });
	
	        player.value.on('pause', () => {
	            emit('triggerEvent', false);
	        });
	        player.value.on('ended', () => {
	            emit('playEnd');
	        });
	        // 1. 视频进入等待缓冲状态
	        player.value.on('waiting', () => {
	            // console.log('视频缓冲中...');
	            emit('loadingStateChange', { bool: true, time: player.value.currentTime }); // 通知父组件显示加载状态
	        });
	        player.value.on('canplay', () => {
	            emit('loadingStateChange', { bool: false, time: player.value.currentTime });
	        });
	        // 监听播放进度更新
	        player.value.on('timeupdate', () => {
	            emit('timeupdate', { playerVideo: player.value });
	        });
	        setTimeout(() => {
	            forceVideoSize();
	        }, 100); // 延迟100ms确保播放器完全初始化
	    }
	};
	
	
	// 生命周期钩子
	onMounted(() => {
	    initPlayer();
	});
	
	// 监听url变化
	watch(() => props.url, (newValue) => {
	    if (!player.value) {
	        initPlayer();
	        return;
	    }
	
	    player.value.src = newValue;
	});
	// 处理容器点击事件
	const handleContainerClick = (e) => {
	    // 排除控制栏区域的点击
	    if (e.target.closest('.xgplayer-control')) return;
	    emit('videoClick', e); // 通知父组件显示加载状态
	
	};
	const forceVideoSize = () => {
	    if (!player.value) return;
	    const videoEl = player.value.root.querySelector('video');
	    const container = player.value.root;
	
	    if (videoEl) {
	        // 完全重置video标签的样式
	        videoEl.style.cssText = `
	      width: 100% !important;
	      height: 100% !important;
	      max-width: none !important;
	      max-height: none !important;
	      object-fit: cover !important;
	      position: absolute !important;
	      top: 0 !important;
	      left: 0 !important;
	      bottom: 0 !important;
	      right: 0 !important;
	      margin: 0 !important;
	      padding: 0 !important;
	      border: none !important;
	    `;
	
	        // 设置播放器容器样式
	        container.style.cssText = `
	      width: 100% !important;
	      height: 100% !important;
	      max-width: none !important;
	      max-height: none !important;
	      position: relative !important;
	      overflow: hidden !important;
	      margin: 0 !important;
	      padding: 0 !important;
	    `;
	    }
	};
	// 组件卸载时销毁播放器
	onUnmounted(() => {
	    if (player.value) {
	        player.value.destroy();
	        player.value = null;
	    }
	});
	</script>
	
	<style scoped lang="scss">
	.video-box {
	    width: 100% !important;
	    height: 100% !important;
	
	    .video-player {
	        width: 100% !important;
	        height: 100% !important;
	        padding: 0 !important;
	        margin: 0 auto;
	    }
	
	}
	</style>
3.1 .m3u8 格式处理需要在index.html 中引入
3.2 注意 vite项目下,使用依赖import 的方式播放有问题,需要改为全局引入静态min.js
  <!-- 先引入 xgplayer 核心库 -->
<script src="https://unpkg.com/xgplayer@latest/dist/index.min.js"></script> 
<!-- 再引入 xgplayer-hls 插件 -->
<script src="https://unpkg.com/xgplayer-hls@latest/dist/index.min.js"></script> 

4.父组件使用

<template>
    <swiper :circular="state.circular" class="m-tiktok-video-swiper" @change="swiperChange"
        @animationfinish="animationfinish" :current="state.current" :vertical="true" duration="300">
        <swiper-item v-for="(item, index) in state.originList" :key="index">
            <view class="swiper-item"
                v-if="index == state.current || index + 1 == state.current || index - 1 == state.current">
                <xgplayerVideo  
                class="m-tiktok-video-player" 
                :url="item.src"
                :poster="poster"
                v-if="index == state.current && item.src" 
                @playEnd="ended"
                @loadingStateChange="onwaiting"
                @triggerEvent="onTriggerEvent"
                @timeupdate="onTimeupdate"
                @videoClick="onVideoClick"
                ></xgplayerVideo>
                <slot :item="item"></slot>
            </view>
        </swiper-item>
    </swiper>
</template>

<script lang="ts" setup>
import { reactive, ref, getCurrentInstance, watch, nextTick } from "vue";
import type { ComponentInternalInstance, PropType } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { getPlayUrl } from "@/api/home";
import { trackEvent } from "@/utils/common";
import xgplayerVideo from "./xgplayerVideo.vue"
const _this = getCurrentInstance() as ComponentInternalInstance;

export interface IvideoItem {
    /**
     * 视频链接
     */
    src: string;
    /**
     * 海报封面
     */
    poster?: string;
}

const emits = defineEmits([
    "change",
    "ended",
    "swiperchange",
    "videoClicks"
]);

const props = defineProps({
    poster: {
        type: String,
        default: "",
    },
    /**
     * 当前播放剧集(索引值)
     */
    current: {
        type: [Number, String],
        default: 0,
    },
    /**
     * 视频列表
     */
    videoList: {
        type: Array as PropType<IvideoItem[]>,
        default: () => [],
    },
    /**
     * 是否循环播放一个视频
     */
    loop: {
        type: Boolean,
        default: true,
    },
    /**
     * 显示原生控制栏
     */
    controls: {
        type: Boolean,
        default: true,
    },
    /**
     * 是否自动播放
     */
    autoplay: {
        type: Boolean,
        default: true,
    },
    /**
     * 是否自动滚动播放
     */
    autoChange: {
        type: Boolean,
        default: true,
    },
});

const state = reactive({
    circular: false,
    originList: [] as any, // 源数据
    originIndex: 0, // 记录源数据的下标
    current: 0,
    videoContexts: [] as any,
    isFirstLoad: true,
    isPause: true,
    bufferStartTime:0
});
const VideoPlayer = ref([])
const videoDomDate = ref({})
const reportedTimes = ref(new Set()); // 记录已上报的时间点

const animationfinish = async () => { };

function ended() {
     trackEvent("play", {
                    id: props.videoList[0].videoId,
                    level: props.current,
                    status:'finish'
            })
    // 自动切换下一个视频
    if (props.autoChange) {
        state.current = state.originIndex + 1;
    }
    emits("ended");
}
/**
 * 初始一个显示的swiper数据
 * @originIndex 从源数据的哪个开始显示默认0
 */
async function initSwiperData(originIndex = state.originIndex) {
    // 确保索引有效
    if (originIndex < 0 || originIndex >= state.originList.length) {
        console.warn("无效的视频索引:", originIndex);
        return;
    }

    const index = originIndex;
    // 延迟播放当前视频,确保DOM已更新
    await nextTick();
    console.log("播放视频:", index, props.videoList[index]);
    // handleCoverClick(index);

    // 数据改变
    emits("change", {
        index: originIndex,
        detail: state.originList[originIndex],
    });

}
// 视频缓冲
const onwaiting = (val) => {
    // state = true 开始缓冲
    // state = false 缓冲结束
    if (val.bool) {
    state.bufferStartTime = Date.now()
        trackEvent("play_stop", {
            id: props.videoList[0].videoId,
            level: props.current,
            stop: Number(val.time.toFixed(2)),
            time:0
        });
    } else {
        if (state.bufferStartTime) {
            const bufferTime = (Date.now() - state.bufferStartTime) / 1000;
            trackEvent("play_stop", {
                id: props.videoList[0].videoId,
                level: props.current,
                stop: Number(val.time.toFixed(2)),
                time:bufferTime
            });
        }
        
    }
    
}
// 播放暂停
const onTriggerEvent = (boo) => {
      console.log(boo);
}
// 点击屏幕
const onVideoClick = (e) => {
    emits("videoClicks", e);
}

// 每隔10秒上报一次
const onTimeupdate = (val) => {
         if (!val.playerVideo || val.playerVideo.paused) return;
            const currentTime = Math.floor(val.playerVideo.currentTime);
            // 视频总时长
            const duration = Math.floor(val.playerVideo.duration || 0);
            // 只处理0秒和10的倍数秒数,且不超过视频总时长
            if ((currentTime === 0 || currentTime % 10 === 0) && 
                currentTime <= duration && 
                !reportedTimes.value.has(currentTime)) {
                    if (props.current == 1) {
                        trackEvent("play_progress", {
                            id: props.videoList[0].videoId,
                            level: props.current,
                            progress: currentTime
                        });
                        }
            reportedTimes.value.add(currentTime);
            // console.log(`上报: ${currentTime}秒`);
            }
       
}
// var hls = new Hls();
const handleCoverClick = async(index) => {
// if (Hls.isSupported()) {
    // 创建新的HLS实例
    // if (hls) {
    //     hls.destroy()
    //  }
    //           // 暂停视频(确保没有播放中的实例)
    //     if (videoDomDate.value.videoDom) {
    //         videoDomDate.value.videoDom.pause();
    //     }
    //       // 加载.m3u8视频源
    //         console.log('HLS媒体已附加',state.originList[index].src,state.current);
    //         if(state.originList[index].src){
    //             hls.loadSource(state.originList[index].src); // 假设item.src是.m3u8格式的URL
    //         }
    //         // 关联HLS实例与视频元素

    // // 关联HLS实例与视频元素
    //     if (videoDomDate.value.videoDom) {
    //         hls.attachMedia(videoDomDate.value.videoDom); // 假设DomVideoPlayer暴露了video元素
    //       }
    //         console.log('开始进入',videoDomDate.value);
            
    //        hls.on(Hls.Events.MEDIA_ATTACHED, () => {
    //         console.log(videoDomDate.value.videoDom,',准备播放');
    //         videoDomDate.value.videoDom.play(); // 开始播放视频
    //        });

  
    VideoPlayer.value.forEach(item => {
        console.log(item);
        if (item) {
            const num = Number(item.$ownerInstance.$el.getAttribute('data-index'))
            console.log(num,'99');
            
            if (num === index) {
                item.play()
            } else {
                state.isPause = true
                item.toSeek(0)
                item.pause();
            }
        }
    })
// }
};

/**
 * swiper滑动时候
 */
async function swiperChange(event: any) {
    const { current } = event.detail;
    state.current = current;
    state.originIndex = current;

    // 确保视频源已加载
    // if (!state.originList[current].src) {
    //     const url = await getVideoUrl(props.videoList[current].videoUrl);
    //     state.originList[current].src = url;
    // }
    initSwiperData();
    emits("swiperchange", current);
    console.log("swiper切换:", current);
}
async function getVideoUrl(videoUrl: string) {
    try {
        if (videoUrl) {
            const {
                data: { url },
            } = await getPlayUrl({
                videoUrl,
            });
            return url;
        }
    } catch (error) {
        console.error("获取视频URL失败:", error);
        throw error;
    }
}
// 监听props.current变化
watch(
    () => props.current,
    async () => {
        console.log(props.current, props.videoList);
        if (props.videoList?.length) {
            const i = props.videoList.findIndex((item: any) => item.num == props.current);
            if (i > -1) {
                state.current = i;
                state.originIndex = i;
                state.originList = props.videoList;
                const url = await getVideoUrl(props.videoList[i].videoHls);
                console.log(url, '=============>');

                state.originList[i].src = url;
                // 埋点
                trackEvent("play", {
                    id: props.videoList[0].videoId,
                    level: props.current,
                    status:'start'
                })
            // Meta Pixel 用于衡量 Facebook广告 的效果
            //#ifdef H5
            window.fbq('track', 'ViewContent',{content_ids :props.videoList[0].videoId});
            //#endif
                if (state.isFirstLoad || !state.videoContexts?.length) {
                    initSwiperData();
                }
            }
        }
    },
    {
        immediate: true,
        deep: true
    }
);
function jumpToVideo(index) {
    if (index >= 0 && index < state.originList.length) {
        state.current = index;
        state.originIndex = index;
        // initSwiperData(index);
    }
}

let loadTimer: any = null;
onLoad(() => {
    // 为了首次只加载一条视频(提高首次加载性能),延迟加载后续视频
    loadTimer = setTimeout(() => {
        state.isFirstLoad = false;
        clearTimeout(loadTimer);
    }, 5000);
});

onUnload(() => {
    clearTimeout(loadTimer);
});

defineExpose({
    initSwiperData,
    jumpToVideo,
});
</script>

<style lang="scss">
.m-tiktok-video-swiper,
.m-tiktok-video-player {
    width: 100%;
    height: 100%;
    background-color: #000;
}

.m-tiktok-video-swiper {
    .swiper-item {
        width: 100%;
        height: 100%;
        position: relative;
    }

    .m-tiktok-video-poster {
        display: block;
        opacity: 1;
        visibility: visible;
        position: absolute;
        left: 0;
        top: 0;
        background-position: center center;
        background-color: #000;
        background-size: 100% auto;
        background-repeat: no-repeat;
        transition: opacity 0.3s ease, visibility 0.3s ease;
        pointer-events: none;
        width: 100%;
        height: 100%;
    }

    .iszan {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        z-index: 99;
    }
}
</style>

网站公告

今日签到

点亮在社区的每一天
去签到