基于Vue3+xgplayer 移动端直播解决方案

基于 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>
相关推荐
码农刚子6 小时前
ASP.NET Core Blazor 核心功能一:Blazor依赖注入与状态管理指南
前端·后端
用户4099322502126 小时前
Vue 3模板如何通过编译三阶段实现从声明式语法到高效渲染的跨越
前端·ai编程·trae
小左OvO6 小时前
基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流
前端·javascript·vue.js
小左OvO6 小时前
基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交
前端·javascript·vue.js
IT_陈寒6 小时前
Vite 5新特性解析:10个提速技巧让你的开发效率翻倍 🚀
前端·人工智能·后端
焦糖小布丁7 小时前
通配符证书能给几个网站用?
前端
qiao若huan喜7 小时前
6、webgl 基本概念 + 四边形纹理
前端·javascript·信息可视化·webgl
刘一说7 小时前
深入理解 Spring Boot Web 开发中的全局异常统一处理机制
前端·spring boot·后端
啃火龙果的兔子7 小时前
前端导出大量数据到PDF方案
前端·pdf