基于 Vue3 + 西瓜视频播放器(xgplayer),打造轻量、流畅、可直接复用的移动端直播组件,适配快速集成场景。

最终效果
- 📺 支持 HLS(m3u8)直播流,播放流畅无卡顿
- 🎬 自定义封面 + 播放按钮,点击即启动
- 📱 移动端适配(内嵌播放、全屏切换、无滚动条)
- 🛡 基础错误处理,兼容浏览器自动播放限制
核心代码实现
1. 依赖安装
css
npm install xgplayer --save
2. 极简直播播放器组件(LivePlayer.vue)
js
<template>
<div :id="id" style="position: relative" :width="props.width" :height="props.height">
<img
v-if="showImage"
style="background-color: black; object-fit: cover"
:width="props.width"
:height="props.height"
:src="props.poster"
@click="clickImage"
/>
<img
v-if="showImage"
class="play-icon"
:src="getAssetsImages(`icon_play.png`)"
alt=""
@click="clickImage"
/>
</div>
</template>
<script setup lang="ts">
import { getAssetsImages } from "@/utils/util.js";
import { ref } from "vue";
import Player from "xgplayer";
import "xgplayer/dist/index.min.css";
const props = defineProps({
id: {
type: String,
required: true,
},
videoUrl: {
type: String,
default: () => "",
},
poster: {
type: String,
default: () => "",
},
playsinline: {
type: Boolean,
default: true,
},
width: {
type: String,
default: "100%",
},
height: {
type: String,
default: "100%",
},
});
const showImage = ref(true);
// 定义一个变量来存储 player 实例
let player: Player;
const clickImage = () => {
if (player == null) {
initPlayer();
showImage.value = false;
}
};
// 初始化西瓜视频
const initPlayer = () => {
player = new Player({
lang: "zh", // 设置播放器的语言为中文(zh)
volume: 0.5, // 设置初始音量为50%
id: props.id, // 使用传入的id属性,可能是一个容器或视频元素的ID
url: props.videoUrl, // 设置视频的URL地址
poster: props.poster, // 设置视频封面图
playsinline: props.playsinline, // 是否允许在移动设备上内嵌播放(不跳转到全屏)
height: props.height, // 设置播放器的高度
width: props.width, // 设置播放器的宽度
isLive: true, // 设置是否直播
playbackRate: [1], // 倍速展示
defaultPlaybackRate: 1,
cssFullscreen: false, // 设置是否使用CSS全屏样式
download: false, // 隐藏下载按钮
autoplay: false, // 自动播放视频
whitelist: [""], // 设置白名单,当前为空
});
// 添加事件监听器,确保播放器准备好后再播放
player.on("ready", () => {
player.play(); // 播放视频
});
};
</script>
<style scoped>
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
}
</style>
3. 页面使用示例(LivePage.vue)
js
<template>
<div class="award-ceremony">
<div class="award-ceremony__content">
<!-- 直播播放器区域 -->
<div class="video_wrapper">
<VideoPlayer
id="videoPlayer"
:live-url="liveStreamUrl"
:poster="livePoster"
/>
</div>
<!-- 主体内容区(评论/节目单) -->
<div class="main_wrapper">
<!-- 切换按钮 -->
<ul class="btn-list">
<li
v-for="(item, index) in btnList"
:key="index"
:class="index === activeIndex ? 'active' : ''"
@click="handleTabClick(index)"
>
{{ item }}
</li>
</ul>
<!-- 评论列表 -->
<ul class="comment-list">
<van-pull-refresh v-model="refreshing" success-text="刷新成功" @refresh="onRefresh">
<van-list
v-model:loading="loading"
offset="100"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<li v-for="(item, index) in commentList" :key="index" class="comment-item">
<!-- 评论用户信息 -->
<div class="comment-user">
<div class="name">{{ item.userName }}</div>
<div class="from" v-if="item.userType === 'leader'">
{{ item.orgName }}
</div>
<div class="from" v-else>
{{ item.company }}
</div>
<div class="from" v-else-if="item.department">{{ item.department }}</div>
</div>
<!-- 评论内容 -->
<div class="comment-content">
<div class="text">
<span>{{ item.content?.substring(0, 64) }}</span>
<span v-show="item.isExpand">{{ item.content?.substring(64) }}</span>
<span v-if="item.content?.length > 64 && !item.isExpand">...</span>
<span v-if="item.content?.length > 64" class="more" @click="toggleExpand(item)">
{{ item.isExpand ? "收起" : "展开" }}
</span>
</div>
<div class="time">{{ formatTime(item.publishTime) }}</div>
</div>
</li>
</van-list>
</van-pull-refresh>
</ul>
<!-- 评论发布框 -->
<van-cell-group>
<van-field
v-model="commentInput"
maxlength="100"
rows="1"
autosize
label=""
type="textarea"
placeholder="请输入(最多100字)"
>
<template #button>
<van-button size="small" :loading="isPublishing" @click="publishComment">发送</van-button>
</template>
</van-field>
</van-cell-group>
</div>
</div>
<!-- 节目单弹窗 -->
<van-overlay
z-index="999"
:show="showProgramModal"
:lock-scroll="false"
@click="showProgramModal = false"
>
<div class="program-wrapper">
<div class="program-bg">
<div class="program-detail" @click.stop>
<div class="title text-gradient">标题占位</div>
<div class="subtitle text-gradient">企业20xx年度先进颁奖典礼</div>
<van-list class="program-list">
<div v-for="(item, index) in programList" :key="index">
<div v-if="item.isImportant" class="important-item">
<p>{{ item.programType }}</p>
<p>{{ item.programName }}</p>
<p>{{ item.host }}</p>
</div>
<div v-else class="program-item">
<p>{{ item.programType }}{{ item.programName }}</p>
<p>{{ item.host }}</p>
</div>
<div class="award-item">{{ item.awardDesc }}</div>
</div>
</van-list>
</div>
</div>
</div>
</van-overlay>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from "vue";
import VideoPlayer from "./VideoPlayer.vue"; // 引入播放器组件(下文附简化版)
import { VanPullRefresh, VanList, VanCellGroup, VanField, VanButton, VanOverlay, VanToast } from "vant";
import "vant/lib/index.css";
// 直播核心配置(替换为实际项目配置)
const liveStreamUrl = ref("https://live-stream.example.com/livestream.m3u8"); // 直播流地址(m3u8)
const livePoster = ref("https://picsum.photos/1080/720"); // 直播封面图
// 标签切换
const btnList = ref(["评论", "节目单"]);
const activeIndex = ref(0);
const showProgramModal = ref(false);
// 评论模块状态
const commentList = ref([]);
const commentInput = ref("");
const loading = ref(false);
const finished = ref(false);
const refreshing = ref(false);
const isPublishing = ref(false);
let page = 1;
const pageSize = 5;
// 节目单数据(实际项目从接口获取)
const programList = ref([
{
isImportant: true,
programType: "开场",
programName: "领导致辞",
host: "张总",
awardDesc: "欢迎各位嘉宾莅临本次颁奖典礼"
},
{
isImportant: false,
programType: "颁奖",
programName: "年度优秀员工",
host: "李经理",
awardDesc: "表彰本年度表现突出的优秀员工代表"
},
{
isImportant: false,
programType: "表演",
programName: "员工才艺展示",
host: "王主持人",
awardDesc: "员工自发组织的文艺表演"
},
{
isImportant: true,
programType: "闭幕",
programName: "总结发言",
host: "刘总",
awardDesc: "本次颁奖典礼总结及未来展望"
}
]);
/** 切换标签(评论/节目单) */
const handleTabClick = (index) => {
activeIndex.value = index;
if (index === 1) {
showProgramModal.value = true;
}
};
/** 加载评论列表(模拟接口请求) */
const onLoad = async () => {
if (finished.value) return;
loading.value = true;
try {
// 模拟接口请求(实际项目替换为真实接口)
await new Promise(resolve => setTimeout(resolve, 800));
// 模拟评论数据
const mockComments = Array.from({ length: pageSize }, (_, i) => ({
userName: `用户${page * pageSize + i + 1}`,
userType: Math.random() > 0.7 ? "leader" : "normal",
orgName: Math.random() > 0.7 ? "企业总部" : "分公司",
company: "中国电信",
department: "技术部",
content: `恭喜获奖的同事!${"直播内容非常精彩,为奋斗者点赞~".repeat(Math.floor(Math.random() * 3) + 1)}`,
publishTime: new Date().toISOString(),
isExpand: false
}));
if (refreshing.value) {
commentList.value = mockComments;
refreshing.value = false;
} else {
commentList.value.push(...mockComments);
}
// 模拟加载完成(第3页后无更多数据)
if (page >= 3) {
finished.value = true;
}
page++;
} catch (error) {
console.error("加载评论失败:", error);
VanToast("加载失败,请稍后重试");
} finally {
loading.value = false;
}
};
/** 刷新评论列表 */
const onRefresh = () => {
finished.value = false;
commentList.value = [];
page = 1;
loading.value = true;
onLoad();
};
/** 发布评论 */
const publishComment = async () => {
const content = commentInput.value.trim();
if (!content) return;
isPublishing.value = true;
try {
// 模拟发布接口请求
await new Promise(resolve => setTimeout(resolve, 500));
// 发布成功后添加到列表头部
commentList.value.unshift({
userName: "当前用户",
userType: "normal",
company: "中国电信",
department: "市场部",
content: content,
publishTime: new Date().toISOString(),
isExpand: false
});
commentInput.value = "";
VanToast("发布成功");
} catch (error) {
console.error("发布评论失败:", error);
VanToast("发布失败,请稍后重试");
} finally {
isPublishing.value = false;
}
};
/** 展开/收起长评论 */
const toggleExpand = (item) => {
item.isExpand = !item.isExpand;
};
/** 格式化时间 */
const formatTime = (timeStr) => {
if (!timeStr) return "";
const date = new Date(timeStr);
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
};
/** 监听节目单弹窗,加载数据(实际项目可在此处请求最新节目单) */
watch(showProgramModal, (isShow) => {
if (isShow) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
});
/** 页面挂载时加载评论 */
onMounted(() => {
onLoad();
});
</script>
<style lang="scss" scoped>
@font-face {
font-family: "wdch";
src: url("https://cdn.example.com/fonts/wdch.ttf") format("truetype"); // 替换为公开字体CDN
}
.award-ceremony {
overflow: hidden;
height: 100vh;
font-size: 12px;
position: relative;
:deep(.van-hairline--top-bottom:after) {
border: none;
}
&__content {
height: 100vh;
background: linear-gradient(180deg, #fff3e6 0%, #ffffff 100%);
.video_wrapper {
width: 100%;
height: 210px;
background: #ca1e00 url("https://picsum.photos/1080/210") no-repeat center bottom; // 替换为公开背景图
background-size: 100% auto;
overflow: hidden;
}
.main_wrapper {
height: calc(100% - 210px);
display: flex;
flex-direction: column;
.btn-list {
flex: none;
display: flex;
justify-content: space-around;
align-items: center;
padding: 4px 0;
background: #fff;
li {
width: 50%;
height: 100%;
line-height: 26px;
text-align: center;
color: #333;
font-size: 14px;
cursor: pointer;
&.active {
font-size: 16px;
color: #e33016;
background: url("https://picsum.photos/200/30") no-repeat center bottom; // 替换为公开按钮背景
background-size: auto 100%;
}
}
}
.comment-list {
flex: auto;
overflow-y: auto;
padding: 0 12px;
box-sizing: border-box;
.comment-item {
margin: 8px 0 0;
padding: 12px 0;
min-height: 96px;
background: linear-gradient(90deg, #ffd3c4, #ffffff, #ffffff);
background-size: 100% 100%;
display: flex;
align-items: center;
border-radius: 4px;
border: 1px solid #ffc9c1;
.comment-user {
padding: 0 8px;
font-size: 14px;
color: #da2a0e;
line-height: 24px;
width: 33.33%;
text-align: center;
.from {
font-size: 12px;
}
}
.comment-content {
height: 100%;
padding: 0 8px;
font-size: 12px;
color: #131415;
line-height: 14px;
width: 66.66%;
border-left: 1px dashed rgba(227, 48, 22, 0.2);
.text {
min-height: 56px;
span {
word-wrap: break-word;
white-space: normal;
}
.more {
color: #666;
margin-left: 4px;
white-space: nowrap;
cursor: pointer;
}
}
.time {
text-align: right;
position: relative;
top: 4px;
color: #999;
}
}
}
}
:deep(.van-cell-group) {
flex: none;
width: 100%;
padding: 8px 12px;
border-top: 1px solid #f2f3f5;
box-shadow: 0 0 5px #f2f3f5;
background: #fff;
.van-field__body {
align-items: flex-end;
}
.van-field__control {
background: rgba(255, 207, 197, 0.9);
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.2);
border-radius: 16px;
padding: 8px;
font-size: 12px;
line-height: 16px;
min-height: 32px;
color: #e53416;
word-wrap: break-word;
white-space: normal;
overflow: hidden;
}
.van-button {
width: 64px;
height: 32px;
background: linear-gradient(180deg, #f35e31 0%, #e33016 100%);
border-radius: 16px;
font-size: 14px;
color: #fffefc;
}
}
}
}
.program-wrapper {
width: 100%;
height: 100%;
padding: 46px 16px 24px; // 适配导航栏高度
.program-bg {
width: 100%;
height: calc(100vh - 46px - 24px);
background: url("https://picsum.photos/1080/720") no-repeat center bottom; // 替换为公开节目单背景
box-sizing: border-box;
background-size: 100% 100%;
text-align: center;
padding: 19vh 12px 4vh;
}
.program-detail {
width: 100%;
height: 100%;
}
.title {
height: 32px;
line-height: 40px;
font-size: 18px;
font-family: "wdch";
}
.subtitle {
line-height: 40px;
font-size: 15px;
font-family: "wdch";
}
.program-list {
height: calc(100% - 72px);
overflow-y: auto;
color: #e33016;
font-size: 14px;
margin: 0 -8px 0 0;
.program-item {
text-align: center;
line-height: 20px;
padding: 4px 0;
}
.award-item {
line-height: 40px;
font-size: 15px;
font-family: "wdch";
background: linear-gradient(180deg, #ff0000 0%, #ff8300 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.important-item {
padding: 4px 0;
> p {
font-size: 15px;
font-family: "wdch";
background: linear-gradient(180deg, #ff0000 0%, #ff8300 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
}
.text-gradient {
background: linear-gradient(180deg, #f0855c 0%, #ef2407 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
}
</style>