记录:vue移动端富文本内容替换自定义视频全屏实现

使用vue3

c 复制代码
<template>
    <div v-html="content" class="html-content text-black ql-editor" @click="jumpLink($event)" v-if="htmlContent"></div>
</template>

<script setup lang="ts">
import { showImagePreview } from 'vant';
import { UtilService } from '@common/services/util.service';
import 'quill/dist/quill.core.css';
import 'quill/dist/quill.snow.css';
import 'quill/dist/quill.bubble.css';
import { onMounted, onUnmounted, ref } from 'vue';
import { Utils } from '@common/utils/utils';
import { log } from '@common/utils';
import LoopBtn from '@mobile/components/player/loopBtn';
import FullscreenBtn from '@mobile/components/player/fullscreenBtn';
import videoJs from 'video.js';
import 'video.js/dist/video-js.min.css';

let props = defineProps(['htmlContent', 'fillImg']);
let emits = defineEmits(['onImageOpen']);

//正在显示提示框,默认不显示
let showing = false;
//是否在预览图片
let previewImg = false;

let content = ref();

let myPlayerList = ref([]);

// 初始化
onBeforeMount(() => {
    if (props.htmlContent.indexOf('<video') > -1) {
        htmlContentFn();
    }
});

onMounted(async () => {
    if (props.htmlContent.indexOf('<video') > -1) {
        if (count.value > 0) {
            for (let idx = 0; idx < count.value; idx++) {
                initPlayer({}, idx);
            }
        }
    }
});

onBeforeUnmount(() => {
    if (myPlayerList.value) {
        myPlayerList.value.forEach((ele) => {
            ele.dispose();
        });
    }
});

let count = ref(0);
function htmlContentFn() {
    var index = props.htmlContent.indexOf('<video');
    while (index !== -1) {
        count.value++;
        index = props.htmlContent.indexOf('<video', index + 1);
    }
    if (count.value > 0) {
        let str = props.htmlContent;
        for (let idx = 0; idx < count.value; idx++) {
            let videoItem =
                '<video id="media-player-' +
                idx +
                '" class="video-js vjs-default-skin vjs-big-play-centered" webkit-playsinline playsinline x5-playsinline x5-video-player-fullscreen="true"' +
                ' x5-video-player-type="h5" x5-video-orientation="portrait" preload="metadata">' +
                ' <source src="' +
                getUrl(str) +
                '" type="application/x-mpegURL"></video>';
            let a = str.indexOf('<video src=');
            let b = str.indexOf('</video>', a);
            let d = str.substring(a, b + 8);
            str = str.replace(d, videoItem);
        }
        content.value = str;
    }
}
function getUrl(value: string) {
    if (Utils.trim(value) != '') {
        let data = '';
        // @ts-ignore
        value.replace(/<video [^>]*src=['"]([^'"]+)[^>]*>/, function (match, capture) {
            data = capture;
        });
        return data;
    }
    return '';
}
/**
 * 初始化播放器
 *
 * @param playOptions {@link Object}
 */
const initPlayer = (playOptions?: any, idx?: any): void => {
    // 播放器配置
    let options = getDefaultOptions();
    Object.assign(options, playOptions);
    // 倍速播放
    options.playbackRates = [0.5, 1, 1.5, 2, 3, 5];
    console.log('===================options==========', options);
    // 判断 m3u8 视频
    if (options.sources && options.sources.length > 0) {
        options.sources.map((s: any) => {
            try {
                if (!Utils.isNullOrEmpty(s.src) && s.src.search(/.m3u8/) > -1) {
                    s.type = 'application/x-mpegURL';
                }
            } catch (e) {
                log(e);
            }
        });
    }
    // -------------------------------------------------------------------------
    // 初始化及初始化后的操作
    // -------------------------------------------------------------------------
    let $player;
    $player = videoJs('media-player-' + idx, options, () => {
        //循环播放
        //@ts-ignore
        videoJs.registerComponent('LoopBtn', LoopBtn);
        const touchOptions = {};
        const controlBarIndex = $player.controlBar.children().length - 1;
        $player.controlBar.addChild('LoopBtn', touchOptions, controlBarIndex);

        //全屏横屏播放
        // @ts-ignore
        videoJs.registerComponent('FullscreenBtn', FullscreenBtn);
        const Index = $player.controlBar.children().length - 1;
        $player.controlBar.addChild('FullscreenBtn', touchOptions, Index);

        // 移除画中画按钮
        $player.controlBar.removeChild('pictureInPictureToggle');
    });
    myPlayerList.value.push($player);
};

/**
 * 播放器默认配置
 *
 * @return {@link Object}
 */
const getDefaultOptions = (): any => {
    return {
        autoplay: false, // 自动播放:true/false
        controls: true, // 是否显示底部控制栏:true/false
        playbackRates: [], // 默认不允许倍速播放
        width: 800, // 视频播放器显示的宽度
        height: 500, // 视频播放器显示的高度
        loop: false, // 是否循环播放:true/false
        muted: false, // 设置默认播放音频:true/false
        poster: '', // 视频开始播放前显示的图像的URL。这通常是一个帧的视频或自定义标题屏幕。一旦用户点击"播放"图像就会消失
        src: 'https://vjs.zencdn.net/v/oceans.mp4', // 要嵌入的视频资源url,The source URL to a video source to embed.
        // techOrder: ['html5', 'flash'], // 使用播放器的顺序,下面的示例说明优先使用html5播放器,如果不支持将使用flash
        notSupportedMessage: false, // 是否允许重写默认的消息显示出来时,video.js无法播放媒体源
        plugins: {}, // 插件
        sources: [{ src: 'https://vjs.zencdn.net/v/oceans.mp4', type: 'video/mp4' }], // 资源文件等价于html中的形式source标签
        aspectRatio: '16:9', // 将播放器置于流体模式下,计算播放器动态大小时使用该值。
        // 该值应该是比用冒号隔开的两个数字(如"16:9"或"4:3")。
        fluid: false, // 是否自适应布局,播放器将会有流体体积。换句话说,它将缩放以适应容器。
        // 如果<video>标签有"vjs-fluid"样式时,这个选项会自动设置为true。
        preload: 'metadata', // 建议浏览器是否在加载<video>元素时开始下载视频数据。(预加载)
        // auto:立即加载视频(如果浏览器支持它)。一些移动设备将不会预加载视频,以保护用户的带宽/数据使用率。这就是为什么这个值被称为"自动",而不是更确凿的东西
        // metadata:只加载视频的元数据,其中包括视频的持续时间和尺寸等信息。有时,元数据会通过下载几帧视频来加载。
        // none,
        controlBar: {
            fullscreenToggle: false,
        },
    };
};
function jumpLink(event: any) {
    if (event.target.nodeName.toUpperCase() === 'IMG') {
        // 预览图片
        previewImg = true;
        showImagePreview({
            images: [event.target.currentSrc],
            onClose() {
                previewImg = false;
                emits('onImageOpen', previewImg);
            },
        });
        emits('onImageOpen', previewImg);
    } else if (event.target.nodeName.toUpperCase() === 'A') {
    // 处理文本内外链接,内连接跳转本系统,外链接如www.baidu.com
        const targetData: string = event.target.getAttribute('data').replace(/'/g, '"');
        const data = JSON.parse(targetData);
        if (data && data.data && typeof data.data === 'string') {
            const url: string = data.data;
            UtilService.openBrowser(url);
        } else if (data && data.data && typeof data.data === 'object') {
            const activityId: string = data.data.acinvityId;
            const type = data.data.type;
            const title: string = data.data.title;
            const sectionNum: number = data.data.sectionNum;
            if (!showing) {
                showing = true;
                UtilService.goToActivity(activityId, type, title, sectionNum).then(() => (showing = false));
            }
        }
    }
}
</script>

<style lang="scss" scoped>
.html-content {
    width: 100%;
    padding: 0;
    margin: 0;
    font-weight: normal;

    video {
        width: inherit;
        max-width: 100%;
        height: auto;
    }

    audio {
        width: inherit;
        max-width: 100%;
    }

    :deep(p) {
        padding: 0;
        //margin: 0 0 0.5rem;
        word-break: break-all;
        font-size: 0.938rem;
        font-weight: normal;
    }

    a.lms-link {
        color: var(--ion-color-primary);
    }

    a.lms-url {
        color: var(--ion-color-primary);
    }

    img {
        border-radius: 0.7rem;
        width: 5.75rem;
        height: 5.75rem;
        object-fit: cover;
    }
}

:deep(.vjs-loop-btn) {
    img {
        width: 1.1rem !important;
        height: 1.1rem !important;
        margin: auto;
    }
}

:deep(.vjs-fullscreen-btn) {
    img {
        width: 0.87rem !important;
        height: 0.87rem !important;
        margin: auto;
    }
}

//全屏样式
:deep(.vjs-fullscreen-enter) {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 999;
    transform: rotate(90deg) translateY(-100vw) translateZ(100px);
    transform-origin: 0 0;
    transition: transform 0.2s;
    height: 100vw !important;
    width: 100vh !important;
    max-width: 100vh !important;

    .vjs-fullscreen-btn {
        margin-right: 1.25rem;
    }
}
</style>

解析:

c 复制代码
// 富文本样式css
import 'quill/dist/quill.core.css';
import 'quill/dist/quill.snow.css';
import 'quill/dist/quill.bubble.css';
//videojs
import videoJs from 'video.js';
import 'video.js/dist/video-js.min.css';
//循环播放 和 全屏播放 按钮组件
import LoopBtn from '@mobile/components/player/loopBtn';
import FullscreenBtn from '@mobile/components/player/fullscreenBtn';

离开页面,重新进入报错

解决 VIDEOJS: WARN: Player "media-player-0" is already initialised. Options will not be applied.

c 复制代码
onBeforeUnmount(() => {
    if (myPlayerList.value) {
        myPlayerList.value.forEach((ele) => {
            ele.dispose();
        });
    }
});

通过videoJs注册组件

videoJs.registerComponent('FullscreenBtn', FullscreenBtn);

videoJs.registerComponent('LoopBtn', LoopBtn);

附带:

循环播放组件

c 复制代码
//@ts-ignore
import videoJs, { VideoJsPlayer } from 'video.js';
import unLoop from '@mobile/assets/icon/unLoop.svg';
import loop from '@mobile/assets/icon/loop.svg';


// 这里直接获取的是Button组件,因为需要的其实就是控制条的一个button
const VideoButton = videoJs.getComponent('Button');

//@ts-ignore
class loopBtn extends VideoButton {
    player: any;
    loop: boolean = false;
    icon: HTMLImageElement;

    constructor(player: any, options: Object) {
        super(player, options);
        //@ts-ignore
        this.addClass('vjs-control');
        //@ts-ignore
        this.addClass('vjs-button');
        //@ts-ignore
        this.addClass('vjs-loop-btn');
        this.player = player;
    }

    // 自定义按钮的dom结构,createEl会在使用组件的时候自动调用
    createEl(tag: string, props?: any, attributes?: any): HTMLButtonElement {
        const nextBtnEl: HTMLButtonElement = document.createElement('button');
        this.icon = document.createElement('img');
        this.icon.src = unLoop;
        // 将按钮图标放到按钮中
        videoJs.dom.appendContent(nextBtnEl, this.icon);
        return nextBtnEl;
    }

    handleClick(): void {
        this.loop = !this.loop;
        if (this.loop) {
            //@ts-ignore
            this.icon.src = loop;
            this.player.loop(true);
        } else {
            //@ts-ignore
            this.icon.src = unLoop;
            this.player.loop(false);
        }
    }
}

export default loopBtn;

全屏播放组件

c 复制代码
//@ts-ignore
import videoJs, { VideoJsPlayer } from 'video.js';
import unFullscreen from '@mobile/assets/icon/unFullscreen.svg';
import fullscreen from '@mobile/assets/icon/fullscreen.svg';

// 这里直接获取的是Button组件
const VideoButton = videoJs.getComponent('Button');

//@ts-ignore
class FullscreenBtn extends VideoButton {
    player: any;
    isFullscreen: boolean = false;
    icon: HTMLImageElement;
    that: any;
    video: any;

    constructor(player: any, options: Object) {
        super(player, options);
        //@ts-ignore
        this.addClass('vjs-control');
        //@ts-ignore
        this.addClass('vjs-button');
        //@ts-ignore
        this.addClass('vjs-fullscreen-btn');
        this.player = player;
        this.video = this.player.el_;
        this.that = this;
    }

    // 自定义按钮的dom结构,createEl会在使用组件的时候自动调用
    createEl(tag: string, props?: any, attributes?: any): HTMLButtonElement {
        const nextBtnEl: HTMLButtonElement = document.createElement('button');
        this.icon = document.createElement('img');
        this.icon.src = fullscreen;
        // 将按钮图标放到按钮中
        videoJs.dom.appendContent(nextBtnEl, this.icon);
        return nextBtnEl;
    }

    handleClick(): void {
        this.isFullscreen = !this.isFullscreen;
        if (this.isFullscreen) {
            //@ts-ignore
            this.icon.src = unFullscreen;
            // if (this.video.parentNode.requestFullscreen) {
            //     this.video.parentNode.requestFullscreen();
            // }
            // if (document.documentElement.requestFullscreen) {
            //     document.documentElement.requestFullscreen();
            // }
            this.video.classList.add('vjs-fullscreen-enter');
        } else {
            //@ts-ignore
            this.icon.src = fullscreen;
            // if (document.fullscreenElement) {
            //     document.exitFullscreen().then();
            // }
            this.video.classList.remove('vjs-fullscreen-enter');
        }
    }
}

export default FullscreenBtn;

总结:

难点1: 原生video全屏无水印,自定义video时水印放于同级父下,ios视频全屏无水印使用伪全屏适配。

难点2: 解决v-html富文本中包含多个视频时,替换掉原生video标签内容,动态渲染内容。

难点3: 离开页面重新进入,解决报错xxx is already initialised问题。

难点4: 多视频播放问题,在【初始化播放器】时let $player;重新赋值。

相关推荐
青灯文案16 分钟前
前端 HTTP 请求由 Nginx 反向代理和 API 网关到后端服务的流程
前端·nginx·http
m0_7482548811 分钟前
DataX3.0+DataX-Web部署分布式可视化ETL系统
前端·分布式·etl
ZJ_.22 分钟前
WPSJS:让 WPS 办公与 JavaScript 完美联动
开发语言·前端·javascript·vscode·ecmascript·wps
GIS开发特训营27 分钟前
Vue零基础教程|从前端框架到GIS开发系列课程(七)响应式系统介绍
前端·vue.js·前端框架·gis开发·webgis·三维gis
Cachel wood1 小时前
python round四舍五入和decimal库精确四舍五入
java·linux·前端·数据库·vue.js·python·前端框架
学代码的小前端1 小时前
0基础学前端-----CSS DAY9
前端·css
joan_851 小时前
layui表格templet图片渲染--模板字符串和字符串拼接
前端·javascript·layui
m0_748236111 小时前
Calcite Web 项目常见问题解决方案
开发语言·前端·rust
Watermelo6172 小时前
详解js柯里化原理及用法,探究柯里化在Redux Selector 的场景模拟、构建复杂的数据流管道、优化深度嵌套函数中的精妙应用
开发语言·前端·javascript·算法·数据挖掘·数据分析·ecmascript
m0_748248942 小时前
HTML5系列(11)-- Web 无障碍开发指南
前端·html·html5