一、项目背景与需求分析
在医疗影像诊断系统中,视频播放组件是核心功能之一。我们开发了一个支持病灶动态标注、全屏缩放、缓存播放的高级视频播放组件,用于超声、内窥镜等医疗视频的会诊分析。
核心需求:
-
自定义播放控件,支持播放速度调节
-
动态显示AI识别的病灶标注框
-
支持全屏双指缩放和拖拽浏览
-
智能缓存机制提升播放体验
-
完善的性能优化
二、技术架构设计
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>
结语
医疗视频播放组件的开发涉及视频处理、性能优化、用户体验等多个方面。通过本文分享的技术方案,我们成功实现了功能完善、性能优秀的医疗视频播放组件,为医疗影像诊断提供了有力的技术支持。
技术要点回顾:
-
动态标注渲染与性能优化
-
智能缓存与平滑切换
-
全屏缩放交互体验
-
全面的兼容性处理
希望本文对从事医疗影像或视频播放组件开发的同行有所启发和帮助!