📜  视频播放器 jetpack compose (1)

📅  最后修改于: 2023-12-03 15:11:56.297000             🧑  作者: Mango

视频播放器 Jetpack Compose

Jetpack Compose 是 Android 官方推出的一款全新的 UI 工具包,它使用 Kotlin 语言、采用声明式、函数式的方式来构建界面,使得 UI 代码更加简洁易懂。在 Jetpack Compose 的帮助下,我们可以创建出一款优秀的视频播放器。

功能特点
  • 播放本地视频文件
  • 播放网络视频
  • 暂停播放/继续播放/停止播放
  • 拖动进度条跳转播放进度
  • 横屏/竖屏自适应
  • 播放列表管理
技术栈
  • Jetpack Compose
  • exoPlayer
架构设计

使用 Jetpack Compose 构建视频播放器的架构设计如下:

┌──────────────────┐
│      Player      │
└──────────────────┘
           │
           │ 视频状态变化
           ▼
┌──────────────────┐
│  Composable UI   │
└──────────────────┘
           │
           │  UI 事件
           ▼
┌──────────────────┐
│   Composable VM  │
└──────────────────┘
  • Player:视频播放器,负责管理视频播放状态,与UI无关
  • Composable UI:播放器UI,基于 Compose 构建
  • Composable VM:UI数据模型,负责管理UI状态
界面展示

界面有点简陋,为了便于演示。此处用图片代替。

  • 竖屏模式

image

  • 横屏模式

image

示例代码
1. 定义UI界面
@Composable
fun VideoPlayerScreen(viewModel: VideoPlayerViewModel) {
    val videoState by viewModel.videoState.observeAsState(VideoPlayerState())

    Box(modifier = Modifier.fillMaxSize()) {
        if (videoState.isLandScape) {
            // 横屏状态
            LandscapePlayerView(videoState)
        } else {
            // 竖屏状态
            PortraitPlayerView(videoState)
        }
        PlayerSurface(videoState, viewModel)
        if (videoState.videoUrl.isNotEmpty()) {
            ControlView(videoState, viewModel)
        }
    }
}
2. 定义 Composable VM 数据模型
class VideoPlayerViewModel : ViewModel() {
    var videoState = mutableStateOf(VideoPlayerState())
    private var currentPosition = 0L
    private var resumePosition = 0L
    
    /**
     * 播放指定的本地视频文件
     */
    fun playLocalVideo(context: Context, assetName: String) {
        val fileDescriptor = context.assets.openFd(assetName)
        val dataSourceFactory = DefaultDataSourceFactory(context, "exoplayer-sample")
        val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(fileDescriptor.uri)
        play(mediaSource)
    }

    /**
     * 播放指定的网络视频
     */
    fun playOnlineVideo(context: Context, url: String) {
        val dataSourceFactory = DefaultDataSourceFactory(context, "exoplayer-sample")
        val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(Uri.parse(url))
        play(mediaSource)
    }

    /**
     * 开始播放视频
     */
    private fun play(mediaSource: MediaSource) {
        videoState.value = videoState.value.copy(isPlaying = true)
        val player = SimpleExoPlayer.Builder(appContext).build()
        player.prepare(mediaSource)
        player.playWhenReady = true
        player.seekTo(currentPosition)
        videoState.value = videoState.value.copy(player = player, videoUrl = mediaSource.toString())
        player.addListener(object : Player.EventListener {
            override fun onPlaybackStateChanged(@Player.State playbackState: Int) {
                super.onPlaybackStateChanged(playbackState)
                when (playbackState) {
                    Player.STATE_READY -> {
                        videoState.value = videoState.value.copy(duration = player.duration)
                    }
                    Player.STATE_ENDED -> {
                        videoState.value = videoState.value.copy(isPlaying = false)
                    }
                }
            }

            override fun onPositionDiscontinuity(reason: Int) {
                super.onPositionDiscontinuity(reason)
                currentPosition = player.currentPosition
            }

            override fun onIsPlayingChanged(isPlaying: Boolean) {
                super.onIsPlayingChanged(isPlaying)
                if (isPlaying) {
                    videoState.value = videoState.value.copy(isPlaying = true)
                } else {
                    videoState.value = videoState.value.copy(isPlaying = false)
                }
            }
        })
    }

    /**
     * 开始播放下一条视频
     */
    fun playNextVideo(context: Context, urlList: List<String>) {
        val currentIndex = urlList.indexOf(videoState.value.videoUrl)
        if (currentIndex == urlList.size - 1) {
            context.toast("已经是最后一条视频了")
            return
        }
        currentPosition = 0L
        resumePosition = 0L
        val nextIndex = currentIndex + 1
        val nextUrl = urlList[nextIndex]
        playOnlineVideo(context, nextUrl)
    }

    /**
     * 暂停视频播放
     */
    fun pause() {
        videoState.value = videoState.value.copy(isPlaying = false)
        resumePosition = videoState.value.player?.currentPosition ?: 0L
        videoState.value.player?.playWhenReady = false
    }

    /**
     * 继续视频播放
     */
    fun resume() {
        videoState.value = videoState.value.copy(isPlaying = true)
        videoState.value.player?.playWhenReady = true
    }

    /**
     * 停止视频播放
     */
    fun stop() {
        videoState.value = videoState.value.copy(isPlaying = false)
        videoState.value.player?.stop()
    }

    /**
     * 跳转至指定时间
     */
    fun seekTo(timeMs: Long) {
        videoState.value.player?.seekTo(timeMs)
    }

    @RequiresApi(Build.VERSION_CODES.N)
    fun isPortrait(activity: Activity): Boolean {
        return activity.display?.rotation == Surface.ROTATION_0 || activity.display?.rotation == Surface.ROTATION_180
    }

    /**
     * 切换横竖屏
     */
    fun toggleScreen(activity: Activity) {
        if (isPortrait(activity)) {
            activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
            videoState.value = videoState.value.copy(isLandScape = true)
        } else {
            activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
            videoState.value = videoState.value.copy(isLandScape = false)
        }
    }
}
3. 定义视频播放器状态模型
data class VideoPlayerState(
    val isPlaying: Boolean = false, // 是否正在播放
    val isLandScape: Boolean = false,// 是否横屏
    val videoUrl: String = "", // 视频URL
    val player: SimpleExoPlayer? = null, // 播放器
    val duration: Long = 0L, // 视频总时长
    val currentTime: Long = 0L // 视频当前播放进度
)
4. 定义控件 View
@Composable
fun ControlView(
    videoState: VideoPlayerState,
    viewModel: VideoPlayerViewModel
) {
    Column(
        Modifier
            .background(Color.Black.copy(alpha = 0.3f))
            .fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Box(modifier = Modifier.weight(1f)) // 占位符
        ControlMenu(
            videoState,
            viewModel,
            Modifier
                .align(Alignment.CenterHorizontally)
                .padding(16.dp)
        )
    }
}

@Composable
fun ControlMenu(
    videoState: VideoPlayerState,
    viewModel: VideoPlayerViewModel,
    modifier: Modifier = Modifier
) {
    Row(modifier = modifier) {
        IconButton(
            modifier = Modifier.size(56.dp),
            onClick = { viewModel.playNextVideo(appContext, urlList) }) {
            Icon(
                imageVector = Icons.Default.SkipNext,
                contentDescription = null
            )
        }
        if (videoState.isPlaying) {
            IconButton(
                modifier = Modifier.size(56.dp),
                onClick = { viewModel.pause() }) {
                Icon(
                    imageVector = Icons.Default.Pause,
                    contentDescription = null
                )
            }
        } else {
            IconButton(
                modifier = Modifier.size(56.dp),
                onClick = { viewModel.resume() }) {
                Icon(
                    imageVector = Icons.Default.PlayArrow,
                    contentDescription = null
                )
            }
        }
        IconButton(
            modifier = Modifier.size(56.dp),
            onClick = { viewModel.stop() }) {
            Icon(
                painter = painterResource(id = R.drawable.ic_stop),
                contentDescription = null
            )
        }
    }
}

@Composable
fun PlayerSurface(
    videoState: VideoPlayerState,
    viewModel: VideoPlayerViewModel
) {
    val surfaceView = remember { SurfaceView(appContext) }
    AndroidView({ surfaceView }) { view ->
        view.holder.addCallback(object : SurfaceHolder.Callback {
            override fun surfaceCreated(holder: SurfaceHolder) {
                videoState.player?.setVideoSurface(holder.surface)
            }

            override fun surfaceDestroyed(holder: SurfaceHolder) {
                // no-op
            }

            override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
                // no-op
            }
        })
    }

    DisposableEffect(videoState.player) {
        onDispose {
            videoState.player?.release()
        }
        videoState.player?.playbackState?.let { playbackState ->
            if (playbackState == Player.STATE_IDLE) {
                if (videoState.videoUrl.startsWith("http")) {
                    viewModel.playOnlineVideo(appContext, videoState.videoUrl)
                } else {
                    viewModel.playLocalVideo(appContext, videoState.videoUrl)
                }
            }
        }
        videoState.player?.videoComponent?.addVideoListener(object : VideoListener {
            override fun onRenderedFirstFrame() {
                viewModel.seekTo(viewModel.resumePosition)
            }

            override fun onVideoSizeChanged(width: Int, height: Int, _unused_: Int, _unused2_: Float) {
                videoState.player?.setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING)
            }
        })

        // The onPositionChanged callback is called whenever the position changes,
        // but we only want to re-compose when the _displayed_ position changes. This
        // ensures that we don't cause unnecessary re-compose calls, since they
        // are very expensive.
        var lastDisplayedPosition = -1L

        val updatePosition = {
            val newPosition = videoState.player?.currentPosition ?: 0L
            if (newPosition != lastDisplayedPosition) {
                lastDisplayedPosition = newPosition
                viewModel.videoState.value = videoState.copy(currentTime = newPosition)
            }
        }

        val handler = Handler(Looper.getMainLooper())
        val updatePositionRunnable = object : Runnable {
            override fun run() {
                updatePosition()
                handler.postDelayed(this, 16)
            }
        }

        videoState.player?.addListener(object : Player.EventListener {
            override fun onPlaybackStateChanged(@Player.State playbackState: Int) {
                super.onPlaybackStateChanged(playbackState)
                if (playbackState == Player.STATE_READY) {
                    handler.postDelayed(updatePositionRunnable, 16)
                    updatePosition()
                } else {
                    handler.removeCallbacks(updatePositionRunnable)
                }
            }
        })

        // When view composition is complete, start playback if necessary
        if (videoState.isPlaying) {
            viewModel.resume()
        }
        onDispose {
            videoState.player?.stop()
        }
    }
}
完整代码

完整代码示例见 GitHub

结语

随着 Jetpack Compose 的不断完善,它将会成为未来 Android UI 开发的主流方式。通过本文,我们了解了如何使用 Jetpack Compose 构建一个简单的视频播放器,利用 Compose 的优势,让界面代码更加简洁易懂,提升了开发效率,同时又可以更好地维护代码,是一个非常棒的开发方式。