npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

veepai-video-player-h5

v1.2.6

Published

移动端 H5 视频播放器组件,支持回放片段时间轴、精灵图预览、移动端优化

Downloads

1,132

Readme

veepai-video-player-h5

移动端 H5 视频回放播放器组件库 - 支持多目播放、时间轴导航、精灵图预览等功能

npm version license

📦 安装

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