Screenshot touch에 결국 비디오 플레이어를 지원하기로 했다. 폰에있는 mp4 파일만 재생하는데 ExoPlayer는 과분하기에 기본 SDK에서 제공하는 VideoView를 이용하기로 했다.
일단 VideoView와 MediaController의 조합이 문제가 많았다. MediaController의 색이 너무 어둡고, viewPager의 스와이프로 VideoView가 넘어가는 중에도 MediaController는 그대로 있는 문제, Touch down에 보여져서 스와이프 하려면 불필요하게 보여지는 문제, 가끔 여러개 겹쳐는 것인지 모르겠지만 Back key가 동작하지 않는 문제도 보였다. 앞, 뒤로 이동시 15초인 것도 마음에 안 든다. 품질면에서 MediaController를 대체하는 VideoController를 적접 만들기로 했다. VideoController를 만들려니 Play, Pause의 이벤트를 전달받을 펼요가 있어서 간단한 MyVideoView를 만들었다.
@OnClick이나 findView , argumentString같은 코드는 바로 전 게시물을 참조하면 된다.
MyVideoView.kt
package com.mdiwebma.screenshot.view
import android.content.Context
import android.util.AttributeSet
import android.widget.VideoView
import com.mdiwebma.base.ActionListener
class MyVideoView @JvmOverloads constructor(
context: Context,
attributes: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : VideoView(context, attributes, defStyleAttr, defStyleRes) {
private var onPlayListener: ActionListener = { }
private var onPauseListener: ActionListener = { }
fun setOnPlayListener(onPlayListener: ActionListener) {
this.onPlayListener = onPlayListener
}
fun setOnPauseListener(onPauseListener: ActionListener) {
this.onPauseListener = onPauseListener
}
override fun start() {
super.start();
onPlayListener.invoke()
}
override fun pause() {
super.pause()
onPauseListener.invoke()
}
}
VideoController
video_controller.xml : 레이아웃과 리소는 https://inducesmile.com/android-programming/how-to-customize-videoview-mediacontroller-in-android/의 코드를 참조했다. SDK 리소스로 정의된 부분은 안드로이드 5.0에서 확인해 보니 정상적으로 보였다.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#88000000"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:paddingTop="4dp">
<!-- <ImageButton-->
<!-- android:id="@+id/prev"-->
<!-- style="@android:style/MediaButton.Previous" />-->
<ImageButton
android:id="@+id/rew"
style="@android:style/MediaButton.Rew" />
<ImageButton
android:id="@+id/play"
style="@android:style/MediaButton.Play"
android:visibility="gone" />
<ImageButton
android:id="@+id/pause"
style="@android:style/MediaButton.Pause" />
<ImageButton
android:id="@+id/ffwd"
style="@android:style/MediaButton.Ffwd" />
<!-- <ImageButton-->
<!-- android:id="@+id/next"-->
<!-- style="@android:style/MediaButton.Next" />-->
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingLeft="4dp"
android:paddingTop="4dp"
android:paddingRight="4dp"
android:textColor="#fff"
android:textSize="14sp"
android:textStyle="bold" />
<SeekBar
android:id="@+id/seek_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_weight="1" />
<TextView
android:id="@+id/duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingLeft="4dp"
android:paddingTop="4dp"
android:paddingRight="4dp"
android:textColor="#fff"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
VideoController.kt: 최소한의 코드로 목적을 달성하기위해서 집중했다. 비디오 뷰 상단에 2dp 정도 되는 작은 프로그래스바가 있어서 콘트롤러가 안 보일 때도 얼마나 진행중인지 알 수 있다. progress 업데이트나 재생시간 표시 부분은 MediaController의 코드를 참고했다.
package com.mdiwebma.screenshot.activity.viewer
import android.view.View
import android.widget.ProgressBar
import android.widget.SeekBar
import android.widget.TextView
import com.mdiwebma.base.ActionListener
import com.mdiwebma.base.OnClick
import com.mdiwebma.base.findView
import com.mdiwebma.base.handleOnClickAnnotation
import com.mdiwebma.screenshot.R
import com.mdiwebma.screenshot.view.MyVideoView
class VideoController(
private val rootView: View,
private val videoView: MyVideoView,
private val topProgressBar: ProgressBar
) {
private val playIcon: View by rootView.findView(R.id.play)
private val pauseIcon: View by rootView.findView(R.id.pause)
private val timeView: TextView by rootView.findView(R.id.time)
private val seekBar: SeekBar by rootView.findView(R.id.seek_bar)
private val durationView: TextView by rootView.findView(R.id.duration)
private var isSeekBarDragging = false
private val onSeekBarChangeListener = object : SeekBar.OnSeekBarChangeListener {
override fun onStartTrackingTouch(seekBar: SeekBar?) {
isSeekBarDragging = true
videoView.removeCallbacks(updateProgressRunnable)
videoView.removeCallbacks(hideRunnable)
}
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (!fromUser) return
val duration = videoView.duration
val newPosition = duration * progress / 1000L
videoView.seekTo(newPosition.toInt())
timeView.text = stringForTime(newPosition.toInt())
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
isSeekBarDragging = false
setProgressAndPostRunnable()
postHideRunnable()
}
}
private val updateProgressRunnable = object : Runnable {
override fun run() {
val pos = setProgress()
// Video 상단의 topProgressBar 를 업데이트해야해서 isVisible()은 체크하지 않는다.
if (!isSeekBarDragging && videoView.isPlaying) {
videoView.postDelayed(this, (1000 - (pos % 1000)).toLong())
}
}
}
private val hideRunnable = Runnable {
hide()
}
private var onHiddenListener: ActionListener = {}
private var onShownListener: ActionListener = {}
init {
handleOnClickAnnotation(rootView)
topProgressBar.max = 1000
seekBar.max = 1000
seekBar.setOnSeekBarChangeListener(onSeekBarChangeListener)
}
fun setOnHiddenListener(onHiddenListener: ActionListener) {
this.onHiddenListener = onHiddenListener
}
fun setOnShownListener(onShownListener: ActionListener) {
this.onShownListener = onShownListener
}
fun show() {
topProgressBar.visibility = View.GONE
rootView.visibility = View.VISIBLE
setProgressAndPostRunnable()
postHideRunnable()
onShownListener.invoke()
}
fun hide() {
topProgressBar.visibility = View.VISIBLE
rootView.visibility = View.GONE
videoView.removeCallbacks(hideRunnable)
setProgress()
onHiddenListener.invoke()
}
fun isVisible() = rootView.visibility == View.VISIBLE
fun onPause() {
playIcon.visibility = View.VISIBLE
pauseIcon.visibility = View.GONE
videoView.removeCallbacks(updateProgressRunnable)
setProgress()
}
fun onPlay() {
if (!isVisible()) topProgressBar.visibility = View.VISIBLE
playIcon.visibility = View.GONE
pauseIcon.visibility = View.VISIBLE
setProgressAndPostRunnable()
}
fun toggle() = if (isVisible()) hide() else show()
private fun postHideRunnable() {
videoView.removeCallbacks(hideRunnable)
videoView.postDelayed(hideRunnable, 3000)
}
private fun setProgress(): Int {
if (isSeekBarDragging) return 0
val position = videoView.currentPosition
val duration = videoView.duration
if (duration > 0) {
// use long to avoid overflow
val progress = (1000L * position / duration).toInt()
seekBar.progress = progress
topProgressBar.progress = progress
}
val secondProgress: Int = videoView.bufferPercentage * 10
seekBar.secondaryProgress = secondProgress
topProgressBar.secondaryProgress = secondProgress
timeView.text = stringForTime(position)
durationView.text = stringForTime(duration)
return position
}
private fun setProgressAndPostRunnable() {
val pos = setProgress()
videoView.removeCallbacks(updateProgressRunnable);
videoView.postDelayed(updateProgressRunnable, (1000 - (pos % 1000)).toLong())
}
private fun stringForTime(timeMs: Int): String {
val totalSeconds = timeMs / 1000
val seconds = totalSeconds % 60
val minutes = totalSeconds / 60 % 60
val hours = totalSeconds / 3600
return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds).toString()
} else {
String.format("%02d:%02d", minutes, seconds).toString()
}
}
@OnClick(R.id.rew)
fun onClickRewind(v: View) {
val pos = videoView.currentPosition - 5000
videoView.seekTo(pos)
setProgress()
postHideRunnable()
}
@OnClick(R.id.play)
fun onClickPlay(v: View) {
videoView.start()
postHideRunnable()
}
@OnClick(R.id.pause)
fun onClickPause(v: View) {
videoView.pause()
postHideRunnable()
}
@OnClick(R.id.ffwd)
fun onClickForward(v: View) {
val pos = videoView.currentPosition + 5000
videoView.seekTo(pos)
setProgress()
postHideRunnable()
}
}
VideoFragment: 비디오를 실제로 재생하는 곳
video_fragemnt.xml :
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.mdiwebma.screenshot.view.MyVideoView
android:id="@+id/video"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true" />
<ProgressBar
android:id="@+id/top_progress"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="2dp"
android:visibility="gone" />
<FrameLayout
android:id="@+id/thumbnail"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_alignLeft="@+id/video"
android:layout_alignTop="@id/video"
android:layout_alignRight="@+id/video"
android:layout_alignBottom="@+id/video">
<ImageView
android:id="@+id/play_icon"
android:layout_width="70dp"
android:layout_height="70dp"
android:layout_gravity="center"
android:background="@drawable/play_icon_80" />
</FrameLayout>
<include
android:id="@+id/video_controller"
layout="@layout/video_controller"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:visibility="gone" />
<ImageView
android:id="@+id/delete_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:padding="10dp"
android:src="@drawable/close_icon" />
</RelativeLayout>
VideoFrament.kt : 스와이프 중에와 Activity가 resume됬을 때 썸네일이 Black으로 잠시 보여지는데 그걸 해결하는데 시간을 많이 썼다.
package com.mdiwebma.screenshot.activity.viewer
import android.graphics.drawable.BitmapDrawable
import android.media.ThumbnailUtils
import android.os.Bundle
import android.provider.MediaStore
import android.view.View
import android.widget.ProgressBar
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import com.mdiwebma.base.BaseFragment
import com.mdiwebma.base.OnClick
import com.mdiwebma.base.argumentString
import com.mdiwebma.base.findView
import com.mdiwebma.base.utils.ViewUtils
import com.mdiwebma.screenshot.R
import com.mdiwebma.screenshot.activity.PhotoViewerActivity
import com.mdiwebma.screenshot.view.MyVideoView
class VideoFragment : BaseFragment(R.layout.video_fragment) {
private val videoView: MyVideoView by findView(R.id.video)
private val topProgressBar: ProgressBar by findView(R.id.top_progress)
private val thumbnailView: View by findView(R.id.thumbnail)
private val deleteIconView: View by findView(R.id.delete_icon)
private val path: String by argumentString(KEY_PATH)
private val thumbnailDrawable: BitmapDrawable by lazy {
BitmapDrawable(
ThumbnailUtils.createVideoThumbnail(
path,
MediaStore.Video.Thumbnails.MINI_KIND
)
)
}
private val lifeCycleObserver = object : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onResume() {
// activity onPuase 에서 돌아온 경우 black thumbnail 을 피하기위해서
if (isVisibleToUser && isPlayingOnPause) videoView.start()
else videoView.seekTo(playPositionOnPause)
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun onPause() {
isPlayingOnPause = videoView.isPlaying
playPositionOnPause = videoView.currentPosition
}
}
private var playPositionOnPause: Int = 0
private var isPlayingOnPause: Boolean = true
private lateinit var videoController: VideoController
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initVideoView()
initVideoController()
if (isVisibleToUser) {
videoView.start()
} else {
// view pager left, right 에서 처음 보여지는 경우 black thumbnail 을 피하기 위해서
thumbnailView.background = thumbnailDrawable
}
requireActivity().lifecycle.addObserver(lifeCycleObserver)
}
private fun initVideoView() {
videoView.setOnPlayListener {
thumbnailView.visibility = View.GONE
thumbnailView.background = null
videoController.onPlay()
}
videoView.setOnPauseListener {
thumbnailView.visibility = View.VISIBLE
videoController.onPause()
}
videoView.setOnCompletionListener {
topProgressBar.visibility = View.GONE
thumbnailView.visibility = View.VISIBLE
videoController.onPause()
}
videoView.setVideoPath(path)
}
private fun initVideoController() {
videoController = VideoController(
requireView().findViewById(R.id.video_controller), videoView, topProgressBar
)
// Controller 와 상위 layout 의 icon 이 겹치지 않도록
videoController.setOnHiddenListener {
(context as? PhotoViewerActivity)?.showGalleryIcon();
}
videoController.setOnShownListener {
(context as? PhotoViewerActivity)?.hideGalleryIcon();
}
}
override fun onDestroyView() {
videoView.stopPlayback()
requireActivity().lifecycle.removeObserver(lifeCycleObserver)
super.onDestroyView()
}
override fun onVisibleToUserChanged(isVisibleToUser: Boolean) {
view ?: return
if (isVisibleToUser) {
videoView.start()
updateDeleteIconView()
} else {
videoController.hide()
videoView.pause()
}
}
private fun updateDeleteIconView() {
val isSlideShowing = (context as? PhotoViewerActivity)?.isSlideShowing == true
ViewUtils.setVisibility(deleteIconView, !isSlideShowing)
}
@OnClick(R.id.root_view)
fun onClickVideo(v: View) {
videoController.toggle()
}
@OnClick(R.id.thumbnail)
fun onClickThumbnail(v: View) {
videoView.start()
}
@OnClick(R.id.delete_icon)
fun onClickDeleteIcon(v: View) {
try {
(context as PhotoViewerActivity).deleteCurrentItemWithSnackBar();
} catch (ignored: Exception) {
}
}
companion object {
private const val KEY_PATH = "path"
@JvmStatic
fun newFragment(path: String): VideoFragment = VideoFragment().apply {
arguments = Bundle().also { it.putString(KEY_PATH, path) }
}
}
}
TODO :
- VideoView가 비율을 맞추다보니, 크기가 작아지는 경우가 있는데 가로 폭에 맞춰서 상하로 스크롤되게 하면 어떨까?
- SeekTo가 잘 안되는 경우가 있는데 key frame 문제일까?
- Black 쎔네일 때문에 이상한 코드가 들었갔는데 다른 방법은 없나?