医疗视频播放组件开发实战:支持病灶标注、缓存播放与性能优化

一、项目背景与需求分析

在医疗影像诊断系统中,视频播放组件是核心功能之一。我们开发了一个支持病灶动态标注、全屏缩放、缓存播放的高级视频播放组件,用于超声、内窥镜等医疗视频的会诊分析。

核心需求:

  1. 自定义播放控件,支持播放速度调节

  2. 动态显示AI识别的病灶标注框

  3. 支持全屏双指缩放和拖拽浏览

  4. 智能缓存机制提升播放体验

  5. 完善的性能优化

二、技术架构设计

2.1 组件架构

javascript

复制代码
// 组件核心结构
CheckVideo.vue
├── Template层
│   ├── 视频容器(主视频+全屏视频)
│   ├── 动态病灶标注框
│   ├── 自定义控制条
│   └── 播放速度菜单
├── Script层
│   ├── 状态管理
│   ├── 视频控制逻辑
│   ├── 缓存机制
│   └── 性能优化
└── Style层
    ├── 响应式布局
    ├── 动画效果
    └── 兼容性处理

2.2 关键技术选型

  • 框架:uni-app(跨平台支持)

  • 视频处理 :原生video组件 + 自定义控件

  • 标注渲染:动态CSS定位 + 节流优化

  • 缓存管理:临时文件缓存 + 智能清理

  • 性能优化:防抖节流 + 数据缓存

javascript 复制代码
<!--
	视频播放组件 - 支持自定义控件、全屏播放、病灶标注等功能
	功能特性:
	1. 自定义播放控件(播放/暂停、进度条、时间显示、全屏切换)
	2. 全屏模式支持双指缩放和拖拽浏览
	3. 根据视频时间轴动态显示病灶标注框
	4. 全屏时自动居中,支持恢复原比例
	5. 支持播放速度控制(0.5x、1x、1.5x、2x)
	6. 支持自动缓存播放(可通过 enableCache 属性控制)
	7. 支持视频下载和分享功能
	
	使用示例:
	<checkVideo 
		:videoUrl="videoUrl" 
		:jsonData="jsonData" 
		:isFrame="isFrame"
		:videoId="videoId"
		:enableCache="true"  // 是否启用缓存播放,默认true
	/>
-->
<template>
	<view class="video-wrapper">
		<!-- 视频容器 -->
	<view class="box" @tap="showControls">
			<!-- 主视频播放器 - 关闭所有系统自带控件,使用自定义控件 -->
		<video
			loop
				:id="videoDomId"
				:src="actualVideoUrl"
				:playback-rate="playbackRate"
				:controls="false"
				:show-fullscreen-btn="false"
				:show-center-play-btn="false"
				:show-play-btn="false"
				:enable-progress-gesture="false"
			style="width: 100%;height: 100%;object-fit: contain;"
			@timeupdate="ontimeupdate"
			@loadedmetadata="onloadedmetadata"
				@play="onPlay"
				@pause="onPause"
		></video>
			<!-- 病灶标注框 - 根据视频当前帧动态显示,颜色根据风险等级变化 -->
		<view
			v-for="item in boxes"
			:key="item.id"
			class="mark"
			:style="{
				border: '2rpx solid ' + item.color,
				top: item.top,
				left: item.left,
				width: item.width,
				height: item.height,
				display: item.display
			}"
		></view>
	</view>

		<!-- 全屏遮罩层 - 全屏播放时显示 -->
	<view v-if="fullScreenVisible" class="fullscreen-mask">
			<!-- 全屏头部 - 悬浮显示,脱离文档流(仅在放大时显示) -->
			<view class="fullscreen-header" @tap.stop>
				<view
					class="close"
					v-if="fullscreenScale !== 1"
					@tap="resetScale"
				>恢复原比例</view>
		</view>
			<view class="fullscreen-inner" @tap="showControls">
				<!-- 可移动区域 - 支持双指缩放和拖拽 -->
		<movable-area class="movable-area" scale-area>
					<!-- 可移动视图 - 包含视频和标注框,支持缩放和拖拽 -->
			<movable-view
				class="movable-view"
				direction="all"
				:scale="true"
						:scale-min="scaleMin"
						:scale-max="scaleMax"
						:scale-value="fullscreenScale"
						:x="fullX"
						:y="fullY"
						out-of-bounds
						:animation="false"
				@change="onMoveChange"
						:style="{
							width: fullWidth + 'px',
							height: fullHeight + 'px'
						}"
			>
						<!-- 全屏视频播放器 -->
				<video
					id="fullscreenVideo"
					ref="customVideo"
					class="fullscreen-video"
							:src="actualVideoUrl"
							:playback-rate="playbackRate"
					:show-fullscreen-btn="false"
					:controls="false"
							:show-center-play-btn="false"
							:show-play-btn="false"
							:enable-progress-gesture="false"
					object-fit="contain"
					@timeupdate="ontimeupdate"
					@loadedmetadata="onloadedmetadata"
							@play="onPlay"
							@pause="onPause"
				></video>
						<!-- 全屏模式下的病灶标注框 -->
				<view
					v-for="item in boxes"
							:key="item.id"
					class="mark"
					:style="{
						border: '2rpx solid ' + item.color,
						top: item.top,
						left: item.left,
						width: item.width,
						height: item.height,
						display: item.display
					}"
				></view>
				<!-- 透明的点击覆盖层 - 用于触发显示/隐藏控制条 -->
				<view class="fullscreen-tap-overlay" @tap="showControls"></view>
			</movable-view>
		</movable-area>
			</view>
		</view>
		<!-- 自定义控制条 - 播放/暂停、进度条、时间显示、全屏切换 -->
		<view
			:class="['controls-bar', {'controls-bar--fullscreen': fullScreenVisible, 'controls-bar--visible': controlsVisible}]"
			@tap.stop="resetControlsTimer"
		>
			<view class="control-btn" @tap="togglePlay">
				
				<u-icon name="pause" color="rgba(255,255,255,0.8)" size="22" v-if="isPlaying"></u-icon>
				<u-icon name="play-right-fill" color="rgba(255,255,255,0.8)" size="22" v-else></u-icon>
			</view>
			<slider
				class="progress"
				:value="progress"
				active-color="#409EFF"
				background-color="rgba(255,255,255,0.3)"
				block-size="14"
				@changing="onSliderChanging"
				@change="onSliderChange"
			/>
			<view class="time">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</view>
			<!-- 播放速度控制按钮 -->
			<view class="control-btn speed-btn" @tap="toggleSpeedMenu">
				<text class="speed-text">{{ playbackRate }}x</text>
			</view>
			<view class="control-btn" @tap="toggleFullscreenBtn">
				<u-icon name="grid" color="rgba(255,255,255,0.8)" size="22" v-if="fullScreenVisible"></u-icon>
				<u-icon name="scan" color="rgba(255,255,255,0.8)" size="22" v-else></u-icon>
			</view>
		</view>
		<!-- 播放速度选择菜单 -->
		<view v-if="showSpeedMenu" class="speed-menu" :class="{'speed-menu--fullscreen': fullScreenVisible}" @tap.stop>
			<view 
				class="speed-menu-item" 
				:class="{active: playbackRate === 0.5}"
				@tap="setPlaybackRate(0.5)"
			>0.5x</view>
			<view 
				class="speed-menu-item" 
				:class="{active: playbackRate === 1}"
				@tap="setPlaybackRate(1)"
			>1x</view>
			<view 
				class="speed-menu-item" 
				:class="{active: playbackRate === 1.5}"
				@tap="setPlaybackRate(1.5)"
			>1.5x</view>
			<view 
				class="speed-menu-item" 
				:class="{active: playbackRate === 2}"
				@tap="setPlaybackRate(2)"
			>2x</view>
			<view class="speed-menu-divider"></view>
			<view 
				class="speed-menu-item action-item" 
				:class="{active: internalEnableCache}"
				@tap="toggleCache"
			>
				<u-icon name="file-text" :color="internalEnableCache ? '#409EFF' : 'rgba(255,255,255,0.8)'" size="18" style="margin-right: 8rpx;"></u-icon>
				<text>缓存播放</text>
			</view>
			<view 
				class="speed-menu-item action-item" 
				@tap="downloadVideo"
			>
				<u-icon name="download" color="rgba(255,255,255,0.8)" size="18" style="margin-right: 8rpx;"></u-icon>
				<text>下载视频</text>
			</view>
			<view 
				class="speed-menu-item action-item" 
				@tap="shareVideo"
			>
				<u-icon name="share" color="rgba(255,255,255,0.8)" size="18" style="margin-right: 8rpx;"></u-icon>
				<text>分享视频</text>
			</view>
		</view>
		<!-- 点击遮罩关闭速度菜单 -->
		<view v-if="showSpeedMenu" class="speed-menu-mask" @tap="closeSpeedMenu"></view>
	</view>
</template>

<script>
	export default {
		// 组件属性定义
		props:{
			videoUrl:{ type:String },      // 视频URL地址
			jsonData:{ type:String },     // JSON数据,包含病灶标注信息(Aimodel数组)
			isFrame:{ type:Boolean },      // 是否显示标注框
			videoId:{ type:String },       // 视频唯一标识ID,用于区分多个视频实例
			enableCache:{ type:Boolean, default: false }  // 是否启用缓存播放(默认启用)
		},
		data() {
			return {
				// 基础配置
				baseUrl: 'https://breast.aiplusfirst.com',  // 视频资源基础URL
				videoWidth:1920,           // 视频原始宽度(默认1920)
				videoHeight:1080,          // 视频原始高度(默认1080)
				oldIsFrame:false,          // 上一次的isFrame状态(已废弃,可删除)
				
				// 标注相关
				boxes: [],                 // 当前显示的标注框数组
				
				// 全屏相关
				fullScreenVisible: false,  // 是否显示全屏
				fullscreenScale: 1,        // 全屏时的缩放比例
				scaleMin: 0.5,             // 最小缩放比例
				scaleMax: 3,             // 最大缩放比例
				fullX: 0,                  // 全屏时视频的X坐标偏移
				fullY: 0,                  // 全屏时视频的Y坐标偏移
				fullWidth: 0,              // 全屏时视频的宽度(按16:9比例计算)
				fullHeight: 0,             // 全屏时视频的高度(按16:9比例计算)
				
				// 播放状态
				isPlaying: false,          // 是否正在播放
				duration: 0,               // 视频总时长(秒)
				currentTime: 0,           // 当前播放时间(秒)
				progress: 0,               // 播放进度(0-100)
				isSliding: false,          // 是否正在拖拽进度条
				playbackRate: 1,           // 播放速度(0.5, 1, 1.5, 2)
				showSpeedMenu: false,      // 是否显示速度选择菜单
				controlsVisible: false,    // 是否显示控制条(默认隐藏,点击视频时显示)
				controlsTimer: null,       // 控制条自动隐藏定时器
				controlsHideDelay: 3000,   // 控制条自动隐藏延迟时间(毫秒),3秒无操作后隐藏
				
				// 临时缓存相关(自动缓存,页面销毁时清理)
				cachedVideoUrl: '',         // 临时缓存的视频文件路径
				isCaching: false,           // 是否正在缓存
				cacheProgress: 0,           // 缓存进度(0-100)
				useCache: false,            // 是否使用缓存
				downloadTask: null,         // 下载任务对象,用于取消下载
				internalEnableCache: true,  // 内部缓存开关状态(用户可通过菜单控制)
				
				// 屏幕信息
				screenW: 0,               // 屏幕宽度
				screenH: 0,               // 屏幕高度
				
				// 性能优化相关
				moveFrameId: null,         // 移动事件节流定时器ID
				pendingMove: null,         // 待处理的移动事件数据
				lastBoxUpdate: 0,          // 上次更新标注框的时间戳
				boxUpdateInterval: 80,     // 标注框更新间隔(毫秒),用于节流优化性能
				seekTimer: null,            // 进度条拖拽时的 seek 节流定时器
				pendingSeekTime: null,      // 待处理的 seek 时间
				lastBoxesHash: '',          // 上次标注框的哈希值,用于避免不必要的更新
				parsedAimodel: null,        // 缓存的解析后的标注数据,避免重复解析
				
				// 视频上下文缓存
				videoContexts: {
					main: null,
					full: null
				}
			};
		},
		// 计算属性
		computed:{
			// 动态生成视频DOM ID,确保多个视频实例不冲突
			videoDomId(){
				return this.videoId ? `video${this.videoId}` : 'checkVideo'
			},
			// 实际使用的视频URL(默认使用原始URL,缓存完成后平滑切换)
			actualVideoUrl(){
				// 如果启用缓存且缓存完成,使用缓存文件;否则使用原始URL
				// 确保视频可以立即播放,缓存完成后平滑切换
				if(this.internalEnableCache && this.useCache && this.cachedVideoUrl){
					return this.cachedVideoUrl
				}
				return this.baseUrl + this.videoUrl
			}
		},
		// 生命周期 - 组件挂载后获取屏幕信息并计算全屏尺寸
		mounted(){
			uni.getSystemInfo({
				success: (res) => {
					this.screenW = res.windowWidth
					this.screenH = res.windowHeight
					this.calcFullSize()
				}
			})
			// 初始化缓存开关状态(从 prop 获取初始值)
			this.internalEnableCache = this.enableCache !== false
			// 如果启用缓存,自动开始缓存视频(临时缓存)
			if(this.internalEnableCache && this.videoUrl){
				this.cacheVideo()
			}
		},
		// 监听器
		watch:{
			// 监听标注框显示/隐藏状态
			isFrame(newVal) {
				// 当关闭标注框时,清空标注数组
				if(!newVal){
					this.boxes = []
					this.lastBoxesHash = ''
				}
			},
			// 监听 jsonData 变化,清除缓存
			jsonData(newVal) {
				this.parsedAimodel = null
				this.lastBoxesHash = ''
				this.boxes = []
			},
			// 监听 videoUrl 变化,自动开始缓存新视频
			videoUrl(newVal, oldVal) {
				if(newVal && newVal !== oldVal){
					// 清理旧视频缓存
					this.clearVideoCache()
					// 如果启用缓存,开始缓存新视频
					if(this.internalEnableCache){
						this.cacheVideo()
					}
				}
			},
			// 监听 enableCache prop 变化,同步到内部状态
			enableCache(newVal) {
				this.internalEnableCache = newVal !== false
				if(this.internalEnableCache){
					// 启用缓存,开始缓存视频
					if(this.videoUrl && !this.useCache && !this.isCaching){
						this.cacheVideo()
					}
				}else{
					// 禁用缓存,清理已缓存的视频
					this.clearVideoCache()
				}
			}
			},
		methods:{
			/**
			 * 视频元数据加载完成事件
			 * 获取视频的实际宽高和总时长
			 */
			onloadedmetadata(e){
				this.videoWidth = e.detail.width
				this.videoHeight = e.detail.height
				this.duration = e.detail.duration || 0
			},
			/**
			 * 计算全屏时视频的尺寸(保持16:9比例)
			 * 根据屏幕宽高比,计算视频在屏幕中的最佳显示尺寸
			 */
			calcFullSize(){
				if(!this.screenW || !this.screenH){
					return
				}
				const targetRatio = 16/9  // 目标宽高比
				const screenRatio = this.screenW / this.screenH
				// 屏幕更宽时,以高度为准
				if(screenRatio > targetRatio){
					this.fullHeight = this.screenH
					this.fullWidth = this.screenH * targetRatio
				}else{
					// 屏幕更高时,以宽度为准
					this.fullWidth = this.screenW
					this.fullHeight = this.screenW / targetRatio
				}
			},
			/**
			 * 视频播放时间更新事件
			 * 更新播放进度,并根据时间轴计算当前帧的标注框
			 * 使用节流优化性能,避免频繁更新标注框
			 */
			ontimeupdate(event){
				// 如果没有标注数据或不需要显示标注框,清空标注
				if(!this.jsonData || !this.isFrame){
					this.boxes = []
					return
				}
				// 更新播放进度(拖拽进度条时不更新,避免冲突)
				if(!this.isSliding){
					this.currentTime = event.detail.currentTime || 0
					this.duration = event.detail.duration || this.duration
					this.progress = this.duration ? (this.currentTime / this.duration) * 100 : 0
				}
				// 节流:限制标注框更新频率,提升性能
				const now = Date.now()
				if(now - this.lastBoxUpdate < this.boxUpdateInterval){
					return
				}
				this.lastBoxUpdate = now
				this.updateBoxes(event)
			},
			/**
			 * 更新标注框位置和大小
			 * 根据视频当前播放时间和视频实际显示尺寸,计算标注框在屏幕上的位置
			 * 标注框颜色根据风险等级变化:绿色(低风险)、金色(中风险)、红色(高风险)
			 * 优化:缓存解析结果,避免重复解析;使用哈希值避免不必要的更新
			 */
			updateBoxes(event){
				// 如果不需要显示标注框,直接返回
				if(!this.isFrame){
					if(this.boxes.length > 0){
						this.boxes = []
					}
					return
				}
				
				const query = uni.createSelectorQuery().in(this);
				// 根据是否全屏选择不同的视频元素
				const targetId = this.fullScreenVisible ? '#fullscreenVideo' : `#${this.videoDomId}`
				query.select(targetId).boundingClientRect((data) => {
					if(!data) return
					
					// 缓存解析结果,避免重复解析JSON
					if(!this.parsedAimodel && this.jsonData){
						try{
							this.parsedAimodel = JSON.parse(this.jsonData).Aimodel || []
						}catch(err){
							this.parsedAimodel = []
						}
					}
					
					const aimodel = this.parsedAimodel || []
					if(!aimodel.length || !event.detail.duration) {
						if(this.boxes.length > 0){
						this.boxes = []
						}
						return
					}
					
					// 计算帧率:总帧数 / 视频时长
					let rate = aimodel.length/event.detail.duration
					// 根据当前播放时间计算当前帧序号
					let frameNum = Math.trunc(event.detail.currentTime*rate)
					const boxes = []
					// 采样多帧数据(最多采样 rate/4 帧,避免显示过多标注)
					const sample = Math.max(1, Math.trunc(rate/4))
					
					// 预计算缩放比例,避免重复计算
					const scaleX = data.width / this.videoWidth
					const scaleY = data.height / this.videoHeight
					
					for (let i = 0; i< sample; i++) {
						const frame = aimodel[frameNum+i]
						if(frame && frame.node && frame.node.length>0){
							// 遍历当前帧的所有病灶节点
							frame.node.forEach((node, idx) => {
								const rect = node.Rect || []
								if(rect.length < 4) return
								// 获取标注框的原始坐标(视频坐标系)
								let startX = rect[0]
								let startY = rect[1]
								let endX = rect[2]
								let endY = rect[3]
								// 将视频坐标系转换为屏幕坐标系(使用预计算的缩放比例)
								let width = (endX-startX) * scaleX
								let height = (endY-startY) * scaleY
								// 根据风险等级设置颜色
								let color = "LimeGreen"  // 默认绿色(低风险)
								if(frame.Birads==2){
									color="Gold"  // 2类:金色(中风险)
								}else if(frame.Birads>=3){
									color="red"   // 3类及以上:红色(高风险)
								}
								boxes.push({
									id: `${frameNum+i}-${idx}`,
									width: width+"px",
									height: height+"px",
									top: startY * scaleY +"px",
									left: startX * scaleX +"px",
									color,
									display:"block"
								})
							})
						}
					}
					
					// 生成哈希值,避免不必要的更新
					const boxesHash = JSON.stringify(boxes)
					if(boxesHash !== this.lastBoxesHash){
					this.boxes = boxes
						this.lastBoxesHash = boxesHash
					}
				}).exec();
			},
			/**
			 * 打开全屏
			 * 重新获取屏幕尺寸,计算全屏视频尺寸,重置缩放和位置为居中状态
			 */
			openCustomFullScreen(){
				uni.getSystemInfo({
					success: (res) => {
						this.screenW = res.windowWidth
						this.screenH = res.windowHeight
						this.fullscreenScale = 1
						// 先初始化位置为0,避免渲染时出现错误位置
						this.fullX = 0
						this.fullY = 0
				this.fullScreenVisible = true
						
						// 等待DOM更新后居中,确保位置正确
						this.$nextTick(() => {
							// 重新计算尺寸并居中
							this.calcFullSize()
							this.centerFull()
							// 同步播放时间
							this.syncCurrentTime()
							// 再次确保居中(处理可能的延迟)
							setTimeout(() => {
								this.centerFull()
							}, 100)
						})
					},
					fail: () => {
						// 获取屏幕信息失败时的降级处理
						this.fullX = 0
						this.fullY = 0
						this.fullScreenVisible = true
						this.fullscreenScale = 1
						this.$nextTick(() => {
							this.centerFull()
							this.syncCurrentTime()
						})
					}
				})
			},
			/**
			 * 关闭全屏
			 */
			closeCustomFullScreen(){
				this.fullScreenVisible = false
			},
			/**
			 * 切换全屏状态
			 */
			toggleFullscreenBtn(){
				this.resetControlsTimer() // 重置定时器
				if(this.fullScreenVisible){
					this.closeCustomFullScreen()
				}else{
					this.openCustomFullScreen()
				}
			},
			/**
			 * 全屏时移动/缩放事件处理
			 * 使用节流优化,避免频繁更新导致卡顿和抖动
			 */
			onMoveChange(e){
				if(!e || !e.detail) return
				
				const { x = 0, y = 0, scale = this.fullscreenScale } = e.detail
				
				// 直接更新,不使用节流,避免抖动
				// movable-view 本身已经有优化,直接更新更流畅
				this.fullX = x
				this.fullY = y
				this.fullscreenScale = scale
			},
			/**
			 * 获取当前激活的视频上下文(根据是否全屏返回不同的视频实例)
			 * 使用缓存避免重复创建
			 */
			getActiveVideoContext(){
				if(this.fullScreenVisible){
					if(!this.videoContexts.full){
						this.videoContexts.full = uni.createVideoContext('fullscreenVideo', this)
					}
					return this.videoContexts.full
				}else{
					if(!this.videoContexts.main){
						this.videoContexts.main = uni.createVideoContext(this.videoDomId, this)
					}
					return this.videoContexts.main
				}
			},
			/**
			 * 获取主视频和全屏视频两个上下文
			 * 用于同步两个视频的播放状态
			 * 使用缓存避免重复创建
			 */
			getBothContexts(){
				if(!this.videoContexts.main){
					this.videoContexts.main = uni.createVideoContext(this.videoDomId, this)
				}
				if(!this.videoContexts.full){
					this.videoContexts.full = uni.createVideoContext('fullscreenVideo', this)
				}
				return {
					main: this.videoContexts.main,
					full: this.videoContexts.full
				}
			},
			/**
			 * 切换控制条显示/隐藏状态
			 * 点击视频时,如果控制条已显示则隐藏,如果已隐藏则显示
			 */
			showControls(){
				// 如果控制条已显示,则隐藏;如果已隐藏,则显示
				if(this.controlsVisible){
					this.hideControls()
				}else{
					this.controlsVisible = true
					this.resetControlsTimer()
				}
			},
			/**
			 * 重置控制条自动隐藏定时器
			 * 在用户操作控件时调用,延长控制条显示时间
			 */
			resetControlsTimer(){
				// 清除之前的定时器
				if(this.controlsTimer){
					clearTimeout(this.controlsTimer)
					this.controlsTimer = null
				}
				// 如果控制条已显示且速度菜单未显示,设置新的自动隐藏定时器
				// 速度菜单显示时,不自动隐藏控制条
				if(this.controlsVisible && !this.showSpeedMenu){
					this.controlsTimer = setTimeout(() => {
						this.hideControls()
					}, this.controlsHideDelay)
				}
			},
			/**
			 * 隐藏控制条
			 */
			hideControls(){
				this.controlsVisible = false
				if(this.controlsTimer){
					clearTimeout(this.controlsTimer)
					this.controlsTimer = null
				}
			},
			/**
			 * 切换播放/暂停状态
			 */
			togglePlay(){
				this.resetControlsTimer() // 重置定时器
				const ctx = this.getActiveVideoContext()
				if(this.isPlaying){
					ctx.pause()
				}else{
					ctx.play()
				}
			},
			/**
			 * 视频开始播放事件
			 */
			onPlay(){
				this.isPlaying = true
			},
			/**
			 * 视频暂停事件
			 */
			onPause(){
				this.isPlaying = false
			},
			/**
			 * 进度条拖拽中事件
			 * 实时更新显示的时间,并使用节流实时跳转视频位置
			 * 让视频内容跟随拖拽实时变化
			 */
			onSliderChanging(e){
				this.resetControlsTimer() // 重置定时器
				this.isSliding = true
				if(!this.duration) return
				const time = (e.detail.value/100)*this.duration
				this.currentTime = time
				this.progress = e.detail.value
				
				// 节流处理:延迟执行 seek,避免频繁操作导致卡顿
				this.pendingSeekTime = time
				
				// 如果已有定时器,只更新待处理时间,不创建新定时器
				if(this.seekTimer) return
				
				// 立即执行一次 seek,让用户一开始拖拽就能看到变化
				const ctx = this.getActiveVideoContext()
				try {
					ctx.seek(time)
				} catch(e){
					console.warn('seek error:', e)
				}
				
				// 设置节流定时器,后续拖拽使用节流
				this.seekTimer = setTimeout(() => {
					if(this.pendingSeekTime !== null){
						const targetTime = this.pendingSeekTime
						const ctx = this.getActiveVideoContext()
						try {
							ctx.seek(targetTime)
						} catch(e){
							console.warn('seek error:', e)
						}
						this.pendingSeekTime = null
					}
					this.seekTimer = null
				}, 50) // 50ms 节流,平衡流畅度和性能
			},
			/**
			 * 进度条拖拽结束事件
			 * 立即跳转到最终目标时间,并同步主视频和全屏视频的播放位置
			 * 确保切换全屏时不会跳回旧位置
			 */
			onSliderChange(e){
				this.resetControlsTimer() // 重置定时器
				this.isSliding = false
				if(!this.duration) return
				
				// 清除节流定时器,立即执行最终 seek
				if(this.seekTimer){
					clearTimeout(this.seekTimer)
					this.seekTimer = null
				}
				
				const target = (e.detail.value/100)*this.duration
				this.currentTime = target
				this.progress = e.detail.value
				this.pendingSeekTime = null
				
				// 同步主、全屏两个上下文,避免切换时跳回旧位置
				const { main, full } = this.getBothContexts()
				const wasPlaying = this.isPlaying
				
				// 先暂停,再seek,最后恢复播放状态,避免跳回起点
				try { 
					if(wasPlaying){
						main.pause()
					}
					main.seek(target)
					// 延迟恢复播放,确保seek完成
					setTimeout(() => {
						if(wasPlaying){
							main.play()
						}
					}, 100)
				} catch(e){
					console.warn('main seek error:', e)
				}
				try { 
					if(wasPlaying){
						full.pause()
					}
					full.seek(target)
					// 延迟恢复播放,确保seek完成
					setTimeout(() => {
						if(wasPlaying){
							full.play()
						}
					}, 100)
				} catch(e){
					console.warn('full seek error:', e)
				}
			},
			/**
			 * 格式化时间显示(秒数转为 MM:SS 格式)
			 */
			formatTime(time){
				const t = Math.floor(time || 0)
				const m = String(Math.floor(t/60)).padStart(2,'0')
				const s = String(t%60).padStart(2,'0')
				return `${m}:${s}`
			},
			/**
			 * 同步全屏视频的播放时间到当前播放位置
			 * 进入全屏时调用,确保全屏视频从正确位置开始播放
			 */
			syncCurrentTime(){
				if(!this.videoContexts.full){
					this.videoContexts.full = uni.createVideoContext('fullscreenVideo', this)
				}
				this.videoContexts.full.seek(this.currentTime || 0)
			},
			/**
			 * 将全屏视频居中显示
			 * 计算视频在屏幕中的居中位置
			 * 使用实际可用区域尺寸,考虑安全区域和状态栏
			 */
			centerFull(){
				// 重新获取屏幕信息,确保在屏幕旋转后也能正确居中
				uni.getSystemInfo({
					success: (res) => {
						this.screenW = res.windowWidth
						this.screenH = res.windowHeight
						this.calcFullSize()
						
						if(!this.fullWidth || !this.fullHeight || !this.screenW || !this.screenH){
							return
						}
						
						// 获取真实可用区域尺寸(考虑状态栏、导航栏等)
						// 优先获取 movable-area 的实际尺寸,如果获取失败则使用 fullscreen-inner 的尺寸
						const query = uni.createSelectorQuery().in(this)
						query.select('.movable-area').boundingClientRect()
						query.select('.fullscreen-inner').boundingClientRect()
						query.exec((res) => {
							// res[0] 是 movable-area 的尺寸
							// res[1] 是 fullscreen-inner 的尺寸
							const areaRect = res[0]
							const innerRect = res[1]
							
							// 优先使用 movable-area 的实际尺寸,如果获取失败则使用 fullscreen-inner 的尺寸
							// 如果都获取失败,则使用屏幕尺寸
							let areaWidth, areaHeight
							if(areaRect && areaRect.width > 0 && areaRect.height > 0){
								areaWidth = areaRect.width
								areaHeight = areaRect.height
							}else if(innerRect && innerRect.width > 0 && innerRect.height > 0){
								areaWidth = innerRect.width
								areaHeight = innerRect.height
							}else{
								// 降级:使用屏幕尺寸
								areaWidth = this.screenW
								areaHeight = this.screenH
							}
							
							// 计算居中位置(movable-view 的 x、y 是相对于 movable-area 的偏移)
							// movable-view的尺寸是fullWidth x fullHeight,需要居中在areaWidth x areaHeight的区域内
							// 注意:movable-view 的 x、y 是相对于 movable-area 左上角的偏移,单位是 px
							const offsetX = (areaWidth - this.fullWidth) / 2
							const offsetY = (areaHeight - this.fullHeight) / 2
							
							// 设置居中位置(确保值不为负数)
							// 注意:movable-view 的 x、y 必须是数字类型,单位是 px
							const newX = Math.max(0, Math.round(offsetX))
							const newY = Math.max(0, Math.round(offsetY))
							
							// 如果位置发生变化,才更新(避免不必要的更新)
							if(this.fullX !== newX || this.fullY !== newY){
								this.fullX = newX
								this.fullY = newY
								// 使用 $nextTick 确保 DOM 更新完成
								this.$nextTick(() => {
									// 再次验证位置是否正确
									console.log('位置已更新:', { fullX: this.fullX, fullY: this.fullY })
								})
							}
							
							// 调试信息
							console.log('centerFull:', {
								screenW: this.screenW,
								screenH: this.screenH,
								areaWidth,
								areaHeight,
								fullWidth: this.fullWidth,
								fullHeight: this.fullHeight,
								offsetX,
								offsetY,
								fullX: this.fullX,
								fullY: this.fullY,
								areaRect: areaRect ? {
									width: areaRect.width,
									height: areaRect.height,
									top: areaRect.top,
									left: areaRect.left
								} : null,
								innerRect: innerRect ? {
									width: innerRect.width,
									height: innerRect.height,
									top: innerRect.top,
									left: innerRect.left
								} : null
							})
						})
					}
				})
			},
			/**
			 * 恢复原比例
			 * 重置缩放为1,并居中显示
			 */
			resetScale(){
				this.fullscreenScale = 1
				this.centerFull()
			},
			/**
			 * 切换播放速度菜单显示/隐藏
			 */
			toggleSpeedMenu(){
				this.resetControlsTimer() // 重置定时器
				this.showSpeedMenu = !this.showSpeedMenu
				// 如果关闭速度菜单,重新启动控制条自动隐藏定时器
				if(!this.showSpeedMenu){
					this.resetControlsTimer()
				}
			},
			/**
			 * 关闭速度菜单
			 */
			closeSpeedMenu(){
				this.showSpeedMenu = false
				// 关闭菜单后,重新启动控制条自动隐藏定时器
				this.resetControlsTimer()
			},
			/**
			 * 设置播放速度
			 * @param {Number} rate - 播放速度(0.5, 1, 1.5, 2)
			 * 注意:uni-app的video组件通过:playback-rate属性绑定来控制播放速度
			 */
			setPlaybackRate(rate){
				this.resetControlsTimer() // 重置定时器
				this.playbackRate = rate
				this.showSpeedMenu = false
				// 注意:uni-app的video组件通过:playback-rate属性自动更新,无需手动调用方法
				// 如果需要重新加载视频以应用速度,可以尝试seek到当前位置
				const { main, full } = this.getBothContexts()
				try {
					// 通过seek触发视频重新应用播放速度
					const currentTime = this.currentTime
					if(currentTime > 0){
						main.seek(currentTime)
						full.seek(currentTime)
					}
				} catch(e){
					console.warn('更新播放速度失败:', e)
				}
			},
			/**
			 * 切换缓存开关
			 */
			toggleCache(){
				this.resetControlsTimer() // 重置定时器
				this.internalEnableCache = !this.internalEnableCache
				// 注意:缓存开关不关闭菜单,用户可以继续操作其他选项
				if(this.internalEnableCache){
					// 启用缓存,开始缓存视频
					if(this.videoUrl && !this.useCache && !this.isCaching){
						this.cacheVideo()
					}
				}else{
					// 禁用缓存,清理已缓存的视频并切换回原始URL
					this.clearVideoCache()
					// 如果正在使用缓存,需要切换回原始URL
					if(this.useCache){
						this.useCache = false
						// 同步两个视频的播放位置
						const { main, full } = this.getBothContexts()
						try {
							const currentTime = this.currentTime
							const wasPlaying = this.isPlaying
							if(currentTime > 0){
								main.seek(currentTime)
								full.seek(currentTime)
							}
							if(wasPlaying){
								main.play()
								full.play()
							}
						} catch(e){
							console.warn('切换视频源失败:', e)
						}
					}
				}
			},
			/**
			 * 检查视频是否已缓存(临时缓存)
			 * @returns {Boolean} 是否已缓存
			 */
			checkVideoCache(){
				// 临时缓存直接检查 cachedVideoUrl
				return !!(this.cachedVideoUrl && this.useCache)
			},
			/**
			 * 自动下载并缓存视频到临时文件
			 * 使用临时文件,页面销毁时自动清理
			 */
			async cacheVideo(){
				// 如果未启用缓存,直接返回
				if(!this.internalEnableCache) return
				if(!this.videoUrl || this.isCaching || this.useCache) return
				
				this.isCaching = true
				this.cacheProgress = 0
				const videoUrl = this.baseUrl + this.videoUrl
				
				try {
					// 下载视频到临时文件(静默缓存,不影响播放)
					this.downloadTask = uni.downloadFile({
						url: videoUrl,
						success: (res) => {
							if(res.statusCode === 200){
								// 保存缓存文件路径
								this.cachedVideoUrl = res.tempFilePath
								this.isCaching = false
								this.cacheProgress = 100
								
								// 平滑切换:在视频暂停或播放间隙切换源
								this.$nextTick(() => {
									const wasPlaying = this.isPlaying
									const currentTime = this.currentTime
									
									// 切换视频源
									this.useCache = true
									
									// 同步两个视频的播放位置
									const { main, full } = this.getBothContexts()
									try {
										main.seek(currentTime)
										if(wasPlaying) main.play()
									} catch(e){}
									try {
										full.seek(currentTime)
										if(wasPlaying) full.play()
									} catch(e){}
									
									console.log('视频缓存完成,已平滑切换:', res.tempFilePath)
								})
							}else{
								throw new Error('下载失败')
							}
						},
						fail: (err) => {
							console.error('视频自动缓存失败:', err)
							this.isCaching = false
							this.cacheProgress = 0
							// 自动缓存失败不影响播放,静默处理
						}
					})
					
					// 监听下载进度
					if(this.downloadTask && this.downloadTask.onProgressUpdate){
						this.downloadTask.onProgressUpdate((res) => {
							this.cacheProgress = res.progress
						})
					}
					
				} catch(e){
					console.error('自动缓存视频异常:', e)
					this.isCaching = false
					this.cacheProgress = 0
				}
			},
			/**
			 * 清除临时视频缓存
			 * 删除临时文件
			 */
			clearVideoCache(){
				if(this.downloadTask){
					// 取消下载任务
					try {
						this.downloadTask.abort()
					} catch(e){
						console.warn('取消下载任务失败:', e)
					}
					this.downloadTask = null
				}
				
				// 删除临时文件
				if(this.cachedVideoUrl){
					try {
						uni.getFileSystemManager().unlinkSync(this.cachedVideoUrl)
					} catch(e){
						console.warn('删除临时文件失败:', e)
					}
				}
				
				this.cachedVideoUrl = ''
				this.useCache = false
				this.isCaching = false
				this.cacheProgress = 0
			},
			/**
			 * 下载视频到相册/文件管理器
			 */
			downloadVideo(){
				this.resetControlsTimer() // 重置定时器
				this.showSpeedMenu = false
				const videoUrl = this.useCache ? this.cachedVideoUrl : (this.baseUrl + this.videoUrl)
				
				uni.showLoading({
					title: '准备下载...'
				})
				
				// 如果已有缓存,直接保存;否则先下载
				if(this.useCache && this.cachedVideoUrl){
					this.saveVideoToAlbum(this.cachedVideoUrl)
				}else{
					// 下载视频
					uni.downloadFile({
						url: this.baseUrl + this.videoUrl,
						success: (res) => {
							if(res.statusCode === 200){
								this.saveVideoToAlbum(res.tempFilePath)
							}else{
								uni.hideLoading()
								uni.showToast({
									title: '下载失败',
									icon: 'none'
								})
							}
						},
						fail: (err) => {
							console.error('下载失败:', err)
							uni.hideLoading()
							uni.showToast({
								title: '下载失败',
								icon: 'none'
							})
						}
					})
				}
			},
			/**
			 * 保存视频到相册
			 * @param {String} filePath - 视频文件路径
			 */
			saveVideoToAlbum(filePath){
				// #ifdef APP-PLUS
				// APP端保存到相册
				uni.saveVideoToPhotosAlbum({
					filePath: filePath,
					success: () => {
						uni.hideLoading()
						uni.showToast({
							title: '保存成功',
							icon: 'success'
						})
					},
					fail: (err) => {
						console.error('保存失败:', err)
						uni.hideLoading()
						uni.showToast({
							title: '保存失败',
							icon: 'none'
						})
					}
				})
				// #endif
				
				// #ifdef H5
				// H5端触发下载
				const link = document.createElement('a')
				link.href = filePath
				link.download = `video_${Date.now()}.mp4`
				document.body.appendChild(link)
				link.click()
				document.body.removeChild(link)
				uni.hideLoading()
				uni.showToast({
					title: '开始下载',
					icon: 'success'
				})
				// #endif
				
				// #ifdef MP
				// 小程序端提示用户长按保存
				uni.hideLoading()
				uni.showModal({
					title: '提示',
					content: '请长按视频选择保存',
					showCancel: false
				})
				// #endif
			},
			/**
			 * 分享视频
			 */
			shareVideo(){
				this.resetControlsTimer() // 重置定时器
				this.showSpeedMenu = false
				const videoUrl = this.baseUrl + this.videoUrl
				
				// #ifdef APP-PLUS
				// APP端使用原生分享
				plus.share.sendWithSystem({
					type: 'web',
					href: videoUrl,
					title: '分享视频',
					content: '查看这个视频'
				}, () => {
					uni.showToast({
						title: '分享成功',
						icon: 'success'
					})
				}, (err) => {
					console.error('分享失败:', err)
					uni.showToast({
						title: '分享失败',
						icon: 'none'
					})
				})
				// #endif
				
				// #ifdef H5
				// H5端复制链接
				if(navigator.share){
					navigator.share({
						title: '分享视频',
						text: '查看这个视频',
						url: videoUrl
					}).then(() => {
						uni.showToast({
							title: '分享成功',
							icon: 'success'
						})
					}).catch((err) => {
						console.error('分享失败:', err)
						// 降级到复制链接
						this.copyVideoUrl(videoUrl)
					})
				}else{
					this.copyVideoUrl(videoUrl)
				}
				// #endif
				
				// #ifdef MP
				// 小程序端使用分享功能
				uni.showToast({
					title: '请使用右上角分享',
					icon: 'none'
				})
				// #endif
			},
			/**
			 * 复制视频链接
			 * @param {String} url - 视频URL
			 */
			copyVideoUrl(url){
				// #ifdef H5
				if(navigator.clipboard){
					navigator.clipboard.writeText(url).then(() => {
						uni.showToast({
							title: '链接已复制',
							icon: 'success'
						})
					}).catch(() => {
						uni.showToast({
							title: '复制失败',
							icon: 'none'
						})
					})
				}else{
					// 降级方案:创建临时input元素
					const input = document.createElement('input')
					input.value = url
					document.body.appendChild(input)
					input.select()
					document.execCommand('copy')
					document.body.removeChild(input)
					uni.showToast({
						title: '链接已复制',
						icon: 'success'
					})
				}
				// #endif
				
				// #ifndef H5
				uni.setClipboardData({
					data: url,
					success: () => {
						uni.showToast({
							title: '链接已复制',
							icon: 'success'
						})
					},
					fail: () => {
						uni.showToast({
							title: '复制失败',
							icon: 'none'
						})
					}
				})
				// #endif
			}
		},
		// 组件销毁前清理定时器和资源,避免内存泄漏
		beforeDestroy() {
			// 清理定时器
			if(this.moveFrameId){
				clearTimeout(this.moveFrameId)
				this.moveFrameId = null
				this.pendingMove = null
			}
			if(this.seekTimer){
				clearTimeout(this.seekTimer)
				this.seekTimer = null
				this.pendingSeekTime = null
			}
			if(this.controlsTimer){
				clearTimeout(this.controlsTimer)
				this.controlsTimer = null
			}
			// 清理视频上下文缓存
			this.videoContexts.main = null
			this.videoContexts.full = null
			// 清理数据缓存
			this.parsedAimodel = null
			this.lastBoxesHash = ''
			// 清理临时视频缓存(自动清理)
			this.clearVideoCache()
		}
	}
</script>

<style lang="scss" scoped>
	/* 视频容器外层 */
	.video-wrapper{
		position: relative;
		width: 100%;
	}
	/* 视频容器 - 保持16:9宽高比 */
	/* 兼容性处理:使用 padding-top 技巧替代 aspect-ratio(低版本小程序不支持) */
	.box{
		width: 100%;
		position: relative;
		/* 16:9 比例 = 9/16 = 56.25% */
		padding-top: 56.25%;
	}
	.box video{
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
	}
	/* 病灶标注框样式 */
	.mark{
		position: absolute;
	}
	/* 自定义全屏按钮(已废弃,保留以防需要) */
	.custom-full-btn{
		position: absolute;
		right: 8rpx;
		top: 8rpx;
		font-size: 24rpx;
		color: #fff;
		background: rgba(0,0,0,0.4);
		padding: 6rpx 12rpx;
		border-radius: 8rpx;
		z-index: 10;
	}
	/* 全屏遮罩层 - 覆盖整个屏幕 */
	.fullscreen-mask{
		position: fixed;
		left: 0;
		top: 0;
		right: 0;
		bottom: 0;
		background: #000;
		z-index: 99999;  /* 确保在最上层 */
		display: flex;
		flex-direction: column;
		overflow: hidden;
		/* 优化:添加淡入效果,避免闪烁 */
		animation: fadeIn 0.2s ease-in;
	}
	@keyframes fadeIn {
		from {
			opacity: 0;
		}
		to {
			opacity: 1;
		}
	}
	/* 全屏内容容器 */
	.fullscreen-inner{
		flex: 1;
		display: flex;
		flex-direction: column;
	}
	/* 全屏头部 - 悬浮显示,脱离文档流,不占用位置 */
	.fullscreen-header{
		position: fixed;
		top: 0;
		left: 0;
		right: 0;
		height: 80rpx;
		display: flex;
		justify-content: flex-end;
		align-items: center;
		padding: 0 20rpx;
		color: #fff;
		z-index: 100001;  /* 确保在最上层,高于控制条 */
		pointer-events: auto;  /* 确保可以接收点击事件 */
	}
	.fullscreen-header .close{
		padding: 10rpx 20rpx;
		border: 1rpx solid rgba(255,255,255,0.4);
		border-radius: 12rpx;
	}
	/* 可移动区域 - 支持双指缩放和拖拽 */
	.movable-area{
		flex: 1;
		width: 100vw;
		height: 100vh;
		position: relative;
		/* 注意:不要使用 flex 居中,movable-view 的 x、y 是相对于 movable-area 左上角的偏移 */
	}
	/* 可移动视图 - 包含视频和标注框 */
	/* 兼容性处理:移除 will-change(小程序可能不支持) */
	.movable-view{
		width: 100vw;
		height: 100vh;
		position: relative;
		/* 使用 transform 优化性能 */
		transform: translateZ(0);
		-webkit-transform: translateZ(0);
	}
	/* 全屏视频样式 */
	.fullscreen-video{
		width: 100%;
		height: 100%;
		object-fit: contain;  /* 保持比例,完整显示 */
	}
	/* 全屏点击覆盖层 - 透明层用于触发显示/隐藏控制条 */
	.fullscreen-tap-overlay{
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		bottom: 0;
		z-index: 1;  /* 在视频上方,但在标注框下方 */
		pointer-events: auto;  /* 确保可以接收点击事件 */
		/* 注意:标注框的 z-index 应该高于此覆盖层,确保标注框可以正常显示 */
	}
	/* 控制条 - 播放/暂停、进度条、时间、全屏按钮 */
	.controls-bar{
		position: absolute;
		left: 0;
		right: 0;
		bottom: 0;
		height: 80rpx;
		display: flex;
		align-items: center;
		padding: 0 16rpx;
		box-sizing: border-box;
		background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 100%);
		color: #fff;
		font-size: 24rpx;
		z-index: 2;  /* 确保不遮挡页面底部按钮 */
		/* 默认隐藏,只使用透明度过渡动画,不移动 */
		opacity: 0;
		transition: opacity 0.25s ease-out;
		pointer-events: none;  /* 隐藏时禁用交互 */
	}
	/* 控制条显示状态 */
	.controls-bar--visible{
		opacity: 1;
		pointer-events: auto;  /* 显示时启用交互 */
	}
	/* 控制按钮样式 */
	.controls-bar .control-btn{
		min-width: 40rpx;
		display: flex;
		justify-content: center;
		align-items: center;
		padding: 10rpx 12rpx;
		
	}
	/* 进度条样式 */
	.controls-bar .progress{
		flex: 1;
		margin: 0 12rpx;
	}
	/* 时间显示样式 */
	.controls-bar .time{
		min-width: 160rpx;
		text-align: right;
	}
	/* 全屏模式下的控制条 - 固定定位,确保在最上层 */
	.controls-bar--fullscreen{
		position: fixed;
		bottom: 0;
		left: 0;
		right: 0;
		z-index: 100000;  /* 全屏时确保在最上层 */
	}
	
	/* 播放速度按钮 */
	.speed-btn{
		min-width: 50rpx !important;
	}
	.speed-text{
		font-size: 22rpx;
		color: rgba(255,255,255,0.8);
	}
	
	/* 播放速度选择菜单 */
	.speed-menu{
		position: absolute;
		bottom: 100rpx;
		right: 20rpx;
		background: rgba(0,0,0,0.85);
		border-radius: 12rpx;
		padding: 10rpx 0;
		min-width: 120rpx;
		z-index: 100001;
		box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.3);
	}
	/* 全屏时的速度菜单位置 - 使用fixed定位,相对于屏幕定位 */
	.speed-menu--fullscreen{
		position: fixed !important;
		bottom: 100rpx !important;
		right: 20rpx !important;
		z-index: 100002 !important;
	}
	.speed-menu-item{
		padding: 20rpx 30rpx;
		color: rgba(255,255,255,0.8);
		font-size: 28rpx;
		text-align: center;
		transition: background-color 0.2s;
	}
	.speed-menu-item:active{
		background-color: rgba(255,255,255,0.1);
	}
	.speed-menu-item.active{
		color: #409EFF;
		background-color: rgba(64,158,255,0.2);
		font-weight: bold;
	}
	.speed-menu-divider{
		height: 1rpx;
		background: rgba(255,255,255,0.2);
		margin: 10rpx 0;
	}
	.speed-menu-item.action-item{
		color: rgba(255,255,255,0.8);
		font-size: 24rpx;
		display: flex;
		align-items: center;
		justify-content: center;
	}
	.speed-menu-item.action-item:active{
		background-color: rgba(255,255,255,0.1);
	}
	
	/* 速度菜单遮罩 */
	.speed-menu-mask{
		position: fixed;
		left: 0;
		top: 0;
		right: 0;
		bottom: 0;
		z-index: 100000;
		background: transparent;
	}
	
</style>

结语

医疗视频播放组件的开发涉及视频处理、性能优化、用户体验等多个方面。通过本文分享的技术方案,我们成功实现了功能完善、性能优秀的医疗视频播放组件,为医疗影像诊断提供了有力的技术支持。

技术要点回顾:

  1. 动态标注渲染与性能优化

  2. 智能缓存与平滑切换

  3. 全屏缩放交互体验

  4. 全面的兼容性处理

希望本文对从事医疗影像或视频播放组件开发的同行有所启发和帮助!

相关推荐
Yutengii2 小时前
b站视频下载到电脑本地的方法有哪些
音视频
summerkissyou19874 小时前
Android13-Audio-AudioTrack-播放流程
android·音视频
Black蜡笔小新5 小时前
安防监控/录像存储EasyCVR视频汇聚平台无法启动的原因排查
音视频
xingqing87y6 小时前
祝寿视频怎么制作:4步制作创意祝寿视频
音视频
qq_256247056 小时前
Spring Boot + NATS 实战:如何让 IM 系统处理图片/视频像处理文本一样快?
spring boot·后端·音视频
好游科技8 小时前
使用WebRTC开发直播系统源码与音视频语聊房实践指南
音视频·webrtc·im即时通讯·社交软件·社交语音视频软件
一点晖光10 小时前
ffmpeg合成的视频在ios浏览器不能播放的问题
ffmpeg·音视频
毕设源码-钟学长10 小时前
【开题答辩全过程】以 基于微信小程序的记账系统为例,包含答辩的问题和答案
微信小程序·小程序
sheji341610 小时前
【开题答辩全过程】以 基于微信小程序的会议预定系统设计与实现为例,包含答辩的问题和答案
微信小程序·小程序