/* * Copyright 2018 Allan Wang * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.pitchedapps.frost.views import android.annotation.SuppressLint import android.content.Context import android.graphics.PointF import android.graphics.RectF import android.util.AttributeSet import android.view.GestureDetector import android.view.MotionEvent import android.view.View import android.widget.Toast import ca.allanwang.kau.ui.ProgressAnimator import ca.allanwang.kau.utils.AnimHolder import ca.allanwang.kau.utils.dpToPx import ca.allanwang.kau.utils.scaleXY import ca.allanwang.kau.utils.toast import com.devbrackets.android.exomedia.ui.widget.VideoControls import com.devbrackets.android.exomedia.ui.widget.VideoView import com.pitchedapps.frost.R import com.pitchedapps.frost.facebook.formattedFbUrl import com.pitchedapps.frost.utils.L import kotlin.math.abs import kotlin.math.max import kotlin.math.min /** * Created by Allan Wang on 2017-10-13. * * VideoView with scalability * Parent must have layout with both height & width as match_parent */ class FrostVideoView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : VideoView(context, attrs, defStyleAttr) { /** * Shortcut for actual video view */ private inline val v get() = videoViewImpl var onFinishedListener: () -> Unit = {} private lateinit var viewerContract: FrostVideoViewerContract lateinit var containerContract: FrostVideoContainerContract var repeat: Boolean = false private val videoDimensions = PointF(0f, 0f) companion object { /** * Padding between minimized video and the parent borders * Note that this is double the actual padding * as we are calculating then dividing by 2 */ private val MINIMIZED_PADDING = 10.dpToPx private val SWIPE_TO_CLOSE_HORIZONTAL_THRESHOLD = 2f.dpToPx private val SWIPE_TO_CLOSE_VERTICAL_THRESHOLD = 5f.dpToPx private val SWIPE_TO_CLOSE_OFFSET_THRESHOLD = 75f.dpToPx const val ANIMATION_DURATION = 200L private const val FAST_ANIMATION_DURATION = 100L } private var videoBounds = RectF() var isExpanded: Boolean = true set(value) { if (field == value) return field = value val origX = translationX val origY = translationY val origScale = scaleX if (field) { ProgressAnimator.ofFloat { duration = ANIMATION_DURATION interpolator = AnimHolder.fastOutSlowInInterpolator(context) withAnimator { viewerContract.onExpand(it) } withAnimator(origScale, 1f) { scaleXY = it } withAnimator(origX, 0f) { translationX = it } withAnimator(origY, 0f) { translationY = it } withEndAction { if (!isPlaying) showControls() else viewerContract.onControlsHidden() } }.start() } else { hideControls() val (scale, tX, tY) = mapBounds() ProgressAnimator.ofFloat { duration = ANIMATION_DURATION interpolator = AnimHolder.fastOutSlowInInterpolator(context) withAnimator { viewerContract.onExpand(1f - it) } withAnimator(origScale, scale) { scaleXY = it } withAnimator(origX, tX) { translationX = it } withAnimator(origY, tY) { translationY = it } }.start() } } /** * Store the boundaries of the minimized video, * and return the necessary transitions to get there */ private fun mapBounds(): Triple { if (videoDimensions.x <= 0f || videoDimensions.y <= 0f) { L.d { "Attempted to toggle video expansion when points have not been finalized" } val dimen = min(height, width).toFloat() videoDimensions.set(dimen, dimen) } val portrait = height > width val scale = min( height / (if (portrait) 4f else 2.3f) / videoDimensions.y, width / (if (portrait) 2.3f else 4f) / videoDimensions.x ) val desiredHeight = scale * videoDimensions.y val desiredWidth = scale * videoDimensions.x val padding = containerContract.lowerVideoPadding val offsetX = width - MINIMIZED_PADDING - desiredWidth val offsetY = height - MINIMIZED_PADDING - desiredHeight val tX = offsetX / 2 - padding.x val tY = offsetY / 2 - padding.y videoBounds.set(offsetX, offsetY, width.toFloat(), height.toFloat()) videoBounds.offset(padding.x, padding.y) L.v { "Video bounds: fullwidth $width, fullheight $height, scale $scale, tX $tX, tY $tY" } return Triple(scale, tX, tY) } fun updateLocation() { L.d { "Update video location" } val (scale, tX, tY) = if (isExpanded) Triple(1f, 0f, 0f) else mapBounds() scaleXY = scale translationX = tX translationY = tY } init { setOnPreparedListener { start() if (isExpanded) showControls() } setOnErrorListener { L.e(it) { "Failed to load video ${videoUri?.toString()?.formattedFbUrl}" } toast(R.string.video_load_failed, Toast.LENGTH_SHORT) destroy() true } setOnCompletionListener { if (repeat) restart() else viewerContract.onVideoComplete() } setOnTouchListener(FrameTouchListener(context)) v.setOnTouchListener(VideoTouchListener(context)) setOnVideoSizedChangedListener { intrinsicWidth, intrinsicHeight, pixelWidthHeightRatio -> // todo use provided ratio? val ratio = min(width.toFloat() / intrinsicWidth, height.toFloat() / intrinsicHeight.toFloat()) /** * Only remap if not expanded and if dimensions have changed */ val shouldRemap = !isExpanded && (videoDimensions.x != ratio * intrinsicWidth || videoDimensions.y != ratio * intrinsicHeight) videoDimensions.set(ratio * intrinsicWidth, ratio * intrinsicHeight) if (shouldRemap) updateLocation() } } fun setViewerContract(contract: FrostVideoViewerContract) { this.viewerContract = contract (videoControls as? VideoControls)?.setVisibilityListener(viewerContract) } fun jumpToStart() { pause() v.seekTo(0) videoControls?.finishLoading() } override fun pause() { audioFocusHelper.abandonFocus() videoViewImpl.pause() keepScreenOn = false if (isExpanded) videoControls?.updatePlaybackState(false) } override fun restart(): Boolean { videoUri ?: return false if (videoViewImpl.restart() && isExpanded && !repeat) { videoControls?.showLoading(true) return true } return false } private fun hideControls() { if (videoControls?.isVisible == true) videoControls?.hide(false) } private fun toggleControls() { if (videoControls?.isVisible == true) hideControls() else showControls() } fun shouldParentAcceptTouch(ev: MotionEvent): Boolean { if (isExpanded) return true return !videoBounds.contains(ev.x, ev.y) } fun destroy() { stopPlayback() if (alpha > 0f) ProgressAnimator.ofFloat { duration = FAST_ANIMATION_DURATION withAnimator(alpha, 0f) { alpha = it } withEndAction { onFinishedListener() } }.start() else onFinishedListener() } private fun onHorizontalSwipe(offset: Float) { val alpha = max((1f - abs(offset / SWIPE_TO_CLOSE_OFFSET_THRESHOLD)) * 0.5f + 0.5f, 0f) this.alpha = alpha } /* * ------------------------------------------------------------------- * Touch Listeners * ------------------------------------------------------------------- */ private inner class FrameTouchListener(context: Context) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { private val gestureDetector: GestureDetector = GestureDetector(context, this) @SuppressLint("ClickableViewAccessibility") override fun onTouch(view: View, event: MotionEvent): Boolean { if (!isExpanded) return false gestureDetector.onTouchEvent(event) return true } override fun onSingleTapConfirmed(event: MotionEvent): Boolean { if (!viewerContract.onSingleTapConfirmed(event)) toggleControls() return true } override fun onDoubleTap(e: MotionEvent): Boolean { isExpanded = !isExpanded return true } } /** * Monitors the view click events to show and hide the video controls if they have been specified. */ private inner class VideoTouchListener(context: Context) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { private val gestureDetector: GestureDetector = GestureDetector(context, this) private val downLoc = PointF() private var baseSwipeX = -1f private var baseTranslateX = -1f private var checkForDismiss = true private var onSwipe = false @SuppressLint("ClickableViewAccessibility") override fun onTouch(view: View, event: MotionEvent): Boolean { gestureDetector.onTouchEvent(event) when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { checkForDismiss = !isExpanded onSwipe = false downLoc.x = event.rawX downLoc.y = event.rawY } MotionEvent.ACTION_MOVE -> { if (onSwipe) { val dx = baseSwipeX - event.rawX translationX = baseTranslateX - dx onHorizontalSwipe(dx) } else if (checkForDismiss) { if (abs(event.rawY - downLoc.y) > SWIPE_TO_CLOSE_VERTICAL_THRESHOLD) checkForDismiss = false else if (abs(event.rawX - downLoc.x) > SWIPE_TO_CLOSE_HORIZONTAL_THRESHOLD) { onSwipe = true baseSwipeX = event.rawX baseTranslateX = translationX } } } MotionEvent.ACTION_UP -> { if (onSwipe) { if (abs(baseSwipeX - event.rawX) > SWIPE_TO_CLOSE_OFFSET_THRESHOLD) destroy() else animate().translationX(baseTranslateX).setDuration( FAST_ANIMATION_DURATION ).withStartAction { animate().alpha(1f) } } } } return true } override fun onSingleTapConfirmed(event: MotionEvent): Boolean { if (viewerContract.onSingleTapConfirmed(event)) return true if (!isExpanded) { isExpanded = true return true } toggleControls() return true } override fun onDoubleTap(e: MotionEvent): Boolean { isExpanded = !isExpanded return true } } }