一周内从0到1开发一款 AR眼镜 相机应用?

news2024/11/25 14:34:02

目录

1. 📂 前言

2. 💠 任务拆分

2.1 产品需求拆分

2.2 开发工作拆分

3. 🔱 开发实现

3.1 代码目录截图

3.2 app 模块

3.3 middleware 模块

3.4 portal 模块

4. ⚛️ 拍照与录像

4.1 前滑后滑统一处理

4.2 初始化 View 以及 Camera

4.3 页面前后滑处理

4.4 字符串资源

4.5 照片视频存放

4.6 拍照

4.7 录像

5. ⚛️ 图片视频查看

5.1 数据结构定义

5.2 图片视频获取工具类

5.3 解耦 viewmodel 和 model 层

5.4 初始化 View

5.5 BannerViewPager 组件加载和使用

5.6 获取数据并加载到 View 显示

5.7 页面前后滑处理

5.8 gradle 依赖

6. ✅ 小结


1. 📂 前言

        背景:为了满足用户对 AR 眼镜相机功能的体验,研发内部决定开发一款带有 AR 眼镜特性相机应用,无产品、设计、测试以及项目同学的参与。

        参与开发人员:OS/应用开发同学(本人)。

        客户与用户:用户是最终使用产品的人,更多关注功能实用性,当前阶段用户是OS/应用开发同学,未来用户是产品经理、设计同学,以及未来会使用此OS的用户;客户是直属领导,更多关注功能完成度。

2.1 产品需求拆分

        由于是研发内部需求,没有产品经理参与,所以需要通过调研已有产品,并结合过往相机应用开发经验,大致拆分为三块需求:拍照、录像以及图片视频查看

2.2 开发工作拆分

        根据拆分需求以及 AR 眼镜的特性,拆分出如下开发工作:

  1. 搭建项目;——0.5人/天(基于 Android应用开发框架轮子 构造相机应用初版代码)

  2. 实现打开应用后默认拍照模式的开发与自测;——0.5人/天(使用CameraX API,参考开源库 KotlinCameraXDemo)

  3. 实现TP点击拍照并保存在图库的开发与自测;——0.5人/天

  4. 实现TP前滑切换为录像模式、TP点击录像、再次TP点击结束录像、并保存在图库、TP再次前滑切换为拍照模式的开发与自测;——1人/天

  5. 实现TP后滑打开图库查看功能的开发与自测;——0.5人/天

  6. 实现图库查看时可前滑后滑浏览图片、视频,对于视频可点击播放与暂停功能的开发与自测;——1人/天(此部分任务,由于本人曾经做过有现成代码可搬运过来,正常情况下可能至少需要3天左右时间的开发与调优)

  7. 请产品同学以及开发同学进行功能体验并优化功能;——0.5人/天

  8. 代码整理、新建仓库上传代码以及内置 APK 在系统 OS;——0.5人/天

3. 🔱 开发实现

3.1 代码目录截图

3.2 app 模块

        主要定制 Application 与 manifest。

3.3 middleware 模块

        中间件层,主要放置一些模块间共用的元素,比如中英文文字翻译、应用 logo、一些 base 类等,由于开发框架文章有对应阐述,此处就不再赘述。

3.4 portal 模块

        按照 MVVM 应用架构,在 MainActivity 类中实现了拍照和录像功能,在 MediaDetailActivity 类中实现了图片视频的查看功能,功能具体代码实现,将在接来下的章节作为本文重点展开。

4. ⚛️ 拍照与录像

4.1 前滑后滑统一处理

        在 BaseActivity 中重写 dispatchGenericMotionEvent方法,提供 scrollForward 和 scrollBackward 方法,方便子类统一处理前滑后滑事件。

abstract class BaseActivity<VB : ViewBinding, VM : BaseViewModel> : AppCompatActivity(), IView {

    protected lateinit var binding: VB

    protected val viewModel: VM by lazy {
        ViewModelProvider(this)[(this.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[1] as Class<VM>]
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = initBinding(layoutInflater)
        setContentView(binding.root)

        initData()
        initViewModel()
        initView()
    }

    override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
        if (event.action == MotionEvent.ACTION_SCROLL && FastScrollUtils.isNotFastScroll(binding.root)) {
            if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0) scrollForward() else scrollBackward()
        }
        return super.dispatchGenericMotionEvent(event)
    }

    abstract fun initBinding(inflater: LayoutInflater): VB
    abstract fun scrollForward()
    abstract fun scrollBackward()


}

        并且加上了防快速操作的逻辑,避免不必要 bug。

/**
 * Description: 防止快速滑动,多次触发事件
 * CreateDate:  2022/11/29 17:59
 * Author:      agg
 */
object FastScrollUtils {

    private const val MIN_CLICK_DELAY_TIME = 500

    fun isNotFastScroll(view: View, time: Int = MIN_CLICK_DELAY_TIME): Boolean {
        var flag = true
        val curClickTime = System.currentTimeMillis()
        view.getTag(view.id)?.let {
            if (curClickTime - (it as Long) < time) {
                flag = false
            }
        }
        if (flag) view.setTag(view.id, curClickTime)
        return flag
    }

}

4.2 初始化 View 以及 Camera

        首先,在 Activity 的初始化过程中,初始化窗口时将 WIndow 设置为 0dof,让可视窗口跟随用户视野移动。

    private fun initWindow() {
        Log.i(TAG, "initWindow: ")
        val lp = window.attributes
        lp.dofIndex = 0
        lp.subType = WindowManager.LayoutParams.MB_WINDOW_IMMERSIVE_0DOF
        window.attributes = lp
    }

        然后,初始化 CameraX 相关配置,包括了 ImageCapture 拍照和 VideoCapture 录像 API 的初始化,bindToLifecycle 绑定生命周期。

    private val CAMERA_MAX_RESOLUTION = Size(3264, 2448)
    private lateinit var mCameraExecutor: ExecutorService
    private var mImageCapture: ImageCapture? = null
    private var mVideoCapture: VideoCapture? = null
    private var mIsVideoModel = false
    private var mVideoRecordTime = 0L

    private fun initCameraView() {
        Log.i(TAG, "initCameraView: ")
        mCameraExecutor = Executors.newSingleThreadExecutor()
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
        cameraProviderFuture.addListener({
            try {
                mImageCapture = ImageCapture.Builder()
                    .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
                    .setTargetResolution(CAMERA_MAX_RESOLUTION).build()
                mVideoCapture = VideoCapture.Builder().build()
                val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
                val cameraProvider = cameraProviderFuture.get()
                cameraProvider.unbindAll()
                cameraProvider.bindToLifecycle(this, cameraSelector, mImageCapture, mVideoCapture)
            } catch (e: java.lang.Exception) {
                Log.e(TAG, "bindCamera Failed!: $e")
            }
        }, ContextCompat.getMainExecutor(this))
    }

        其次,如果拍照、录像需要预览界面,一是需要增加 PreviewView 预览组件,二是将 bindToLifecycle 方法参数中增加 Preview 即可,如下代码所示:

<?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"
    android:id="@+id/parent"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:keepScreenOn="true">

    <androidx.camera.view.PreviewView
        android:id="@+id/previewView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
cameraProviderFuture.addListener({
    try {
        // ...
        // 可预览
        val preview = Preview.Builder().build()
        binding.previewView.apply {
            implementationMode = PreviewView.ImplementationMode.COMPATIBLE
            preview.setSurfaceProvider(surfaceProvider)
        }
        cameraProvider.bindToLifecycle(
            this, cameraSelector, preview, mImageCapture, mVideoCapture
        )
//      cameraProvider.bindToLifecycle(this, cameraSelector, mImageCapture, mVideoCapture)
    } catch (e: java.lang.Exception) {
        Log.e(TAG, "bindCamera Failed!: $e")
    }
}, ContextCompat.getMainExecutor(this))

        最后,监听窗口点击并处理对应事件即可。

    private val mVideoIsRecording = AtomicBoolean(false)
    private fun isRecording(): Boolean = mVideoIsRecording.get()

    override fun initView() {
        initWindow()
        initCameraView()
        binding.parent.setOnClickListener {
            Log.i(TAG, "click: model=$mIsVideoModel,isRecordingVideo=${isRecording()}")
            try {
                if (mIsVideoModel) if (isRecording()) stopRecord() else startRecord() else takePicture()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

4.3 页面前后滑处理

    /**
     * 往前滑动:切换为录像模式/拍照模式
     */
    override fun scrollForward() {
        Log.i(TAG, "scrollForward: model=$mIsVideoModel,isRecordingVideo=${isRecording()}")
        if (mIsVideoModel) {
            if (isRecording()) {
                MBToast(
                    this, Toast.LENGTH_SHORT, getString(R.string.record_video_in_progress)
                ).show()
            } else {
                mIsVideoModel = false
                MBToast(this, Toast.LENGTH_SHORT, getString(R.string.photo_model)).show()
            }
        } else {
            mIsVideoModel = true
            MBToast(this, Toast.LENGTH_SHORT, getString(R.string.video_model)).show()
        }
    }

    /**
     * 往后滑动:打开图库查看功能
     */
    override fun scrollBackward() {
        if (isRecording()) {
            MBToast(this, Toast.LENGTH_SHORT, getString(R.string.record_video_in_progress)).show()
        } else {
            Log.i(TAG, "scrollBackward: 打开图库查看功能")
            MediaDetailActivity.launch(this)
        }
    }

4.4 字符串资源

// 中文:
<resources>
    <string name="app_name">Mo相机</string>
    <string name="loading">加载中…</string>
    <string name="tap_to_snap">单击拍照</string>
    <string name="tap_to_record_video">单击录像</string>
    <string name="tap_to_stop_record_video">单击暂停录像</string>
    <string name="scroll_backward_to_gallery">后滑打开图库</string>
    <string name="scroll_forward_to_snap_model">前滑切换拍照模式</string>
    <string name="scroll_forward_to_video_model">前滑切换录像模式</string>
    <string name="record_video_start">开始录像</string>
    <string name="record_video_in_progress">正在录像</string>
    <string name="record_video_complete">录像完成</string>
    <string name="take_picture_complete">拍照完成</string>
    <string name="photo_model">拍照模式</string>
    <string name="video_model">录像模式</string>
</resources>

// 英文:
<resources>
    <string name="app_name">MoCamera</string>
    <string name="loading">Loading…</string>
    <string name="tap_to_snap">Tap To Snap</string>
    <string name="tap_to_record_video">Tap To Record Video</string>
    <string name="tap_to_stop_record_video">Tap To Stop Record Video</string>
    <string name="scroll_backward_to_gallery">Scroll Backward To Gallery</string>
    <string name="scroll_forward_to_snap_model">Scroll Forward To Snap Model</string>
    <string name="scroll_forward_to_video_model">Scroll Forward To Video Model</string>
    <string name="record_video_start">Record Video Start</string>
    <string name="record_video_in_progress">Video Recording In Progress</string>
    <string name="record_video_complete">Record Video Complete</string>
    <string name="take_picture_complete">Take Picture Complete</string>
    <string name="photo_model">Photo Model</string>
    <string name="video_model">Video Model</string>
</resources>

4.5 照片视频存放

        initOutputDirectory 初始化存放目录,以及 createPhotoFile 创建照片存放路径和 createVideoFile 创建视频存放路径、updateMediaFile 更新系统相册。

class MainViewModel : BaseViewModel() {

    private lateinit var outputDirectory: File

    fun init(context: ContextWrapper) {
        viewModelScope.launch { SoundPoolTools.init(context) }
        initOutputDirectory(context)
    }

    /**
     * 更新系统相册
     */
    fun updateMediaFile(context: Context, file: File) {
        val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
        intent.data = Uri.fromFile(file)
        context.sendBroadcast(intent)
    }

    fun createPhotoFile(): File {
        return File(
            outputDirectory, System.currentTimeMillis().toString() + Constants.PHOTO_EXTENSION
        )
    }

    fun createVideoFile(): File {
        return File(
            outputDirectory, System.currentTimeMillis().toString() + Constants.VIDEO_EXTENSION
        )
    }

    private fun initOutputDirectory(context: ContextWrapper) {
        outputDirectory = File(context.externalMediaDirs[0], context.getString(R.string.app_name))
        if (!outputDirectory.exists()) outputDirectory.mkdir()
    }

}

4.6 拍照

    /**
     * 拍照
     */
    private fun takePicture() {
        Log.i(TAG, "takePicture: ")
        SoundPoolTools.playCameraPhoto(this)

        val photoFile = viewModel.createPhotoFile()
        mImageCapture?.takePicture(ImageCapture.OutputFileOptions.Builder(photoFile).build(),
            mCameraExecutor,
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exc: ImageCaptureException) {
                    Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
                }

                override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                    val savedUri = output.savedUri ?: Uri.fromFile(photoFile)
                    Log.i(TAG, "Photo capture succeeded: $savedUri")
                    runOnUiThread {
                        MBToast(
                            this@MainActivity,
                            Toast.LENGTH_SHORT,
                            getString(R.string.take_picture_complete)
                        ).show()
                    }
                    viewModel.updateMediaFile(this@MainActivity, photoFile)
                }
            })
    }

4.7 录像

    private fun startRecord() {
        if (isRecording()) {
            MBToast(this, Toast.LENGTH_SHORT, getString(R.string.record_video_in_progress)).show()
            return
        }
        Log.i(TAG, "startRecord: ")

        val videoFile = viewModel.createVideoFile()
        mVideoCapture?.startRecording(VideoCapture.OutputFileOptions.Builder(videoFile).build(),
            mCameraExecutor,
            object : VideoCapture.OnVideoSavedCallback {
                override fun onVideoSaved(output: VideoCapture.OutputFileResults) {
                    mVideoIsRecording.set(false)
                    val savedUri = output.savedUri ?: Uri.fromFile(videoFile)
                    Log.i(TAG, "onVideoSaved:${savedUri.path}")
                    runOnUiThread {
                        MBToast(
                            this@MainActivity,
                            Toast.LENGTH_SHORT,
                            getString(R.string.record_video_complete)
                        ).show()

                    }
                    viewModel.updateMediaFile(this@MainActivity, videoFile)
                }

                override fun onError(videoCaptureError: Int, message: String, cause: Throwable?) {
                    mVideoIsRecording.set(false)
                    Log.e(TAG, "onError:${message}")
                }
            })
        mVideoIsRecording.set(true)

        if (isRecording()) {
            MBToast(this, Toast.LENGTH_SHORT, getString(R.string.record_video_start)).show()
        }
    }

    private fun stopRecord() {
        Log.i(TAG, "stopRecord: ")
        if (mVideoIsRecording.get()) mVideoCapture?.stopRecording()
    }

5. ⚛️ 图片视频查看

5.1 数据结构定义

        首先,定义图片、视频及其父类的数据结构。

open class MediaInfo(
    @Expose
    var size: Long = 0L, // 大小 单位B
    @Expose
    var width: Float = 0f, // 宽
    @Expose
    var height: Float = 0f, // 高
    @Expose
    var localPath: String = "", // 系统绝对路径
    @Expose
    var localPathUri: String = "", // 媒体文件Uri
    @Expose
    var fileName: String = "", // 文件名
    @Expose
    var mimeType: String = "", // 媒体类型
    @Expose
    var mediaId: String = "", // 媒体ID
    @Expose
    var lastModified: Long = 0L, // 最后更改时间
) {

    companion object {
        const val PHOTO_MIMETYPE_DEFAULT = "image/jpeg"
        const val VIDEO_MIMETYPE_DEFAULT = "video/mp4"

        const val PHOTO_MIMETYPE_DEFAULT_CONTAIN = "image"
        const val VIDEO_MIMETYPE_DEFAULT_CONTAIN = "video"
    }

    enum class MediaType(val type: Int) {
        NOT_DEFINE(0),
        PHOTO(1),
        VIDEO(2),
    }

}
data class PhotoInfo(
    var photoId: String = "",
    @SerializedName("photoCoverFull") var photoCoverFull: String = "", // 在线图片url 或者 系统图片media路径
    @SerializedName("photoCover") // 在线图片缩略图 或者 系统图片media路径
    var photoCover: String = "",
) : MediaInfo() {
    override fun equals(other: Any?): Boolean {
        return if (other is PhotoInfo) {
            other.photoId == this.photoId
        } else {
            false
        }
    }

    override fun hashCode(): Int {
        return photoId.hashCode()
    }
}
/**
 * Description:
 * CreateDate:      2022/11/16 18:09
 * Author:          agg
 *
 * 码率(比特率),单位为 bps,比特率越高,传送的数据速度越快。在压缩视频时指定码率,则可确定压缩后的视频大小。
 * 视频大小(byte) = (duration(ms) / 1000) * (biteRate(bit/s) / 8)
 */
data class VideoInfo(
    var firstFrame: Bitmap? = null, // 视频第一帧图,业务层使用,可能为空。
    @Expose
    var duration: Long = 0L, // 视频长度 ms
    @Expose
    var biteRate: Long = 0L, // 视频码率 bps

    /* --------not necessary, maybe not value---- */
    @Expose
    var addTime: Long = 0L, // 视频添加时间
    @Expose
    var videoRotation: Int = 0, // 视频方向
    /* --------not necessary, maybe not value---- */
) : MediaInfo()

        其次,需要提供混合图片视频的数据结构,混合展示图片视频时需使用到。

data class MixMediaInfoList(
    var isLoadVideoListFinish: Boolean = false,
    var isLoadPhotoListFinish: Boolean = false,
    var videoList: MutableList<MediaInfo> = mutableListOf(),
    var photoList: MutableList<MediaInfo> = mutableListOf(),
    var maxMediaList: MutableList<MediaInfo> = mutableListOf(),
) {

    suspend fun getMaxMediaList(): MutableList<MediaInfo> = withContext(Dispatchers.IO) {
        if (maxMediaList.isEmpty()) {
            maxMediaList.addAll(videoList)
            maxMediaList.addAll(photoList)
            maxMediaList.sortByDescending { it.lastModified }
        }
        maxMediaList
    }

}

5.2 图片视频获取工具类

        首先,提供图片获取工具类,并将其转为自定义的数据结构 PhotoInfo。

object PhotoInfoUtils {

    /**
     * 获取系统所有图片文件
     */
    suspend fun getSysPhotos(contentResolver: ContentResolver): MutableList<MediaInfo> =
        withContext(Dispatchers.IO) {
            val photoList: MutableList<MediaInfo> = mutableListOf()
            var cursor: Cursor? = null
            try {
                cursor = contentResolver.query(
                    Media.EXTERNAL_CONTENT_URI, arrayOf(
                        Media._ID,
                        Media.DATA,
                        Media.DISPLAY_NAME,
                        Media.MIME_TYPE, // 媒体类型
                        Media.SIZE,
                        Media.WIDTH, // 图片宽
                        Media.HEIGHT, // 图片高
                        Media.DATE_MODIFIED,
                    ), null, null, ContactsContract.Contacts._ID + " DESC", null
                )
                cursor?.moveToFirst()
                if (cursor == null || cursor.isAfterLast) return@withContext photoList

                cursor.let {
                    while (!it.isAfterLast) {
                        val photoLibraryInfo = getPhoto(it)
                        // cursor并不能保证每张图都能获取到宽高,仅在这里取size
                        photoList.add(photoLibraryInfo)
                        it.moveToNext()
                    }
                }
            } catch (e: Exception) {
                LogUtils.e(e)
            } finally {
                cursor?.close()
            }
            photoList
        }

    @SuppressLint("Range")
    private fun getPhoto(cursor: Cursor): PhotoInfo {
        val phoneLibraryInfo = PhotoInfo()
        phoneLibraryInfo.photoId = cursor.getString(cursor.getColumnIndex(Media._ID))
        phoneLibraryInfo.localPath = cursor.getString(cursor.getColumnIndex(Media.DATA))
        phoneLibraryInfo.mimeType = cursor.getString(cursor.getColumnIndex(Media.MIME_TYPE))
        phoneLibraryInfo.size = cursor.getLong(cursor.getColumnIndex(Media.SIZE))
        phoneLibraryInfo.width = cursor.getFloat(cursor.getColumnIndex(Media.WIDTH))
        phoneLibraryInfo.height = cursor.getFloat(cursor.getColumnIndex(Media.HEIGHT))
        phoneLibraryInfo.lastModified = cursor.getLong(cursor.getColumnIndex(Media.DATE_MODIFIED))

        val thumbnailUri: Uri = ContentUris.withAppendedId(
            Media.EXTERNAL_CONTENT_URI, phoneLibraryInfo.photoId.toLong()
        )
        phoneLibraryInfo.photoCoverFull = thumbnailUri.toString()
        phoneLibraryInfo.photoCover = thumbnailUri.toString()
        phoneLibraryInfo.localPathUri = thumbnailUri.toString()
        return phoneLibraryInfo
    }

}

        然后,提供视频获取工具类,并将其转为自定义的数据结构 VideoInfo。

/**
 * Description: 视频信息工具类
 * CreateDate:  2022/11/16 18:46
 * Author:      agg
 */
@SuppressLint("Range", "Recycle")
object VideoInfoUtils {

    private const val VIDEO_FIRST_FRAME_TIME_US = 1000L
    private const val URI_VIDEO_PRE = "content://media/external/video/media"

    /**
     * 获取系统所有视频文件
     */
    suspend fun getSysVideos(contentResolver: ContentResolver): MutableList<MediaInfo> =
        withContext(Dispatchers.IO) {
            val videoList: MutableList<MediaInfo> = mutableListOf()
            var cursor: Cursor? = null
            try {
                val queryArray = arrayOf(
                    Media._ID,
                    Media.SIZE, // 视频大小
                    Media.WIDTH, // 视频宽
                    Media.HEIGHT, // 视频高
                    Media.DATA, // 视频绝对路径
                    Media.DISPLAY_NAME, // 视频文件名
                    Media.MIME_TYPE, // 媒体类型
                    Media.DURATION, // 视频长度
                    Media.DATE_ADDED, // 视频添加时间
                    Media.DATE_MODIFIED, // 视频最后更改时间
                ).toMutableList()
//                if (SDK_INT >= Build.VERSION_CODES.R) queryArray.add(Media.BITRATE)// 视频码率
                cursor = contentResolver.query(
                    Media.EXTERNAL_CONTENT_URI,
                    queryArray.toTypedArray(),
                    null,
                    null,
                    Media.DATE_ADDED + " DESC",
                    null
                )
                cursor?.moveToFirst()
                if (cursor == null || cursor.isAfterLast) return@withContext videoList
                while (!cursor.isAfterLast) {
                    getVideoInfo(cursor).run { if (duration > 0 && size > 0) videoList.add(this) }
                    cursor.moveToNext()
                }
            } catch (e: Exception) {
                LogUtils.e(e)
            } finally {
                cursor?.close()
            }
            videoList
        }

    /**
     * 获取视频文件信息
     * 注:(1)暂未包括videoRotation;(2)biteRate通过文件大小和视频时长计算
     */
    private suspend fun getVideoInfo(cursor: Cursor): VideoInfo = withContext(Dispatchers.IO) {
        val videoInfo = VideoInfo()
        videoInfo.mediaId = cursor.getString(cursor.getColumnIndex(Media._ID))
        videoInfo.size = cursor.getLong(cursor.getColumnIndex(Media.SIZE))
        videoInfo.width = cursor.getFloat(cursor.getColumnIndex(Media.WIDTH))
        videoInfo.height = cursor.getFloat(cursor.getColumnIndex(Media.HEIGHT))
        videoInfo.localPath = cursor.getString(cursor.getColumnIndex(Media.DATA))
        videoInfo.localPathUri = getVideoPathUri(videoInfo.mediaId).toString()
        videoInfo.fileName = cursor.getString(cursor.getColumnIndex(Media.DISPLAY_NAME))
        videoInfo.mimeType = cursor.getString(cursor.getColumnIndex(Media.MIME_TYPE))
//        不能在这获取第一帧,太耗时,改为使用的地方去获取第一帧。
//        videoInfo.firstFrame = getVideoThumbnail(cursor.getString(cursor.getColumnIndex(Media.DATA)))
        videoInfo.duration = cursor.getLong(cursor.getColumnIndex(Media.DURATION))
        videoInfo.biteRate = ((8 * videoInfo.size * 1024) / (videoInfo.duration / 1000f)).toLong()
//            if (SDK_INT >= Build.VERSION_CODES.R) cursor.getLong(cursor.getColumnIndex(Media.BITRATE))
//            else ((8 * videoInfo.size * 1024) / (videoInfo.duration / 1000f)).toLong()
        videoInfo.addTime = cursor.getLong(cursor.getColumnIndex(Media.DATE_ADDED))
        videoInfo.lastModified = cursor.getLong(cursor.getColumnIndex(Media.DATE_MODIFIED))
        videoInfo
    }

    /**
     * 获取视频文件信息
     * 注:(1)暂未包括lastModified、addTime;(2)mediaId以filePath代替
     *
     * @param path 视频文件的路径
     * @return VideoInfo 视频文件信息
     */
    suspend fun getVideoInfo(path: String?): VideoInfo = withContext(Dispatchers.IO) {
        val videoInfo = VideoInfo()
        if (!path.isNullOrEmpty()) {
            val media = MediaMetadataRetriever()
            try {
                media.setDataSource(path)
                videoInfo.size =
                    File(path).let { if (FileUtils.isFileExists(it)) it.length() else 0 }
                videoInfo.width = media.extractMetadata(METADATA_KEY_VIDEO_WIDTH)?.toFloat() ?: 0f
                videoInfo.height = media.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)?.toFloat() ?: 0f
                videoInfo.localPath = path
                videoInfo.localPathUri = getVideoPathUri(Utils.getApp(), path).toString()
                videoInfo.fileName = path.split(File.separator).let {
                    if (it.isNotEmpty()) it[it.size - 1] else ""
                }
                videoInfo.mimeType = media.extractMetadata(METADATA_KEY_MIMETYPE) ?: ""
//                videoInfo.firstFrame = media.getFrameAtTime(VIDEO_FIRST_FRAME_TIME_US)?.let { compressVideoThumbnail(it) }
                videoInfo.duration = media.extractMetadata(METADATA_KEY_DURATION)?.toLong() ?: 0
                videoInfo.biteRate = media.extractMetadata(METADATA_KEY_BITRATE)?.toLong() ?: 0
                videoInfo.videoRotation =
                    media.extractMetadata(METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
                videoInfo.mediaId = path
            } catch (e: Exception) {
            } finally {
                media.release()
            }
        }
        videoInfo
    }

    /**
     * 获取视频缩略图:通过Uri抓取第一帧
     * @param videoUriString 视频在媒体库中Uri。如:content://media/external/video/media/11378
     */
    suspend fun getVideoThumbnail(context: Context, videoUriString: String): Bitmap? =
        withContext(Dispatchers.IO) {
            var bitmap: Bitmap? = null
            val retriever = MediaMetadataRetriever()
            try {
                retriever.setDataSource(context, Uri.parse(videoUriString))
                // OPTION_CLOSEST_SYNC:在给定的时间,检索最近一个同步与数据源相关联的的帧(关键帧)
                // OPTION_CLOSEST:表示获取离该时间戳最近帧(I帧或P帧)
                bitmap = if (SDK_INT >= Build.VERSION_CODES.O_MR1) {
                    retriever.getScaledFrameAtTime(
                        VIDEO_FIRST_FRAME_TIME_US,
                        OPTION_CLOSEST_SYNC,
                        THUMBNAIL_DEFAULT_COMPRESS_VALUE.toInt(),
                        THUMBNAIL_DEFAULT_COMPRESS_VALUE.toInt()
                    )
                } else {
                    retriever.getFrameAtTime(VIDEO_FIRST_FRAME_TIME_US)
                        ?.let { compressVideoThumbnail(it) }
                }
            } catch (e: Exception) {
            } finally {
                try {
                    retriever.release()
                } catch (e: Exception) {
                }
            }
            bitmap
        }

    /**
     * 通过视频资源ID,直接获取视频Uri
     * @param mediaId 视频资源ID
     */
    fun getVideoPathUri(mediaId: String): Uri =
        Uri.withAppendedPath(Uri.parse(URI_VIDEO_PRE), mediaId)

    /**
     * 通过视频资源本地路径,获取视频Uri
     * @param path 视频资源本地路径
     */
    fun getVideoPathUri(context: Context, path: String): Uri? {
        var uri: Uri? = null
        var cursor: Cursor? = null
        try {
            cursor = context.contentResolver.query(
                Media.EXTERNAL_CONTENT_URI,
                arrayOf(Media._ID),
                Media.DATA + "=? ",
                arrayOf(path),
                null
            )
            uri = if (cursor != null && cursor.moveToFirst()) {
                Uri.withAppendedPath(
                    Uri.parse(URI_VIDEO_PRE),
                    "" + cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID))
                )
            } else {
                if (File(path).exists()) {
                    val values = ContentValues()
                    values.put(Media.DATA, path)
                    context.contentResolver.insert(Media.EXTERNAL_CONTENT_URI, values)
                } else {
                    null
                }
            }
        } catch (e: Exception) {
        } finally {
            cursor?.close()
        }
        return uri
    }

}

        同时,提供视频缩略图工具类,方便业务代码使用。

/**
 * Description: 视频缩略图工具类
 * 宽高压缩、缩放法压缩可针对Bitmap操作,而采样率压缩和质量压缩针对于File、Resource操作
 * CreateDate:  2022/11/24 18:48
 * Author:      agg
 */
object VideoThumbnailUtils {

    /**
     * 视频缩略图默认压缩尺寸
     */
    const val THUMBNAIL_DEFAULT_COMPRESS_VALUE = 1024f

    /**
     * 视频缩略图默认压缩比例
     */
    private const val THUMBNAIL_DEFAULT_SCALE_VALUE = 0.5f

    private const val MAX_IMAGE_SIZE = 500 * 1024  //图片压缩阀值

    /**
     * 压缩视频缩略图
     * @param bitmap 视频缩略图
     */
    fun compressVideoThumbnail(bitmap: Bitmap): Bitmap? {
        val width: Int = bitmap.width
        val height: Int = bitmap.height
        val max: Int = Math.max(width, height)
        if (max > THUMBNAIL_DEFAULT_COMPRESS_VALUE) {
            val scale: Float = THUMBNAIL_DEFAULT_COMPRESS_VALUE / max
            val w = (scale * width).roundToInt()
            val h = (scale * height).roundToInt()
            return compressVideoThumbnail(bitmap, w, h)
        }
        return bitmap
    }

    /**
     * 压缩视频缩略图:宽高压缩
     * 注:如果用户期望的长度和宽度和原图长度宽度相差太多的话,图片会很不清晰。
     * @param bitmap 视频缩略图
     */
    fun compressVideoThumbnail(bitmap: Bitmap, width: Int, height: Int): Bitmap? {
        return Bitmap.createScaledBitmap(bitmap, width, height, true)
    }

    /**
     * 压缩视频缩略图:缩放法压缩
     * 注:长度和宽度没有变,内存缩小4倍(内存像素宽高各缩小一半)
     */
    fun compressVideoThumbnailMatrix(bitmap: Bitmap): Bitmap? {
        val matrix = Matrix()
        matrix.setScale(THUMBNAIL_DEFAULT_SCALE_VALUE, THUMBNAIL_DEFAULT_SCALE_VALUE)
        return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
    }

    /**
     * 压缩视频缩略图:采样率压缩
     * @param filePath 视频缩略图路径
     */
    fun compressVideoThumbnailSample(filePath: String, width: Int, height: Int): Bitmap? {
        return BitmapFactory.Options().run {
            // inJustDecodeBounds 设置为 true后,BitmapFactory.decodeFile 不生成Bitmap对象,而仅仅是读取该图片的尺寸和类型信息。
            inJustDecodeBounds = true
            BitmapFactory.decodeFile(filePath, this)
            inSampleSize = calculateInSampleSize(this, width, height)
            inJustDecodeBounds = false
            BitmapFactory.decodeFile(filePath, this)
        }
    }

    /**
     * 压缩图片到指定宽高
     * @param localPath 图片本地路径
     * @param size 原图所占空间大小
     */
    fun compressLocalImage(localPath: String, size: Int): Bitmap? {
        return if (size > MAX_IMAGE_SIZE) {
            val scale = size.toDouble() / MAX_IMAGE_SIZE
            val simpleSize = ceil(sqrt(scale)).toInt() //取最接近的平方根
            var bitmap = BitmapFactory.Options().run {
                inPreferredConfig = Bitmap.Config.RGB_565
                inSampleSize = simpleSize
                inJustDecodeBounds = false

                val ins = Utils.getApp().contentResolver.openInputStream(Uri.parse(localPath))
                BitmapFactory.decodeStream(ins, null, this)
            }

            val angle = readImageRotation(localPath)
            if (angle != 0 && bitmap != null) {
                bitmap = rotatingImageView(angle, bitmap)
            }
            if (bitmap == null) {
                return null
            }
            val result = tryCompressAgain(bitmap)

            result
        } else { //不压缩
            null
        }
    }

    /**
     * 试探性进一步压缩体积
     */
    private fun tryCompressAgain(original: Bitmap): Bitmap {
        val out = ByteArrayOutputStream()
        original.compress(Bitmap.CompressFormat.JPEG, 100, out)
        val matrix = Matrix()
        var resultBp = original

        try {
            var scale = 1.0f
            while (out.toByteArray().size > MAX_IMAGE_SIZE) {
                matrix.setScale(scale, scale)//每次缩小 1/10
                resultBp =
                    Bitmap.createBitmap(
                        original,
                        0,
                        0,
                        original.width,
                        original.height,
                        matrix,
                        true
                    )
                out.reset()
                resultBp.compress(Bitmap.CompressFormat.JPEG, 100, out)
                scale *= 0.9f
            }
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            out.close()
        }
        return resultBp
    }

    //获取图片旋转角度
    private fun readImageRotation(path: String): Int {
        return kotlin.runCatching {
            val ins = Utils.getApp().contentResolver.openInputStream(Uri.parse(path)) ?: return 0
            val exifInterface = ExifInterface(ins)
            val orientation: Int = exifInterface.getAttributeInt(
                ExifInterface.TAG_ORIENTATION,
                ExifInterface.ORIENTATION_NORMAL
            )
            ins.close()
            return when (orientation) {
                ExifInterface.ORIENTATION_ROTATE_90 -> 90
                ExifInterface.ORIENTATION_ROTATE_180 -> 180
                ExifInterface.ORIENTATION_ROTATE_270 -> 270
                else -> 0
            }
        }.getOrNull() ?: 0
    }

    /**
     * 旋转图片
     */
    private fun rotatingImageView(angle: Int, bitmap: Bitmap): Bitmap? {
        // 旋转图片 动作
        val matrix = Matrix()
        matrix.postRotate(angle.toFloat())
        // 创建新的图片
        return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
    }

    /**
     * 计算采样率:值为2的幂。例如, 一个分辨率为2048x1536的图片,如果设置 inSampleSize 为4,那么会产出一个大约512x384大小的Bitmap。
     * @param options
     * @param reqWidth 想要压缩到的宽度
     * @param reqHeight  想要压缩到的高度
     * @return
     */
    fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
        var inSampleSize = 1
        if (options.outHeight > reqHeight || options.outWidth > reqWidth) {
            val halfHeight: Int = options.outHeight / 2
            val halfWidth: Int = options.outWidth / 2
            while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
                inSampleSize *= 2
            }
        }
        return inSampleSize
    }

    /**
     * 计算采样率:值为整数的采样率。
     */
    fun calculateInSampleSizeFixed(options: BitmapFactory.Options, width: Int, height: Int): Int {
        val hRatio = ceil(options.outHeight.div(height.toDouble())) // 大于1:图片高度>手机屏幕高度
        val wRatio = ceil(options.outWidth.div(width.toDouble())) // 大于1:图片宽度>手机屏幕宽度
        return if (hRatio > wRatio) hRatio.toInt() else wRatio.toInt()
    }

}

5.3 解耦 viewmodel 和 model 层

class MediaDetailViewModel : BaseViewModel() {

    private val model by lazy { MediaDetailModel() }
    val getSysPhotosLiveData: MutableLiveData<List<MediaInfo>> by lazy { MutableLiveData<List<MediaInfo>>() }
    val getSysVideosLiveData: MutableLiveData<List<MediaInfo>> by lazy { MutableLiveData<List<MediaInfo>>() }

    fun getSysVideos(contentResolver: ContentResolver) {
        viewModelScope.launch(Dispatchers.IO) {
            getSysVideosLiveData.postValue(model.getSysVideos(contentResolver))
        }
    }

    fun getSysPhotos(contentResolver: ContentResolver) {
        viewModelScope.launch(Dispatchers.IO) {
            getSysPhotosLiveData.postValue(model.getSysPhotos(contentResolver))
        }
    }

}

open class BaseViewModel : ViewModel()


class MediaDetailModel {

    suspend fun getSysVideos(contentResolver: ContentResolver) =
        VideoInfoUtils.getSysVideos(contentResolver)

    suspend fun getSysPhotos(contentResolver: ContentResolver) =
        PhotoInfoUtils.getSysPhotos(contentResolver)

}

5.4 初始化 View

        首先,在 Activity 的初始化过程中,初始化窗口时将 WIndow 设置为 0dof,让可视窗口跟随用户视野移动。

    private fun initWindow() {
        Log.i(TAG, "initWindow: ")
        val lp = window.attributes
        lp.dofIndex = 0
        lp.subType = WindowManager.LayoutParams.MB_WINDOW_IMMERSIVE_0DOF
        window.attributes = lp
    }

        其次,引入图片视频 BannerViewPager 组件。

<?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"
    android:id="@+id/parent"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.zhpan.bannerview.BannerViewPager
        android:id="@+id/bvFeedPhotoContent"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

        最后,初始化 BannerViewPager 组件。

    private fun initBannerViewPager() {
        (binding.bvFeedPhotoContent as BannerViewPager<MediaInfo>).apply {
            bigDetailAdapter = MediaDetailItemAdapter()
            adapter = bigDetailAdapter
            setLifecycleRegistry(lifecycle)
            setCanLoop(false)
            setAutoPlay(false)
            setIndicatorVisibility(GONE)
            disallowParentInterceptDownEvent(true) // 不允许内部拦截,使得activity可以获得下滑能力
            registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
                /**
                 * 某个页面被选中(从0计数) 翻页成功才会调用
                 * @param position 翻页后的视图在集合中位置
                 */
                @SuppressLint("SetTextI18n")
                override fun onPageSelected(position: Int) {
                    super.onPageSelected(position)
                    // 刷新上个页面的信息(图片缩放置为初始状态、视频停止播放)
                    Log.i(TAG, "onPageSelected: position = $position")
                }
            })
        }.create()
    }

    private fun updateViewPager(allMediaInfoList: MutableList<MediaInfo>) {
        Log.i(TAG, "updateViewPager: size = ${allMediaInfoList.size}")
        binding.bvFeedPhotoContent.refreshData(allMediaInfoList)
        binding.bvFeedPhotoContent.setCurrentItem(0, false)
    }

5.5 BannerViewPager 组件加载和使用

        首先,自定义适配器 MediaDetailItemAdapter。

class MediaDetailItemAdapter : BaseBannerAdapter<MediaInfo>() {

    private var context: Context? = null

    override fun getLayoutId(viewType: Int): Int = R.layout.media_detail_item_adapter

    override fun createViewHolder(
        parent: ViewGroup, itemView: View?, viewType: Int
    ): BaseViewHolder<MediaInfo> {
        context = parent.context
        return super.createViewHolder(parent, itemView, viewType)
    }

    override fun bindData(
        holder: BaseViewHolder<MediaInfo>, mediaInfo: MediaInfo, position: Int, pageSize: Int
    ) {
        val vpViewGroup = holder.findViewById<VideoPlayerViewGroup>(R.id.vpViewGroup)
        val ivAvatarDetail = holder.findViewById<PhotoView>(R.id.ivAvatarDetail)
        val ivAvatarDetailLarge =
            holder.findViewById<SubsamplingScaleImageView>(R.id.ivAvatarDetailLarge)
        if (mediaInfo is PhotoInfo) {
            vpViewGroup.visibility = GONE
            initPhotoInfo(mediaInfo, ivAvatarDetail, ivAvatarDetailLarge)
        } else if (mediaInfo is VideoInfo) {
            vpViewGroup.visibility = VISIBLE
            ivAvatarDetail.visibility = GONE
            ivAvatarDetailLarge.visibility = GONE
            vpViewGroup.initVideoInfo(mediaInfo)
        }
    }

    fun releaseAllVideos() {
        GSYVideoManager.releaseAllVideos()
    }

    private fun initPhotoInfo(
        photoInfo: PhotoInfo,
        ivAvatarDetail: PhotoView,
        ivAvatarDetailLarge: SubsamplingScaleImageView
    ) {
        ivAvatarDetail.visibility = VISIBLE
        ivAvatarDetailLarge.visibility = VISIBLE
        ivAvatarDetail.maximumScale = MAX_SCALE
        ivAvatarDetail.minimumScale = MIN_SCALE
        ivAvatarDetail.scale = MIN_SCALE

        val imageUrl = photoInfo.photoCoverFull
        val thumbUrl = photoInfo.photoCover
        if (imageUrl.isNotEmpty()) {
            ivAvatarDetail.visibility = VISIBLE
            context?.let {
                val isLarge = photoInfo.size > 5000 * 1024 // 大于5m认为是大图
                if (!isLarge) {
                    ivAvatarDetail.visibility = VISIBLE
                    Glide.with(it).load(imageUrl).thumbnail(Glide.with(it).load(thumbUrl))
                        .override(SIZE_ORIGINAL, SIZE_ORIGINAL).into(ivAvatarDetail)
                    ivAvatarDetailLarge.visibility = INVISIBLE
                } else {
                    ivAvatarDetailLarge.visibility = VISIBLE
                    Glide.with(it).load(imageUrl).downloadOnly(object : SimpleTarget<File>() {
                        override fun onResourceReady(
                            resource: File, transition: Transition<in File>?
                        ) {
                            // 在宽高均大于手机屏幕的图片被下载到media后会被强制旋转,这里需要禁止
                            ivAvatarDetailLarge.orientation =
                                SubsamplingScaleImageView.ORIENTATION_USE_EXIF
                            ivAvatarDetailLarge.setImage(
                                ImageSource.uri(Uri.fromFile(resource))
                            )
                            ivAvatarDetail.visibility = INVISIBLE
                        }
                    })
                }
            }
        } else {
            ivAvatarDetail.visibility = GONE
            ivAvatarDetailLarge.visibility = GONE
        }
    }

    companion object {
        const val MAX_SCALE = 5F
        const val MIN_SCALE = 1F
    }

}

        对于视频组件,需要自定义 View 及其 ViewGroup。

class VideoPlayerView : StandardGSYVideoPlayer {

    constructor(context: Context?) : super(context)

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)

    constructor(context: Context?, fullFlag: Boolean?) : super(context, fullFlag)

    override fun getLayoutId(): Int = R.layout.video_player_view

    override fun touchSurfaceMoveFullLogic(absDeltaX: Float, absDeltaY: Float) {
        super.touchSurfaceMoveFullLogic(absDeltaX, absDeltaY)
        //不给触摸快进,如果需要,屏蔽下方代码即可
        mChangePosition = false

        //不给触摸音量,如果需要,屏蔽下方代码即可
        mChangeVolume = false

        //不给触摸亮度,如果需要,屏蔽下方代码即可
        mBrightness = false
    }

    override fun touchDoubleUp(e: MotionEvent?) {
        //super.touchDoubleUp();
        //不需要双击暂停
    }

}

        对应的,视频播放的核心操作逻辑放在 VideoPlayerViewGroup。

class VideoPlayerViewGroup(context: Context, attrs: AttributeSet?) :
    ConstraintLayout(context, attrs), GSYVideoProgressListener, SeekBar.OnSeekBarChangeListener {

    companion object {
        private const val DELAY_DISMISS_3_SECOND = 3000L
        private const val VIDEO_RESOLUTION_BEYOND_2K = 3500f
    }

    private val binding by lazy {
        VideoPlayerViewGroupBinding.inflate(LayoutInflater.from(context), this, true)
    }

    private val runnable: Runnable = Runnable {
        binding.playBtn.isVisible = false
        binding.currentPosition.isVisible = false
        binding.seekbar.isVisible = false
        binding.duration.isVisible = false
    }

    private var videoInfo: VideoInfo? = null

    /**
     * seekbar拖动后的进度
     */
    private var seekbarTouchFinishProgress = -1L

    /**
     * 视频未初始化时,记录seekbar拖动后的进度,初始化后,需定位到记录位置。
     */
    private var seekbarTouchFinishProgressNotInitial = -1L

    fun initVideoInfo(videoInfo: VideoInfo) {
        this.videoInfo = videoInfo
        updateVideoSeekbar(0, 0, videoInfo.duration)

        if (videoInfo.firstFrame != null) {
            binding.firstFrame.setImageBitmap(videoInfo.firstFrame)
        } else {
            CoroutineScope(Dispatchers.Main).launch {
                videoInfo.firstFrame = getVideoThumbnail(context, videoInfo.localPathUri)
                binding.firstFrame.setImageBitmap(videoInfo.firstFrame)
            }
        }
        binding.playBtn.setOnClickListener { handleVideoPlayer() }
        binding.clickScreen.setOnClickListener { handleClickScreen() }
        binding.seekbar.setOnSeekBarChangeListener(this)

        if (!binding.firstFrame.isVisible) restoreVideoPlayer()
        if (!binding.seekbar.isVisible) {
            binding.currentPosition.isVisible = true
            binding.seekbar.isVisible = true
            binding.duration.isVisible = true
            binding.playBtn.isVisible = true
        }
    }

    /**
     * 更新seekbar
     */
    private fun updateVideoSeekbar(
        progress: Int, currentPosition: Long, duration: Long
    ) {
        binding.currentPosition.text = convertToVideoTimeFromSecond(currentPosition / 1000)
        binding.seekbar.progress = progress
        binding.duration.text = convertToVideoTimeFromSecond(duration / 1000)
    }

    private fun handleVideoPlayer() {
        when {
            binding.firstFrame.isVisible -> {
                seekbarTouchFinishProgress = -1L
                binding.firstFrame.isVisible = false
                binding.playBtn.setImageResource(R.drawable.ic_pause)

                // 初始化播放
                binding.videoPlayer.backButton?.isVisible = false
                if (videoInfo?.width ?: 0f > VIDEO_RESOLUTION_BEYOND_2K || videoInfo?.height ?: 0f > VIDEO_RESOLUTION_BEYOND_2K) {
                    PlayerFactory.setPlayManager(SystemPlayerManager::class.java)
                } else {
                    PlayerFactory.setPlayManager(IjkPlayerManager::class.java)
                }
                binding.videoPlayer.setUp(videoInfo?.localPathUri, true, "")
                binding.videoPlayer.startPlayLogic()
                binding.videoPlayer.setGSYVideoProgressListener(this)
                binding.videoPlayer.setVideoAllCallBack(object : GSYSampleCallBack() {
                    override fun onAutoComplete(url: String?, vararg objects: Any?) {
                        super.onAutoComplete(url, *objects)
                        restoreVideoPlayer()
                        updateVideoSeekbar(0, 0, videoInfo?.duration ?: 0)
                    }
                })

                if (binding.seekbar.isVisible) {
                    seekbarDelayDismiss()
                } else {
                    binding.playBtn.isVisible = false
                }
            }

            binding.videoPlayer.currentState == GSYVideoView.CURRENT_STATE_PAUSE -> {
                binding.playBtn.setImageResource(R.drawable.ic_pause)
                binding.videoPlayer.onVideoResume(false)

                // playBtn不可见,则底部seekbar不可见
                if (binding.seekbar.isVisible) {
                    seekbarDelayDismiss()
                } else {
                    binding.playBtn.isVisible = false
                }
            }

            binding.videoPlayer.isInPlayingState -> {
                binding.playBtn.setImageResource(R.drawable.ic_play)
                binding.videoPlayer.onVideoPause()

                handler.removeCallbacks(runnable)
            }
        }
    }

    private fun handleClickScreen() {
        when {
            binding.firstFrame.isVisible -> {
                if (binding.videoPlayer.currentState == -1 || binding.videoPlayer.currentState == CURRENT_STATE_NORMAL) {
                    binding.currentPosition.isVisible = !binding.currentPosition.isVisible
                    binding.seekbar.isVisible = !binding.seekbar.isVisible
                    binding.duration.isVisible = !binding.duration.isVisible
                    binding.playBtn.isVisible = true
                }
            }

            binding.videoPlayer.currentState == GSYVideoView.CURRENT_STATE_PAUSE -> {
                binding.currentPosition.isVisible = !binding.currentPosition.isVisible
                binding.seekbar.isVisible = !binding.seekbar.isVisible
                binding.duration.isVisible = !binding.duration.isVisible
                binding.playBtn.isVisible = true
            }

            binding.videoPlayer.isInPlayingState -> {
                if (binding.playBtn.isVisible) {
                    if (binding.seekbar.isVisible) {
                        handler.post(runnable)
                    } else {
                        binding.currentPosition.isVisible = true
                        binding.seekbar.isVisible = true
                        binding.duration.isVisible = true

                        seekbarDelayDismiss()
                    }
                } else {
                    if (!binding.seekbar.isVisible) {
                        binding.currentPosition.isVisible = true
                        binding.seekbar.isVisible = true
                        binding.duration.isVisible = true
                    }
                    binding.playBtn.isVisible = true
                    seekbarDelayDismiss()
                }
            }
        }
    }

    /**
     * 进度条延迟3秒消失
     */
    private fun seekbarDelayDismiss() {
        handler.removeCallbacks(runnable)
        handler.postDelayed(runnable, DELAY_DISMISS_3_SECOND)
    }

    /**
     * 还原播放
     */
    private fun restoreVideoPlayer() {
        binding.firstFrame.isVisible = true
        binding.playBtn.setImageResource(R.drawable.ic_play)
        binding.playBtn.isVisible = true
        binding.currentPosition.isVisible = true
        binding.seekbar.isVisible = true
        binding.duration.isVisible = true
        if (handler != null) handler.removeCallbacks(runnable)

        binding.videoPlayer.onVideoPause()
        binding.videoPlayer.release()
        binding.videoPlayer.onVideoReset()
        binding.videoPlayer.setVideoAllCallBack(null)
    }

    override fun onProgress(
        progress: Long, secProgress: Long, currentPosition: Long, duration: Long
    ) {
        if (seekbarTouchFinishProgress != -1L && progress <= binding.seekbar.progress) {
            return
        }
        seekbarTouchFinishProgress = -1
        if (seekbarTouchFinishProgressNotInitial != -1L) {
            seekbarTouchFinishProgress = seekbarTouchFinishProgressNotInitial
            binding.videoPlayer.seekTo(seekbarTouchFinishProgressNotInitial)
            seekbarTouchFinishProgressNotInitial = -1
            return
        }
        updateVideoSeekbar(progress.toInt(), currentPosition, videoInfo?.duration ?: 0)
    }

    override fun onStartTrackingTouch(seekBar: SeekBar) {
        handler.removeCallbacks(runnable)
    }

    override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
    }

    override fun onStopTrackingTouch(seekBar: SeekBar) {
        val position = (seekBar.progress.toLong() + 1) * (videoInfo?.duration ?: 0) / 100
        if (binding.videoPlayer.isInPlayingState) seekbarTouchFinishProgress = position
        binding.currentPosition.text = convertToVideoTimeFromSecond(position / 1000)
        binding.videoPlayer.seekTo(position)
        if (binding.videoPlayer.isInPlayingState && binding.videoPlayer.currentState != GSYVideoView.CURRENT_STATE_PAUSE) seekbarDelayDismiss()
        if (binding.videoPlayer.currentState == -1 || binding.videoPlayer.currentState == CURRENT_STATE_NORMAL) {
            seekbarTouchFinishProgressNotInitial = position
        }
    }

}

        最后,附上 xml 文件。

1)media_detail_item_adapter.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.agg.mocamera.portal.feature.home.view.VideoPlayerViewGroup
        android:id="@+id/vpViewGroup"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:visibility="gone" />

    <com.github.chrisbanes.photoview.PhotoView
        android:id="@+id/ivAvatarDetail"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
        android:id="@+id/ivAvatarDetailLarge"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:visibility="invisible" />

</androidx.constraintlayout.widget.ConstraintLayout>

2)video_player_view.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#121212">

    <FrameLayout
        android:id="@+id/surface_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center" />

</RelativeLayout>

3)video_player_view_group.xml

<?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/videoViewParent"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:ignore="SpUsage">

    <com.agg.mocamera.portal.feature.home.view.VideoPlayerView
        android:id="@+id/videoPlayer"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <View
        android:id="@+id/clickScreen"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <ImageView
        android:id="@+id/firstFrame"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:contentDescription="@null"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/playBtn"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:contentDescription="@null"
        android:src="@drawable/ic_play"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.agg.ui.MBTextView
        android:id="@+id/currentPosition"
        android:layout_width="wrap_content"
        android:layout_height="20dp"
        android:layout_marginStart="15dp"
        android:layout_marginBottom="58dp"
        android:gravity="center"
        android:textColor="#FFFFFF"
        android:textSize="12dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        tools:text="00:00" />

    <SeekBar
        android:id="@+id/seekbar"
        style="@style/Widget.AppCompat.ProgressBar.Horizontal"
        android:layout_width="0dp"
        android:layout_height="24dp"
        android:duplicateParentState="false"
        android:max="100"
        android:maxHeight="6dp"
        android:minHeight="6dp"
        android:progress="0"
        android:progressDrawable="@drawable/video_seek_bar_progress"
        android:splitTrack="false"
        android:thumb="@drawable/ic_video_seek_bar_thumb"
        app:layout_constraintBottom_toBottomOf="@id/currentPosition"
        app:layout_constraintEnd_toStartOf="@id/duration"
        app:layout_constraintStart_toEndOf="@id/currentPosition"
        app:layout_constraintTop_toTopOf="@id/currentPosition" />

    <com.agg.ui.MBTextView
        android:id="@+id/duration"
        android:layout_width="wrap_content"
        android:layout_height="20dp"
        android:layout_marginEnd="15dp"
        android:gravity="center"
        android:textColor="#FFFFFF"
        android:textSize="12dp"
        app:layout_constraintBottom_toBottomOf="@id/currentPosition"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@id/currentPosition"
        tools:text="04:59" />

</androidx.constraintlayout.widget.ConstraintLayout>

5.6 获取数据并加载到 View 显示

    private var allPhotoInfoList: MutableList<MediaInfo> = mutableListOf()
    private var mixMediaInfoList = MixMediaInfoList()
    private var bigDetailAdapter: MediaDetailItemAdapter? = null


    private fun initAllMediaInfoList() {
        viewModel.getSysPhotos(contentResolver)
        viewModel.getSysVideos(contentResolver)
    }

    override fun initViewModel() {
        viewModel.getSysPhotosLiveData.observe(this) { photoList ->
            Log.i(TAG, "initViewModel: photoList = ${photoList.size}")
            if (!mixMediaInfoList.isLoadPhotoListFinish) {
                mixMediaInfoList.isLoadPhotoListFinish = true
                mixMediaInfoList.photoList.addAll(photoList)
                if (mixMediaInfoList.isLoadVideoListFinish) {
                    lifecycleScope.launch { updateViewPager(mixMediaInfoList.getMaxMediaList()) }
                }
            }
        }
        viewModel.getSysVideosLiveData.observe(this) { videoList ->
            Log.i(TAG, "initViewModel: videoList = ${videoList.size}")
            if (!mixMediaInfoList.isLoadVideoListFinish) {
                mixMediaInfoList.isLoadVideoListFinish = true
                mixMediaInfoList.videoList.addAll(videoList)
                if (mixMediaInfoList.isLoadPhotoListFinish) {
                    lifecycleScope.launch { updateViewPager(mixMediaInfoList.getMaxMediaList()) }
                }
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        bigDetailAdapter?.releaseAllVideos()
    }

5.7 页面前后滑处理

    override fun scrollForward() {
        val position = binding.bvFeedPhotoContent.currentItem
        Log.i(TAG, "scrollForward: position=$position,size=${allPhotoInfoList.size}")
        binding.bvFeedPhotoContent.currentItem = position + 1
    }

    override fun scrollBackward() {
        val position = binding.bvFeedPhotoContent.currentItem
        Log.i(TAG, "scrollBackward: position=$position,size=${allPhotoInfoList.size}")
        binding.bvFeedPhotoContent.currentItem = position - 1
    }

5.8 gradle 依赖

    // CameraX core library using the camera2 implementation
    // The following line is optional, as the core library is included indirectly by camera-camera2
    // implementation "androidx.camera:camera-core:${camerax_version}"
    implementation "androidx.camera:camera-camera2:${CAMERAX}"
    // If you want to additionally use the CameraX Lifecycle library
    implementation "androidx.camera:camera-lifecycle:${CAMERAX}"
    // If you want to additionally use the CameraX View class
    implementation "androidx.camera:camera-view:${CAMERA_VIEW}"
    // If you want to additionally use the CameraX Extensions library
    // implementation "androidx.camera:camera-extensions:1.0.0-alpha31"

    implementation "com.github.zhpanvip:BannerViewPager:${BANNER_VIEW_PAGER}"
    implementation "com.github.chrisbanes:PhotoView:${PHOTO_VIEW}"
    implementation "com.davemorrissey.labs:subsampling-scale-image-view-androidx:${SUBSAMPLING_SCALE_IMAGE_VIEW_ANDROIDX}"
    // ijkplayer
    implementation "com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-java:${GSY_VIDEO_PLAYER}"
    implementation "com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-armv7a:${GSY_VIDEO_PLAYER}"
    implementation 'com.tencent.tav:libpag:4.2.41'


    CAMERAX = "1.1.0-alpha11"
    CAMERA_VIEW = "1.0.0-alpha31"
    BANNER_VIEW_PAGER = "3.5.0"
    PHOTO_VIEW = "2.0.0"
    SUBSAMPLING_SCALE_IMAGE_VIEW_ANDROIDX = "3.10.0"
    GSY_VIDEO_PLAYER = "v8.3.4-release-jitpack"

6. ✅ 小结

        对于 AR 眼镜上的 Camera 功能,可以去实现很多有意思的产品功能,本文只是一个基础的拍照、录像和查看实现方案,更多业务细节请参考产品逻辑去创造。

        另外,由于本人能力有限,如有错误,敬请批评指正,谢谢。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2234304.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

信息安全工程师(76)网络安全应急响应技术原理与应用

前言 网络安全应急响应&#xff08;Network Security Incident Response&#xff09;是针对潜在或已发生的网络安全事件而采取的网络安全措施&#xff0c;旨在降低网络安全事件所造成的损失并迅速恢复受影响的系统和服务。 一、网络安全应急响应概述 定义&#xff1a;网络安全应…

用图说明 CPU、MCU、MPU、SoC 的区别

CPU CPU 负责执行构成计算机程序的指令&#xff0c;执行这些指令所指定的算术、逻辑、控制和输入/输出&#xff08;I/O&#xff09;操作。 MCU (microcontroller unit) 不同的 MCU 架构如下&#xff0c;注意这里的 MPU 表示 memory protection unit MPU (microprocessor un…

vue3动态监听div高度案例

案例场景 场景描述&#xff1a;现在左边的线条长度需要根据右边盒子的高度进行动态变化 实践代码案例 HTML部分 <div v-for"(device, index) in devices" :key"index"><!-- 动态设置 .left-bar 的高度 --><div class"left-bar"…

【Docker系列】指定系统平台拉取 openjdk:8 镜像

&#x1f49d;&#x1f49d;&#x1f49d;欢迎来到我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐:kwan 的首页,持续学…

【含文档+源码】基于SpringBoot+Vue的新型吃住玩一体化旅游管理系统的设计与实现

开题报告 本文旨在探讨新型吃住玩一体化旅游管理系统的设计与实现。该系统融合了用户注册与登录、旅游景点管理、旅游攻略发帖、特色旅游路线推荐、附近美食推荐以及酒店客房推荐与预定等多项功能&#xff0c;旨在为游客提供全方位、一体化的旅游服务体验。在系统设计中&#…

B3735 [信息与未来 2018] 圣诞树

题目描述 圣诞树共有 nn 层&#xff0c;从上向下数第 11 层有 11 个星星、第 22 层有 22 个星星、以此类推&#xff0c;排列成下图所示的形状。 星星和星星之间用绳子连接。第 1,2,\cdots, n - 11,2,⋯,n−1 层的每个星星都向下一层最近的两个星星连一段绳子&#xff0c;最后一…

解决 Hypack 安装不能正常运行的引导及微软 VC++ 运行库 VCRedist

解决 Hypack 安装不能正常运行的引导及微软 VC 运行库 VCRedist 前言1、常见 Hypack 安装不能正常运行的错误1.1、无法打开大地测量参数1.2、无法运行硬件设置和组合 2、从 Hypack 2013 开始&#xff0c;程序安装后&#xff0c;在程序目标目录&#xff0c;有支持目录 Support &…

给大模型研究生一些救命建议

这篇写给大模型方向的研一新生&#xff0c;我呆证看完以后能救你们大命 首先我知道大模型算法工程师这个 title 十分诱人&#xff0c;你们现在也被导师说得一腔热血。 但是&#xff0c;大模型它跟七八年前的 CV、NLP 都不太一样&#xff0c;最不一样的点在哪里呢? 就是LLM …

Oracle基础查询

第一章 数据查询 1.1 单表查询 1.1.1 数据准备 找到分享的sql文件&#xff0c;选中文件&#xff0c;右键进行复制&#xff0c;选中以wateruser用户登录的连接&#xff0c;右键粘贴&#xff0c;然后ok确认&#xff0c;就可以将两个sql文件添加到了Datagrip的工程中&#xff0c;打…

解决com.mysql.jdbc.NonRegisteringDriver内存泄漏问题

1. 问题背景 线上出现内存报警&#xff0c;通过dump文件&#xff0c;MAT分析&#xff0c;发现mysql-connector-java 有内存泄漏问题 2.问题分析 然后看大对象列表&#xff0c;NonRegisteringDriver 对象确实占内存比较多&#xff0c;里面村的数据库连接的虚引用占比较多 3.解…

如何优雅处理异常?处理异常的原则

前言 在我们日常工作中&#xff0c;经常会遇到一些异常&#xff0c;比如&#xff1a;NullPointerException、NumberFormatException、ClassCastException等等。 那么问题来了&#xff0c;我们该如何处理异常&#xff0c;让代码变得更优雅呢&#xff1f; 1 不要忽略异常 不知…

华为2288HV2服务器安装BCLinux8U6无法显示完整安装界面的问题处理

本文记录了华为2288HV2服务器安装BCLinux8U6无法显示完整安装界面&#xff0c;在安装过程中配置选择时&#xff0c;右侧安装按钮不可见&#xff0c;导致安装无法继续的问题处理过程。 一、问题现象 华为2288HV2服务器安装BCLinux8U6时无法显示完整的安装界面&#xff0c;问题…

Qt多边形填充/不填充绘制

1 填充多边形绘制形式 void GraphicsPolygonItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {Q_UNUSED(option);Q_UNUSED(widget);//painter->setPen(pen()); // 设置默认画笔//painter->setBrush(brush()); // 设置默…

Python设计模式探究:单例模式实现及应用解析

&#x1f49d;&#x1f49d;&#x1f49d;欢迎莅临我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐&#xff1a;「storm…

Linux权限解析:用户、组和权限的协同

​​​​​​​在Linux系统中&#xff0c;权限决定了谁能做什么。本文将指导你如何掌握这些权限&#xff0c;以确保你的系统既安全又高效&#xff01; 目录 1.shell命令及其运行原理 2.Linu权限的概念 (1) 用户 (2) 切换用户命令su (3) 指令提权命令sudo (4) 什么是权限…

java、excel表格合并、指定单元格查找、合并文件夹

#创作灵感# 公司需求 记录工作内容 后端&#xff1a;JAVA、Solon、easyExcel、FastJson2 前端&#xff1a;vue2.js、js、HTML 模式1&#xff1a;合并文件夹 * 现有很多文件夹 想合并全部全部的文件夹的文件到一个文件夹内 * 每个部门发布的表格 合并全部的表格为方便操作 模…

koa项目实战 == 实现注册登录鉴权

一. 项目的初始化 1 npm 初始化 npm init -y生成package.json文件: 记录项目的依赖 2 git 初始化 git init生成’.git’隐藏文件夹, git 的本地仓库 3 创建 ReadMe 文件 二. 搭建项目 1 安装 Koa 框架 npm install koa2 编写最基本的 app 创建src/main.js const Koa…

JAVA基础:单元测试;注解;枚举;网络编程 (学习笔记)

单元测试 操作步骤&#xff1a; a.导包import org.junit; b.三个注解 Test Before After c.点击Test 运行就可以了 用在不需要控制台输入的情境下&#xff1a;javaweb&#xff0c;框架项目&#xff0c;微服务项目 供开发人员自己做测试。 package com.page…

四个TikTok万能爆单选品法,第1个超过75%卖家会用!

做TK想爆单至关键的一步就是选品&#xff01;7分靠选品、3分靠运营&#xff0c;一开始你要把品选错了&#xff0c;再怎么运营都是在浪费时间。接下来分享一些万能选品思路&#xff0c;不管做什么类目的商家都可以参考&#xff01; 一、热卖品榜单选品 这是一种很常见&#xf…

Linux开发工具——make/Makefile

目录 一、什么是makefile&#xff1f; 二、为什么要有makefile&#xff1f; 三、makefile的使用 1.依赖关系与依赖方法 2.伪目标 3.定义变量 4.特殊符号 四、makefile的执行逻辑 一、什么是makefile&#xff1f; Makefile是一种自动化构建工具&#xff0c;make是一条指…