From c2ca9066c6fd760bd6ef5d2f8f0530a89bfa7b66 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Tue, 24 Oct 2017 23:29:55 -0400 Subject: WIP: Feature/pip video 2 (#405) * Add dependency * Test new video view * Add initial video bindings * Implement drag to dismiss * Begin initial integration * Fix typo * Fix up url formatter * Update changelog * Create first fully integrated video build * Update translations * Update translations 2 --- .../com/pitchedapps/frost/views/FrostVideoView.kt | 234 +++++++++++++++++++++ .../pitchedapps/frost/views/FrostVideoViewer.kt | 121 +++++++++++ 2 files changed, 355 insertions(+) create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoView.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt (limited to 'app/src/main/kotlin/com/pitchedapps/frost/views') diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoView.kt new file mode 100644 index 00000000..eaa4e698 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoView.kt @@ -0,0 +1,234 @@ +package com.pitchedapps.frost.views + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.PointF +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import ca.allanwang.kau.utils.dpToPx +import ca.allanwang.kau.utils.scaleXY +import com.devbrackets.android.exomedia.ui.widget.VideoView +import com.pitchedapps.frost.utils.L + +/** + * 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 backgroundView: View? = null + var onFinishedListener: () -> Unit = {} + var viewerContract: FrostVideoViewerContract? = null + + 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 + val ANIMATION_DURATION = 300L + private val FAST_ANIMATION_DURATION = 100L + } + + private var upperMinimizedX = 0f + private var upperMinimizedY = 0f + + var isExpanded: Boolean = true + set(value) { + if (field == value) return + if (videoDimensions.x <= 0f || videoDimensions.y <= 0f) + return L.d("Attempted to toggle video expansion when points have not been finalized") + field = value + if (field) { + animate().scaleXY(1f).translationX(0f).translationY(0f).setDuration(ANIMATION_DURATION).withStartAction { + backgroundView?.animate()?.alpha(1f)?.setDuration(ANIMATION_DURATION) + viewerContract?.onFade(1f, ANIMATION_DURATION) + } + } else { + hideControls() + val height = height + val width = width + val scale = Math.min(height / 4f / videoDimensions.y, width / 2.3f / videoDimensions.x) + val desiredHeight = scale * videoDimensions.y + val desiredWidth = scale * videoDimensions.x + val translationX = (width - MINIMIZED_PADDING - desiredWidth) / 2 + val translationY = (height - MINIMIZED_PADDING - desiredHeight) / 2 + upperMinimizedX = width - desiredWidth - MINIMIZED_PADDING + upperMinimizedY = height - desiredHeight - MINIMIZED_PADDING + animate().scaleXY(scale).translationX(translationX).translationY(translationY).setDuration(ANIMATION_DURATION).withStartAction { + backgroundView?.animate()?.alpha(0f)?.setDuration(ANIMATION_DURATION) + viewerContract?.onFade(0f, ANIMATION_DURATION) + } + } + } + + init { + setOnPreparedListener { + start() + showControls() + } + setOnCompletionListener { + viewerContract?.onVideoComplete() + } + setOnTouchListener(FrameTouchListener(context)) + v.setOnTouchListener(VideoTouchListener(context)) + setOnVideoSizedChangedListener { intrinsicWidth, intrinsicHeight -> + val ratio = Math.min(width.toFloat() / intrinsicWidth, height.toFloat() / intrinsicHeight.toFloat()) + videoDimensions.set(ratio * intrinsicWidth, ratio * intrinsicHeight) + } + } + + fun jumpToStart() { + pause() + videoControls?.hide() + v.seekTo(0) + videoControls?.finishLoading() + } + + private fun hideControls() { + if (videoControls?.isVisible == true) + videoControls?.hide() + } + + private fun toggleControls() { + if (videoControls?.isVisible == true) + hideControls() + else + showControls() + } + + fun shouldParentAcceptTouch(ev: MotionEvent): Boolean { + if (isExpanded) return true + return ev.x >= upperMinimizedX && ev.y >= upperMinimizedY + } + + fun destroy() { + stopPlayback() + if (alpha > 0f) + animate().alpha(0f).setDuration(FAST_ANIMATION_DURATION).withEndAction { onFinishedListener() }.withStartAction { + viewerContract?.onFade(0f, FAST_ANIMATION_DURATION) + }.start() + else + onFinishedListener() + } + + private fun onHorizontalSwipe(offset: Float) { + val alpha = Math.max((1f - Math.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) != true) + 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 (Math.abs(event.rawY - downLoc.y) > SWIPE_TO_CLOSE_VERTICAL_THRESHOLD) + checkForDismiss = false + else if (Math.abs(event.rawX - downLoc.x) > SWIPE_TO_CLOSE_HORIZONTAL_THRESHOLD) { + onSwipe = true + baseSwipeX = event.rawX + baseTranslateX = translationX + } + } + } + MotionEvent.ACTION_UP -> { + if (onSwipe) { + if (Math.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) == true) return true + if (!isExpanded) { + isExpanded = true + return true + } + toggleControls() + return true + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + isExpanded = !isExpanded + return true + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt new file mode 100644 index 00000000..0f7d49e8 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt @@ -0,0 +1,121 @@ +package com.pitchedapps.frost.views + +import android.content.Context +import android.graphics.Color +import android.net.Uri +import android.support.constraint.ConstraintLayout +import android.support.v7.widget.Toolbar +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import ca.allanwang.kau.utils.* +import com.mikepenz.google_material_typeface_library.GoogleMaterial +import com.pitchedapps.frost.R +import com.pitchedapps.frost.facebook.formattedFbUrl +import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.utils.Prefs +import com.pitchedapps.frost.utils.frostDownload + +/** + * Created by Allan Wang on 2017-10-13. + */ +class FrostVideoViewer @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), FrostVideoViewerContract { + + override fun onFade(alpha: Float, duration: Long) { + toolbar.animate().alpha(alpha).setDuration(duration) + } + + override fun onSingleTapConfirmed(event: MotionEvent): Boolean { + if (restarter.isVisible) { + restarter.performClick() + return true + } + return false + } + + override fun onVideoComplete() { + video.jumpToStart() + restarter.fadeIn() + } + + val container: ViewGroup by bindView(R.id.video_container) + val toolbar: Toolbar by bindView(R.id.video_toolbar) + val background: View by bindView(R.id.video_background) + val video: FrostVideoView by bindView(R.id.video) + val restarter: ImageView by bindView(R.id.video_restart) + + + companion object { + /** + * Simplified binding to add video to layout, and remove it when finished + * This is under the assumption that the container allows for overlays, + * such as a FrameLayout + */ + inline fun showVideo(container: ViewGroup, url: String, crossinline onFinish: () -> Unit): FrostVideoViewer { + val videoViewer = FrostVideoViewer(container.context) + container.addView(videoViewer) + videoViewer.bringToFront() + L.d("Create video view", url) + videoViewer.setVideo(url) + videoViewer.video.onFinishedListener = { container.removeView(videoViewer); onFinish() } + return videoViewer + } + } + + init { + inflate(R.layout.view_video, true) + alpha = 0f + background.setBackgroundColor(if (Prefs.bgColor.isColorDark) Prefs.bgColor.withMinAlpha(200) else Color.BLACK) + video.backgroundView = background + video.viewerContract = this + video.pause() + toolbar.inflateMenu(R.menu.menu_video) + toolbar.setBackgroundColor(Prefs.headerColor) + context.setMenuIcons(toolbar.menu, Prefs.iconColor, + R.id.action_pip to GoogleMaterial.Icon.gmd_picture_in_picture_alt, + R.id.action_download to GoogleMaterial.Icon.gmd_file_download + ) + toolbar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_pip -> video.isExpanded = false + R.id.action_download -> context.frostDownload(video.videoUri) + } + true + } + restarter.gone().setIcon(GoogleMaterial.Icon.gmd_replay, 64) + restarter.setOnClickListener { + video.restart() + restarter.fadeOut { restarter.gone() } + } + } + + fun setVideo(url: String) { + animate().alpha(1f).setDuration(FrostVideoView.ANIMATION_DURATION).start() + video.setVideoURI(Uri.parse(url.formattedFbUrl)) + } + + /** + * Handle back presses + * returns true if consumed, false otherwise + */ + fun onBackPressed(): Boolean { + if (video.isExpanded) { + video.isExpanded = false + return true + } + return false + } + + fun pause() = video.pause() + +} + +interface FrostVideoViewerContract { + fun onSingleTapConfirmed(event: MotionEvent): Boolean + fun onFade(alpha: Float, duration: Long) + fun onVideoComplete() +} \ No newline at end of file -- cgit v1.2.3