如果业务使用子窗体无法满足,请参考另一种方案:
写文章 - Uniapp 基于renderjs封装原生h5 video组件(解决自定义UI层级) - 掘金 (juejin.cn)
问题背景
在 UniApp 开发中,当使用原生组件(如 video
)时,会遇到原生组件层级最高的限制,导致自定义的 UI 控件(如播放按钮、进度条等)无法覆盖在视频组件之上。这是由原生组件渲染层级决定的常规限制。
解决方案
通过使用 subNVue 子窗体 技术,可以实现在原生组件上方叠加自定义 UI。subNVue 是基于原生渲染的视图组件,支持覆盖在原生组件之上。
优缺点
-
优点:
- 覆盖能力强:子窗体可以覆盖在video组件之上,有效解决video层级过高导致其他UI元素无法覆盖的问题,实现自定义UI的展示。
- 灵活自定义UI:可以自由设计子窗体的UI,包括布局、样式、交互等,满足各种复杂的自定义需求。
- 与父窗体通信:子窗体与父窗体之间可以进行数据通信,方便实现交互逻辑,如根据父窗体的数据更新子窗体显示,或者子窗体向父窗体传递事件。
-
缺点:
- 性能消耗较大:子窗体是原生的nvue页面,相比于普通的vue页面,其渲染和交互会占用更多的内存和性能资源,一个页面加载太多子窗体可能会导致卡顿。
- 配置较为复杂:需要在pages.json中进行配置,包括设置子窗体的路径、id、样式等,且不同平台(如app、小程序)的配置可能有所不同,增加了开发的复杂度。
- 平台兼容性问题:子窗体仅支持app平台,对于其他平台如H5、小程序等无法使用,需要针对不同平台进行适配
注意事项
*HarmonyOS Next 不支持子窗体方案
实现步骤
1. 创建 subNVue 子窗体
在 pages.json
中配置 subNVue 页面:
子窗体配置参考:uniapp.dcloud.net.cn/collocation...
json
{
"pages": [
{
"path": "pages/video-page/index",
"style": {
"navigationBarTitleText": "视频播放页",
"subNVues": [{
"id": "customControls", // 全局唯一id
"path": "pages/video-page/subnvue/controls",
"style": {
"position": "absolute",
"left": "0px",
"top": "0px",
"height": "211.5px",
"background": "transparent"
}
}]
}
}
]
}
2. 编写子窗体页面(subnvue/controls.nvue)
使用原生渲染语法编写 UI
html
<template>
<view class="controls" :style="styleObj" @tap.stop.prevent="handleCustomTap">
<image class="controls-edit" src="/static/images/report/edit.png" @click="handleEdit"></image>
<view class="controls-hidden">
<image
class="img"
src="/static/images/report/original-hidden.png"
@click="handleHidden"
></image>
<text class="text" @click="handleHidden">隐藏视频</text>
</view>
<view class="controls-progress" :style="progressStyle">
<image
v-if="currentStatus === CUSTOM_PLAY_STATUS_OBJ.START"
class="controls-progress__play"
src="/static/images/video/original-pause.png"
@tap.stop.prevent="handleVideoPlay"
></image>
<image
v-else
class="controls-progress__play"
src="/static/images/video/original-play.png"
@tap.stop.prevent="handleVideoPlay"
></image>
<text class="controls-progress__time common-center">{{
formatDurationTime({time: currentTime * 1000})
}}
</text>
<view class="controls-progress__slider">
<uv-slider
v-model="currentTime"
:max="duration"
:step="1"
@change="handleChangeEnd"
@changing="onChanging"
></uv-slider>
</view>
<text class="controls-progress__duration common-center">{{
formatDurationTime({time: duration * 1000})
}}
</text>
<image
class="controls-progress__landscape"
src="/static/images/video/original-landscape.png"
@click="handleFullScreen"
></image>
</view>
</view>
</template>
<script setup>
import {computed, ref, watch, onBeforeUnmount, onMounted} from "vue";
import {formatDurationTime} from 'etah-sdk/lib/date';
import usePlayer, {CUSTOM_PLAY_STATUS_OBJ, PlayerSubNVueEvent} from '@/hooks/usePlayer';
const PAGE_VARS = {
LOG_PREFIX: 'SubControls',
};
const {isStart, isPaused, isEnd} = usePlayer();
// 总时长
const duration = ref(0);
// 屏幕宽度
const windowWidth = ref(0);
// 视频高度
const videoHeight = ref(211.5);
const syncTime = ref(0);
const syncStatus = ref(0);
// 当前播放状态
const currentStatus = ref(CUSTOM_PLAY_STATUS_OBJ.INIT);
// 当前播放时间
const currentTime = ref(0);
// 是否手动改变时间
const isHandChange = ref(false);
// 外层盒子样式
const styleObj = computed(() => {
return {
height: '211.5px',
}
});
// 进度条样式
const progressStyle = computed(() => {
return {
width: windowWidth.value + 'px',
}
})
const handleEdit = () => {
triggerEvent('handleEdit');
};
const handleHidden = () => {
triggerEvent('handleHidden');
};
const handleFullScreen = () => {
triggerEvent('handleFullScreen');
};
const handleCustomTap = ()=>{
triggerEvent('handleCustomTap');
}
// 鼠标按下
const onChanging = () => {
console.log('onChanging');
isHandChange.value = true;
triggerEvent('onChanging');
};
// 松开拖动mouseup 或点击滑块条时触发,适合不希望在拖动滑块过程频繁触发回调的场景实用
const handleChangeEnd = (val) => {
isHandChange.value = false;
triggerEvent('changeTime', val);
};
// 播放状态
const handleVideoPlay = () => {
// 播放中
if (isStart(currentStatus.value)) {
// 请求暂停
triggerEvent('handlePausedReq');
currentStatus.value = CUSTOM_PLAY_STATUS_OBJ.PAUSED;
return true;
}
// 暂停状态
if (isPaused(currentStatus.value)) {
// 请求继续播放
triggerEvent('handleResumeReq');
currentStatus.value = CUSTOM_PLAY_STATUS_OBJ.START;
return true;
}
// 播放完成状态
if (isEnd(currentStatus.value)) {
// 请求重头开始播放
//emits('handleRestartReq');
triggerEvent('handleRestartReq');
// 播放成功会回调改状态
return true;
}
return false;
};
const onInformationHandler = (params) => {
if (params.duration) {
duration.value = params.duration;
}
syncTime.value = params.time;
syncStatus.value = params.status;
};
const triggerEvent = (event, params = null) => {
console.log(`${PAGE_VARS.LOG_PREFIX}: triggerEvent, event(${event}, params{${params})`);
uni.$emit(PlayerSubNVueEvent.SubControlsAction, {event, params});
}
watch(
() => syncStatus.value,
(val) => {
currentStatus.value = val;
},
);
watch(
() => syncTime.value,
(val) => {
if (!isHandChange.value) {
currentTime.value = val;
}
},
);
onMounted(() => {
// 初始化宽度(nvue无法设置100%)
const systemInfo = uni.getSystemInfoSync();
console.log(`${PAGE_VARS.LOG_PREFIX}: onMounted`, JSON.stringify(systemInfo), systemInfo.windowWidth);
windowWidth.value = systemInfo.windowWidth;
// 监听事件
uni.$on(PlayerSubNVueEvent.VideoInformation, onInformationHandler);
});
onBeforeUnmount(() => {
uni.$off(PlayerSubNVueEvent.VideoInformation, onInformationHandler);
});
</script>
<style scoped lang="scss">
$imgSize: 24px;
$top: 13px;
$left: 16px;
.controls {
position: relative;
&-edit {
width: #{$imgSize};
height: #{$imgSize};
position: absolute;
right: #{$left};
top: #{$top};
}
&-hidden {
position: absolute;
right: 8px;
top: 105.75px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: center;
.img {
width: #{$imgSize};
height: #{$imgSize};
}
.text {
height: 14px;
font-weight: 600;
font-size: 10px;
color: #ffffff;
line-height: 14px;
}
}
&-progress {
width: 500px;
position: absolute;
bottom: 0;
left: 0;
height: 48px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.6) 100%);
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
&__play {
width: #{$imgSize};
height: #{$imgSize};
margin-right: 16px;
}
&__time,
&__duration {
font-size: 12px;
color: #fff;
font-weight: 600;
// min-width: 33px;
}
&__slider {
flex: 1;
margin: 0 8px;
}
&__landscape {
width: #{$imgSize};
height: #{$imgSize};
margin-left: 16px;
}
}
}
</style>
3. Video 组件中引入子窗体
html
<template>
<view class="reactive-video relative" :style="styleObj" :class="{ 'is-tap': isTap }">
<video
:id="`sVideo${uid}`"
class="w-100p h-100p"
:show-center-play-btn="false"
:src="playUrl"
:controls="isControls"
:autoplay="autoplay"
v-bind="$attrs"
@error="videoErrorCallback"
@play="handlePlay"
@pause="handlePause"
@ended="handleEnded"
@timeupdate="handleUpdateTime"
@loadeddata="loadeddata"
@loadedmetadata="loadedmetadata"
@controlstoggle="handleControlsToggle"
@fullscreenchange="handleFullScreenChange"
<!--触发自定义按钮显示隐藏-->
<cover-view class="overlay" v-if="isShowOverlay" @click="handleTap"></cover-view>
</video>
</view>
</template>
<script>
...
// #ifdef APP-PLUS
const subNVue = uni.getSubNVueById('controls');
// #endif
// 是否展示自定义ui层
const isShowOverlay = computed(() => {
return !isInitPlayImg.value && !isTap.value;
});
// 打开子窗体
const openSubControl = () => {
subNVue?.show('fade-in', 200);
};
// 关闭子窗体
const closeSubControl = () => {
subNVue?.hide('none');
};
const handleTap = () => {
isTap.value = !isTap.value;
// 如果是显示状态
if (isTap.value) {
startTapTimeout();
}
};
const startTapTimeout = () => {
clearTapTimeout();
tabTimeout.value = setTimeout(() => {
isTap.value = false;
}, PAGE_VARS.TAP_TIMEOUT_NUM);
};
const clearTapTimeout = () => {
if (tabTimeout.value) {
clearTimeout(tabTimeout.value);
tabTimeout.value = null;
}
};
watch(
() => isTap.value,
(val) => {
// #ifdef APP-PLUS
if (val) {
openSubControl();
} else {
closeSubControl();
}
// #endif
},
);
onMounted(() => {
const systemInfo = uni.getSystemInfoSync();
videoPlayer.value = uni.createVideoContext(`sVideo${uid.value}`, instance);
// 监听事件
// #ifdef APP-PLUS
// 设置样式
subNVue?.setStyle({
top: systemInfo.statusBarHeight + 'px',
});
uni.$on(PlayerSubNVueEvent.SubControlsAction, onSubControlsHandler);
// #endif
});
onBeforeUnmount(() => {
// #ifdef APP-PLUS
uni.$off(PlayerSubNVueEvent.SubControlsAction, onSubControlsHandler);
// #endif
});
</script>
遇到哪些问题
1. 在app端在video绑定click,touch事件不触发
解决办法:使用组件覆盖在video上,该组件上绑定事件,用于触发自定义ui层的显示或者隐藏
2. 开发nvue子窗体时,width,height 100%等样式问题
原因:基于原生引擎的渲染,虽然还是前端技术栈,但和web开发肯定是有区别的
解决办法:基于支持的样式开发, 100%等样式使用uni.getSystemInfoSync()获取信息后设置