原生Js实现浏览器播放DASH视频流

最近在实现仿b站的项目时学习到视频流的知识特此来记录一下。

介绍

主流的视频平台使用的Http视频流大部分为DASH流和HLS流,而DASH主要在PC端使用,HLS主要在移动端以及直播方面使用。DASH不能像 HLS 在 Safari 上那样直接通过 <video> 播放,大多数浏览器需要额外的 JavaScript 处理,本文章以b站的视频流为例来实现原生Js处理DASH视频流实现PC浏览器播放。

前提

浏览器需要支持Media Source Extensions,MacOS以及IOS不支持。

正文

一个DASH文件包含一个头部用于存储帧信息以及.m4s文件,通过控制台抓取请求我们可以发现b站的视频通常都是请求.m4s文件获取资源。

通过过滤器筛选出所有请求

Range用于请求头可以告知服务器返回文件的哪一部分,b站视频通过配置Range来将整个资源分片请求,这就是为什么视频加载和跳转很快的原因。服务器响应时会带上Content-Range响应头来表示返回的部分在整个文件中的范围。

这时候我们就可以构建一个MediaSource()对象实例SourceBuffer,通过appendBuffer方法加载资源

代码

mimeCodec 指的是 MIME 类型 + 编解码格式 的字符串,可以从后端返回的视频信息中获得,用于检查浏览器是否支持该编解码格式,确保流式播放时解码正确。

我们可以从视频信息中获取DASH头信息包括initialization和index_range,其中index_range记录了关键帧在文件中的位置,可以通过解析index_range返回的数据来获取整个文件的分片信息。

一个 video 元素可以同时使用多个 SourceBuffer,我们可以同时加载视频流与音频流。

ini 复制代码
    const mimeCodec = `${videosStreamInfo[i].mimeType}; codecs=${videosStreamInfo[i].codecs}`;

    let audioMimeCodec = '';
    let audioStreamUrl = '';
    let audioInitialization = '';
    let audioIndexRange = '';
    if(audioStreamInfo) {
        audioMimeCodec = `${audioStreamInfo.mimeType}; codecs=${audioStreamInfo.codecs}`;
        audioStreamUrl = formatVideoUrl(audioStreamInfo.baseUrl);
        audioInitialization = audioStreamInfo.SegmentBase.Initialization;
        audioIndexRange = audioStreamInfo.SegmentBase.indexRange;
        videoItem.audioLastSegmentIndex = 0;
        videoItem.audioStartRange = 0;
    };

    // 检查浏览器是否支持该类型编码器
    if(MediaSource.isTypeSupported(mimeCodec)) {
        const mediaSource = new MediaSource();
        const videoStreamUrl = formatVideoUrl(videosStreamInfo[i].baseUrl);
        // 在 DASH 方式下,关键帧信息移到了 sidx box 里
        const videoInitialization = videosStreamInfo[i].SegmentBase.Initialization;
        const videoIndexRange = videosStreamInfo[i].SegmentBase.indexRange;

        // 所有信息存在实例上停止播放不会消失
        videoItem.mediaSource = mediaSource;
        // 如果 MediaSource 被关闭(即 mediaSource.readyState 变为 closed 或 ended),它关联的 blob URL 将会失效
        videoItem.videoSrc = URL.createObjectURL(mediaSource);
        videoBlobUrls.value.push(videoItem.videoSrc);
        // 支持编码的格式索引
        videoItem.videoSupportIndex = i;
        videoItem.videoLastSegmentIndex = 0;
        videoItem.videoStartRange = Number(videoIndexRange.split('-')[1]) + 1;
        mediaSource.addEventListener('sourceopen', sourceOpen);

通过index记录当前分片的索引值,在每次加载后自增,再根据索引获取当前关键帧的大小来构造Range就可以持续请求。

ini 复制代码
    async function loadChunk(sourceBuffer: SourceBuffer, videoStreamUrl: string, lastSegments: Segment[], type: string, isJump: boolean = false) {
        if (!sourceBuffer.updating && videoItem.mediaSource.readyState === 'open' && (shouldLoadNextSegment() || isJump)) {
            let lastSegmentIndex;
            let range = '';
            if(type === 'video') {
                lastSegmentIndex = videoItem.videoLastSegmentIndex;
                if(lastSegmentIndex < lastSegments.length) 
                    range = `${videoItem.videoStartRange}-${videoItem.videoStartRange + lastSegments[lastSegmentIndex].referencedSize - 1}`;
            }
            else if(type === 'audio') {
                lastSegmentIndex = videoItem.audioLastSegmentIndex;
                if(lastSegmentIndex < lastSegments.length) 
                    range = `${videoItem.audioStartRange}-${videoItem.audioStartRange + lastSegments[lastSegmentIndex].referencedSize - 1}`;
            }
            else return;
            if(lastSegmentIndex < lastSegments.length) {
                // 加载片段到 SourceBuffer
                const arrayBuffer = await fetchVideoArrayBuffer(videoStreamUrl, range);
                await waitSourceBuffer(sourceBuffer);
                await insertStream(videoRef.value, sourceBuffer, arrayBuffer);
                handleLoadChunk(lastSegments, type);
            }
            else {
                // 当所有片段加载完成后,结束流
                // 无法确定是否会再次播放片段不要调用endOfStream
                // videoItem.mediaSource.endOfStream();
                videoRef.value.removeEventListener('timeupdate', videoItem.handleTimeUpdate);
            };
        };
    };
    function handleLoadChunk(lastSegments: Segment[] = [], type: string): void {
        if(type === 'video') {
            videoItem.videoStartRange = videoItem.videoStartRange + lastSegments[videoItem.videoLastSegmentIndex].referencedSize;
            videoItem.videoLastSegmentIndex++;
        }
        else if(type === 'audio') {
            videoItem.audioStartRange = videoItem.audioStartRange + lastSegments[videoItem.audioLastSegmentIndex].referencedSize;
            videoItem.audioLastSegmentIndex++;
        }
        else return;
    };
    function insertStream(mediaElement: HTMLMediaElement ,sourceBuffer: SourceBuffer, arrayBuffer: ArrayBuffer) {
        return new Promise(resolve => {
            try {
                if (mediaElement.error) {
                    console.error('Media Error:', mediaElement.error);
                    return;
                };
                sourceBuffer.addEventListener('error', (event: Event) => {
                    console.error('append err:', event);
                });
                sourceBuffer.addEventListener('updateend', function updateListener() {
                    sourceBuffer.removeEventListener('updateend', updateListener);
                    resolve(arrayBuffer);
                }, { once: true });
                sourceBuffer.appendBuffer(arrayBuffer);
            } catch(err) {
                console.log('insert err', err);   
            };
        });
    };

可以通过buffered属性获取已缓冲的长度,自定义删除已播放的内容,减少内存占用。

javascript 复制代码
    function clearSourceBuffer(sourceBuffer: SourceBuffer) {
        return new Promise<void>((resolve) => {
            const buffered = sourceBuffer.buffered;
            if (buffered.length > 0) {
                const start = buffered.start(0);
                const end = buffered.end(buffered.length - 1);
                // 不会清除关键元数据
                sourceBuffer.remove(start, end);
                // 完成删除后退出
                sourceBuffer.addEventListener('updateend', () => resolve(), { once: true });
            } else {
                resolve();
            };
        });
    };

以上就是全部内容了,全部代码可在github.com/haodadadada... 拉取,小老哥如果感兴趣可以点个star,小弟万分感谢。第一次发文如果有问题可以在评论区指出。

相关推荐
祈澈菇凉18 分钟前
解释什么是受控组件和非受控组件
前端·javascript·react.js
徐小黑ACG41 分钟前
使用vite新建vue3项目 以及elementui的使用 vite组件问题
前端·javascript·elementui
糕冷小美n1 小时前
Electron打包文件生成.exe文件打开即可使用
前端·javascript·electron
puppy0_01 小时前
【万字长文】前端如何处理计算密集型操作(数据量10w+)
前端·javascript
Sailing1 小时前
递归陷阱:如何优雅地递归获取数据?别让你的微前端卡死!
前端·javascript·面试
前端大卫2 小时前
【Chrome 官方示例】🔥手把手教你解锁 Performace 选项卡
前端·javascript·性能优化
苏州第一深情2 小时前
SpeechSynthesisUtterance文字语音播报, 循环播报, 方法封装多组件使用, 自定义播报音色音量音调
前端·javascript·vue.js
JiangJiang2 小时前
Vue3源码:5个问题带你读懂watch
javascript·vue.js·面试
如此风景2 小时前
TypeScript中的Record
javascript
王小菲2 小时前
深入解析 JavaScript 闭包机制:从作用域到高阶应用
前端·javascript·面试