coding-blocks/CBOnlineApp

View on GitHub
app/src/main/java/com/codingblocks/cbonlineapp/util/widgets/VdoPlayerControls.kt

Summary

Maintainability
D
2 days
Test Coverage
package com.codingblocks.cbonlineapp.util.widgets

import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.*
import androidx.core.view.isVisible
import com.airbnb.lottie.LottieAnimationView
import com.codingblocks.cbonlineapp.R
import com.codingblocks.cbonlineapp.util.VideoUtils.digitalClockTime
import com.codingblocks.cbonlineapp.util.VideoUtils.getClosestFloatIndex
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.vdocipher.aegis.media.ErrorDescription
import com.vdocipher.aegis.media.Track
import com.vdocipher.aegis.player.VdoPlayer
import com.vdocipher.aegis.player.VdoPlayer.VdoInitParams
import java.math.RoundingMode
import java.text.DecimalFormat
import kotlin.math.max
import kotlin.math.min

class VdoPlayerControls @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : FrameLayout(context, attrs, defStyle) {

    companion object {
        private const val TAG = "VdoPlayerControlView"

        private const val DEFAULT_FAST_FORWARD_MS = 10000
        private const val DEFAULT_REWIND_MS = 10000
        private const val DEFAULT_SHOW_TIMEOUT_MS = 3000

        private val allowedSpeedList = floatArrayOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f)
        private val allowedSpeedStrList = arrayOf<CharSequence>("0.5x", "0.75x", "1x", "1.25x", "1.5x", "1.75x", "2x")
    }

    interface ControllerVisibilityListener {
        /**
         * Called when the visibility of the controller ui changes.
         *
         * @param visibility new visibility of controller ui. Either [View.VISIBLE] or
         * [View.GONE].
         */
        fun onControllerVisibilityChange(visibility: Int)
    }

    interface FullscreenActionListener {
        /**
         * @return if enter or exit fullscreen action was handled
         */
        fun onFullscreenAction(enterFullscreen: Boolean): Boolean
    }

    interface VdoParamsGenerator {
        /**
         * @return new vdo params
         */
        fun getNewVdoInitParams(): VdoInitParams?
    }

    private val playButton: View
    private val pauseButton: View
    private val fastForwardButton: LottieAnimationView
    private val rewindButton: LottieAnimationView
    private val durationView: TextView
    private val positionView: TextView
    private val seekBar: SeekBar
    private val speedControlButton: ImageButton
    private val backButton: ImageButton

    private var helperThread: HandlerThread? = null
    private var helperHandler: Handler? = null

    //    private val captionsButton: ImageButton
    private val qualityButton: ImageButton
    private val enterFullscreenButton: ImageButton
    private val exitFullscreenButton: ImageButton
    private val loaderView: ProgressBar
    private val errorView: ImageButton
    private val errorTextView: TextView
    private val controlPanel: View
    private val controllerBackground: View
    private val lock: ImageView

    private val ffwdMs: Int
    private val rewindMs: Int
    private val showTimeoutMs: Int

    private var scrubbing: Boolean = false
    private var attachedToWindow: Boolean = false
    private var fullscreen: Boolean = false
    private var chosenSpeedIndex = 2

    private var needNewVdoParams = false
    private var player: VdoPlayer? = null
    private val uiListener: UiListener
    private var lastErrorParams: VdoPlayer.VdoInitParams? = null
    private var fullscreenActionListener: FullscreenActionListener? = null
    private var visibilityListener: ControllerVisibilityListener? = null
    private var vdoParamsGenerator: VdoParamsGenerator? = null

    private val hideAction = Runnable { hide() }

    private val ERROR_CODES_FOR_NEW_PARAMS: List<Int> = listOf(2013, 2018)

    init {
        ffwdMs = DEFAULT_FAST_FORWARD_MS
        rewindMs = DEFAULT_REWIND_MS
        showTimeoutMs = DEFAULT_SHOW_TIMEOUT_MS

        uiListener = UiListener()
        LayoutInflater.from(context).inflate(R.layout.vdo_control_view, this)

        playButton = findViewById(R.id.vdo_play)
        playButton.setOnClickListener(uiListener)
        pauseButton = findViewById(R.id.vdo_pause)
        pauseButton.setOnClickListener(uiListener)
        pauseButton.visibility = GONE
        fastForwardButton = findViewById(R.id.vdo_ffwd)
        fastForwardButton.setOnClickListener(uiListener)
        rewindButton = findViewById(R.id.vdo_rewind)
        rewindButton.setOnClickListener(uiListener)
        durationView = findViewById(R.id.vdo_duration)
        positionView = findViewById(R.id.vdo_position)
        seekBar = findViewById(R.id.vdo_seekbar)
        seekBar.setOnSeekBarChangeListener(uiListener)
        speedControlButton = findViewById(R.id.vdo_speed)
        speedControlButton.setOnClickListener(uiListener)
        backButton = findViewById(R.id.vdo_back)
//        captionsButton.setOnClickListener(uiListener)
        qualityButton = findViewById(R.id.vdo_quality)
        qualityButton.setOnClickListener(uiListener)
        enterFullscreenButton = findViewById(R.id.vdo_enter_fullscreen)
        enterFullscreenButton.setOnClickListener(uiListener)
        exitFullscreenButton = findViewById(R.id.vdo_exit_fullscreen)
        exitFullscreenButton.setOnClickListener(uiListener)
        exitFullscreenButton.visibility = GONE
        loaderView = findViewById(R.id.vdo_loader)
        loaderView.visibility = GONE
        errorView = findViewById(R.id.vdo_error)
        errorView.setOnClickListener(uiListener)
        errorView.visibility = GONE
        errorTextView = findViewById(R.id.vdo_error_text)
        errorTextView.setOnClickListener(uiListener)
        errorTextView.visibility = GONE
        controlPanel = findViewById(R.id.vdo_control_panel)
        controllerBackground = findViewById(R.id.vdo_controller_bg)
        setOnClickListener(uiListener)
        lock = findViewById(R.id.lock)
        lock.setOnClickListener(uiListener)
    }

    fun setPlayer(newPlayer: VdoPlayer?) {
        if (newPlayer === player) return

        player?.removePlaybackEventListener(uiListener)
        player = newPlayer
        newPlayer?.addPlaybackEventListener(uiListener)
    }

    fun setFullscreenActionListener(listener: FullscreenActionListener) {
        fullscreenActionListener = listener
    }

    fun setControllerVisibilityListener(listener: ControllerVisibilityListener) {
        visibilityListener = listener
    }

    fun show() {
        if (!controllerVisible()) {
            controlPanel.visibility = VISIBLE
            updateAll()
            visibilityListener?.onControllerVisibilityChange(controlPanel.visibility)
        }
        hideAfterTimeout()
    }

    fun setVdoParamsGenerator(vdoParamsGenerator: VdoParamsGenerator) {
        this.vdoParamsGenerator = vdoParamsGenerator
    }

    fun hide() {
        if (controllerVisible() && lastErrorParams == null) {
            controlPanel.visibility = GONE
            removeCallbacks(hideAction)
            visibilityListener?.onControllerVisibilityChange(controlPanel.visibility)
        }
    }

    fun setFullscreenState(fullscreen: Boolean) {
        this.fullscreen = fullscreen
        updateFullscreenButtons()
    }

    fun setScreenLock(locked: Boolean) {
        val playing = player?.playWhenReady ?: false
        lock.isSelected = locked
        qualityButton.isVisible = !locked
        speedControlButton.isVisible = !locked
        enterFullscreenButton.isVisible = if (fullscreen) false else !locked
        exitFullscreenButton.isVisible = if (fullscreen) !locked else false
        playButton.isVisible = if (playing) false else !locked
        pauseButton.isVisible = if (playing) !locked else false
        fastForwardButton.isVisible = !locked
        rewindButton.isVisible = !locked
        seekBar.isEnabled = !locked
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        attachedToWindow = true

        helperThread = HandlerThread(TAG)
        helperThread!!.start()
        helperHandler = Handler(helperThread!!.looper)
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        attachedToWindow = false
        removeCallbacks(hideAction)

        helperThread!!.quit()
        helperThread = null
        helperHandler = null
    }

    fun controllerVisible(): Boolean {
        return controlPanel.visibility == View.VISIBLE
    }

    private fun hideAfterTimeout() {
        removeCallbacks(hideAction)
        val playing = player?.playWhenReady ?: false
        if (showTimeoutMs > 0 && attachedToWindow && lastErrorParams != null && playing) {
            postDelayed(hideAction, showTimeoutMs.toLong())
        }
    }

    private fun updateAll() {
        updatePlayPauseButtons()
        updateSpeedControlButton()
    }

    private fun updatePlayPauseButtons() {
        if (!controllerVisible() || !attachedToWindow) {
            return
        }

        val playbackState = player?.playbackState ?: VdoPlayer.STATE_IDLE
        val playing = (player?.playWhenReady ?: false) &&
            playbackState != VdoPlayer.STATE_IDLE &&
            playbackState != VdoPlayer.STATE_ENDED
        playButton.visibility = if (playing || lock.isSelected) GONE else VISIBLE
        pauseButton.visibility = if (playing && !lock.isSelected) VISIBLE else GONE
    }

    private fun rewind() {
        if (rewindMs > 0) {
            rewindButton.playAnimation()
            player?.seekTo(max(0, player!!.currentTime - rewindMs))
        }
    }

    private fun fastForward() {
        if (ffwdMs > 0) {
            fastForwardButton.playAnimation()
            player?.seekTo(min(player!!.duration, player!!.currentTime + ffwdMs))
        }
    }

    private fun updateLoader(loading: Boolean) {
        loaderView.visibility = if (loading) VISIBLE else GONE
        controlPanel.isVisible = !loading
    }

    private fun updateSpeedControlButton() {
        if (!controllerVisible() || !attachedToWindow) {
            return
        }

        player?.let {
            if (it.isSpeedControlSupported) {
                speedControlButton.isVisible = !lock.isSelected
                qualityButton.isVisible = !lock.isSelected

                val speed = it.playbackSpeed
                chosenSpeedIndex = getClosestFloatIndex(allowedSpeedList, speed)
//                speedControlButton.text = allowedSpeedStrList[chosenSpeedIndex]
            } else {
                speedControlButton.visibility = GONE
            }
        }
    }

    private fun changeDefaultPlaybackQuality(trackType: Int) {
        val playerRef = player ?: return
        val availableTracks = playerRef.availableTracks.filter { it.type == trackType }
        val trackHolders = availableTracks.map { TrackHolder(it) }.toMutableList()

        if (trackHolders.size > 1) {
            player?.selectedTracks = arrayOf(trackHolders[0].track)
        }
    }

    private fun toggleFullscreen() {
        fullscreenActionListener?.let {
            val handled = it.onFullscreenAction(!fullscreen)
            if (handled) {
                fullscreen = !fullscreen
                updateFullscreenButtons()
            }
        }
    }

    private fun updateFullscreenButtons() {
        if (!controllerVisible() || !attachedToWindow) {
            return
        }

        enterFullscreenButton.visibility = if (fullscreen) GONE else VISIBLE
        exitFullscreenButton.visibility = if (fullscreen) VISIBLE else GONE
    }

    private fun showSpeedControlDialog() {
        MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog)
            .setSingleChoiceItems(
                allowedSpeedStrList,
                chosenSpeedIndex
            ) { dialog, which ->
                player?.let { it.playbackSpeed = allowedSpeedList[which] }
                dialog.dismiss()
            }
            .setTitle("Choose playback speed")
            .show()
    }

    private fun showTrackSelectionDialog(trackType: Int) {
        val playerRef = player ?: return

        // get all available tracks of type trackType
        val availableTracks = playerRef.availableTracks.filter { it.type == trackType }

        // get the selected track of type trackType
        val selectedTrack = playerRef.selectedTracks.find { it.type == trackType }

        // get index of selected type track to indicate selection in dialog
        var selectedIndex = availableTracks.indexOf(selectedTrack)

        // first, let's convert tracks to array of TrackHolders for better display in dialog
        val trackHolders = availableTracks.map { TrackHolder(it) }.toMutableList()

        // if captions tracks are available, lets add a DISABLE_CAPTIONS track for turning off captions
        if (trackType == Track.TYPE_CAPTIONS && trackHolders.size > 0) {
            trackHolders.add(TrackHolder(Track.DISABLE_CAPTIONS))

            // if no captions are selected, indicate DISABLE_CAPTIONS as selected in dialog
            if (selectedIndex < 0) selectedIndex = trackHolders.size - 1
        } else if (trackType == Track.TYPE_VIDEO) {
            // todo auto option
            if (trackHolders.size == 1) {
                // just show a default track option
                trackHolders.clear()
                trackHolders.add(TrackHolder.DEFAULT)
            }
        }

        val trackHolderArr: Array<TrackHolder> = trackHolders.toTypedArray()
        Log.i(TAG, "total ${trackHolders.size}, selected $selectedIndex")

        // show the type tracks in dialog for selection
        val title = if (trackType == Track.TYPE_CAPTIONS) "CAPTIONS" else "Streaming at"
        showSelectionDialog(title, trackHolderArr, selectedIndex)
    }

    private fun showSelectionDialog(title: CharSequence, trackHolders: Array<TrackHolder>, selectedTrackIndex: Int) {
        val adapter: ListAdapter = ArrayAdapter(context, R.layout.simple_list_item_single_choice, trackHolders)
        MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog)
            .setTitle(title)
            .setSingleChoiceItems(adapter, selectedTrackIndex) { dialog, which ->
                player?.let {
                    if (selectedTrackIndex != which) {
                        // set selection
                        val selectedTrack = trackHolders[which].track
                        Log.i(TAG, "selected track index: " + which + ", " + selectedTrack.toString())
                        it.setSelectedTracks(arrayOf(selectedTrack))
                    } else {
                        Log.i(TAG, "track selection unchanged")
                    }
                }
                dialog.dismiss()
            }
            .show()
    }

    private fun showError(errorDescription: ErrorDescription) {
        updateLoader(false)
        controlPanel.visibility = View.GONE
        errorView.visibility = View.VISIBLE
        errorTextView.visibility = View.VISIBLE
        val errMsg: String = getErrorMessage(errorDescription)
        errorTextView.text = errMsg
        show()
    }

    private fun getErrorMessage(errorDescription: ErrorDescription): String {
        val messagePrefix = "Error: " + errorDescription.errorCode + ". "
        return when (errorDescription.errorCode) {
            5110, 5124, 5130 -> messagePrefix + "Please check your internet connection and try restarting " +
                "the app."
            5160, 5161 -> messagePrefix + "Downloaded media files have been accidentally deleted by " +
                "some other app in your mobile. Kindly download the video again and do " +
                "not use cleaner apps."
            6101, 6120 -> messagePrefix + "Kindly try restarting the phone and app."
            1220, 1250, 1253, 2021, 6155, 6156, 6157, 6166, 6178, 6186 -> messagePrefix + "Phone is not compatible for secure playback. " +
                "Kindly update your OS, restart the phone and app. If still not " +
                "corrected, factory reset can be tried if possible."
            6187 -> messagePrefix + "Rental license for downloaded video has expired. Kindly " +
                "download again."
            else -> """
                An error occurred: ${errorDescription.errorCode}
                Retrying again
                """.trimIndent()
        }
    }

    fun retryAfterError() {
        if (player != null && lastErrorParams != null) {
            if (!needNewVdoParams) {
                errorView.visibility = GONE
                errorTextView.visibility = GONE
                controlPanel.visibility = VISIBLE
                player!!.load(lastErrorParams)
                lastErrorParams = null
            } else if (vdoParamsGenerator != null && helperHandler != null) {
                helperHandler!!.post {
                    val retryParams = vdoParamsGenerator!!.getNewVdoInitParams()
                    if (retryParams != null) {
                        post {
                            errorView.visibility = GONE
                            errorTextView.visibility = GONE
                            controlPanel.visibility = VISIBLE
                            player!!.load(retryParams)
                            lastErrorParams = null
                        }
                    }
                }
            } else {
                Log.e(TAG, "cannot retry loading params")
                Toast.makeText(context, "cannot retry loading params", Toast.LENGTH_SHORT).show()
            }
        }
    }

    private inner class UiListener :
        VdoPlayer.PlaybackEventListener,
        SeekBar.OnSeekBarChangeListener,
        OnClickListener {

        override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {}

        override fun onStartTrackingTouch(seekBar: SeekBar) {
            scrubbing = true
            removeCallbacks(hideAction)
        }

        override fun onStopTrackingTouch(seekBar: SeekBar) {
            scrubbing = false
            val seekTarget = seekBar.progress
            player?.seekTo(seekTarget.toLong())
            hideAfterTimeout()
        }

        override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
            updatePlayPauseButtons()
            updateLoader(playbackState == VdoPlayer.STATE_BUFFERING)
        }

        override fun onClick(v: View) {
            var hideAfterTimeout = true

            if (player == null) return

            if (v === rewindButton) {
                rewind()
            } else if (v === playButton) {
                if (VdoPlayer.STATE_ENDED == player!!.playbackState) {
                    player!!.seekTo(0)
                }
                player!!.playWhenReady = true
            } else if (v === pauseButton) {
                hideAfterTimeout = false
                player!!.playWhenReady = false
            } else if (v === fastForwardButton) {
                fastForward()
            } else if (v === speedControlButton) {
                hideAfterTimeout = false
                showSpeedControlDialog()
//            } else if (v === captionsButton) {
//                hideAfterTimeout = false
//                showTrackSelectionDialog(Track.TYPE_CAPTIONS)
            } else if (v === qualityButton) {
                hideAfterTimeout = false
                showTrackSelectionDialog(Track.TYPE_VIDEO)
            } else if (v === enterFullscreenButton || v === exitFullscreenButton) {
                toggleFullscreen()
            } else if (v === errorView || v === errorTextView) {
                retryAfterError()
            } else if (v == lock) {
                setScreenLock(!lock.isSelected)
            } else if (v === this@VdoPlayerControls) {
                hideAfterTimeout = false
                if (controllerVisible()) {
                    hide()
                } else {
                    show()
                }
            }

            if (hideAfterTimeout) {
                hideAfterTimeout()
            }
        }

        override fun onSeekTo(millis: Long) {}

        override fun onProgress(millis: Long) {
            positionView.text = digitalClockTime(millis.toInt())
            seekBar.progress = millis.toInt()
        }

        override fun onBufferUpdate(bufferTime: Long) {
            seekBar.secondaryProgress = bufferTime.toInt()
        }

        override fun onPlaybackSpeedChanged(speed: Float) {
            updateSpeedControlButton()
        }

        override fun onLoading(vdoInitParams: VdoPlayer.VdoInitParams) {
            updateLoader(true)
        }

        override fun onLoaded(vdoInitParams: VdoPlayer.VdoInitParams) {
            player?.let {
                durationView.text = "/${digitalClockTime(it.duration.toInt())}"
                seekBar.max = it.duration.toInt()
                changeDefaultPlaybackQuality(Track.TYPE_VIDEO)
                updateSpeedControlButton()
            }
        }

        override fun onLoadError(vdoParams: VdoPlayer.VdoInitParams, errorDescription: ErrorDescription) {
            lastErrorParams = vdoParams
            needNewVdoParams = ERROR_CODES_FOR_NEW_PARAMS.contains(errorDescription.errorCode)
            showError(errorDescription)
        }

        override fun onMediaEnded(vdoInitParams: VdoPlayer.VdoInitParams) {
            // todo
        }

        override fun onError(vdoParams: VdoPlayer.VdoInitParams, errorDescription: ErrorDescription) {
            lastErrorParams = vdoParams
            needNewVdoParams = ERROR_CODES_FOR_NEW_PARAMS.contains(errorDescription.errorCode)
            showError(errorDescription)
        }

        override fun onTracksChanged(availableTracks: Array<Track>, selectedTracks: Array<Track>) {}
    }

    /**
     * A helper class that holds a Track instance and overrides [kotlin.toString] for
     * captions tracks for displaying to user.
     */
    private open class TrackHolder internal constructor(internal val track: Track?) {

        /**
         * Change this implementation to show track descriptions as per your app's UI requirements.
         */
        override fun toString(): String {
            return when {
                track == null ->
                    "Default"
                track === Track.DISABLE_CAPTIONS ->
                    "Turn off Captions"
                track.type == Track.TYPE_VIDEO ->
                    "${track.bitrate / 1024}kbps (${dataExpenditurePerHour(track.bitrate)})"
                track.type == Track.TYPE_CAPTIONS ->
                    track.language ?: "unknown"
                else ->
                    track.toString()
            }
        }

        private fun dataExpenditurePerHour(bitsPerSec: Int): String {
            val bytesPerHour = if (bitsPerSec <= 0) 0 else bitsPerSec * 3600L / 8
            if (bytesPerHour == 0L) {
                return "-"
            } else {
                val megabytesPerHour = bytesPerHour / (1024 * 1024).toFloat()

                return when {
                    megabytesPerHour < 1 ->
                        "1 MB per hour"
                    megabytesPerHour < 1000 ->
                        megabytesPerHour.toInt().toString() + " MB per hour"
                    else -> {
                        val df = DecimalFormat("#.#")
                        df.roundingMode = RoundingMode.CEILING
                        df.format(megabytesPerHour / 1024) + " GB per hour"
                    }
                }
            }
        }

        companion object {
            internal val DEFAULT: TrackHolder = TrackHolder(null)
        }
    }
}