veepai-video-player-h5
v1.2.6
Published
移动端 H5 视频播放器组件,支持回放片段时间轴、精灵图预览、移动端优化
Downloads
1,132
Maintainers
Readme
veepai-video-player-h5
移动端 H5 视频回放播放器组件库 - 支持多目播放、时间轴导航、精灵图预览等功能
📦 安装
npm install veepai-video-player-h5
# 或
yarn add veepai-video-player-h5
# 或
pnpm add veepai-video-player-h5🎯 核心功能
播放器功能
- ✅ HLS 视频播放 - 基于 xgplayer 3.x,支持 M3U8 流媒体播放
- ✅ 多目播放 - 支持同时播放多个视频通道(1-4 路)
- ✅ 回放片段管理 - 自动处理多个回放片段的连续播放
- ✅ 16:9 比例锁定 - 固定播放器显示比例,保证画面显示一致
- ✅ 播放控制 - 播放/暂停/快进/倍速/全屏控制
- ✅ 移动端优化 - 针对 iOS/Android 触摸交互优化
时间轴功能
- ✅ 可视化时间轴 - Canvas 绘制,支持拖动、缩放
- ✅ 回放片段标记 - 蓝色区域显示有录像的时间段
- ✅ 精灵图预览 - 悬浮显示时间点的视频缩略图
- ✅ 日期切换 - 左右切换查看不同日期的录像
- ✅ 时间跳转 - 点击时间轴快速跳转到指定时间点(如 AI_H5 back 云回放)
- ✅ 播放指针 - 实时显示当前播放位置
高级功能
- ✅ 连续播放 - 可配置是否自动连续播放多个片段
- ✅ 时间戳跳转 - 根据时间戳精确跳转到回放片段
- ✅ 数据聚合 - 自动合并相同 key 的连续片段
- ✅ 封面图展示 - 支持显示回放片段封面
- ✅ 播放状态同步 - 实时同步播放时间和状态
- ✅ 日期禁用 - 禁止选择未来日期
📚 组件说明
MultiChannelPlayer - 多目播放器组件
组件介绍
MultiChannelPlayer 是核心播放器组件,支持单目/多目视频播放,内置时间轴导航功能。基于 xgplayer 3.x 实现 HLS 视频播放,支持移动端触摸交互。
Props 属性
| 属性 | 类型 | 必填 | 默认值 | 说明 |
| ------------------ | ----------------- | ---- | ------- | ----------------------- |
| deviceId | string | ✅ | - | 设备 ID |
| userId | string | ✅ | - | 用户 ID |
| channels | ChannelConfig[] | ✅ | - | 通道配置列表 |
| isShowTimeline | boolean | ❌ | true | 是否显示时间轴 |
| m3u8BaseUrl | string | ❌ | - | M3U8 接口基础 URL |
| timelineHeight | number | ❌ | 44 | 时间轴高度(px) |
| timelineColors | TimelineColors | ❌ | - | 时间轴颜色配置 |
| continuousPlay | boolean | ❌ | false | 是否连续播放片段 |
| showProgress | boolean | ❌ | false | 是否显示进度条 |
| showFullscreen | boolean | ❌ | false | 是否显示全屏按钮 |
| showPlaybackRate | boolean | ❌ | false | 是否显示倍速按钮 |
| onGetReplayData | Function | ✅ | - | 获取回放数据的 API 函数 |
| onGetSprite | Function | ✅ | - | 获取精灵图的 API 函数 |
ChannelConfig 类型
interface ChannelConfig {
id: number; // 通道 ID(0-3)
width: string; // 播放器宽度,如 '100%', '50%'
height: string; // 播放器高度,如 '209px', '25vh'
channel?: number; // 通道编号(可选)
}Events 事件
| 事件名 | 参数 | 说明 |
| ------------------- | ------------------------------------ | ------------------------ |
| replayData | data: Record<number, ReplayItem[]> | 回放数据加载完成 |
| playingTimeUpdate | timestamp: number | 播放时间更新(每秒触发) |
| dateChange | dateRange: [string, string] | 日期切换事件 |
Methods 方法
| 方法名 | 参数 | 返回值 | 说明 |
| --------------------------- | ------------------------------------- | --------------- | ---------------------------------- | -------------- |
| deviceBackPlayerList | dateRange: [string, string] | Promise<void> | 加载指定日期范围的回放数据 |
| seekToTimestamp | timestamp: number, endTime?: number | void | 跳转到指定时间戳 |
| initChannelPlayNoTimeline | - | void | 初始化播放器并自动播放(无时间轴) |
| initChannelPlayNoAutoPlay | - | void | 初始化播放器但不自动播放 |
| getPlayerInstance | channelId: number | Player | null | 获取播放器实例 |
| getCurrentTimelineDate | - | string | 获取当前时间轴选中的日期 |
| playVideo | channelId?: number | Promise<void> | 播放视频 |
| pauseVideo | channelId?: number | void | 暂停视频 |
| togglePlayPause | channelId?: number | Promise<void> | 切换播放/暂停状态 |
| destroyPlayers | - | void | 销毁所有播放器实例 |
TimeLine - 时间轴组件
组件介绍
TimeLine 是独立的时间轴导航组件,基于 Canvas 绘制,支持拖动、缩放、精灵图预览等功能。
Props 属性
| 属性 | 类型 | 必填 | 默认值 | 说明 |
| -------------- | ------------------ | ---- | -------- | ----------------------- |
| width | string \| number | ❌ | '100%' | 时间轴宽度 |
| height | number | ❌ | 44 | 时间轴高度(px) |
| setStartTime | string | ❌ | '' | 初始显示时间 |
| playTime | string | ❌ | '' | 当前播放时间 |
| timeRange | string[] | ❌ | [] | 时间范围 [开始, 结束] |
| markTime | MarkTimeItem[] | ❌ | [] | 标记时间段(回放片段) |
| isAutoPlay | boolean | ❌ | false | 是否自动播放 |
| colors | TimelineColors | ❌ | - | 自定义颜色配置 |
| minPxSecond | number | ❌ | 65 | 最小像素秒(缩放限制) |
MarkTimeItem 类型
interface MarkTimeItem {
beginTime: string; // 开始时间 'YYYY-MM-DD HH:mm:ss'
endTime: string; // 结束时间 'YYYY-MM-DD HH:mm:ss'
bgColor: string; // 标记颜色,如 '#2AC3E4'
}TimelineColors 类型
interface TimelineColors {
background?: string; // 背景色
meddleLine?: string; // 中间线颜色
meddleDate?: string; // 中间时间颜色
moveLine?: string; // 移动线颜色
moveDate?: string; // 移动时间颜色
scaleLine?: string; // 刻度线颜色
scaleBar?: string; // 刻度背景色
scaleText?: string; // 刻度文字颜色
smallScaleLine?: string; // 小刻度线颜色
}Events 事件
| 事件名 | 参数 | 说明 |
| --------------- | ------------------------------ | --------------------- |
| slideToMark | timestamp: number | 点击/滑动到标记时间点 |
| change | time: string | 时间变化 |
| requestSprite | { begin, end, hoverTimeStr } | 请求精灵图数据 |
| clearSprite | - | 清除精灵图 |
| dateChange | dateRange: [string, string] | 日期范围变化 |
💡 使用示例
示例 1:回放页面(带时间轴)
适用场景:回放查看页面,需要时间轴导航和日期切换
<template>
<div class="back-page">
<!-- 播放按钮(可选) -->
<van-icon v-if="showPlayerButton" name="play-circle-o" class="play-button" @click="handlePlayButtonClick" />
<!-- 多目播放器 -->
<MultiChannelPlayer ref="multiPlayerRef" :device-id="queryParams.deviceID" :user-id="queryParams.uid" :channels="channels" :m3u8BaseUrl="m3u8BaseUrl" :is-show-timeline="true" :timeline-height="55" :continuous-play="false" :show-progress="true" :show-fullscreen="true" :show-playback-rate="true" :on-get-replay-data="fetchReplayData" :on-get-sprite="fetchSprite" @replayData="handleReplayData" @playingTimeUpdate="handlePlayingTimeUpdate" @dateChange="handleDateChange" />
<!-- 回放片段列表 -->
<div class="replay-list">
<div class="hour-group" v-for="[hour, hourData] in sortedReplayData" :key="hour">
<div class="hour-title">{{ hour }}:00</div>
<div class="items">
<div v-for="(item, idx) in hourData" :key="idx" class="item" :class="{ selected: isItemSelected(item) }" @click="handleItemClick(item)">
<van-image v-if="item.coverUrl" :src="item.coverUrl" />
<div class="time">{{ formatTime(item.startTime) }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from "vue";
import { MultiChannelPlayer } from "veepai-video-player-h5";
import "veepai-video-player-h5/dist/style.css";
import { getReplayData, getSprite } from "@/api/device";
import dayjs from "dayjs";
// 播放器引用
const multiPlayerRef = ref<any>(null);
// 查询参数
const queryParams = ref({
uid: "3013168",
deviceID: "VQDG0482895PMXW",
channel: "0",
});
// 通道配置(单目)
const channels = ref([{ id: 0, width: "100%", height: "209px", channel: 0 }]);
// M3U8 基础 URL
const m3u8BaseUrl = ref("https://veepai-dev.eye4.cn:8282/UCS/video/m3u8File");
// 播放按钮状态
const showPlayerButton = ref(false);
const firstItemData = ref<any>(null);
// 回放数据
const replayDataObj = ref({});
const currentPlayingTimestamp = ref<number>(0);
const isPlaying = ref<boolean>(false);
// 排序后的回放数据(按时间倒序)
const sortedReplayData = computed(() => {
const entries = Object.entries(replayDataObj.value);
return entries.sort((a, b) => {
const [hourA, dataA] = a;
const [hourB, dataB] = b;
const startTimeA = dataA.length > 0 ? dataA[0].startTime : 0;
const startTimeB = dataB.length > 0 ? dataB[0].startTime : 0;
return startTimeB - startTimeA;
});
});
// API 适配器
const fetchReplayData = async (params: any) => {
const res = await getReplayData(params);
return res as any;
};
const fetchSprite = async (params: any) => {
const res = await getSprite(params);
return res as any;
};
// 处理回放数据加载完成
const handleReplayData = (arr: any = []) => {
replayDataObj.value = handleReplayDataFormat(arr[0]);
// 显示播放按钮
if (Object.keys(replayDataObj.value).length > 0) {
nextTick(() => {
const firstHour = Object.keys(replayDataObj.value)[0];
const firstHourData = replayDataObj.value[firstHour];
if (firstHourData && firstHourData.length > 0) {
firstItemData.value = firstHourData[0];
showPlayerButton.value = true;
}
});
}
};
// 处理播放时间更新
const handlePlayingTimeUpdate = (timestamp: number) => {
currentPlayingTimestamp.value = timestamp;
};
// 处理日期切换
const handleDateChange = (dateRange: [string, string]) => {
console.log("日期切换:", dateRange);
replayDataObj.value = {};
currentPlayingTimestamp.value = 0;
};
// 播放按钮点击
const handlePlayButtonClick = () => {
if (firstItemData.value && multiPlayerRef.value) {
multiPlayerRef.value.seekToTimestamp(firstItemData.value.startTime, firstItemData.value.endTime);
showPlayerButton.value = false;
}
};
// 判断是否选中
const isItemSelected = (item: any) => {
if (!currentPlayingTimestamp.value) return false;
return item.startTime <= currentPlayingTimestamp.value && item.endTime >= currentPlayingTimestamp.value;
};
// 点击回放片段
const handleItemClick = (item: any) => {
if (multiPlayerRef.value) {
multiPlayerRef.value.seekToTimestamp(item.startTime, item.endTime);
}
};
// 格式化时间
const formatTime = (timestamp: number) => {
return dayjs(timestamp).format("HH:mm:ss");
};
// 处理回放数据格式化
const handleReplayDataFormat = (data: any) => {
// 按小时分组
const result: Record<string, any[]> = {};
data.forEach((item: any) => {
const hour = dayjs(item.startTime).format("HH");
if (!result[hour]) {
result[hour] = [];
}
result[hour].push(item);
});
return result;
};
</script>
<style scoped>
.back-page {
position: relative;
}
.play-button {
position: absolute;
left: 50%;
top: 100px;
transform: translateX(-50%);
width: 54px;
height: 54px;
z-index: 999;
color: #2ac3e4;
font-size: 54px;
cursor: pointer;
}
.replay-list {
padding: 12px;
background: #f5f7fa;
}
.hour-group {
margin-bottom: 12px;
}
.hour-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
}
.items {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.item {
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
}
.item.selected {
border-color: #2ac3e4;
}
.time {
padding: 4px 8px;
font-size: 12px;
color: #fff;
background: rgba(0, 0, 0, 0.6);
}
</style>示例 2:消息详情页(无时间轴)
适用场景:消息详情页,只需播放单个视频片段
<template>
<div class="detail-page">
<!-- 播放按钮 -->
<van-icon v-if="showPlayerButton" name="play-circle-o" class="play-button" @click="handlePlayButtonClick" />
<!-- 视频播放器 -->
<MultiChannelPlayer ref="PlayerRef" :device-id="selectedMessage.deviceID" :user-id="selectedMessage.warnInfo.userId" :channels="channels" :m3u8BaseUrl="m3u8BaseUrl" :is-show-timeline="false" :show-progress="true" :show-fullscreen="true" :show-playback-rate="true" :on-get-replay-data="fetchReplayData" :on-get-sprite="fetchSprite" />
<!-- 消息列表 -->
<div class="message-list">
<div v-for="(item, index) in messageList" :key="index" class="message-item" :class="{ selected: isItemSelected(item) }" @click="handleMessageClick(item)">
<van-image :src="item.warnInfo.cover" />
<div class="info">
<div class="summary">{{ item.aiInfo?.Summary || item.warnInfo.message }}</div>
<div class="time">{{ formatTimestamp(item.warnInfo.timestamp) }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from "vue";
import { MultiChannelPlayer } from "veepai-video-player-h5";
import "veepai-video-player-h5/dist/style.css";
import dayjs from "dayjs";
// 播放器引用
const PlayerRef = ref<any>(null);
// 播放按钮状态
const showPlayerButton = ref(false);
// 选中的消息
const selectedMessage = ref<any>({});
const currentVideoId = ref("");
const isPlaying = ref(false);
// 通道配置
const channels = ref([{ id: 0, channelId: 0, channel: 0, width: "100vw", height: "209px" }]);
// M3U8 基础 URL
const m3u8BaseUrl = ref("https://veepai-dev.eye4.cn:8282/UCS/video/deviceEventMsg/v3");
// 消息列表
const messageList = ref([]);
// 生成视频 ID
const getVideoId = (item: any) => {
return `${item.deviceID}-${item.warnInfo.timestamp}-${item.warnInfo.channel}`;
};
// 初始化播放器(不自动播放)
const initPlayerNotAutoPlay = () => {
channels.value = [
{
id: 0,
channelId: 0,
channel: 0,
width: "100vw",
height: "209px",
},
];
nextTick(() => {
if (PlayerRef.value && typeof PlayerRef.value.initChannelPlayNoAutoPlay === "function") {
PlayerRef.value.initChannelPlayNoAutoPlay();
}
});
// 显示播放按钮
if (messageList.value.length > 0) {
showPlayerButton.value = true;
}
};
// 播放视频
const play = () => {
const { deviceID, warnInfo } = selectedMessage.value;
const { userId, uid, startTime, endTime } = warnInfo;
const _uid = userId || uid;
const channelURL = "0";
const begin = warnInfo.startTime || startTime;
const end = warnInfo.endTime || endTime;
channels.value = [{ id: 0, channelId: 0, begin, end, channel: 0, width: "100vw", height: "209px" }];
nextTick(() => {
if (PlayerRef.value && typeof PlayerRef.value.initChannelPlayNoTimeline === "function") {
PlayerRef.value.initChannelPlayNoTimeline();
}
});
};
// 播放按钮点击
const handlePlayButtonClick = () => {
const firstVideoItem = messageList.value[0];
if (firstVideoItem) {
firstVideoItem.messageType = "video";
selectedMessage.value = firstVideoItem;
// 清空 currentVideoId,确保走播放分支
currentVideoId.value = "";
handleMessageClick(firstVideoItem);
showPlayerButton.value = false;
}
};
// 点击消息
const handleMessageClick = (item: any) => {
const videoId = getVideoId(item);
// 如果点击的是同一个 item,切换播放/暂停
if (currentVideoId.value === videoId) {
if (PlayerRef.value?.togglePlayPause) {
PlayerRef.value.togglePlayPause(0).then(() => {
const player = PlayerRef.value.getPlayerInstance(0);
if (player) {
isPlaying.value = !player.paused;
}
});
}
} else {
// 点击不同的 item,切换视频
currentVideoId.value = videoId;
item.messageType = "video";
selectedMessage.value = item;
isPlaying.value = true;
play();
}
};
// 判断是否选中
const isItemSelected = (item: any) => {
if (!currentVideoId.value) return false;
return getVideoId(item) === currentVideoId.value;
};
// 格式化时间戳
const formatTimestamp = (timestamp: number) => {
return dayjs(timestamp).format("YYYY-MM-DD HH:mm:ss");
};
// API 适配器
const fetchReplayData = async (params: any) => {
return [];
};
const fetchSprite = async (params: any) => {
return {};
};
// 初始化
initPlayerNotAutoPlay();
</script>
<style scoped>
.detail-page {
position: relative;
}
.play-button {
position: absolute;
left: 50%;
top: 100px;
transform: translateX(-50%);
width: 54px;
height: 54px;
z-index: 999;
color: #2ac3e4;
font-size: 54px;
cursor: pointer;
}
.message-list {
padding: 12px;
}
.message-item {
display: flex;
gap: 12px;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
background: #fff;
cursor: pointer;
}
.message-item.selected {
background: rgba(42, 195, 228, 0.1);
}
.info {
flex: 1;
}
.summary {
font-size: 14px;
margin-bottom: 8px;
}
.time {
font-size: 12px;
color: #666;
}
</style>🔧 API 函数说明
onGetReplayData
获取回放数据的 API 函数,需要返回 ReplayItem[] 数组。
interface ReplayItem {
key: string; // 片段唯一标识
startTime: number; // 开始时间戳(毫秒)
endTime: number; // 结束时间戳(毫秒)
duration: number; // 时长(秒)
channel?: number; // 通道编号
coverUrl?: string; // 封面图 URL
}示例:
const fetchReplayData = async (params: { deviceid: string; userid: string; begin: number; end: number; channel: number; provider: string; bucket: string }) => {
const res = await getReplayData(params);
return res as ReplayItem[];
};onGetSprite
获取精灵图数据的 API 函数,用于时间轴悬浮预览。
interface SpriteData {
data: string; // 精灵图 Base64 数据
meta: {
webp_width: number; // 精灵图总宽度
webp_height: number; // 精灵图总高度
sprite_width: number; // 单帧宽度
sprite_height: number; // 单帧高度
total: number; // 总帧数
};
}示例:
const fetchSprite = async (params: { deviceid: string; userid: string; begin: number; end: number; channel: number }) => {
const res = await getSprite(params);
return res as SpriteData;
};⚙️ 配置说明
连续播放配置
控制是否自动连续播放多个回放片段。
<MultiChannelPlayer :continuous-play="false" />true:播放完一个片段后自动播放下一个片段false:播放完一个片段后停止
播放器控件配置
控制播放器控制栏显示的按钮。
<MultiChannelPlayer :show-progress="true" :show-fullscreen="true" :show-playback-rate="true" />show-progress:显示进度条show-fullscreen:显示全屏按钮show-playback-rate:显示倍速按钮
时间轴颜色配置
自定义时间轴颜色主题。
const timelineColors = {
background: "#E8F2F7", // 背景色
meddleLine: "#2AC3E4", // 中心线颜色
scaleLine: "#666666", // 刻度线颜色
scaleText: "#666666", // 刻度文字颜色
smallScaleLine: "#808080", // 小刻度线颜色
};📋 常见问题
Q: 如何实现点击列表项跳转播放?
A: 使用 seekToTimestamp 方法:
const handleItemClick = (item: any) => {
if (multiPlayerRef.value) {
multiPlayerRef.value.seekToTimestamp(item.startTime, item.endTime);
}
};Q: 如何获取当前播放状态?
A: 监听 playingTimeUpdate 事件:
<MultiChannelPlayer @playingTimeUpdate="handlePlayingTimeUpdate" />
<script setup>
const handlePlayingTimeUpdate = (timestamp: number) => {
console.log("当前播放时间:", timestamp);
};
</script>Q: 如何切换日期查看录像?
A: 监听 dateChange 事件并调用 deviceBackPlayerList:
<MultiChannelPlayer @dateChange="handleDateChange" />
<script setup>
const handleDateChange = (dateRange: [string, string]) => {
// 日期已自动切换,组件会自动加载新数据
console.log("切换到日期:", dateRange);
};
</script>Q: 如何实现播放/暂停控制?
A: 使用 togglePlayPause 方法:
const handleTogglePlay = async () => {
if (multiPlayerRef.value) {
await multiPlayerRef.value.togglePlayPause(0); // 0 表示第一个通道
}
};Q: 如何销毁播放器?
A: 在组件卸载时调用 destroyPlayers:
import { onBeforeUnmount } from "vue";
onBeforeUnmount(() => {
if (multiPlayerRef.value) {
multiPlayerRef.value.destroyPlayers();
}
});Q: 播放器比例如何设置?
A: 播放器已固定为 16:9 比例,通过 CSS aspect-ratio 实现,无需额外配置。
🌟 高级用法
数据聚合处理
如果回放数据需要聚合(合并相同 key 的连续片段),组件内部已实现自动聚合功能。
// 聚合前:
[
{ key: '001', startTime: 1000, endTime: 2000, duration: 1 },
{ key: '001', startTime: 2000, endTime: 3000, duration: 1 },
]
// 聚合后:
[
{
key: '001',
startTime: 1000,
endTime: 3000,
duration: 2,
aggregatedCount: 2
},
]自定义时间范围
设置时间轴显示的时间范围:
const timeRange = ref<[string, string]>([dayjs().subtract(4, "hour").format("YYYY-MM-DD HH:mm:ss"), dayjs().add(4, "hour").format("YYYY-MM-DD HH:mm:ss")]);多目播放配置
同时播放多个视频通道:
const channels = ref([
{ id: 0, width: "50%", height: "209px", channel: 0 },
{ id: 1, width: "50%", height: "209px", channel: 1 },
{ id: 2, width: "50%", height: "209px", channel: 2 },
{ id: 3, width: "50%", height: "209px", channel: 3 },
]);📝 开发说明
技术栈
- Vue 3 - 渐进式 JavaScript 框架
- TypeScript - 类型安全
- xgplayer 3.x - 西瓜播放器(HLS 播放)
- Canvas API - 时间轴绘制
- dayjs - 时间处理
浏览器兼容
- Chrome/Edge (Chromium) ✅
- Safari (iOS/macOS) ✅
- Firefox ✅
- Android WebView ✅
移动端优化
- 触摸事件支持
- iOS 日期格式兼容
- 高 DPI 屏幕适配
- 性能优化(Canvas 防抖)
📄 License
MIT License
🤝 贡献
欢迎提交 Issue 和 Pull Request!
最后更新: 2025-11-26
