记录: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;重新赋值。

相关推荐
别拿曾经看以后~33 分钟前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
我要洋人死35 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人1 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人1 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR1 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香1 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596931 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai1 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_9151 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼2 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍