Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Auto-hide Image Viewer toolbar #507

Merged
merged 2 commits into from
Mar 10, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
36 changes: 31 additions & 5 deletions app/src/main/java/app/pachli/fragment/ViewImageFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.GestureDetectorCompat
import androidx.lifecycle.lifecycleScope
import app.pachli.R
import app.pachli.ViewMediaActivity
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible
Expand All @@ -41,6 +41,8 @@ import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.ortiz.touchview.OnTouchCoordinatesListener
import com.ortiz.touchview.TouchImageView
import kotlin.math.abs
Expand All @@ -50,24 +52,29 @@ class ViewImageFragment : ViewMediaFragment() {

private val binding by viewBinding(FragmentViewImageBinding::bind)

private lateinit var toolbar: View

// Volatile: Image requests happen on background thread and we want to see updates to it
// immediately on another thread. Atomic is an overkill for such thing.
@Volatile
private var startedTransition = false

override fun setupMediaView(showingDescription: Boolean) {
private var scheduleToolbarHide = false

override fun setupMediaView(
isToolbarVisible: Boolean,
showingDescription: Boolean,
) {
binding.photoView.transitionName = attachment.url
binding.mediaDescription.text = attachment.description
binding.captionSheet.visible(showingDescription)

startedTransition = false
loadImageFromNetwork(attachment.url, attachment.previewUrl, binding.photoView)

// Only schedule hiding the toolbar once
scheduleToolbarHide = isToolbarVisible
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar = (requireActivity() as ViewMediaActivity).toolbar
return inflater.inflate(R.layout.fragment_view_image, container, false)
}

Expand Down Expand Up @@ -183,9 +190,19 @@ class ViewImageFragment : ViewMediaFragment() {
}
},
)

val captionSheetParams = (binding.captionSheet.layoutParams as CoordinatorLayout.LayoutParams)
(captionSheetParams.behavior as BottomSheetBehavior).addBottomSheetCallback(
object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) = cancelToolbarHide()
override fun onSlide(bottomSheet: View, slideOffset: Float) = cancelToolbarHide()
},
)
}

override fun onToolbarVisibilityChange(visible: Boolean) {
super.onToolbarVisibilityChange(visible)

if (!userVisibleHint) return

isDescriptionVisible = showingDescription && visible
Expand All @@ -203,6 +220,15 @@ class ViewImageFragment : ViewMediaFragment() {
.start()
}

override fun shouldScheduleToolbarHide(): Boolean {
return if (scheduleToolbarHide) {
scheduleToolbarHide = false
true
} else {
false
}
}

override fun onStop() {
super.onStop()
Glide.with(this).clear(binding.photoView)
Expand Down
81 changes: 75 additions & 6 deletions app/src/main/java/app/pachli/fragment/ViewMediaFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,22 @@
package app.pachli.fragment

import android.content.Context
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import androidx.annotation.CallSuper
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import app.pachli.ViewMediaActivity
import app.pachli.core.network.model.Attachment
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

/** Interface for actions that may happen while media is being displayed */
interface MediaActionsListener {
Expand All @@ -51,16 +58,56 @@ abstract class ViewMediaFragment : Fragment() {
* Called after [onResume], subclasses should override this and update
* the contents of views (including loading any media).
*
* @param isToolbarVisible True if the toolbar is visible
* @param showingDescription True if the media's description should be shown
*/
abstract fun setupMediaView(showingDescription: Boolean)
abstract fun setupMediaView(
isToolbarVisible: Boolean,
showingDescription: Boolean,
)

/**
* Called when the visibility of the toolbar changes.
*
* @param visible True if the toolbar is visible
*/
abstract fun onToolbarVisibilityChange(visible: Boolean)
@CallSuper
protected open fun onToolbarVisibilityChange(visible: Boolean) {
if (visible && shouldScheduleToolbarHide()) {
hideToolbarAfterDelay()
} else {
hideToolbarJob?.cancel()
}
}

/**
* Called when the toolbar becomes visible, returns whether or not to schedule hiding the toolbar
*/
protected abstract fun shouldScheduleToolbarHide(): Boolean

/** Hoist toolbar hiding to activity so it can track state across different fragments */
private var hideToolbarJob: Job? = null

/**
* Schedule hiding the toolbar after a delay
*/
protected fun hideToolbarAfterDelay() {
hideToolbarJob?.cancel()
hideToolbarJob = lifecycleScope.launch {
delay(CONTROLS_TIMEOUT)
mediaActivity.onMediaTap()
nikclayton marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
* Cancel previously scheduled hiding of the toolbar
*/
protected fun cancelToolbarHide() {
hideToolbarJob?.cancel()
}

protected lateinit var mediaActivity: ViewMediaActivity
private set

protected var showingDescription = false
protected var isDescriptionVisible = false
Expand All @@ -82,6 +129,7 @@ abstract class ViewMediaFragment : Fragment() {

override fun onAttach(context: Context) {
super.onAttach(context)
mediaActivity = activity as ViewMediaActivity
mediaActionsListener = context as MediaActionsListener
}

Expand Down Expand Up @@ -109,18 +157,36 @@ abstract class ViewMediaFragment : Fragment() {
}

private fun finalizeViewSetup() {
val mediaActivity = activity as ViewMediaActivity

showingDescription = !TextUtils.isEmpty(attachment.description)
isDescriptionVisible = showingDescription
setupMediaView(showingDescription && mediaActivity.isToolbarVisible)
setupMediaView(mediaActivity.isToolbarVisible, showingDescription && mediaActivity.isToolbarVisible)

removeToolbarListener = (activity as ViewMediaActivity)
removeToolbarListener = mediaActivity
.addToolbarVisibilityListener { isVisible ->
onToolbarVisibilityChange(isVisible)
}
}

override fun onPause() {
super.onPause()

// If <= API 23 then multi-window mode is not available, so this is a good time to
// pause everything
if (Build.VERSION.SDK_INT <= 23) {
hideToolbarJob?.cancel()
}
}

override fun onStop() {
super.onStop()

// If > API 23 then this might be multi-window, and definitely wasn't paused in onPause,
// so pause everything now.
if (Build.VERSION.SDK_INT > 23) {
hideToolbarJob?.cancel()
}
}

override fun onDestroyView() {
removeToolbarListener?.invoke()
transitionComplete = null
Expand All @@ -132,6 +198,9 @@ abstract class ViewMediaFragment : Fragment() {

protected const val ARG_ATTACHMENT = "attach"

@JvmStatic
protected val CONTROLS_TIMEOUT = 2.seconds // Consistent with YouTube player

/**
* @param attachment The media attachment to display in the fragment
* @param shouldCallMediaReady If true this fragment should call
Expand Down
38 changes: 10 additions & 28 deletions app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,24 +47,19 @@ import androidx.media3.exoplayer.util.EventLogger
import androidx.media3.ui.AspectRatioFrameLayout
import app.pachli.BuildConfig
import app.pachli.R
import app.pachli.ViewMediaActivity
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible
import app.pachli.core.network.model.Attachment
import app.pachli.databinding.FragmentViewVideoBinding
import app.pachli.fragment.ViewVideoFragment.Companion.CONTROLS_TIMEOUT
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.abs
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient

Expand All @@ -89,10 +84,6 @@ class ViewVideoFragment : ViewMediaFragment() {

private lateinit var toolbar: View

/** Hoist toolbar hiding to activity so it can track state across different fragments */
private var hideToolbarJob: Job? = null

private lateinit var mediaActivity: ViewMediaActivity
private lateinit var mediaPlayerListener: Player.Listener
private var isAudio = false

Expand All @@ -116,7 +107,6 @@ class ViewVideoFragment : ViewMediaFragment() {

@SuppressLint("PrivateResource", "MissingInflatedId")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
mediaActivity = activity as ViewMediaActivity
toolbar = mediaActivity.toolbar
val rootView = inflater.inflate(R.layout.fragment_view_video, container, false)

Expand Down Expand Up @@ -243,7 +233,7 @@ class ViewVideoFragment : ViewMediaFragment() {
if (isPlaying) {
hideToolbarAfterDelay()
} else {
hideToolbarJob?.cancel()
cancelToolbarHide()
}
}

Expand Down Expand Up @@ -301,7 +291,6 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT <= 23) {
binding.videoView.onPause()
releasePlayer()
hideToolbarJob?.cancel()
}
}

Expand All @@ -313,7 +302,6 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT > 23) {
binding.videoView.onPause()
releasePlayer()
hideToolbarJob?.cancel()
}
}

Expand Down Expand Up @@ -365,7 +353,10 @@ class ViewVideoFragment : ViewMediaFragment() {
}

@SuppressLint("ClickableViewAccessibility")
override fun setupMediaView(showingDescription: Boolean) {
override fun setupMediaView(
isToolbarVisible: Boolean,
showingDescription: Boolean,
) {
startedTransition = false

binding.mediaDescription.text = attachment.description
Expand All @@ -389,15 +380,9 @@ class ViewVideoFragment : ViewMediaFragment() {
binding.videoView.requestFocus()
}

private fun hideToolbarAfterDelay() {
hideToolbarJob?.cancel()
hideToolbarJob = lifecycleScope.launch {
delay(CONTROLS_TIMEOUT)
mediaActivity.onMediaTap()
}
}

override fun onToolbarVisibilityChange(visible: Boolean) {
super.onToolbarVisibilityChange(visible)

if (!userVisibleHint) return
view ?: return

Expand All @@ -420,16 +405,13 @@ class ViewVideoFragment : ViewMediaFragment() {
},
)
.start()
}

if (visible && (binding.videoView.player?.isPlaying == true) && !isAudio) {
hideToolbarAfterDelay()
} else {
hideToolbarJob?.cancel()
}
override fun shouldScheduleToolbarHide(): Boolean {
return (binding.videoView.player?.isPlaying == true) && !isAudio
}

companion object {
private val CONTROLS_TIMEOUT = 2.seconds // Consistent with YouTube player
private const val SEEK_POSITION = "seekPosition"
}
}