Last active
August 31, 2026 16:01
-
-
Save lby1992/608be5e0b50b4f152a9a72c389379df7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | |
| // ) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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