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>