如何使用ExoPlayer在播放视频的同时下载视频?

18

背景

我正在开发一个可以播放一些短视频的应用程序。

我想避免用户每次播放视频时都访问互联网,以使其更快并降低数据使用量。

问题

目前我只找到了如何播放或下载(它只是一个文件,所以我可以像下载其他文件一样下载它)。

以下是从URL播放视频文件的代码(样例可在此处找到):

gradle

...
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'com.google.android.exoplayer:exoplayer-core:2.8.4'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.8.4'
...

清单文件

<manifest package="com.example.user.myapplication" xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

    <application
        android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"
        tools:ignore="AllowBackup,GoogleAppIndexingWarning">
        <activity
            android:name=".MainActivity" android:screenOrientation="portrait">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>

                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>

</manifest>

activity_main.xml

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="match_parent" tools:context=".MainActivity">

    <com.google.android.exoplayer2.ui.PlayerView
        android:id="@+id/playerView" android:layout_width="match_parent" android:layout_height="match_parent"
        app:resize_mode="zoom"/>
</FrameLayout>

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private var player: SimpleExoPlayer? = null
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun onStart() {
        super.onStart()
        playVideo()
    }

    private fun playVideo() {
        player = ExoPlayerFactory.newSimpleInstance(this@MainActivity, DefaultTrackSelector())
        playerView.player = player
        player!!.addVideoListener(object : VideoListener {
            override fun onVideoSizeChanged(width: Int, height: Int, unappliedRotationDegrees: Int, pixelWidthHeightRatio: Float) {
            }

            override fun onRenderedFirstFrame() {
                Log.d("appLog", "onRenderedFirstFrame")
            }
        })
        player!!.addListener(object : PlayerEventListener() {
            override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
                super.onPlayerStateChanged(playWhenReady, playbackState)
                when (playbackState) {
                    Player.STATE_READY -> Log.d("appLog", "STATE_READY")
                    Player.STATE_BUFFERING -> Log.d("appLog", "STATE_BUFFERING")
                    Player.STATE_IDLE -> Log.d("appLog", "STATE_IDLE")
                    Player.STATE_ENDED -> Log.d("appLog", "STATE_ENDED")
                }
            }
        })
        player!!.volume = 0f
        player!!.playWhenReady = true
        player!!.repeatMode = Player.REPEAT_MODE_ALL
        player!!.playVideoFromUrl(this@MainActivity, "https://sample-videos.com/video123/mkv/720/big_buck_bunny_720p_1mb.mkv")
    }

    override fun onStop() {
        super.onStop()
        playerView.player = null
        player!!.release()
        player = null
    }


    abstract class PlayerEventListener : Player.EventListener {
        override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters?) {}
        override fun onSeekProcessed() {}
        override fun onTracksChanged(trackGroups: TrackGroupArray?, trackSelections: TrackSelectionArray?) {}
        override fun onPlayerError(error: ExoPlaybackException?) {}
        override fun onLoadingChanged(isLoading: Boolean) {}
        override fun onPositionDiscontinuity(reason: Int) {}
        override fun onRepeatModeChanged(repeatMode: Int) {}
        override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {}
        override fun onTimelineChanged(timeline: Timeline?, manifest: Any?, reason: Int) {}
        override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {}
    }

    companion object {
        @JvmStatic
        fun getUserAgent(context: Context): String {
            val packageManager = context.packageManager
            val info = packageManager.getPackageInfo(context.packageName, 0)
            val appName = info.applicationInfo.loadLabel(packageManager).toString()
            return Util.getUserAgent(context, appName)
        }
    }

    fun SimpleExoPlayer.playVideoFromUri(context: Context, uri: Uri) {
        val dataSourceFactory = DefaultDataSourceFactory(context, MainActivity.getUserAgent(context))
        val mediaSource = ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(uri)
        prepare(mediaSource)
    }


    fun SimpleExoPlayer.playVideoFromUrl(context: Context, url: String) = playVideoFromUri(context, Uri.parse(url))

    fun SimpleExoPlayer.playVideoFile(context: Context, file: File) = playVideoFromUri(context, Uri.fromFile(file))
}

我尝试过的

我已经阅读了文档,并通过询问这里获得了以下链接:

https://medium.com/google-exoplayer/downloading-streams-6d259eec7f95 https://medium.com/google-exoplayer/downloading-adaptive-streams-37191f9776e

所以很遗憾,目前我能想到的唯一解决办法是在另一个线程上下载文件,这将导致设备连接两次,从而使用了两倍的带宽。

问题

  1. 如何在使用ExoPlayer播放视频文件的同时,将其下载到某个文件路径中?
  2. 是否有一种方式可以启用ExoPlayer上的缓存机制(使用磁盘)来实现完全相同的目的?

注意:为了明确。我不想下载文件然后再播放它。


编辑:我找到了一种从API缓存中获取和使用文件的方法(在这里中写了),但似乎这被视为不安全的(在这里写了)。

因此,考虑到ExoPlayer API支持的简单缓存机制,我的当前问题是:

  1. 如果文件已缓存,如何以安全的方式使用它?
  2. 如果文件部分缓存(意味着我们已经下载了一部分),如何继续准备它(而无需实际播放它或等待整个播放完成),直到可以以安全的方式使用它为止?

我为此创建了一个Github存储库这里。您可以尝试一下。


1
我不是Exo的专家。但是有一段时间我在研究它,然后偶然发现了这个。整个想法是使用代理。你可以看看实现方式。 - ADM
1
@ADM 看起来很有前途,但我想知道如何以官方方式或尽可能接近官方的方式实现缓存。我会给你一个赞,因为这可能是我们将要使用的东西(我需要考虑一下,看看它是否确实适合我们的情况),而且你也费心帮忙,所以谢谢。但我希望能在这里看到一个真正的解决方案。 - android developer
是的,不久前我也遇到过这个问题。也许需要在客户端和服务器之间建立连接。我在 Github 网站上找到了一个项目,可能可以解决你的问题。如下所示: AndroidVideoCache - Longalei
2个回答

14
我查看了erdemguven的示例代码(在此处),似乎有一些可行的东西。这基本上就是erdemguven写的,但我将其写入文件而不是字节数组,并创建数据源。我认为,由于ExoPlayer专家erdemguven将此作为访问缓存的正确方式呈现,那么我的修改也是“正确”的,不会违反任何规则。

以下是代码。 getCachedData 是新添加的内容。

class MainActivity : AppCompatActivity(), CacheDataSource.EventListener, TransferListener {

    private var player: SimpleExoPlayer? = null

    companion object {
        // About 10 seconds and 1 meg.
//        const val VIDEO_URL = "https://sample-videos.com/video123/mp4/720/big_buck_bunny_720p_1mb.mp4"

        // About 1 minute and 5.3 megs
        const val VIDEO_URL = "http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4"

        // The full movie about 355 megs.
//        const val VIDEO_URL = "http://distribution.bbb3d.renderfarming.net/video/mp4/bbb_sunflower_1080p_60fps_normal.mp4"

        // Use to download video other than the one you are viewing. See #3 test of the answer.
//        const val VIDEO_URL_LIE = "http://file-examples.com/wp-content/uploads/2017/04/file_example_MP4_480_1_5MG.mp4"

        // No changes in code deleted here.

    //NOTE: I know I shouldn't use an AsyncTask. It's just a sample...
    @SuppressLint("StaticFieldLeak")
    fun tryShareCacheFile() {
        // file is cached and ready to be used
        object : AsyncTask<Void?, Void?, File>() {
            override fun doInBackground(vararg params: Void?): File {
                val tempFile = FilesPaths.FILE_TO_SHARE.getFile(this@MainActivity, true)
                getCachedData(this@MainActivity, cache, VIDEO_URL, tempFile)
                return tempFile
            }

            override fun onPostExecute(result: File) {
                super.onPostExecute(result)
                val intent = prepareIntentForSharingFile(this@MainActivity, result)
                startActivity(intent)
            }
        }.execute()
    }

    private var mTotalBytesToRead = 0L
    private var mBytesReadFromCache: Long = 0
    private var mBytesReadFromNetwork: Long = 0

    @WorkerThread
    fun getCachedData(
        context: Context, myCache: Cache?, url: String, tempfile: File
    ): Boolean {
        var isSuccessful = false
        val myUpstreamDataSource = DefaultHttpDataSourceFactory(ExoPlayerEx.getUserAgent(context)).createDataSource()
        val dataSource = CacheDataSource(
            myCache,
            // If the cache doesn't have the whole content, the missing data will be read from upstream
            myUpstreamDataSource,
            FileDataSource(),
            // Set this to null if you don't want the downloaded data from upstream to be written to cache
            CacheDataSink(myCache, CacheDataSink.DEFAULT_BUFFER_SIZE.toLong()),
            /* flags= */ 0,
            /* eventListener= */ this
        )

        // Listen to the progress of the reads from cache and the network.
        dataSource.addTransferListener(this)

        var outFile: FileOutputStream? = null
        var bytesRead = 0

        // Total bytes read is the sum of these two variables.
        mTotalBytesToRead = C.LENGTH_UNSET.toLong()
        mBytesReadFromCache = 0
        mBytesReadFromNetwork = 0

        try {
            outFile = FileOutputStream(tempfile)
            mTotalBytesToRead = dataSource.open(DataSpec(Uri.parse(url)))
            // Just read from the data source and write to the file.
            val data = ByteArray(1024)

            Log.d("getCachedData", "<<<<Starting fetch...")
            while (bytesRead != C.RESULT_END_OF_INPUT) {
                bytesRead = dataSource.read(data, 0, data.size)
                if (bytesRead != C.RESULT_END_OF_INPUT) {
                    outFile.write(data, 0, bytesRead)
                }
            }
            isSuccessful = true
        } catch (e: IOException) {
            // error processing
        } finally {
            dataSource.close()
            outFile?.flush()
            outFile?.close()
        }

        return isSuccessful
    }

    override fun onCachedBytesRead(cacheSizeBytes: Long, cachedBytesRead: Long) {
        Log.d("onCachedBytesRead", "<<<<Cache read? Yes, (byte read) $cachedBytesRead (cache size) $cacheSizeBytes")
    }

    override fun onCacheIgnored(reason: Int) {
        Log.d("onCacheIgnored", "<<<<Cache ignored. Reason = $reason")
    }

    override fun onTransferInitializing(source: DataSource?, dataSpec: DataSpec?, isNetwork: Boolean) {
        Log.d("TransferListener", "<<<<Initializing isNetwork=$isNetwork")
    }

    override fun onTransferStart(source: DataSource?, dataSpec: DataSpec?, isNetwork: Boolean) {
        Log.d("TransferListener", "<<<<Transfer is starting isNetwork=$isNetwork")
    }

    override fun onTransferEnd(source: DataSource?, dataSpec: DataSpec?, isNetwork: Boolean) {
        reportProgress(0, isNetwork)
        Log.d("TransferListener", "<<<<Transfer has ended isNetwork=$isNetwork")
    }

    override fun onBytesTransferred(
        source: DataSource?,
        dataSpec: DataSpec?,
        isNetwork: Boolean,
        bytesTransferred: Int
    ) {
        // Report progress here.
        if (isNetwork) {
            mBytesReadFromNetwork += bytesTransferred
        } else {
            mBytesReadFromCache += bytesTransferred
        }

        reportProgress(bytesTransferred, isNetwork)
    }

    private fun reportProgress(bytesTransferred: Int, isNetwork: Boolean) {
        val percentComplete =
            100 * (mBytesReadFromNetwork + mBytesReadFromCache).toFloat() / mTotalBytesToRead
        val completed = "%.1f".format(percentComplete)
        Log.d(
            "TransferListener", "<<<<Bytes transferred: $bytesTransferred isNetwork=$isNetwork" +
                    " $completed% completed"
        )
    }

    // No changes below here.
}

以下是我测试这个问题所做的事情,但这并不是详尽无遗的:
  1. 通过FAB在邮件中分享视频。我收到了视频并可以播放它。
  2. 在物理设备上关闭所有网络访问(打开飞行模式),然后通过电子邮件分享视频。当我重新打开网络(关闭飞行模式)时,我收到并能够播放视频。这表明视频必须来自缓存,因为网络不可用。
  3. 更改代码,使得不再从缓存中复制VIDEO_URL,而是指定复制VIDEO_URL_LIE。(应用程序仍然只播放VIDEO_URL。)由于我没有下载VIDEO_URL_LIE的视频,所以该视频不在缓存中,因此应用程序必须从网络获取视频。我成功地通过电子邮件接收到了正确的视频,并能够播放它。这表明如果缓存不可用,应用程序可以访问底层资源。

我绝不是ExoPlayer专家,所以您可能很快就能向我提出任何问题。


以下代码将跟踪读取视频并将其存储在本地文件中的进度。
// Get total bytes if known. This is C.LENGTH_UNSET if the video length is unknown.
totalBytesToRead = dataSource.open(DataSpec(Uri.parse(url)))

// Just read from the data source and write to the file.
val data = ByteArray(1024)
var bytesRead = 0
var totalBytesRead = 0L
while (bytesRead != C.RESULT_END_OF_INPUT) {
    bytesRead = dataSource.read(data, 0, data.size)
    if (bytesRead != C.RESULT_END_OF_INPUT) {
        outFile.write(data, 0, bytesRead)
        if (totalBytesToRead == C.LENGTH_UNSET.toLong()) {
            // Length of video in not known. Do something different here.
        } else {
            totalBytesRead += bytesRead
            Log.d("Progress:", "<<<< Percent read: %.2f".format(totalBytesRead.toFloat() / totalBytesToRead))
        }
    }
}

稍后会检查你的工作,但是你不应该使用cacheSpancachedFile的代码,因为那是我的“糟糕”解决方案... - android developer
@androiddeveloper 这是死代码,我已经将其删除。与此同时,当 myCache 被识别为 SimpleCache 而不仅仅是 CachegetCachedData() 中时,似乎会创建一个独立的缓存。这导致了第一次分享视频时有很大的延迟,因为需要重新获取数据(?不确定)。无论如何,如果在缓存中,现在共享数据不再有延迟。 - Cheticamp
@androiddeveloper 我认为那是危险的部分。根据ojw28 此处所说,“假设我们将媒体作为简单文件进行缓存并不正确。” CacheDataSource隐藏了底层实现的细节,并使对缓存的访问看起来像是一个简单的文件,所以这是“安全的”。这就是我的理解。 - Cheticamp
所以这是一样的吗?我觉得最好避免依赖于听众,而且实际上要传递给另一个听众。你也可能会写得更少,因为不需要编写接口的额外函数...如果你也加入了这个解决方案,那就很棒了,但承诺就是承诺。你获得+1。 - android developer
我如何为这种方法添加暂停/恢复功能? - Vincent Paing
显示剩余15条评论

0

你可以使用ExoPlayer的SimpleCache和LeastRecentlyUsedCacheEvictor来在流媒体时进行缓存。代码应该类似于:

temporaryCache = new SimpleCache(new File(context.getExternalCacheDir(), "player"), new LeastRecentlyUsedCacheEvictor(bytesToCache));
cacheSourceFactory = new CacheDataSourceFactory(temporaryCache, buildDataSourceFactory(), CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR);

这是2个变量的初始化。它如何与播放器类集成?它在我的代码中如何“粘合”?你能否展示一个更完整的代码?我应该在我的代码中哪里添加它?“buildDataSourceFactory”的实现在哪里?编辑:找到了解决方法。很快会发布答案。 - android developer
@deepakkumar 为什么你问我这个问题,而我才是那个提出这个问题的人? - android developer
@androiddeveloper 正如您上面提到的:“找到了方法。很快会发布答案”。 - Deepak Kumar
@deepakkumar 我已经回答过了,但因为其他人发布了更好的答案,我已将其删除,并接受了更好的答案。 - android developer

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接