Seize the day

POST : Android Dev Study

VideoView 다루기

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 쎔네일 때문에 이상한 코드가 들었갔는데 다른 방법은 없나?

 

top

posted at

2020. 4. 3. 01:43


CONTENTS

Seize the day
BLOG main image
김대정의 앱 개발 노트와 사는 이야기
RSS 2.0Tattertools
공지
아카이브
최근 글 최근 댓글
카테고리 태그 구름사이트 링크