本文介绍了一个基于 Vue 3 + TypeScript 的视频播放系统,该系统采用了代理模式、事件驱动架构和状态管理,实现了跨平台的视频播放控制。系统主要应用于写作教学场景,支持教师讲解视频的播放、进度同步、时间轴事件触发等功能。
系统架构
1. 整体架构图
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ TeacherWindow │ │ VideoPlayer │ │ VideoProxy │
│ (UI 组件) │◄──►│ (播放器组件) │◄──►│ (代理层) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ write.ts │ │ TimelineEmitter │
│ (状态管理) │ │ (时间轴管理) │
└─────────────────┘ └─────────────────┘
2. 核心组件关系
- TeacherWindow.vue: UI 展示层,包含视频播放器组件
- VideoPlayer.vue: 跨平台视频播放器,支持 App 和 Web 环境
- VideoProxy.ts: 视频代理层,统一管理播放状态和事件
- write.ts: 业务逻辑层,处理视频播放的业务逻辑
VideoProxy.ts
这是一个视频代理类,采用了代理模式设计,用于统一管理视频播放器的状态和行为,提供事件驱动的视频控制接口。
1. 导入和类型定义
import { EventEmitter } from "eventemitter3"
import WriteUtilsVideoPlayer from "@/components/write/Utils/VideoPlayer.vue"
import { $pick } from "@/utils/global-properties"
// 定义视频代理支持的事件类型
type VideoProxyEvents = {
ready: () => void // 视频准备就绪事件
end: () => void // 视频播放结束事件
error: () => void // 视频播放错误事件
meta: (duration: number) => void // 视频元数据事件(获取时长)
tick: (time: number, duration?: number) => void // 视频播放进度事件
}
2. VideoProxy 类结构
export class VideoProxy extends EventEmitter<VideoProxyEvents> {
private videoRef: InstanceType<typeof WriteUtilsVideoPlayer> | null = null // 视频组件引用
private isActive: boolean = false // 是否激活状态
private isReady: boolean = false // 是否准备就绪
3. 构造函数
constructor() {
super()
this.on('ready', () => {
this.isReady = true
this.play() // 准备就绪后自动播放
})
}
4. 核心方法解析
takeOver() - 接管视频组件
takeOver(videoRef: InstanceType<typeof WriteUtilsVideoPlayer>) {
this.videoRef = videoRef
this.videoRef.setBus(this) // 设置事件总线
}
作用:
- 接收一个视频组件实例
- 建立代理与视频组件的连接
- 设置事件通信机制
active 属性设置器
set active(isActive: boolean) {
if (!isActive) {
this.off('end') // 取消激活时移除事件监听
this.off('tick')
}
if (this.videoRef) {
this.isActive = isActive
this.videoRef.setActive(isActive) // 通知视频组件激活状态
this.play() // 激活时自动播放
}
}
作用:
- 控制代理的激活状态
- 状态变化时自动管理事件监听
- 激活时自动开始播放
播放控制方法
// 播放视频
play() {
if (this.videoRef && this.isActive && this.isReady) {
this.videoRef.play() // 只有在激活且准备就绪时才播放
}
}
// 暂停播放
pause() {
if (this.videoRef) {
this.videoRef.pause()
}
}
// 恢复播放
resume() {
if (this.videoRef) {
this.videoRef.resume()
}
}
// 跳转到指定时间
seek(time: number) {
if (this.videoRef) {
this.videoRef.seek(time)
}
}
5. 设计模式分析
代理模式 (Proxy Pattern)
// VideoProxy 作为视频组件的代理
// 封装了视频播放的复杂逻辑
// 提供了统一的接口来控制视频播放
代理模式的优势:
- ✅ 封装了视频播放的复杂逻辑
- ✅ 提供了统一的控制接口
- ✅ 可以添加额外的控制逻辑(如状态检查)
- ✅ 实现了组件间的解耦
事件驱动架构
// 继承自 EventEmitter,支持事件机制
// 通过事件与视频组件通信
// 支持多种事件类型
事件类型说明:
ready
: 视频准备就绪end
: 视频播放结束error
: 视频播放错误meta
: 获取视频元数据tick
: 播放进度更新
状态管理
private isActive: boolean = false // 控制代理是否激活
private isReady: boolean = false // 控制视频是否准备就绪
状态逻辑:
- 只有同时满足
isActive
和isReady
时才能播放 - 状态变化时自动触发相应行为
6. 使用流程
// 1. 创建代理实例
const videoProxy = new VideoProxy()
// 2. 接管视频组件
videoProxy.takeOver(videoComponent)
// 3. 激活代理
videoProxy.active = true
// 4. 监听事件
videoProxy.on("tick", (time) => {
console.log("播放进度:", time)
})
// 5. 控制播放
videoProxy.play()
videoProxy.pause()
videoProxy.seek(30)
7. 在 write.ts 中的使用
// 创建视频代理实例
const videoTeacherProxy: VideoProxy = new VideoProxy()
const videoSceneFrameProxy: VideoProxy = new VideoProxy()
// 监听播放进度事件
videoTeacherProxy.on("tick", (time) => {
__updateTimelineEmitters(time) // 更新时间轴发射器
})
// 监听播放结束事件
videoTeacherProxy.on("end", () => {
__handleCleanForVideo() // 清理视频相关状态
resolve()
})
8. 优点总结
- ✅ 封装性: 封装了视频播放的复杂逻辑
- ✅ 统一接口: 提供了统一的播放控制接口
- ✅ 事件驱动: 支持事件机制,易于扩展
- ✅ 状态管理: 清晰的状态管理逻辑
- ✅ 解耦设计: 实现了组件间的解耦
- ✅ 类型安全: 使用 TypeScript 提供类型安全
9. 应用场景
这个代理类主要用于:
- 写作模块的视频播放控制
- 统一管理多个视频组件
- 提供事件驱动的视频控制接口
- 确保视频播放的状态一致性
这是一个设计良好的视频代理类,很好地体现了代理模式和事件驱动的设计思想,为视频播放提供了统一、可靠的接口。
源码:
import { EventEmitter } from "eventemitter3"
import WriteUtilsVideoPlayer from "@/components/write/Utils/VideoPlayer.vue"
import { $pick } from "@/utils/global-properties"
type VideoProxyEvents = {
ready: () => void
end: () => void
error: () => void
meta: (duration: number) => void
tick: (time: number, duration?: number) => void
}
export class VideoProxy extends EventEmitter<VideoProxyEvents> {
private videoRef: InstanceType<typeof WriteUtilsVideoPlayer> | null = null
private isActive: boolean = false
private isReady: boolean = false
constructor() {
super()
this.on("ready", () => {
this.isReady = true
this.play()
})
}
takeOver(videoRef: InstanceType<typeof WriteUtilsVideoPlayer>) {
this.videoRef = videoRef
this.videoRef.setBus(this)
}
set active(isActive: boolean) {
if (!isActive) {
this.off("end")
this.off("tick")
}
if (this.videoRef) {
this.isActive = isActive
this.videoRef.setActive(isActive)
this.play()
}
}
play() {
if (this.videoRef && this.isActive && this.isReady) {
this.videoRef.play()
}
}
pause() {
if (this.videoRef) {
this.videoRef.pause()
}
}
resume() {
if (this.videoRef) {
this.videoRef.resume()
}
}
seek(time: number) {
if (this.videoRef) {
this.videoRef.seek(time)
}
}
}
VideoPlayer.vue
这是一个跨平台的视频播放器组件,支持 App 原生播放和 Web 浏览器播放两种模式。
1. 模板结构
<template>
<!-- Web 环境:使用 HTML5 video 元素 -->
<video
v-if="!isApp && active"
preload="auto"
webkit-playsinline
ref="videoRef"
:src="src"
playbackRate="1"
@canplay="handleCanPlay"
:autoplay="autoplay"
:muted="muted"
@ended="handleVideoEnd"
@error="handleError"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
:style="{...}"
class="video-player"
></video>
<!-- App 环境:使用原生容器 -->
<div v-else-if="active" class="video-player" ref="videoRef"></div>
</template>
2. 核心状态管理
const isApp = ref(browserInfo().isApp) // 判断是否为 App 环境
const videoRef = ref<HTMLVideoElement | null>(null) // 视频元素引用
const bus = ref<VideoProxy>() // 事件总线(VideoProxy 实例)
const active = ref(false) // 是否激活状态
const playerId = ref("") // 播放器唯一标识
3. Props 配置
const props = defineProps({
src: { type: String, default: "" }, // 视频源地址
duration: { type: Number, default: 0 }, // 视频时长
autoplay: { type: Boolean, default: false }, // 是否自动播放
muted: { type: Boolean, default: false }, // 是否静音
fitMode: { type: String, default: "W" }, // 适配模式:H-高宽比,W-宽度
scale: { type: Number, default: 1 }, // 缩放比例
zIndex: { type: Number, default: 0 }, // 层级
})
4. 核心方法解析
App 环境相关方法
// 显示原生视频视图
async function showNativeVideo() {
const rect = videoRef.value?.getBoundingClientRect()
if (!rect) return
let { left, right, top, bottom, width, height } = rect
await $bridge.showLiveVideoView({
centerX: ((right + left) / 2 / window.innerWidth).toString(),
centerY: ((bottom + top) / 2 / window.innerHeight).toString(),
width: (width / window.innerWidth).toString(),
height: (height / window.innerHeight).toString(),
cornerRadius: "12",
zIndex: "1",
playerId: playerId.value,
renderMode: "0",
})
}
// 移除原生视频视图
function removeNativeVideo() {
$bridge.removeVideoPlayerView({
playerId: playerId.value,
})
}
状态控制方法
// 设置激活状态
const setActive = async (val: boolean) => {
active.value = val
await nextTick()
if (isApp.value) {
if (!val) {
removeNativeVideo() // 取消激活时移除原生视图
} else {
showNativeVideo() // 激活时显示原生视图
bus.value?.emit("ready") // 发送准备就绪事件
}
}
}
// 设置事件总线
const setBus = (_bus: VideoProxy) => {
bus.value = _bus
}
播放控制方法
// 播放视频
const play = () => {
if (isApp.value) {
// App 环境:调用原生播放接口
$bridge.playLiveVideo({
playUrl: props.src,
onTick: (time: number) => {
isTeacherSpeaking.value = true
bus.value?.emit("tick", time / 1000, 51) // 发送播放进度事件
},
onStatus: (status: string) => {
if (status == "4") {
isTeacherSpeaking.value = false
bus.value?.emit("end") // 发送播放结束事件
}
},
playerId: playerId.value,
})
} else {
// Web 环境:调用 HTML5 video 播放
videoRef.value?.play()
}
}
// 暂停播放
const pause = () => {
if (isApp.value) {
$bridge.pauseLiveVideo({ playerId: playerId.value })
} else {
videoRef.value?.pause()
}
}
// 恢复播放
const resume = () => {
if (isApp.value) {
$bridge.resumeLiveVideo({ playerId: playerId.value })
} else {
videoRef.value?.play()
}
}
// 跳转到指定时间
const seek = (time: number) => {
if (isApp.value) {
$bridge.seekLiveVideoTo({
playerId: playerId.value,
seekTime: time.toString(),
})
} else {
if (videoRef.value) {
videoRef.value.currentTime = time
}
}
}
事件处理方法
// 视频可以播放时
const handleCanPlay = () => {
bus.value?.emit("ready")
}
// 视频播放结束时
const handleVideoEnd = () => {
isTeacherSpeaking.value = false
bus.value?.emit("end")
}
// 视频播放错误时
const handleError = () => {
bus.value?.emit("error")
}
// 视频播放进度更新时(Web 环境)
const handleTimeUpdate = () => {
isTeacherSpeaking.value = true
bus.value?.emit(
"tick",
videoRef.value?.currentTime || 0,
videoRef.value?.duration
)
}
// 视频元数据加载完成时
const handleLoadedMetadata = () => {
bus.value?.emit("meta", videoRef.value?.duration || 0)
}
5. 设计模式分析
适配器模式 (Adapter Pattern)
- 统一了 App 原生播放和 Web HTML5 播放的接口
- 通过
isApp
判断使用不同的播放实现 - 对外提供统一的播放控制方法
事件驱动架构
- 通过
bus
(VideoProxy) 发送事件 - 支持
ready
、end
、error
、tick
、meta
等事件 - 实现了组件间的解耦
状态管理
active
: 控制组件是否激活isTeacherSpeaking
: 管理教师说话状态- 状态变化时自动触发相应行为
6. 使用场景
这个组件主要用于:
- 写作模块的视频播放
- 教师讲解视频的播放控制
- 场景视频的播放管理
- 跨平台视频播放的统一接口
7. 优点
- ✅ 跨平台兼容性好
- ✅ 事件驱动,易于扩展
- ✅ 状态管理清晰
- ✅ 接口统一,使用简单
- ✅ 支持多种播放控制功能
这是一个设计良好的视频播放器组件,很好地处理了跨平台播放的复杂性,并提供了统一的事件接口。
源码
<template>
<video
v-if="!isApp && active"
preload="auto"
webkit-playsinline
ref="videoRef"
:src="src"
playbackRate="1"
@canplay="handleCanPlay"
:autoplay="autoplay"
:muted="muted"
@ended="handleVideoEnd"
@error="handleError"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
:style="{
width: fitMode === 'W' ? `calc(100% * ${scale})` : 'auto',
height: fitMode === 'H' ? `calc(100% * ${scale})` : 'auto',
zIndex: zIndex,
}"
class="video-player"
></video>
<div v-else-if="active" class="video-player" ref="videoRef"></div>
</template>
<script setup lang="ts">
import { v4 as uuidv4 } from "uuid"
import { nextTick, onBeforeMount, onMounted, ref, watch } from "vue"
import { $bridge, $pick } from "@/utils/global-properties"
import { VideoProxy } from "@/store/modules/write/VideoProxy"
import { browserInfo } from "@/utils/tool"
const isApp = ref(browserInfo().isApp)
const videoRef = ref<HTMLVideoElement | null>(null)
const bus = ref<VideoProxy>()
import { storeToRefs } from "pinia"
import { useWriteStore } from "@/store"
const writeStore = useWriteStore()
const { isTeacherSpeaking } = storeToRefs(writeStore)
const props = defineProps({
src: {
type: String,
default: "",
},
duration: {
type: Number,
default: 0,
},
autoplay: {
type: Boolean,
default: false,
},
muted: {
type: Boolean,
default: false,
},
fitMode: {
type: String,
default: "W", //H:高宽比适配,W:宽度适配
},
scale: {
type: Number,
default: 1,
},
zIndex: {
type: Number,
default: 0,
},
})
const active = ref(false)
const playerId = ref("")
onBeforeMount(() => {
playerId.value = uuidv4()
})
async function showNativeVideo() {
const rect = videoRef.value?.getBoundingClientRect()
if (!rect) return
let { left, right, top, bottom, width, height } = rect
await $bridge.showLiveVideoView({
centerX: ((right + left) / 2 / window.innerWidth).toString(),
centerY: ((bottom + top) / 2 / window.innerHeight).toString(),
width: (width / window.innerWidth).toString(),
height: (height / window.innerHeight).toString(),
cornerRadius: "12",
zIndex: "1",
playerId: playerId.value,
renderMode: "0",
})
}
function removeNativeVideo() {
$bridge.removeVideoPlayerView({
playerId: playerId.value,
})
}
const setActive = async (val: boolean) => {
active.value = val
await nextTick()
console.log("🚀 ~ setActive ~ val:", val, props.src)
if (isApp.value) {
if (!val) {
removeNativeVideo()
} else {
showNativeVideo()
bus.value?.emit("ready")
}
}
}
const setBus = (_bus: VideoProxy) => {
bus.value = _bus
}
const play = () => {
console.log("DEBUG_LOG:play call ", props.src)
if (isApp.value) {
$bridge.playLiveVideo({
playUrl: props.src,
onTick: (time: number) => {
isTeacherSpeaking.value = true
bus.value?.emit("tick", time / 1000, 51)
},
onStatus: (status: string) => {
if (status == "4") {
isTeacherSpeaking.value = false
bus.value?.emit("end")
}
},
playerId: playerId.value,
})
} else {
videoRef.value?.play()
}
}
const pause = () => {
if (isApp.value) {
$bridge.pauseLiveVideo({
playerId: playerId.value,
})
} else {
videoRef.value?.pause()
}
}
const resume = () => {
if (isApp.value) {
$bridge.resumeLiveVideo({
playerId: playerId.value,
})
} else {
videoRef.value?.play()
}
}
const seek = (time: number) => {
if (isApp.value) {
$bridge.seekLiveVideoTo({
playerId: playerId.value,
seekTime: time.toString(),
})
} else {
if (videoRef.value) {
videoRef.value.currentTime = time
}
}
}
const handleCanPlay = () => {
console.log("DEBUG_LOG:call can play ", props.src)
bus.value?.emit("ready")
}
const handleVideoEnd = () => {
isTeacherSpeaking.value = false
bus.value?.emit("end")
}
const handleError = () => {
bus.value?.emit("error")
}
const handleTimeUpdate = () => {
isTeacherSpeaking.value = true
bus.value?.emit(
"tick",
videoRef.value?.currentTime || 0,
videoRef.value?.duration
)
}
const handleLoadedMetadata = () => {
bus.value?.emit("meta", videoRef.value?.duration || 0)
}
watch(
() => props.src,
(val) => {
console.log("DEBUG_LOG:call video src change", val)
}
)
defineExpose({
play,
pause,
resume,
seek,
setActive,
setBus,
})
</script>
<style scoped lang="scss">
.video-player {
width: 100%;
height: 100%;
position: absolute;
}
</style>
使用
在TeacherWindow.vue
组件中使用
<template>
<WriteUtilsVideoPlayer src="xxx" :fitMode="'W'" ref="videoTeacherEnterRef" />
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useWriteStore } from "@/store"
const writeStore = useWriteStore()
const { videoTeacherProxy } = writeStore
import WriteUtilsVideoPlayer from "@/components/write/Utils/VideoPlayer.vue"
const videoTeacherEnterRef = ref<InstanceType<
typeof WriteUtilsVideoPlayer
> | null>(null)
onMounted(() => {
if (videoTeacherEnterRef.value) {
videoTeacherProxy.takeOver(videoTeacherEnterRef.value)
}
})
</script>
<style lang="scss" scoped></style>
在 write.ts 中的使用
// 创建视频代理实例
const videoTeacherProxy: VideoProxy = new VideoProxy()
// 监听播放进度事件
videoTeacherProxy.on("tick", (time) => {
__updateTimelineEmitters(time) // 更新时间轴发射器
})
// 监听播放结束事件
videoTeacherProxy.on("end", () => {
__handleCleanForVideo() // 清理视频相关状态
})
-
技术亮点
1. 跨平台兼容性
- 统一接口设计,支持 App 和 Web 环境
- 自动环境检测,选择合适的播放方式
- 事件机制统一,屏蔽平台差异
2. 事件驱动架构
- 松耦合设计,组件间通过事件通信
- 支持多种事件类型,易于扩展
- 时间轴事件系统,精确控制播放进度
3. 状态管理
- 清晰的状态定义和管理
- 状态变化自动触发相应行为
- 防止状态不一致问题
4. 代理模式应用
- 封装复杂播放逻辑
- 提供统一控制接口
- 支持状态检查和事件管理
应用场景
1. 写作教学
- 教师讲解视频播放
- 进度同步和时间轴事件
- 焦点和气泡显示
2. 交互式教学
- 视频播放与用户交互结合
- 实时状态反馈
- 多组件协调工作
总结
这个视频播放系统通过代理模式、事件驱动架构和状态管理,实现了一个功能完整、扩展性强的跨平台视频播放解决方案。系统设计清晰,代码结构良好,为写作教学等场景提供了可靠的视频播放支持。
核心优势:
- ✅ 跨平台兼容性好
- ✅ 事件驱动,易于扩展
- ✅ 状态管理清晰
- ✅ 接口统一,使用简单
- ✅ 支持复杂的时间轴事件
这个系统是一个很好的视频播放解决方案,值得在其他项目中参考和应用。