Media3在线本地视频播放器

Media3在线本地视频播放器

settings.gradle.kts镜像仓库

scss 复制代码
maven { setUrl("https://maven.aliyun.com/repository/public") }

maven {
    setUrl("https://maven.aliyun.com/repository/jcenter")
}
maven {
    setUrl("https://maven.aliyun.com/repository/central")
}
maven {
    setUrl("https://maven.aliyun.com/repository/google")
}

maven { setUrl("https://mirrors.tencent.com/nexus/repository/maven-public/") }
maven {
    setUrl("https://artifact.bytedance.com/repository/Volcengine/")
}
maven {
    setUrl("https://developer.huawei.com/repo/")
}
maven { setUrl("https://jitpack.io") }

maven { setUrl("https://s01.oss.sonatype.org/content/groups/public") }

gradle-wrapper.properties腾讯云快速构建gradle插件

ini 复制代码
distributionUrl=https://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip

res/xml/network_security_config.xml配置网络,添加到application

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <!-- 网络配置,允许明文通信 -->
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>
ini 复制代码
android:networkSecurityConfig="@xml/network_security_config"

AndroidManifest.xml配置网络权限

ini 复制代码
<uses-permission android:name="android.permission.INTERNET" />
ini 复制代码
<activity
    android:name=".VideoPlayerActivity"
    android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout|uiMode"
    android:exported="false"
    android:launchMode="singleTask"
    android:turnScreenOn="true" />

build.gradle.ktsdependencies下集成视频网络框架库

scss 复制代码
implementation("androidx.media3:media3-exoplayer:1.4.0")
implementation("androidx.media3:media3-ui:1.4.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

activity_video_player.xml添加视频播放组件

ini 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/black"
    android:fitsSystemWindows="true"
    tools:context=".VideoPlayerActivity">
    <androidx.media3.ui.PlayerView
        android:id="@+id/playerView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:keepScreenOn="true"
        app:auto_show="false"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:resize_mode="fit"
        app:show_buffering="when_playing" />
</androidx.constraintlayout.widget.ConstraintLayout>

VideoPlayerActivity创建视频播放活动界面

kotlin 复制代码
package cn.nio.media3videodemo

import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView

class VideoPlayerActivity : AppCompatActivity() {
    private var player: ExoPlayer? = null
    private var videoUrl: String? = null
    private lateinit var playerView: PlayerView
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_video_player)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, 0, systemBars.right, systemBars.bottom)
            insets
        }
        playerView = findViewById<PlayerView>(R.id.playerView)
        val videoUrl = intent.getStringExtra(VIDEO_URL)
        videoUrl?.let {
            initializePlayer(it)
        }
    }

    private fun initializePlayer(videoUrl: String) {
        // 创建ExoPlayer实例
        try {
            player = ExoPlayer.Builder(this).build()
            // 将播放器与视图绑定
            playerView.player = player
            // 创建媒体项
            val mediaItem = MediaItem.fromUri(videoUrl)
            // 设置媒体项并准备播放
            player?.setMediaItem(mediaItem)
            player?.prepare()
            // 开始播放
            player?.playWhenReady = true
        } catch (e: Exception) {
            Toast.makeText(this, e.message ?: "视频播放失败", Toast.LENGTH_SHORT).show()
        }
    }

    // 释放播放器资源
    private fun releasePlayer() {
        player?.release()
        player = null
    }

    // 生命周期管理
    override fun onStart() {
        super.onStart()
        if (player == null) {
            videoUrl?.let {
                initializePlayer(it)
            }

        }
    }

    override fun onPause() {
        super.onPause()
        player?.playWhenReady = false
    }

    override fun onStop() {
        super.onStop()
        releasePlayer()
    }

    override fun onDestroy() {
        super.onDestroy()
        releasePlayer()
    }

    companion object {
        const val VIDEO_URL = "video_url"
        fun start(context: AppCompatActivity, videoUrl: String) {
            val intent = Intent(context, VideoPlayerActivity::class.java)
            intent.putExtra(VIDEO_URL, videoUrl)
            context.startActivity(intent)
        }
    }
}

VideoDownloader视频下载

借助okhttp,下载视频到应用内部缓存,下载到外置存储,复制文件到系统Movies目录,使用系统自带播放器播放视频

scss 复制代码
package cn.nio.media3videodemo

import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream

/**
 * @desc 下载视频到本地
 * @Author Developer
 */
class VideoDownloader(private val context: Context) {
    private val client = OkHttpClient()

    /**
     * 下载视频到应用内部存储 不需要文件权限
     * @param url 视频下载地址
     * @param fileName 保存的文件名
     * @param listener 下载监听器
     */
    suspend fun downloadVideo(
        url: String,
        fileName: String,
        listener: DownloadListener,
    ) = withContext(Dispatchers.IO) {
        val request = Request.Builder()
            .url(url)
            .build()

        try {
            client.newCall(request).execute().use { response ->
                if (!response.isSuccessful) {
                    withContext(Dispatchers.Main) {
                        listener.onFailure("下载失败: ${response.code}")
                    }
                    return@withContext
                }

                val body = response.body ?: run {
                    withContext(Dispatchers.Main) {
                        listener.onFailure("没有下载内容")
                    }
                    return@withContext
                }

                // 获取应用内部存储的文件目录
                val fileDir = context.filesDir
                val videoFile = File(fileDir, fileName)

                // 写入文件
                val inputStream = body.byteStream()
                val outputStream = FileOutputStream(videoFile)
                val totalBytes = body.contentLength()
                var downloadedBytes = 0L
                val buffer = ByteArray(8192)
                var bytesRead: Int

                while (inputStream.read(buffer).also { bytesRead = it } != -1) {
                    outputStream.write(buffer, 0, bytesRead)
                    downloadedBytes += bytesRead

                    // 计算并回调进度
                    if (totalBytes > 0) {
                        val progress = (downloadedBytes * 100 / totalBytes).toInt()
                        withContext(Dispatchers.Main) {
                            listener.onProgress(progress)
                        }
                    }
                }

                outputStream.flush()
                outputStream.close()
                inputStream.close()

                // 下载完成回调
                withContext(Dispatchers.Main) {
                    listener.onSuccess(videoFile.absolutePath)
                }
            }
        } catch (e: IOException) {
            withContext(Dispatchers.Main) {
                listener.onFailure("下载出错: ${e.message ?: "未知错误"}")
            }
        }
    }

    /**
     * 下载视频到公共Movies文件夹 需要文件权限
     * @param url 视频下载地址
     * @param fileName 保存的文件名(带扩展名)
     * @param listener 下载监听器
     */
    suspend fun downloadVideoToPublicFolder(
        url: String,
        fileName: String,
        listener: DownloadListener,
    ) = withContext(Dispatchers.IO) {
        val request = Request.Builder()
            .url(url)
            .build()

        try {
            client.newCall(request).execute().use { response ->
                if (!response.isSuccessful) {
                    withContext(Dispatchers.Main) {
                        listener.onFailure("下载失败: ${response.code}")
                    }
                    return@withContext
                }

                val body = response.body ?: run {
                    withContext(Dispatchers.Main) {
                        listener.onFailure("没有下载内容")
                    }
                    return@withContext
                }

                // 根据Android版本获取输出流(直接写入公共文件夹)
                val outputStream = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    // Android 10及以上使用MediaStore
                    val contentValues = ContentValues().apply {
                        put(MediaStore.Video.Media.DISPLAY_NAME, fileName)
                        put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
                        put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES)
                        put(MediaStore.Video.Media.IS_PENDING, 1)
                    }

                    val uri = context.contentResolver.insert(
                        MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
                        contentValues
                    ) ?: run {
                        withContext(Dispatchers.Main) {
                            listener.onFailure("无法创建文件")
                        }
                        return@withContext
                    }

                    // 保存uri用于后续更新状态
                    val resultUri = uri

                    // 获取输出流
                    val os = context.contentResolver.openOutputStream(uri) ?: run {
                        withContext(Dispatchers.Main) {
                            listener.onFailure("无法打开输出流")
                        }
                        // 清理未成功创建的文件
                        context.contentResolver.delete(uri, null, null)
                        return@withContext
                    }

                    // 包装输出流,添加完成后的回调处理
                    object : BufferedOutputStream(os) {
                        override fun close() {
                            super.close()
                            // 完成文件创建,更新状态
                            contentValues.clear()
                            contentValues.put(MediaStore.Video.Media.IS_PENDING, 0)
                            context.contentResolver.update(resultUri, contentValues, null, null)
                        }
                    }
                } else {
                    // Android 10以下直接操作文件
                    val moviesDir =
                        Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
                    if (!moviesDir.exists()) {
                        moviesDir.mkdirs()
                    }

                    val targetFile = File(moviesDir, fileName)
                    FileOutputStream(targetFile).buffered()
                }

                // 写入文件
                val inputStream = body.byteStream().buffered()
                val totalBytes = body.contentLength()
                var downloadedBytes = 0L
                val buffer = ByteArray(8192)
                var bytesRead: Int

                while (inputStream.read(buffer).also { bytesRead = it } != -1) {
                    outputStream.write(buffer, 0, bytesRead)
                    downloadedBytes += bytesRead

                    // 计算并回调进度
                    if (totalBytes > 0) {
                        val progress = (downloadedBytes * 100 / totalBytes).toInt()
                        withContext(Dispatchers.Main) {
                            listener.onProgress(progress)
                        }
                    }
                }

                outputStream.flush()
                outputStream.close()
                inputStream.close()

                // 下载完成回调(返回文件Uri或路径)
                val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    // Android 10及以上返回Uri
                    if (outputStream is WrappedOutputStream) {
                        outputStream.uri.toString()
                    } else {
                        "下载成功"
                    }
                } else {
                    // Android 10以下返回文件路径
                    val moviesDir =
                        Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
                    File(moviesDir, fileName).absolutePath
                }

                withContext(Dispatchers.Main) {
                    listener.onSuccess(result)
                }
            }
        } catch (e: IOException) {
            withContext(Dispatchers.Main) {
                listener.onFailure("下载出错: ${e.message ?: "未知错误"}")
            }
        }
    }

    // 辅助类用于保存Android Q及以上的Uri
    private class WrappedOutputStream(
        private val outputStream: OutputStream,
        val uri: Uri,
    ) : BufferedOutputStream(outputStream)

    // 下载监听器接口
    interface DownloadListener {
        fun onProgress(progress: Int)
        fun onSuccess(filePath: String)
        fun onFailure(errorMessage: String)
    }

    companion object {
        /**
         * 复制文件到系统Movies目录
         * @param context 上下文
         * @param sourceFile 源文件
         * @param fileName 目标文件名(带扩展名)
         * @return 复制成功返回目标文件的Uri,失败返回null
         */
        suspend fun copyToMoviesDirectory(
            context: Context,
            sourceFile: File,
            fileName: String,
        ): Uri? = withContext(Dispatchers.IO) {
            return@withContext try {
                if (!sourceFile.exists() || !sourceFile.canRead()) {
                    return@withContext null
                }

                // 根据Android版本选择不同的复制方式
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    // Android 10及以上使用MediaStore
                    val contentValues = ContentValues().apply {
                        put(MediaStore.Video.Media.DISPLAY_NAME, fileName)
                        put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") // 根据实际类型修改
                        put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES)
                        put(MediaStore.Video.Media.IS_PENDING, 1)
                    }

                    val resolver = context.contentResolver
                    val uri =
                        resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)

                    uri?.let {
                        resolver.openOutputStream(uri)?.use { outputStream ->
                            FileInputStream(sourceFile).use { inputStream ->
                                inputStream.copyTo(outputStream)
                            }
                        }

                        // 完成文件创建
                        contentValues.clear()
                        contentValues.put(MediaStore.Video.Media.IS_PENDING, 0)
                        resolver.update(uri, contentValues, null, null)
                        uri
                    }
                } else {
                    // Android 10以下直接操作文件
                    val moviesDir =
                        Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
                    if (!moviesDir.exists()) {
                        moviesDir.mkdirs()
                    }

                    val targetFile = File(moviesDir, fileName)
                    FileInputStream(sourceFile).use { input ->
                        FileOutputStream(targetFile).use { output ->
                            input.copyTo(output)
                        }
                    }
                    Uri.fromFile(targetFile)
                }
            } catch (e: Exception) {
                e.printStackTrace()
                null
            }
        }

        /**
         * 使用系统自带播放器播放视频
         * @param videoPath 视频文件的绝对路径
         */
        fun playVideoWithSystemPlayer(activity: FragmentActivity, videoPath: String) {
            // 检查文件是否存在
            val videoFile = File(videoPath)
            if (!videoFile.exists()) {
                Toast.makeText(activity, "视频文件不存在", Toast.LENGTH_SHORT).show()
                return
            }

            // 创建Intent
            val intent = Intent(Intent.ACTION_VIEW)
            // 设置视频文件的Uri
            val uri = Uri.fromFile(videoFile)
            // 设置数据类型为视频
            intent.setDataAndType(uri, "video/*")


            // 检查是否有应用可以处理该Intent
            if (intent.resolveActivity(activity.packageManager) != null) {
                activity.startActivity(intent)
            } else {
                Toast.makeText(activity, "没有找到可以播放视频的应用", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

效果支持视频流介绍

官网介绍

官网地址Media3 | Jetpack | Android Developers

相关推荐
脚踏实地,坚持不懈!1 小时前
Android 系统工程师(性能/功耗/稳定性)岗位问题深度解析:从内核源码到实战排查(完善版)
android·linux·运维·服务器
Kapaseker1 小时前
适配小米“中”折屏,非得买一台吗
android·kotlin
恋猫de小郭1 小时前
Flutter 状态管理基准测评,一个很有趣的观点
android·前端·flutter
可乐鸡翅yeah_1 小时前
App WebView 加载 M3U8 流媒体踩坑,安卓 iOS 混合开发播放异常定位
android·ios·harmonyos·m3u8·m3u8在线播放
木卫四科技2 小时前
从函数劫持到智能体控制平面:Hook 如何从 Linux-Android 演化到 Agent Runtime
android·linux·人工智能·安全
菠萝加点糖2 小时前
Android applicationIdSuffix 详解
android·gitee·kotlin
AIGC小尼2 小时前
穿山甲 + 腾讯短剧短视频聚合广告平台|Android+SpringBoot+Vue+Docker 完整部署指南
android·vue.js·spring boot·聚合广告·广告平台·穿山甲广告·腾讯广告
JMchen1234 小时前
【Android 性能优化实战 60 讲】06 GPU 呈现模式与卡顿视觉验证:拆解柱状图分层,秒辨渲染慢与等待慢
android·性能优化·实战·源码分析·渲染优化·gpu呈现模式·卡顿优化
91刘仁德11 小时前
MYSQL 事务原理及使用
android·mysql·adb
一笑的小酒馆13 小时前
AndroidKMP之瀑布流实现
android