Uniapp video使用子窗体解决app端自定义UI层级问题

如果业务使用子窗体无法满足,请参考另一种方案:

写文章 - 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()获取信息后设置

参考资料

1.uni-app subNVue 原生子窗体开发指南 ask.dcloud.net.cn/article/359...

2.nvue简介 uniapp.dcloud.net.cn/tutorial/nv...

完整代码见如下git仓库:路径juejin\uniapp\video

github.com/yc-lm/docum...

相关推荐
W.Y.B.G41 分钟前
Vue3 项目通过 docxtemplater 插件动态渲染 .docx 文档(带图片)预览,并导出
vue.js·word
最初@10 小时前
el-table + el-pagination 前端实现分页操作
前端·javascript·vue.js·ajax·html
zhu_zhu_xia11 小时前
vue3中ref和reactive的差异分析
前端·javascript·vue.js
计算机-秋大田11 小时前
基于Spring Boot的ONLY在线商城系统设计与实现的设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计
等什么君!13 小时前
element-plus 的简单应用
前端·javascript·vue.js
计算机-秋大田14 小时前
基于Spring Boot的网上商城系统的设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计
叶落无痕12314 小时前
uni-app jyf-parser将字符串转化为html 和 rich-text
uni-app
DT——14 小时前
uniapp 和 webview 之间的通信
uni-app
爱吃水果和蔬菜丫14 小时前
el-select开启filterable模式,限制输入框输入类型
javascript·vue.js·ecmascript
丁总学Java15 小时前
Vue中动态搜索表单的「默认值」设计:从原理到最佳实践!!!
前端·javascript·vue.js·ts