Skip to content

Instantly share code, notes, and snippets.

@lby1992
Last active August 31, 2026 16:01
Show Gist options
  • Select an option

  • Save lby1992/608be5e0b50b4f152a9a72c389379df7 to your computer and use it in GitHub Desktop.

Select an option

Save lby1992/608be5e0b50b4f152a9a72c389379df7 to your computer and use it in GitHub Desktop.
package dev.dl.demoapp.player
import android.content.Context
import android.graphics.Matrix
import android.graphics.RectF
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.Surface
import android.view.TextureView
import android.widget.FrameLayout
/**
* TextureView 视频显示 + Zoom / Pan。
*
* 默认 ResizeMode = FitCenter。
*
* 不假设视频为 16:9。
*
* 支持:
* - 任意视频宽高比
* - ExoPlayer / Media3 pixelWidthHeightRatio
* - IJK / FFmpeg SAR
* - 双指缩放
* - 双指中心缩放
* - 单指拖动
* - 边界限制
* - 横竖屏切换后 resetZoomAndTranslate()
*/
class ZoomableTextureVideoView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : FrameLayout(
context,
attrs,
defStyleAttr,
) {
// ========================================================================
// TextureView
// ========================================================================
private val textureView = TextureView(context)
private var surface: Surface? = null
/**
* Surface 创建 / 销毁回调。
*
* available:
* Surface
*
* destroyed:
* null
*/
var onSurfaceChanged: ((Surface?) -> Unit)? = null
// ========================================================================
// Gesture
// ========================================================================
private val scaleGestureDetector = ScaleGestureDetector(context, ScaleGestureListener())
private val generalGestureDetector = GestureDetector(context, GeneralGestureListener())
var onSingleTap: ((x: Float, y: Float) -> Unit)? = null
var onDoubleTap: ((x: Float, y: Float) -> Unit)? = null
private var activePointerId =
MotionEvent.INVALID_POINTER_ID
private var lastTouchX = 0f
private var lastTouchY = 0f
// ========================================================================
// Video information
// ========================================================================
private var videoWidth = 0
private var videoHeight = 0
/**
* Pixel aspect ratio。
*
* ExoPlayer:
*
* pixelWidthHeightRatio
*
* IJK / FFmpeg:
*
* sarNum / sarDen
*/
private var pixelAspectRatio = 1f
// ========================================================================
// Matrix
// ========================================================================
/**
* FitCenter Matrix。
*
* 只负责:
*
* TextureView
* ↓
* FitCenter
*/
private val baseMatrix = Matrix()
/**
* 用户操作 Matrix。
*
* 只负责:
*
* Zoom + Pan
*/
private val userMatrix = Matrix()
/**
* 最终 Matrix。
*/
private val finalMatrix = Matrix()
// ========================================================================
// Zoom
// ========================================================================
private var zoom = 1f
var maxZoom = 4f
// ========================================================================
// Init
// ========================================================================
init {
addView(
textureView,
LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT,
),
)
textureView.surfaceTextureListener =
object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(
surfaceTexture: SurfaceTexture,
width: Int,
height: Int,
) {
Log.i("ZoomTest", "onSurfaceTextureAvailable")
// 释放掉旧的
surface?.release()
surface = Surface(surfaceTexture)
onSurfaceChanged?.invoke(surface)
}
override fun onSurfaceTextureSizeChanged(
surfaceTexture: SurfaceTexture,
width: Int,
height: Int,
) {
Log.i("ZoomTest", "onSurfaceTextureSizeChanged")
// Surface 没有发生变化。
//
// TextureView 的尺寸变化由 View 自己处理。
}
override fun onSurfaceTextureDestroyed(
surfaceTexture: SurfaceTexture,
): Boolean {
Log.i("ZoomTest", "onSurfaceTextureDestroyed")
onSurfaceChanged?.invoke(null)
surface?.release()
surface = null
return true
}
override fun onSurfaceTextureUpdated(
surfaceTexture: SurfaceTexture,
) {
// Log.i("ZoomTest", "onSurfaceTextureUpdated")
// 不需要处理。
}
}
textureView.setLayerType(LAYER_TYPE_HARDWARE, null)
}
// ========================================================================
// Video size - ExoPlayer
// ========================================================================
/**
* Media3 / ExoPlayer。
*
* videoSize.pixelWidthHeightRatio
*/
fun updateVideoSize(
width: Int,
height: Int,
pixelWidthHeightRatio: Float,
) {
if (width <= 0 || height <= 0) {
return
}
videoWidth = width
videoHeight = height
pixelAspectRatio = if (pixelWidthHeightRatio > 0f) {
pixelWidthHeightRatio
} else {
1f
}
rebuildBaseMatrix()
clampTranslation()
applyMatrix()
}
// ========================================================================
// Video size - IJK / FFmpeg
// ========================================================================
/**
* IJK / FFmpeg。
*
* SAR = sarNum / sarDen
*/
fun updateVideoSize(
width: Int,
height: Int,
sarNum: Int,
sarDen: Int,
) {
if (width <= 0 || height <= 0) {
return
}
videoWidth = width
videoHeight = height
pixelAspectRatio = if (sarNum > 0 && sarDen > 0) {
sarNum.toFloat() / sarDen.toFloat()
} else {
1f
}
rebuildBaseMatrix()
clampTranslation()
applyMatrix()
}
// ========================================================================
// View size changed
// ========================================================================
override fun onSizeChanged(
width: Int,
height: Int,
oldWidth: Int,
oldHeight: Int,
) {
super.onSizeChanged(
width,
height,
oldWidth,
oldHeight,
)
Log.i("ZoomTest", "onSizeChanged: ${width}x${height}")
rebuildBaseMatrix()
/**
* 保留用户当前 zoom / pan,
* 但是重新限制边界。
*/
clampTranslation()
applyMatrix()
}
// ========================================================================
// FitCenter
// ========================================================================
/**
* 根据当前 View 和视频比例,
* 构建 FitCenter Matrix。
*
* 重要:
*
* TextureView 的内容坐标已经是:
*
* 0 .. width
* 0 .. height
*
* 因此这里不能拿 videoWidth / videoHeight
* 作为 Matrix 的 source Rect。
*/
private fun rebuildBaseMatrix() {
baseMatrix.reset()
if (width <= 0 || height <= 0 ||
videoWidth <= 0 || videoHeight <= 0
) {
return
}
/**
* 视频实际显示比例。
*
* 例如:
*
* 1920 × 1080
* SAR = 1
*
* => 16:9
*
* 1440 × 1080
* SAR = 4/3
*
* => 16:9
*/
val videoAspectRatio = videoWidth.toFloat() * pixelAspectRatio / videoHeight.toFloat()
val viewAspectRatio = width.toFloat() / height.toFloat()
val centerX = width / 2f
val centerY = height / 2f
/**
* TextureView 默认内容铺满整个 View。
*
* 所以 FitCenter 实际上只需要:
*
* - 横向压缩
* 或
* - 纵向压缩
*
* 并且以 View 中心为缩放中心。
*/
if (videoAspectRatio > viewAspectRatio) {
/**
* 视频更宽。
*
* width 保持不变,
* height 缩小。
*/
val scaleY = viewAspectRatio / videoAspectRatio
baseMatrix.setScale(
1f,
scaleY,
centerX,
centerY,
)
} else {
/**
* 视频更高 / 更窄。
*
* height 保持不变,
* width 缩小。
*/
val scaleX = videoAspectRatio / viewAspectRatio
baseMatrix.setScale(
scaleX,
1f,
centerX,
centerY,
)
}
}
// ========================================================================
// Reset
// ========================================================================
/**
* 重置 Zoom + Translate。
*
* 恢复到当前视频尺寸下的 FitCenter。
*/
fun resetZoomAndTranslate() {
zoom = 1f
userMatrix.reset()
applyMatrix()
}
// ========================================================================
// Touch interception
// ========================================================================
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
/**
* 双指出现:
*
* 交给当前 View 处理。
*/
if (event.pointerCount >= 2) {
return true
}
/**
* 已经放大:
*
* 单指拖动也交给当前 View。
*/
if (zoom > 1f) {
return true
}
return false
}
// ========================================================================
// Touch
// ========================================================================
override fun onTouchEvent(event: MotionEvent): Boolean {
generalGestureDetector.onTouchEvent(event)
scaleGestureDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
activePointerId = event.getPointerId(0)
lastTouchX = event.x
lastTouchY = event.y
return true
}
MotionEvent.ACTION_MOVE -> {
/**
* 双指缩放期间,
* 不执行普通 pan。
*/
if (
event.pointerCount == 1 &&
!scaleGestureDetector.isInProgress &&
zoom > 1f
) {
val index = event.findPointerIndex(activePointerId)
if (index >= 0) {
val x = event.getX(index)
val y = event.getY(index)
val dx = x - lastTouchX
val dy = y - lastTouchY
userMatrix.postTranslate(dx, dy)
clampTranslation()
applyMatrix()
lastTouchX = x
lastTouchY = y
}
}
return true
}
MotionEvent.ACTION_POINTER_UP -> {
val pointerIndex = event.actionIndex
val pointerId = event.getPointerId(pointerIndex)
if (pointerId == activePointerId) {
val newIndex = if (pointerIndex == 0) {
1
} else {
0
}
if (newIndex < event.pointerCount) {
activePointerId = event.getPointerId(newIndex)
lastTouchX = event.getX(newIndex)
lastTouchY = event.getY(newIndex)
}
}
return true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
activePointerId = MotionEvent.INVALID_POINTER_ID
return true
}
}
return true
}
// ========================================================================
// Scale gesture
// ========================================================================
private inner class ScaleGestureListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(
detector: ScaleGestureDetector,
): Boolean {
// Log.i(
// "ZoomTest",
// "onScale: factor=${detector.scaleFactor}, zoom=$zoom"
// )
/**
* JustPlayer 的缩放阻尼。
*
* 原始思路:
*
* factor + (1 - factor) * 2 / 3
*
* 等价于:
*
* 1 + (factor - 1) / 3
*/
val adjustedScale = 1f + (detector.scaleFactor - 1f) / 1.2f
val newZoom = (zoom * adjustedScale).coerceIn(1f, maxZoom)
if (newZoom == zoom) {
return true
}
val realScale = newZoom / zoom
/**
* 以双指中心进行缩放。
*/
userMatrix.postScale(
realScale,
realScale,
detector.focusX,
detector.focusY,
)
zoom = newZoom
clampTranslation()
applyMatrix()
return true
}
override fun onScaleEnd(
detector: ScaleGestureDetector,
) {
/**
* 接近 1x 时吸附回 FitCenter。
*/
if (zoom <= 1.02f) {
resetZoomAndTranslate()
}
}
}
// ========================================================================
// Tap gesture
// ========================================================================
private inner class GeneralGestureListener : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
onSingleTap?.invoke(e.x, e.y)
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
onDoubleTap?.invoke(e.x, e.y)
return true
}
}
// ========================================================================
// Boundary
// ========================================================================
/**
* 限制视频移动范围。
*
* 当前最终视频矩形:
*
* BaseMatrix
* +
* UserMatrix
*
* 得到。
*
* 如果视频小于 View:
* 保持居中。
*
* 如果视频大于 View:
* 不允许出现空白。
*/
private fun clampTranslation() {
if (width <= 0 || height <= 0) {
return
}
/**
* TextureView 内容坐标。
*
* 这里非常重要:
*
* 不使用 videoWidth / videoHeight。
*
* 因为 TextureView.setTransform()
* 操作的是 TextureView 自身内容坐标。
*/
val rect = RectF(
0f,
0f,
width.toFloat(),
height.toFloat(),
)
val matrix = Matrix(baseMatrix)
matrix.postConcat(userMatrix)
matrix.mapRect(rect)
var dx = 0f
var dy = 0f
// --------------------------------------------------------------------
// X
// --------------------------------------------------------------------
if (rect.width() <= width) {
/**
* 视频小于 View:
*
* 水平居中。
*/
dx = width / 2f - rect.centerX()
} else {
/**
* 视频大于 View:
*
* 左边不能露白。
*/
if (rect.left > 0f) {
dx = -rect.left
}
/**
* 右边不能露白。
*/
if (rect.right < width) {
dx = width - rect.right
}
}
// --------------------------------------------------------------------
// Y
// --------------------------------------------------------------------
if (rect.height() <= height) {
/**
* 视频小于 View:
*
* 垂直居中。
*/
dy = height / 2f - rect.centerY()
} else {
/**
* 上边不能露白。
*/
if (rect.top > 0f) {
dy = -rect.top
}
/**
* 下边不能露白。
*/
if (rect.bottom < height) {
dy = height - rect.bottom
}
}
if (dx != 0f || dy != 0f) {
userMatrix.postTranslate(dx, dy)
}
}
// ========================================================================
// Apply
// ========================================================================
private fun applyMatrix() {
finalMatrix.set(baseMatrix)
finalMatrix.postConcat(userMatrix)
// Log.i(
// "ZoomTest",
// "applyMatrix: zoom=$zoom, matrix=$finalMatrix"
// )
// Log.i(
// "ZoomTest",
// "texture available=${textureView.isAvailable}, " +
// "surface=$surface"
// )
textureView.setTransform(finalMatrix)
// !!! 此处必须调用invalidate,否则在暂停/停止状态下,没有新帧会来触发绘制,导致缩放等手势不显示效果
// textureView.invalidate()
textureView.postInvalidateOnAnimation()
// textureView.requestLayout()
// val matrix = textureView.getTransform(null)
// Log.i(
// "ZoomTest",
// "ourMatrix=$finalMatrix"
// )
//
// Log.i(
// "ZoomTest",
// "actualMatrix=$matrix"
// )
}
}
package dev.dl.demoapp.player
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.util.Log
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
class ExoPlayerImpl(
context: Context
) {
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var currentMediaSource: String? = null
private var playerView: ZoomableTextureVideoView? = null
private val _playerState = MutableStateFlow<PlayerState>(PlayerState.Idle)
val playerState = _playerState.asStateFlow()
private val _playerEvent = Channel<PlayerEvent>(
capacity = 5,
onBufferOverflow = BufferOverflow.DROP_OLDEST
) // 允许事件缓存一部分
/**
* 播放器的事件
*/
val playerEvent = _playerEvent.receiveAsFlow()
private val _playerUiEvent = Channel<PlayerUiEvent>(
capacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val uiEvent = _playerUiEvent.receiveAsFlow()
private val _currentPosition = MutableStateFlow<Long>(0L)
val currentPosition = _currentPosition.asStateFlow()
private var isFirstFrameLoaded = false
private val internalPlayerListener: Player.Listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
logging("onIsPlayingChanged: $isPlaying")
if (isPlaying) {
updatePlayerState(PlayerState.Playing)
startTrackingPosition()
} else {
stopTrackingPosition()
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> {
logging("onPlaybackStateChanged: STATE_BUFFERING")
updatePlayerState(PlayerState.Buffering(!isFirstFrameLoaded)) // 判断是否首次
}
Player.STATE_ENDED -> {
logging("onPlaybackStateChanged: STATE_ENDED")
updatePlayerState(PlayerState.PlayCompleted)
sendPlayerEvent(PlayerEvent.EndReached)
}
Player.STATE_IDLE -> {
logging("onPlaybackStateChanged: STATE_IDLE")
updatePlayerState(PlayerState.Idle)
}
Player.STATE_READY -> {
logging("onPlaybackStateChanged: STATE_READY")
updatePlayerState(PlayerState.ReadyForPlay(internalPlayer.duration))
if (!isFirstFrameLoaded) {
isFirstFrameLoaded = true
sendPlayerEvent(PlayerEvent.FirstFrameLoaded)
}
}
}
}
override fun onPlayerError(error: PlaybackException) {
logging("onPlayerError: ${error.errorCodeName}")
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
playerView?.updateVideoSize(
videoSize.width,
videoSize.height,
videoSize.pixelWidthHeightRatio
)
}
}
private val internalPlayer by lazy {
ExoPlayer.Builder(context)
.build()
.apply {
volume = 1f
addListener(internalPlayerListener)
}
}
val isPlaying: Boolean
get() = internalPlayer.isPlaying
fun updateMediaSource(
url: String
) {
if (url == currentMediaSource) {
return
}
if (!checkMediaSource(url)) {
// TODO log invalid url or repeat setting
updatePlayerState(PlayerState.Error(IllegalArgumentException("Invalid url: $url")))
sendPlayerEvent(PlayerEvent.Error(IllegalArgumentException("Invalid url: $url")))
return
}
stop()
resetDisplayTransform()
val mediaItem = MediaItem.Builder()
.setUri(url)
.build()
internalPlayer.setMediaItem(mediaItem)
internalPlayer.prepare()
}
fun attachView(
view: ZoomableTextureVideoView
) {
detachView()
with(view) {
playerView = this
this.onSurfaceChanged = { surface ->
internalPlayer.setVideoSurface(surface)
}
onSingleTap = { x, y ->
sendPlayerUiEvent(PlayerUiEvent.SingleTap(x, y))
}
onDoubleTap = { x, y ->
sendPlayerUiEvent(PlayerUiEvent.DoubleTap(x, y))
}
}
}
fun detachView() {
playerView = null
}
fun play(
seekToPosition: Long? = null
) {
if (seekToPosition != null) {
internalPlayer.seekTo(seekToPosition)
}
internalPlayer.play()
}
fun pause() {
if (!isPlaying) {
return
}
internalPlayer.pause()
updatePlayerState(PlayerState.Paused)
}
/**
* 停止播放,重置当前进度
*
* Note: 在现代手机App中,stop功能似乎没多少存在的必要了,可以考虑去除,避免语义上的选择困难症
*/
private fun stop() {
internalPlayer.stop()
isFirstFrameLoaded = false
resetPosition()
}
/**
* 彻底释放播放器,无法再复用
*/
fun release() {
internalPlayer.release()
}
fun seekTo(
targetPosition: Long
) {
if (internalPlayer.duration == C.TIME_UNSET) return
internalPlayer.seekTo(targetPosition.coerceIn(0L, internalPlayer.duration))
}
/**
* 重置画面缩放、移动等状态
*/
fun resetDisplayTransform() {
playerView?.resetZoomAndTranslate()
}
suspend fun captureDisplay(): Bitmap? {
// TODO capture from surface
return withContext(Dispatchers.Main) {
val position = internalPlayer.currentPosition
val retriever = MediaMetadataRetriever()
val source = currentMediaSource ?: return@withContext null
retriever.setDataSource(source)
retriever.getFrameAtTime()
null
}
}
private fun checkMediaSource(
url: String,
): Boolean {
// 暂时先只允许http
try {
val uri = url.toUri()
return true
// return "http".equals(uri.scheme, true)
} catch (e: Exception) {
// TODO log invalid url
return false
}
}
private fun resetPosition() {
updateCurrentPosition(0L)
}
private fun updatePlayerState(
newState: PlayerState
) {
_playerState.value = newState
}
private fun sendPlayerEvent(
event: PlayerEvent
) {
_playerEvent.trySendBlocking(event)
.onFailure {
logging("Failed to send event: $event. Caused by $it")
}
}
private fun sendPlayerUiEvent(
event: PlayerUiEvent
) {
_playerUiEvent.trySendBlocking(event)
.onFailure {
logging("Failed to send ui event: $event. Caused by $it")
}
}
private var positionJob: Job? = null
private fun startTrackingPosition() {
positionJob?.cancel()
positionJob = coroutineScope.launch() {
while (isActive) {
withContext(Dispatchers.Main) {
updateCurrentPosition(internalPlayer.currentPosition)
}
delay(200L.milliseconds)
}
}
}
private fun stopTrackingPosition() {
positionJob?.cancel()
positionJob = null
}
private fun updateCurrentPosition(
newPosition: Long,
) {
_currentPosition.value = newPosition
}
private fun logging(msg: String) {
Log.i("ExoPlayerImpl", msg)
}
}
package dev.dl.demoapp.player
sealed interface PlayerState {
data object Idle: PlayerState
data class Buffering(
val isFirstFrame: Boolean,
): PlayerState
data class ReadyForPlay(
val duration: Long,
): PlayerState
data object Playing: PlayerState
data object Paused: PlayerState
data object Stopped: PlayerState
data object PlayCompleted: PlayerState
data class Error(
val error: Throwable?
): PlayerState
}
sealed interface PlayerEvent {
data object FirstFrameLoaded: PlayerEvent
/**
* 当前视频播放结束
*/
data object EndReached: PlayerEvent
data class Error(
val detail: Throwable?
): PlayerEvent
}
sealed interface PlayerUiEvent {
data class SingleTap(
val x: Float,
val y: Float,
): PlayerUiEvent
data class DoubleTap(
val x: Float,
val y: Float,
): PlayerUiEvent
}
package dev.dl.demoapp.player
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.widget.Button
import android.widget.SeekBar
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import androidx.transition.Slide
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import dev.dl.demoapp.R
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class ExoVideoPlayerActivity : ComponentActivity() {
private lateinit var rootLayout: ConstraintLayout
private lateinit var playerView: ZoomableTextureVideoView
private lateinit var titleText: TextView
private lateinit var controllerLayout: ConstraintLayout
private lateinit var playBtn: Button
private lateinit var seekBar: SeekBar
private lateinit var player: ExoPlayerImpl
private val controlsTransition by lazy {
TransitionSet().apply {
duration = 150L
addTransition(
Slide(Gravity.TOP).apply {
addTarget(titleText)
}
)
addTransition(
Slide(Gravity.BOTTOM).apply {
addTarget(controllerLayout)
}
)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_exo_video_player)
initView()
initPlayer()
}
private fun initView() {
rootLayout = findViewById(R.id.rootLayout)
playerView = findViewById(R.id.playerView)
titleText = findViewById(R.id.titleText)
controllerLayout = findViewById(R.id.controllerLayout)
playBtn = findViewById(R.id.playBtn)
seekBar = findViewById(R.id.seekBar)
playBtn.setOnClickListener {
val state = player.playerState.value
if (player.isPlaying) {
pause()
} else if (state == PlayerState.Stopped || state == PlayerState.PlayCompleted) {
replay()
} else {
play()
}
}
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
private var isPlayingBeforeSeeking = false
override fun onProgressChanged(
seekbar: SeekBar,
progress: Int,
fromUser: Boolean
) {
if (fromUser) {
player.seekTo(progress.toLong())
}
}
override fun onStartTrackingTouch(seekbar: SeekBar) {
isPlayingBeforeSeeking = player.isPlaying
pause()
}
override fun onStopTrackingTouch(seekbar: SeekBar) {
if (isPlayingBeforeSeeking) {
play()
}
}
})
}
private fun initPlayer() {
player = ExoPlayerImpl(applicationContext)
player.attachView(playerView)
lifecycleScope.launch {
player.playerState.collectLatest {
handleState(it)
}
}
lifecycleScope.launch {
player.playerEvent.collectLatest {
handleEvent(it)
}
}
lifecycleScope.launch {
player.currentPosition.collectLatest {
seekBar.progress = it.toInt()
}
}
player.updateMediaSource("asset:///test.mp4")
}
private fun handleState(state: PlayerState) {
Log.i("ExoPlayerImpl", "state: $state")
// TODO 非Idle状态下,显示VideoMaskView
when (state) {
PlayerState.Idle -> {
playBtn.text = "Please set source"
playBtn.isEnabled = false
}
is PlayerState.Buffering -> {
playBtn.text = "Buffering"
playBtn.isEnabled = false
}
PlayerState.Paused -> {
playBtn.text = "Play"
playBtn.isEnabled = true
}
PlayerState.Stopped -> {
playBtn.text = "Replay"
playBtn.isEnabled = true
}
PlayerState.PlayCompleted -> {
playBtn.text = "Replay"
playBtn.isEnabled = true
}
PlayerState.Playing -> {
playBtn.text = "Pause"
playBtn.isEnabled = true
}
is PlayerState.ReadyForPlay -> {
playBtn.text = "Play"
playBtn.isEnabled = true
seekBar.isEnabled = true
seekBar.max = state.duration.toInt()
}
is PlayerState.Error -> {
playBtn.text = "Retry"
playBtn.isEnabled = true
}
}
}
private fun handleEvent(event: PlayerEvent) {
Log.i("ExoPlayerImpl", "event: $event")
when (event) {
PlayerEvent.FirstFrameLoaded -> {
player.play()
}
else -> {}
}
}
override fun onDestroy() {
super.onDestroy()
player.release()
}
private fun showControls() {
TransitionManager.beginDelayedTransition(
rootLayout,
controlsTransition
)
titleText.visibility = View.VISIBLE
controllerLayout.visibility = View.VISIBLE
hideControlsLater()
}
private fun hideControls() {
TransitionManager.beginDelayedTransition(
rootLayout,
controlsTransition
)
titleText.visibility = View.GONE
controllerLayout.visibility = View.GONE
}
private val hideControlsRunnable = Runnable {
hideControls()
}
private fun hideControlsLater() {
resetControlsTimer()
}
private fun resetControlsTimer() {
rootLayout.removeCallbacks(hideControlsRunnable)
rootLayout.postDelayed(
hideControlsRunnable,
2000L
)
}
private fun play() {
player.play()
}
private fun pause() {
player.pause()
}
private fun replay() {
player.play(seekToPosition = 0L)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment