视频播放系统架构设计与实现


本文介绍了一个基于 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     // 控制视频是否准备就绪

状态逻辑:

  • 只有同时满足 isActiveisReady 时才能播放
  • 状态变化时自动触发相应行为

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. 应用场景

这个代理类主要用于:

  1. 写作模块的视频播放控制
  2. 统一管理多个视频组件
  3. 提供事件驱动的视频控制接口
  4. 确保视频播放的状态一致性

这是一个设计良好的视频代理类,很好地体现了代理模式和事件驱动的设计思想,为视频播放提供了统一、可靠的接口。

源码:

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) 发送事件
  • 支持 readyenderrortickmeta 等事件
  • 实现了组件间的解耦

状态管理

  • active: 控制组件是否激活
  • isTeacherSpeaking: 管理教师说话状态
  • 状态变化时自动触发相应行为

6. 使用场景

这个组件主要用于:

  1. 写作模块的视频播放
  2. 教师讲解视频的播放控制
  3. 场景视频的播放管理
  4. 跨平台视频播放的统一接口

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. 交互式教学

  • 视频播放与用户交互结合
  • 实时状态反馈
  • 多组件协调工作

总结

这个视频播放系统通过代理模式、事件驱动架构和状态管理,实现了一个功能完整、扩展性强的跨平台视频播放解决方案。系统设计清晰,代码结构良好,为写作教学等场景提供了可靠的视频播放支持。

核心优势:

  • ✅ 跨平台兼容性好
  • ✅ 事件驱动,易于扩展
  • ✅ 状态管理清晰
  • ✅ 接口统一,使用简单
  • ✅ 支持复杂的时间轴事件

这个系统是一个很好的视频播放解决方案,值得在其他项目中参考和应用。


文章作者: 高红翔
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 高红翔 !
  目录