Skip to content

Instantly share code, notes, and snippets.

@lby1992
Created August 23, 2026 12:16
Show Gist options
  • Select an option

  • Save lby1992/3643bb243360722be8a7874c04a71446 to your computer and use it in GitHub Desktop.

Select an option

Save lby1992/3643bb243360722be8a7874c04a71446 to your computer and use it in GitHub Desktop.
package dev.dl.demoapp.player
import android.os.Bundle
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.core.view.isVisible
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.transition.Slide
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import dev.dl.demoapp.R
class ExoVideoPlayerActivity : ComponentActivity() {
private lateinit var rootLayout: ConstraintLayout
private lateinit var textureView: VideoTextureView
private lateinit var gestureLayer: GestureLayer
private lateinit var titleText: TextView
private lateinit var controllerLayout: ConstraintLayout
private lateinit var playBtn: Button
private lateinit var seekBar: SeekBar
private lateinit var player: ExoPlayer
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)
textureView = findViewById(R.id.textureView)
gestureLayer = findViewById<GestureLayer>(R.id.gestureLayer)
.apply {
onClick = {
if (titleText.isVisible) {
hideControls()
rootLayout.removeCallbacks(hideControlsRunnable)
} else {
showControls()
}
}
onScale = { scale, centerX, centerY ->
textureView.scale(scale, centerX, centerY)
}
onMove = { dx, dy ->
textureView.translate(dx, dy)
}
}
titleText = findViewById(R.id.titleText)
controllerLayout = findViewById(R.id.controllerLayout)
playBtn = findViewById(R.id.playBtn)
seekBar = findViewById(R.id.seekBar)
playBtn.setOnClickListener {
}
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekbar: SeekBar,
p1: Int,
p2: Boolean
) {
TODO("Not yet implemented")
}
override fun onStartTrackingTouch(seekbar: SeekBar) {
TODO("Not yet implemented")
}
override fun onStopTrackingTouch(seekbar: SeekBar) {
TODO("Not yet implemented")
}
})
}
private fun initPlayer() {
player = ExoPlayer.Builder(applicationContext)
.build()
player.setVideoTextureView(textureView)
player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
}
})
}
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
)
}
}
package dev.dl.demoapp.player
import android.content.Context
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
import android.view.TextureView
class VideoTextureView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : TextureView(context, attrs, defStyleAttr) {
// ------------------------------------------------------------
// Video
// ------------------------------------------------------------
private var videoWidth = 0
private var videoHeight = 0
private var initialized = false
// ------------------------------------------------------------
// User transform
// ------------------------------------------------------------
/**
* 用户缩放倍数。
*
* 1f = 默认 FIT_CENTER
*/
private var userScale = 1f
/**
* 用户平移。
*
* 单位:TextureView 坐标
*/
private var translationX = 0f
private var translationY = 0f
// ------------------------------------------------------------
// Matrix
// ------------------------------------------------------------
/**
* 默认 FIT_CENTER
*/
private val baseMatrix = Matrix()
/**
* 用户 Matrix
*/
private val userMatrix = Matrix()
/**
* 最终 Matrix
*/
private val finalMatrix = Matrix()
// ------------------------------------------------------------
// Rect
// ------------------------------------------------------------
/**
* 默认视频区域。
*
* 这是 FIT_CENTER 后的视频区域。
*/
private val baseVideoRect = RectF()
/**
* 当前经过用户 scale 后的视频区域。
*
* 不包含 translation。
*/
private val scaledVideoRect = RectF()
/**
* 最终视频区域。
*/
private val transformedVideoRect = RectF()
// ------------------------------------------------------------
// Config
// ------------------------------------------------------------
companion object {
/**
* 最小缩放。
*
* 1f 表示不允许缩小到 FIT_CENTER 以下。
*/
private const val MIN_SCALE = 1f
/**
* 最大缩放。
*/
private const val MAX_SCALE = 5f
/**
* 防止浮点误差导致 1f 附近不断抖动。
*/
private const val EPSILON = 0.0001f
}
// ============================================================
// Public API
// ============================================================
/**
* 设置视频原始尺寸。
*
* 例如:
*
* 1920 x 1080
*/
fun setVideoSize(
width: Int,
height: Int
) {
if (width <= 0 || height <= 0) {
return
}
val changed =
videoWidth != width ||
videoHeight != height
videoWidth = width
videoHeight = height
initialized = true
rebuildBaseMatrix()
if (changed) {
/*
* 视频源发生变化。
*
* 最安全的行为是恢复默认 FIT_CENTER。
*/
resetUserTransform()
}
applyTransform()
}
/**
* 用户缩放。
*
* @param scale 缩放增量
*
* 例如:
*
* 1.1f -> 放大 10%
* 0.9f -> 缩小 10%
*
* @param centerX 缩放中心,TextureView 坐标
* @param centerY 缩放中心,TextureView 坐标
*/
fun scale(
scale: Float,
centerX: Float,
centerY: Float
) {
if (!initialized) {
return
}
if (scale <= 0f) {
return
}
if (width <= 0 || height <= 0) {
return
}
val oldScale = userScale
val newScale = (oldScale * scale)
.coerceIn(
MIN_SCALE,
MAX_SCALE
)
if (abs(newScale - oldScale) <= EPSILON) {
return
}
/*
* 缩放中心必须限制在 TextureView 内。
*
* 防止 GestureLayer 给出异常坐标。
*/
val pivotX = centerX.coerceIn(
0f,
width.toFloat()
)
val pivotY = centerY.coerceIn(
0f,
height.toFloat()
)
/*
* 先计算缩放前后的视频位置。
*
* 这样可以保证:
*
* 用户手指所在位置尽量保持不动。
*/
val scaleRatio = newScale / oldScale
translationX =
pivotX -
(pivotX - translationX) * scaleRatio
translationY =
pivotY -
(pivotY - translationY) * scaleRatio
userScale = newScale
/*
* scale 回到 1 时,
* 直接清掉 translation。
*
* 否则可能出现:
*
* scale = 1
* 但是视频已经偏移。
*/
if (isApproximatelyOne(userScale)) {
userScale = 1f
translationX = 0f
translationY = 0f
}
clampTranslation()
applyTransform()
}
/**
* 用户拖动。
*/
fun translate(
dx: Float,
dy: Float
) {
if (!initialized) {
return
}
if (width <= 0 || height <= 0) {
return
}
/*
* 没有放大时禁止拖动。
*/
if (isApproximatelyOne(userScale)) {
translationX = 0f
translationY = 0f
applyTransform()
return
}
translationX += dx
translationY += dy
clampTranslation()
applyTransform()
}
/**
* 恢复默认 FIT_CENTER。
*/
fun resetTransform() {
if (!initialized) {
return
}
resetUserTransform()
applyTransform()
}
/**
* 当前用户缩放倍数。
*/
fun getScale(): Float {
return userScale
}
/**
* 当前 X 平移。
*/
fun getVideoTranslationX(): Float {
return translationX
}
/**
* 当前 Y 平移。
*/
fun getVideoTranslationY(): Float {
return translationY
}
/**
* 获取最终 Matrix。
*
* 返回副本,外部修改不会影响内部状态。
*/
fun getTransformMatrix(): Matrix {
return Matrix(finalMatrix)
}
/**
* 当前视频最终显示区域。
*/
fun getVideoRect(): RectF {
return RectF(transformedVideoRect)
}
// ============================================================
// View lifecycle
// ============================================================
override fun onSizeChanged(
w: Int,
h: Int,
oldw: Int,
oldh: Int
) {
super.onSizeChanged(
w,
h,
oldw,
oldh
)
if (!initialized) {
return
}
if (w <= 0 || h <= 0) {
return
}
rebuildBaseMatrix()
/*
* View 尺寸发生变化时,
* 原来的 translation 很可能已经越界。
*
* 保留 scale,
* 重新 clamp translation。
*/
clampTranslation()
applyTransform()
}
// ============================================================
// Base Matrix
// ============================================================
/**
* 计算 FIT_CENTER。
*/
private fun rebuildBaseMatrix() {
if (width <= 0 || height <= 0) {
return
}
if (videoWidth <= 0 || videoHeight <= 0) {
return
}
val src = RectF(
0f,
0f,
videoWidth.toFloat(),
videoHeight.toFloat()
)
val dst = RectF(
0f,
0f,
width.toFloat(),
height.toFloat()
)
baseMatrix.reset()
baseMatrix.setRectToRect(
src,
dst,
Matrix.ScaleToFit.CENTER
)
/*
* 保存默认视频区域。
*/
baseVideoRect.set(
0f,
0f,
videoWidth.toFloat(),
videoHeight.toFloat()
)
baseMatrix.mapRect(baseVideoRect)
}
// ============================================================
// User Matrix
// ============================================================
/**
* 构造用户 Matrix。
*
* 注意:
*
* baseMatrix 负责:
*
* video -> FIT_CENTER
*
* userMatrix 负责:
*
* scale + translation
*/
private fun rebuildUserMatrix() {
userMatrix.reset()
/*
* scale 以 TextureView 中心为基准。
*
* translation 单独处理。
*/
userMatrix.setScale(
userScale,
userScale,
width / 2f,
height / 2f
)
userMatrix.postTranslate(
translationX,
translationY
)
}
// ============================================================
// Final Matrix
// ============================================================
private fun applyTransform() {
if (!initialized) {
return
}
if (width <= 0 || height <= 0) {
return
}
rebuildUserMatrix()
/*
* 最终:
*
* baseMatrix
* +
* userMatrix
*/
finalMatrix.set(baseMatrix)
finalMatrix.postConcat(userMatrix)
setTransform(finalMatrix)
/*
* 更新最终视频区域。
*/
transformedVideoRect.set(
0f,
0f,
videoWidth.toFloat(),
videoHeight.toFloat()
)
finalMatrix.mapRect(
transformedVideoRect
)
}
// ============================================================
// Boundary
// ============================================================
/**
* 限制 translation。
*
* 核心规则:
*
* 1. 视频比 View 小:
* 居中
*
* 2. 视频比 View 大:
* 至少覆盖整个 View
*
* 因此不会出现黑边。
*/
private fun clampTranslation() {
if (width <= 0 || height <= 0) {
return
}
if (videoWidth <= 0 || videoHeight <= 0) {
return
}
/*
* 先得到 FIT_CENTER 后的视频尺寸。
*/
val baseWidth = baseVideoRect.width()
val baseHeight = baseVideoRect.height()
/*
* 用户 scale 后的尺寸。
*/
val scaledWidth =
baseWidth * userScale
val scaledHeight =
baseHeight * userScale
/*
* X 方向。
*/
translationX =
clampAxis(
translation = translationX,
contentSize = scaledWidth,
viewportSize = width.toFloat()
)
/*
* Y 方向。
*/
translationY =
clampAxis(
translation = translationY,
contentSize = scaledHeight,
viewportSize = height.toFloat()
)
}
/**
* 单轴边界限制。
*
* 如果内容比 View 小:
*
* 永远居中。
*
* 如果内容比 View 大:
*
* 左边不能出现黑边
* 右边不能出现黑边
*/
private fun clampAxis(
translation: Float,
contentSize: Float,
viewportSize: Float
): Float {
/*
* 默认情况下:
*
* FIT_CENTER 已经居中。
*
* 所以 translation = 0
* 就代表居中。
*/
if (contentSize <= viewportSize + EPSILON) {
return 0f
}
/*
* 内容相对于 View 多出来的尺寸。
*/
val overflow =
contentSize - viewportSize
/*
* 因为内容是围绕 View 中心缩放,
* 所以最大平移范围是:
*
* ± overflow / 2
*/
val maxTranslation =
overflow / 2f
return translation.coerceIn(
-maxTranslation,
maxTranslation
)
}
// ============================================================
// Reset
// ============================================================
private fun resetUserTransform() {
userScale = 1f
translationX = 0f
translationY = 0f
userMatrix.reset()
}
// ============================================================
// Utils
// ============================================================
private fun isApproximatelyOne(
value: Float
): Boolean {
return abs(value - 1f) <= EPSILON
}
private fun abs(value: Float): Float {
return kotlin.math.abs(value)
}
}
package dev.dl.demoapp.player
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
import android.view.ViewConfiguration
import kotlin.math.abs
class GestureLayer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
var onClick: (() -> Unit)? = null
var onScale: (
(scale: Float, centerX: Float, centerY: Float) -> Unit
)? = null
var onMove: (
(dx: Float, dy: Float) -> Unit
)? = null
private val touchSlop =
ViewConfiguration.get(context).scaledTouchSlop
private var isScaling = false
private var isMoving = false
private var clickCandidate = false
private var lastX = 0f
private var lastY = 0f
private val scaleDetector =
ScaleGestureDetector(
context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(
detector: ScaleGestureDetector
): Boolean {
isScaling = true
isMoving = false
clickCandidate = false
return true
}
override fun onScale(
detector: ScaleGestureDetector
): Boolean {
onScale?.invoke(
detector.scaleFactor,
detector.focusX,
detector.focusY
)
return true
}
override fun onScaleEnd(
detector: ScaleGestureDetector
) {
isScaling = false
}
}
)
init {
isClickable = true
isFocusable = true
}
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
isMoving = false
isScaling = false
clickCandidate = true
}
MotionEvent.ACTION_POINTER_DOWN -> {
/*
* 多指操作从这里开始。
* 是否真正进入 scale 状态由
* ScaleGestureDetector.onScaleBegin() 决定。
*/
clickCandidate = false
isMoving = false
}
MotionEvent.ACTION_MOVE -> {
/*
* ScaleGestureDetector 正在处理缩放,
* 不允许同时产生 move。
*/
if (isScaling || scaleDetector.isInProgress) {
return true
}
/*
* 单指拖动。
*/
if (event.pointerCount != 1) {
return true
}
val x = event.x
val y = event.y
val dx = x - lastX
val dy = y - lastY
if (!isMoving) {
if (
abs(dx) > touchSlop ||
abs(dy) > touchSlop
) {
isMoving = true
clickCandidate = false
}
}
if (isMoving) {
onMove?.invoke(dx, dy)
}
lastX = x
lastY = y
}
MotionEvent.ACTION_POINTER_UP -> {
/*
* 一个手指离开,但还有一个手指存在。
*
* 重新建立单指拖动基准点,
* 避免从双指操作切换到单指时产生巨大 dx/dy。
*/
if (event.pointerCount == 2) {
val remainingIndex =
if (event.actionIndex == 0) 1 else 0
lastX = event.getX(remainingIndex)
lastY = event.getY(remainingIndex)
}
}
MotionEvent.ACTION_UP -> {
if (
clickCandidate &&
!isMoving &&
!isScaling
) {
onClick?.invoke()
}
reset()
}
MotionEvent.ACTION_CANCEL -> {
reset()
}
}
return true
}
private fun reset() {
isScaling = false
isMoving = false
clickCandidate = false
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment