美文网首页
基于安卓原生做一个视频播放器

基于安卓原生做一个视频播放器

作者: AT方 | 来源:发表于2023-04-22 15:55 被阅读0次

    Android原生自带有一个MediaExtractor,用于音视频数据分离和提取,接来下就基于这个,做一个支持音视频提取的工具类MMExtractor:

    
    class MMExtractor(path: String?) {
    
        /**音视频分离器*/
        private var mExtractor: MediaExtractor? = null
        
        /**音频通道索引*/
        private var mAudioTrack = -1
        
        /**视频通道索引*/
        private var mVideoTrack = -1
        
        /**当前帧时间戳*/
        private var mCurSampleTime: Long = 0
        
        /**开始解码时间点*/
        private var mStartPos: Long = 0
    
        init {
            //【1,初始化】
            mExtractor = MediaExtractor()
            mExtractor?.setDataSource(path)
        }
    
        /**
         * 获取视频格式参数
         */
        fun getVideoFormat(): MediaFormat? {
            //【2.1,获取视频多媒体格式】
            for (i in 0 until mExtractor!!.trackCount) {
                val mediaFormat = mExtractor!!.getTrackFormat(i)
                val mime = mediaFormat.getString(MediaFormat.KEY_MIME)
                if (mime.startsWith("video/")) {
                    mVideoTrack = i
                    break
                }
            }
            return if (mVideoTrack >= 0)
                mExtractor!!.getTrackFormat(mVideoTrack)
            else null
        }
    
        /**
         * 获取音频格式参数
         */
        fun getAudioFormat(): MediaFormat? {
            //【2.2,获取音频频多媒体格式】
            for (i in 0 until mExtractor!!.trackCount) {
                val mediaFormat = mExtractor!!.getTrackFormat(i)
                val mime = mediaFormat.getString(MediaFormat.KEY_MIME)
                if (mime.startsWith("audio/")) {
                    mAudioTrack = i
                    break
                }
            }
            return if (mAudioTrack >= 0) {
                mExtractor!!.getTrackFormat(mAudioTrack)
            } else null
        }
    
        /**
         * 读取视频数据
         */
        fun readBuffer(byteBuffer: ByteBuffer): Int {
            //【3,提取数据】
            byteBuffer.clear()
            selectSourceTrack()
            var readSampleCount = mExtractor!!.readSampleData(byteBuffer, 0)
            if (readSampleCount < 0) {
                return -1
            }
            mCurSampleTime = mExtractor!!.sampleTime
            mExtractor!!.advance()
            return readSampleCount
        }
    
        /**
         * 选择通道
         */
        private fun selectSourceTrack() {
            if (mVideoTrack >= 0) {
                mExtractor!!.selectTrack(mVideoTrack)
            } else if (mAudioTrack >= 0) {
                mExtractor!!.selectTrack(mAudioTrack)
            }
        }
    
        /**
         * Seek到指定位置,并返回实际帧的时间戳
         */
        fun seek(pos: Long): Long {
            mExtractor!!.seekTo(pos, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
            return mExtractor!!.sampleTime
        }
    
        /**
         * 停止读取数据
         */
        fun stop() {
            //【4,释放提取器】
            mExtractor?.release()
            mExtractor = null
        }
    
        fun getVideoTrack(): Int {
            return mVideoTrack
        }
    
        fun getAudioTrack(): Int {
            return mAudioTrack
        }
    
        fun setStartPos(pos: Long) {
            mStartPos = pos
        }
    
        /**
         * 获取当前帧时间
         */
        fun getCurrentTimestamp(): Long {
            return mCurSampleTime
        }
    }
    

    关键部分有5个,做一下简单讲解:

    【1,初始化】
    很简单,两句代码:新建,然后设置音视频文件路径

    mExtractor = MediaExtractor()
    mExtractor?.setDataSource(path)
    

    【2.1/2.2,获取音视频多媒体格式】
    音频和视频是一样的:

    1)遍历视频文件中所有的通道,一般是音频和视频两个通道;
    
    2) 然后获取对应通道的编码格式,判断是否包含"video/"或者"audio/"开头的编码格式;
    

    3)最后通过获取的索引,返回对应的音视频多媒体格式信息。

    【3,提取数据】
    重点看看如何提取数据:

    1)readBuffer(byteBuffer: ByteBuffer)中的参数就是解码器传进来的,用于存放待解码数据的缓冲区。

    2)selectSourceTrack()方法中,根据当前选择的通道(同时只选择一个音/视频通道),调用mExtractor!!.selectTrack(mAudioTrack)将通道切换正确。

    3)然后读取数据:

    var readSampleCount = mExtractor!!.readSampleData(byteBuffer, 0)
    此时,将返回读取到的音视频数据流的大小,小于0表示数据已经读完。

    4)进入下一帧:先记录当前帧的时间戳,然后调用advance进入下一帧,这时读取指针将自动移动到下一帧开头。

    //记录当前帧的时间戳
    mCurSampleTime = mExtractor!!.sampleTime
    //进入下一帧
    mExtractor!!.advance()
    【4,释放提取器】
    客户端退出解码的时候,需要调用stop是否提取器相关资源。

    说明:seek(pos: Long)方法,主要用于跳播,快速将数据定位到指定的播放位置,但是,由于视频中,除了I帧以外,PB帧都需要依赖其他的帧进行解码,所以,通常只能seek到I帧,但是I帧通常和指定的播放位置有一定误差,因此需要指定seek靠近哪个关键帧,有以下三种类型:
    SEEK_TO_PREVIOUS_SYNC:跳播位置的上一个关键帧
    SEEK_TO_NEXT_SYNC:跳播位置的下一个关键帧
    SEEK_TO_CLOSEST_SYNC:距离跳播位置的最近的关键帧
    

    到这里你就可以明白,为什么我们平时在看视频时,拖动进度条释放以后,视频通常会在你释放的位置往前一点

    封装音频和视频提取器
    上面封装的工具中,可以支持音频和视频的数据提取,下面我们将利用这个工具,用于分别提取音频和视频的数据。

    先回顾一下,上篇文章定义的提取器模型:

    interface IExtractor {
    
        fun getFormat(): MediaFormat?
    
        /**
         * 读取音视频数据
         */
        fun readBuffer(byteBuffer: ByteBuffer): Int
    
        /**
         * 获取当前帧时间
         */
        fun getCurrentTimestamp(): Long
    
        /**
         * Seek到指定位置,并返回实际帧的时间戳
         */
        fun seek(pos: Long): Long
    
        fun setStartPos(pos: Long)
    
        /**
         * 停止读取数据
         */
        fun stop()
    }
    

    有了上面封装的工具,一切就变得很简单了,做一个代理转接就行了。

    视频提取器

    class VideoExtractor(path: String): IExtractor {
    
        private val mMediaExtractor = MMExtractor(path)
    
        override fun getFormat(): MediaFormat? {
            return mMediaExtractor.getVideoFormat()
        }
    
        override fun readBuffer(byteBuffer: ByteBuffer): Int {
            return mMediaExtractor.readBuffer(byteBuffer)
        }
    
        override fun getCurrentTimestamp(): Long {
            return mMediaExtractor.getCurrentTimestamp()
        }
    
        override fun seek(pos: Long): Long {
            return mMediaExtractor.seek(pos)
        }
    
        override fun setStartPos(pos: Long) {
            return mMediaExtractor.setStartPos(pos)
        }
    
        override fun stop() {
            mMediaExtractor.stop()
        }
    }
    音频提取器
    class AudioExtractor(path: String): IExtractor {
    
        private val mMediaExtractor = MMExtractor(path)
    
        override fun getFormat(): MediaFormat? {
            return mMediaExtractor.getAudioFormat()
        }
    
        override fun readBuffer(byteBuffer: ByteBuffer): Int {
            return mMediaExtractor.readBuffer(byteBuffer)
        }
    
        override fun getCurrentTimestamp(): Long {
            return mMediaExtractor.getCurrentTimestamp()
        }
    
        override fun seek(pos: Long): Long {
            return mMediaExtractor.seek(pos)
        }
    
        override fun setStartPos(pos: Long) {
            return mMediaExtractor.setStartPos(pos)
        }
    
        override fun stop() {
            mMediaExtractor.stop()
        }
    }
    

    二、视频播放
    我们先来定义一个视频解码器子类,继承BaseDecoder

    class VideoDecoder(path: String,
                       sfv: SurfaceView?,
                       surface: Surface?): BaseDecoder(path) {
        private val TAG = "VideoDecoder"
        
        private val mSurfaceView = sfv
        private var mSurface = surface
        
        override fun check(): Boolean {
            if (mSurfaceView == null && mSurface == null) {
                Log.w(TAG, "SurfaceView和Surface都为空,至少需要一个不为空")
                mStateListener?.decoderError(this, "显示器为空")
                return false
            }
            return true
        }
    
        override fun initExtractor(path: String): IExtractor {
            return VideoExtractor(path)
        }
    
        override fun initSpecParams(format: MediaFormat) {
        }
    
        override fun configCodec(codec: MediaCodec, format: MediaFormat): Boolean {
            if (mSurface != null) {
                codec.configure(format, mSurface , null, 0)
                notifyDecode()
            } else {
                mSurfaceView?.holder?.addCallback(object : SurfaceHolder.Callback2 {
                    override fun surfaceRedrawNeeded(holder: SurfaceHolder) {
                    }
    
                    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
                    }
    
                    override fun surfaceDestroyed(holder: SurfaceHolder) {
                    }
    
                    override fun surfaceCreated(holder: SurfaceHolder) {
                        mSurface = holder.surface
                        configCodec(codec, format)
                    }
                })
    
                return false
            }
            return true
        }
    
        override fun initRender(): Boolean {
            return true
        }
    
        override fun render(outputBuffers: ByteBuffer,
                            bufferInfo: MediaCodec.BufferInfo) {
        }
    
        override fun doneDecode() {
        }
    }
    

    ,定义好了解码流程框架,子类定义就很简单清晰了,只需按部就班,填写基类中预留的虚函数即可。

    检查参数
    可以看到,视频解码支持两种类型渲染表面,一个是SurfaceView,一个Surface。当其实最后都是传递Surface给MediaCodec

    SurfaceView应该是大家比较熟悉的View了,最常使用的就是用来做MediaPlayer的显示。当然也可以绘制图片、动画等。
    Surface应该不是很常用了,这里为了支持后续使用OpenGL来渲染视频,所以预先做了支持。
    生成数据提取器

    override fun initExtractor(path: String): IExtractor {
        return VideoExtractor(path)
    }
    

    配置解码器
    解码器的配置只需一句代码:

    codec.configure(format, mSurface , null, 0)
    

    在BaseDecoder初始化解码器的方法initCodec()中, 调用了configCodec方法后,会进入waitDecode方法,将线程挂起。

    abstract class BaseDecoder(private val mFilePath: String): IDecoder {
        //省略其他
        ......
        
        private fun initCodec(): Boolean {
            try {
                val type = mExtractor!!.getFormat()!!.getString(MediaFormat.KEY_MIME)
                mCodec = MediaCodec.createDecoderByType(type)
                if (!configCodec(mCodec!!, mExtractor!!.getFormat()!!)) {
                    waitDecode()
                }
                mCodec!!.start()
            
                mInputBuffers = mCodec?.inputBuffers
                mOutputBuffers = mCodec?.outputBuffers
            } catch (e: Exception) {
                return false
            }
            return true
        }
    }
    

    就是因为考虑到一个问题,SurfaceView的创建是有一个时间过程的,并非马上可以使用,需要通过CallBack来监听它的状态。

    在surface初始化完毕后,再配置MediaCodec。

    override fun surfaceCreated(holder: SurfaceHolder) {
        mSurface = holder.surface
        configCodec(codec, format)
    }
    

    如果使用OpenGL直接传递surface进来,直接配置MediaCodec即可。

    渲染
    视频的渲染并不需要客户端手动去渲染,只需提供绘制表面surface,调用releaseOutputBuffer,将2个参数设置为true即可。所以,这里也不用在做什么操作了。

    mCodec!!.releaseOutputBuffer(index, true)
    

    三、音频播放
    有了上面视频播放器的基础以后,音频播放器也是分分钟搞定的事了。

    class AudioDecoder(path: String): BaseDecoder(path) {
        /**采样率*/
        private var mSampleRate = -1
        
        /**声音通道数量*/
        private var mChannels = 1
    
        /**PCM采样位数*/
        private var mPCMEncodeBit = AudioFormat.ENCODING_PCM_16BIT
    
        /**音频播放器*/
        private var mAudioTrack: AudioTrack? = null
    
        /**音频数据缓存*/
        private var mAudioOutTempBuf: ShortArray? = null
        
        override fun check(): Boolean {
            return true
        }
    
        override fun initExtractor(path: String): IExtractor {
            return AudioExtractor(path)
        }
    
        override fun initSpecParams(format: MediaFormat) {
            try {
                mChannels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
                mSampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
    
                mPCMEncodeBit = if (format.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
                    format.getInteger(MediaFormat.KEY_PCM_ENCODING)
                } else {
                    //如果没有这个参数,默认为16位采样
                    AudioFormat.ENCODING_PCM_16BIT
                }
            } catch (e: Exception) {
            }
        }
    
        override fun configCodec(codec: MediaCodec, format: MediaFormat): Boolean {
            codec.configure(format, null , null, 0)
            return true
        }
    
        override fun initRender(): Boolean {
            val channel = if (mChannels == 1) {
                //单声道
                AudioFormat.CHANNEL_OUT_MONO
            } else {
                //双声道
                AudioFormat.CHANNEL_OUT_STEREO
            }
    
            //获取最小缓冲区
            val minBufferSize = AudioTrack.getMinBufferSize(mSampleRate, channel, mPCMEncodeBit)
    
            mAudioOutTempBuf = ShortArray(minBufferSize/2)
    
            mAudioTrack = AudioTrack(
                AudioManager.STREAM_MUSIC,//播放类型:音乐
                mSampleRate, //采样率
                channel, //通道
                mPCMEncodeBit, //采样位数
                minBufferSize, //缓冲区大小
                AudioTrack.MODE_STREAM) //播放模式:数据流动态写入,另一种是一次性写入
                
            mAudioTrack!!.play()
            return true
        }
    
        override fun render(outputBuffer: ByteBuffer,
                            bufferInfo: MediaCodec.BufferInfo) {
            if (mAudioOutTempBuf!!.size < bufferInfo.size / 2) {
                mAudioOutTempBuf = ShortArray(bufferInfo.size / 2)
            }
            outputBuffer.position(0)
            outputBuffer.asShortBuffer().get(mAudioOutTempBuf, 0, bufferInfo.size/2)
            mAudioTrack!!.write(mAudioOutTempBuf!!, 0, bufferInfo.size / 2)
        }
    
        override fun doneDecode() {
            mAudioTrack?.stop()
            mAudioTrack?.release()
        }
    }
    

    初始化流程和视频是一样的,不一样的地方有三个:

    1. 初始化解码器
      音频不需要surface,直接传null
    codec.configure(format, null , null, 0)
    
    1. 获取参数不一样
      音频播放需要获取采样率,通道数,采样位数等

    2. 需要初始化一个音频渲染器:AudioTrack
      由于解码出来的数据是PCM数据,所以直接使用AudioTrack播放即可。在initRender()
      中对其进行初始化。

    根据通道数量配置单声道和双声道
    根据采样率、通道数、采样位数计算获取最小缓冲区

    AudioTrack.getMinBufferSize(mSampleRate, channel, mPCMEncodeBit)
    

    创建AudioTrack,并启动

    mAudioTrack = AudioTrack(
                AudioManager.STREAM_MUSIC,//播放类型:音乐
                mSampleRate, //采样率
                channel, //通道
                mPCMEncodeBit, //采样位数
                minBufferSize, //缓冲区大小
                AudioTrack.MODE_STREAM) //播放模式:数据流动态写入,另一种是一次性写入
                
    mAudioTrack!!.play()
    
    1. 手动渲染音频数据,实现播放
      最后就是将解码出来的数据写入AudioTrack,实现播放。

    有一点注意的点是,需要把解码数据由ByteBuffer类型转换为ShortBuffer,这时Short数据类型的长度要减半。

    四、调用并播放
    以上,基本实现了音视频的播放流程,如无意外,在页面上调用以上音视频解码器,就可以实现播放了。

    简单看下页面和相关调用。

    main_activity.xml

    <?xml version="1.0" encoding="utf-8"?>
    <android.support.constraint.ConstraintLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:tools="http://schemas.android.com/tools"
            xmlns:app="http://schemas.android.com/apk/res-auto"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".MainActivity">
        <SurfaceView android:id="@+id/sfv"
                     app:layout_constraintTop_toTopOf="parent"
                     android:layout_width="match_parent"
                     android:layout_height="200dp"/>
    </android.support.constraint.ConstraintLayout>
    
    

    MainActivity.kt

    class MainActivity : AppCompatActivity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
            initPlayer()
        }
    
        private fun initPlayer() {
            val path = Environment.getExternalStorageDirectory().absolutePath + "/mvtest.mp4"
            
            //创建线程池
            val threadPool = Executors.newFixedThreadPool(2)
            
            //创建视频解码器
            val videoDecoder = VideoDecoder(path, sfv, null)
            threadPool.execute(videoDecoder)
    
            //创建音频解码器
            val audioDecoder = AudioDecoder(path)
            threadPool.execute(audioDecoder)
            
            //开启播放
            videoDecoder.goOn()
            audioDecoder.goOn()
        }
    }
    

    至此,基本上实现音视频的解码和播放。但是如果你真正把代码跑起来的话,你会发现:视频和音频为什么不同步啊,视频就像倍速播放一样,一下就播完了,但是音频却很正常。

    这就要引出下一个不可避免的问题了,那就是音视频同步。

    五、音视频同步

    同步信号来源

    由于视频和音频是两个独立的任务在运行,视频和音频的解码速度也不一样,解码出来的数据也不一定马上就可以显示出来。

    在第一篇文章的时候有说过,解码有两个重要的时间参数:PTS和DTS,分别用于表示渲染的时间和解码时间,这里就需要用到PTS。

    播放器中一般存在三个时间,音频的时间,视频的时间,还有另外一个就是系统时间。这样可以用来实现同步的时间源就有三个:

    视频时间戳
    音频时间戳
    外部时间戳
    
    视频PTS

    通常情况下,由于人类对声音比较敏感,并且视频解码的PTS通常不是连续,而音频的PTS是比较连续的,如果以视频为同步信号源的话,基本上声音都会出现异常,而画面的播放也会像倍速播放一样。

    音频PTS

    那么剩下的两个选择中,以音频的PTS作为同步源,让画面适配音频是比较不错的一种选择。

    但是这里不采用,而是使用系统时间作为同步信号源。因为如果以音频PTS作为同步源的话,需要比较复杂的同步机制,音频和视频两者之间也有比较多的耦合。

    系统时间

    而系统时间作为统一信号源则非常适合,音视频彼此独立互不干扰,同时又可以保证基本一致。

    实现音视频同步

    要实现音视频之间的同步,这里需要考虑的有两个点:

    1. 比对

    在解码数据出来以后,检查PTS时间戳和当前系统流过的时间差距,快则延时,慢则直接播放

    1. 矫正

    在进入暂停或解码结束,重新恢复播放时,需要将系统流过的时间做一下矫正,将暂停的时间减去,恢复真正的流逝时间,即已播放时间。

    重新看回BaseDecoder解码流程:

    abstract class BaseDecoder(private val mFilePath: String): IDecoder {
        //省略其他
        ......
        
        /**
         * 开始解码时间,用于音视频同步
         */
        private var mStartTimeForSync = -1L
    
        final override fun run() {
            if (mState == DecodeState.STOP) {
                mState = DecodeState.START
            }
            mStateListener?.decoderPrepare(this)
    
            //【解码步骤:1. 初始化,并启动解码器】
            if (!init()) return
    
            Log.i(TAG, "开始解码")
    
            while (mIsRunning) {
                if (mState != DecodeState.START &&
                    mState != DecodeState.DECODING &&
                    mState != DecodeState.SEEKING) {
                    Log.i(TAG, "进入等待:$mState")
                    
                    waitDecode()
                    
                    // ---------【同步时间矫正】-------------
                    //恢复同步的起始时间,即去除等待流失的时间
                    mStartTimeForSync = System.currentTimeMillis() - getCurTimeStamp()
                }
    
                if (!mIsRunning ||
                    mState == DecodeState.STOP) {
                    mIsRunning = false
                    break
                }
    
                if (mStartTimeForSync == -1L) {
                    mStartTimeForSync = System.currentTimeMillis()
                }
    
                //如果数据没有解码完毕,将数据推入解码器解码
                if (!mIsEOS) {
                    //【解码步骤:2. 见数据压入解码器输入缓冲】
                    mIsEOS = pushBufferToDecoder()
                }
    
                //【解码步骤:3. 将解码好的数据从缓冲区拉取出来】
                val index = pullBufferFromDecoder()
                if (index >= 0) {
                    // ---------【音视频同步】-------------
                    if (mState == DecodeState.DECODING) {
                        sleepRender()
                    }
                    //【解码步骤:4. 渲染】
                    render(mOutputBuffers!![index], mBufferInfo)
                    //【解码步骤:5. 释放输出缓冲】
                    mCodec!!.releaseOutputBuffer(index, true)
                    if (mState == DecodeState.START) {
                        mState = DecodeState.PAUSE
                    }
                }
                //【解码步骤:6. 判断解码是否完成】
                if (mBufferInfo.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM) {
                    Log.i(TAG, "解码结束")
                    mState = DecodeState.FINISH
                    mStateListener?.decoderFinish(this)
                }
            }
            doneDecode()
            release()
        }
    }
    

    在不考虑暂停、恢复的情况下,什么时候进行时间同步呢?
    答案是:数据解码出来以后,渲染之前。

    解码器进入解码状态以后,来到【解码步骤:3. 将解码好的数据从缓冲区拉取出来】,这时如果数据是有效的,那么进入比对。

    // ---------【音视频同步】-------------
    final override fun run() {
        
        //......
        
        //【解码步骤:3. 将解码好的数据从缓冲区拉取出来】
        val index = pullBufferFromDecoder()
        if (index >= 0) {
            // ---------【音视频同步】-------------
            if (mState == DecodeState.DECODING) {
                sleepRender()
            }
            //【解码步骤:4. 渲染】
            render(mOutputBuffers!![index], mBufferInfo)
            //【解码步骤:5. 释放输出缓冲】
            mCodec!!.releaseOutputBuffer(index, true)
            if (mState == DecodeState.START) {
                mState = DecodeState.PAUSE
            }
        }
        
        //......
    }
    
    private fun sleepRender() {
        val passTime = System.currentTimeMillis() - mStartTimeForSync
        val curTime = getCurTimeStamp()
        if (curTime > passTime) {
            Thread.sleep(curTime - passTime)
        }
    }
    
    override fun getCurTimeStamp(): Long {
        return mBufferInfo.presentationTimeUs / 1000
    }
    

    同步的原理如下:

    进入解码前,获取当前系统时间,存放在mStartTimeForSync,一帧数据解码出来以后,计算当前系统时间和mStartTimeForSync的距离,也就是已经播放的时间,如果当前帧的PTS大于流失的时间,进入sleep,否则直接渲染。

    考虑暂停情况下的时间矫正
    在进入暂停以后,由于系统时间一直在走,而mStartTimeForSync并没有随着系统时间累加,所以当恢复播放以后,重新将mStartTimeForSync加上这段暂停的时间段。

    只不过计算方法有多种:

    一种是记录暂停的时间,恢复时用系统时间减去暂停时间,就是暂停的时间段,然后用mStartTimeForSync加上这段暂停的时间段,就是新的mStartTimeForSync;

    另一个种是用恢复播放时的系统时间,减去当前正要播放的帧的PTS,得出的值就是mStartTimeForSync。

    这里采用第二种

    if (mState != DecodeState.START &&
    mState != DecodeState.DECODING &&
    mState != DecodeState.SEEKING) {
    Log.i(TAG, "进入等待:$mState")

    waitDecode()
    
    // ---------【同步时间矫正】-------------
    //恢复同步的起始时间,即去除等待流失的时间
    mStartTimeForSync = System.currentTimeMillis() - getCurTimeStamp()
    

    }
    至此,从解码到播放,再到音视频同步,一个简单的播放器就做完了。

    相关文章

      网友评论

          本文标题:基于安卓原生做一个视频播放器

          本文链接:https://www.haomeiwen.com/subject/irbsjdtx.html