refactor: Restructure ViewMediaActivity and related fragments (#489)

Highlights:

- Implement fragment transitions for video to improve the UX, video
won't start playing until the transition completes
- Remove rxJava
- Move duplicate code in to base classes

Details:

`MediaActionsListener`:

- Move to `ViewMediaFragment` as it's used by both subclasses
- Remove need for separate `VideoActionsListener`
- Rename methods to better reflect their purpose and improve readability

`ViewMediaFragment`:

- Move duplicated code from `ViewImageFragment` and `ViewVideoFragment`
- Rewrite code that handles fragment transitions to use a
`CompleteableDeferred` instead of `BehaviorSubject` (removes rxJava).
- Rename methods and properties to better reflect their purpose and
improve readability
- Add extra comments

`ViewImageFragment`:
- Rewrite code that handles fragment transitions to use a
`CompleteableDeferred` instead of `BehaviorSubject` (removes rxJava).

`ViewVideoFragment`:

- Implement fragment transitions for video to improve the UX, video
won't start playing until the transition completes
- Manage toolbar visibility with a coroutine instead of a handler
- Add extra comments

`ViewMediaActivity`:
- Rename properties to better reflect their purpose and improve
readability
- Add extra comments

`ImagePagerAdapter`:
- Rename properties to better reflect their purpose and improve
readability
- Add extra comments
This commit is contained in:
Nik Clayton 2024-03-03 16:07:39 +01:00 committed by GitHub
parent 72ef8cfe4a
commit 1026fccc40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 204 additions and 158 deletions

View File

@ -51,8 +51,7 @@ import app.pachli.core.navigation.AttachmentViewData
import app.pachli.core.navigation.ViewMediaActivityIntent import app.pachli.core.navigation.ViewMediaActivityIntent
import app.pachli.core.navigation.ViewThreadActivityIntent import app.pachli.core.navigation.ViewThreadActivityIntent
import app.pachli.databinding.ActivityViewMediaBinding import app.pachli.databinding.ActivityViewMediaBinding
import app.pachli.fragment.ViewImageFragment import app.pachli.fragment.MediaActionsListener
import app.pachli.fragment.ViewVideoFragment
import app.pachli.pager.ImagePagerAdapter import app.pachli.pager.ImagePagerAdapter
import app.pachli.pager.SingleImagePagerAdapter import app.pachli.pager.SingleImagePagerAdapter
import app.pachli.util.getTemporaryMediaFilename import app.pachli.util.getTemporaryMediaFilename
@ -76,7 +75,7 @@ typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
* Show one or more media items (pictures, video, audio, etc). * Show one or more media items (pictures, video, audio, etc).
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener { class ViewMediaActivity : BaseActivity(), MediaActionsListener {
@Inject @Inject
lateinit var okHttpClient: OkHttpClient lateinit var okHttpClient: OkHttpClient
@ -88,15 +87,21 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
var isToolbarVisible = true var isToolbarVisible = true
private set private set
private var attachments: List<AttachmentViewData>? = null private var attachmentViewData: List<AttachmentViewData>? = null
private val toolbarVisibilityListeners = mutableListOf<ToolbarVisibilityListener>() private val toolbarVisibilityListeners = mutableListOf<ToolbarVisibilityListener>()
private var imageUrl: String? = null private var imageUrl: String? = null
/** True if a download to share media is in progress */ /** True if a download to share media is in progress */
private var isDownloading: Boolean = false private var isDownloading: Boolean = false
/**
* Adds [listener] to the list of toolbar listeners and immediately calls
* it with the current toolbar visibility.
*
* @return A function that must be called to remove the listener.
*/
fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0<Boolean> { fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0<Boolean> {
this.toolbarVisibilityListeners.add(listener) toolbarVisibilityListeners.add(listener)
listener(isToolbarVisible) listener(isToolbarVisible)
return { toolbarVisibilityListeners.remove(listener) } return { toolbarVisibilityListeners.remove(listener) }
} }
@ -108,16 +113,16 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
supportPostponeEnterTransition() supportPostponeEnterTransition()
// Gather the parameters. // Gather the parameters.
attachments = ViewMediaActivityIntent.getAttachments(intent) attachmentViewData = ViewMediaActivityIntent.getAttachments(intent)
val initialPosition = ViewMediaActivityIntent.getAttachmentIndex(intent) val initialPosition = ViewMediaActivityIntent.getAttachmentIndex(intent)
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener // Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener
// but it cannot be expressed and if I don't specify type explicitly compilation fails // but it cannot be expressed and if I don't specify type explicitly compilation fails
// (probably a bug in compiler) // (probably a bug in compiler)
val adapter: ViewMediaAdapter = if (attachments != null) { val adapter: ViewMediaAdapter = if (attachmentViewData != null) {
val realAttachs = attachments!!.map(AttachmentViewData::attachment) val attachments = attachmentViewData!!.map(AttachmentViewData::attachment)
// Setup the view pager. // Setup the view pager.
ImagePagerAdapter(this, realAttachs, initialPosition) ImagePagerAdapter(this, attachments, initialPosition)
} else { } else {
imageUrl = ViewMediaActivityIntent.getImageUrl(intent) imageUrl = ViewMediaActivityIntent.getImageUrl(intent)
?: throw IllegalArgumentException("attachment list or image url has to be set") ?: throw IllegalArgumentException("attachment list or image url has to be set")
@ -137,12 +142,12 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
// Setup the toolbar. // Setup the toolbar.
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
val actionBar = supportActionBar supportActionBar?.apply {
if (actionBar != null) { setDisplayHomeAsUpEnabled(true)
actionBar.setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true)
actionBar.setDisplayShowHomeEnabled(true) title = getPageTitle(initialPosition)
actionBar.title = getPageTitle(initialPosition)
} }
binding.toolbar.setNavigationOnClickListener { supportFinishAfterTransition() } binding.toolbar.setNavigationOnClickListener { supportFinishAfterTransition() }
binding.toolbar.setOnMenuItemClickListener { item: MenuItem -> binding.toolbar.setOnMenuItemClickListener { item: MenuItem ->
when (item.itemId) { when (item.itemId) {
@ -170,7 +175,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.view_media_toolbar, menu) menuInflater.inflate(R.menu.view_media_toolbar, menu)
// We don't support 'open status' from single image views // We don't support 'open status' from single image views
menu.findItem(R.id.action_open_status)?.isVisible = (attachments != null) menu.findItem(R.id.action_open_status)?.isVisible = (attachmentViewData != null)
return true return true
} }
@ -179,15 +184,15 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
return true return true
} }
override fun onBringUp() { override fun onMediaReady() {
supportStartPostponedEnterTransition() supportStartPostponedEnterTransition()
} }
override fun onDismiss() { override fun onMediaDismiss() {
supportFinishAfterTransition() supportFinishAfterTransition()
} }
override fun onPhotoTap() { override fun onMediaTap() {
isToolbarVisible = !isToolbarVisible isToolbarVisible = !isToolbarVisible
for (listener in toolbarVisibilityListeners) { for (listener in toolbarVisibilityListeners) {
listener(isToolbarVisible) listener(isToolbarVisible)
@ -214,14 +219,12 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
private fun getPageTitle(position: Int): CharSequence { private fun getPageTitle(position: Int): CharSequence {
if (attachments == null) { attachmentViewData ?: return ""
return "" return String.format(Locale.getDefault(), "%d/%d", position + 1, attachmentViewData?.size)
}
return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments?.size)
} }
private fun downloadMedia() { private fun downloadMedia() {
val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url val url = imageUrl ?: attachmentViewData!![binding.viewPager.currentItem].attachment.url
val filename = Uri.parse(url).lastPathSegment val filename = Uri.parse(url).lastPathSegment
Toast.makeText(applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT).show() Toast.makeText(applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT).show()
@ -248,12 +251,12 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
private fun onOpenStatus() { private fun onOpenStatus() {
val attach = attachments!![binding.viewPager.currentItem] val attach = attachmentViewData!![binding.viewPager.currentItem]
startActivityWithSlideInAnimation(ViewThreadActivityIntent(this, attach.statusId, attach.statusUrl)) startActivityWithSlideInAnimation(ViewThreadActivityIntent(this, attach.statusId, attach.statusUrl))
} }
private fun copyLink() { private fun copyLink() {
val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url val url = imageUrl ?: attachmentViewData!![binding.viewPager.currentItem].attachment.url
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(null, url)) clipboard.setPrimaryClip(ClipData.newPlainText(null, url))
} }
@ -268,7 +271,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
if (imageUrl != null) { if (imageUrl != null) {
shareMediaFile(directory, imageUrl!!) shareMediaFile(directory, imageUrl!!)
} else { } else {
val attachment = attachments!![binding.viewPager.currentItem].attachment val attachment = attachmentViewData!![binding.viewPager.currentItem].attachment
shareMediaFile(directory, attachment.url) shareMediaFile(directory, attachment.url)
} }
} }

View File

@ -19,7 +19,6 @@ package app.pachli.fragment
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PointF import android.graphics.PointF
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
@ -30,6 +29,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
import androidx.lifecycle.lifecycleScope
import app.pachli.R import app.pachli.R
import app.pachli.ViewMediaActivity import app.pachli.ViewMediaActivity
import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.hide
@ -43,35 +43,21 @@ import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.target.Target
import com.ortiz.touchview.OnTouchCoordinatesListener import com.ortiz.touchview.OnTouchCoordinatesListener
import com.ortiz.touchview.TouchImageView import com.ortiz.touchview.TouchImageView
import io.reactivex.rxjava3.subjects.BehaviorSubject
import kotlin.math.abs import kotlin.math.abs
import kotlinx.coroutines.launch
class ViewImageFragment : ViewMediaFragment() { class ViewImageFragment : ViewMediaFragment() {
interface PhotoActionsListener {
fun onBringUp()
fun onDismiss()
fun onPhotoTap()
}
private val binding by viewBinding(FragmentViewImageBinding::bind) private val binding by viewBinding(FragmentViewImageBinding::bind)
private lateinit var photoActionsListener: PhotoActionsListener
private lateinit var toolbar: View private lateinit var toolbar: View
private var transition = BehaviorSubject.create<Unit>()
// Volatile: Image requests happen on background thread and we want to see updates to it // 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. // immediately on another thread. Atomic is an overkill for such thing.
@Volatile @Volatile
private var startedTransition = false private var startedTransition = false
override fun onAttach(context: Context) { override fun setupMediaView(showingDescription: Boolean) {
super.onAttach(context)
photoActionsListener = context as PhotoActionsListener
}
override fun setupMediaView(
showingDescription: Boolean,
) {
binding.photoView.transitionName = attachment.url binding.photoView.transitionName = attachment.url
binding.mediaDescription.text = attachment.description binding.mediaDescription.text = attachment.description
binding.captionSheet.visible(showingDescription) binding.captionSheet.visible(showingDescription)
@ -82,7 +68,6 @@ class ViewImageFragment : ViewMediaFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar = (requireActivity() as ViewMediaActivity).toolbar toolbar = (requireActivity() as ViewMediaActivity).toolbar
this.transition = BehaviorSubject.create()
return inflater.inflate(R.layout.fragment_view_image, container, false) return inflater.inflate(R.layout.fragment_view_image, container, false)
} }
@ -95,7 +80,7 @@ class ViewImageFragment : ViewMediaFragment() {
object : GestureDetector.SimpleOnGestureListener() { object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent) = true override fun onDown(e: MotionEvent) = true
override fun onSingleTapConfirmed(e: MotionEvent): Boolean { override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
photoActionsListener.onPhotoTap() mediaActionsListener.onMediaTap()
return false return false
} }
}, },
@ -191,7 +176,7 @@ class ViewImageFragment : ViewMediaFragment() {
*/ */
private fun onGestureEnd(view: View) { private fun onGestureEnd(view: View) {
if (abs(view.translationY) > 180) { if (abs(view.translationY) > 180) {
photoActionsListener.onDismiss() mediaActionsListener.onMediaDismiss()
} else { } else {
view.animate().translationY(0f).scaleX(1f).scaleY(1f).start() view.animate().translationY(0f).scaleX(1f).scaleY(1f).start()
} }
@ -200,11 +185,6 @@ class ViewImageFragment : ViewMediaFragment() {
) )
} }
override fun onResume() {
super.onResume()
finalizeViewSetup()
}
override fun onToolbarVisibilityChange(visible: Boolean) { override fun onToolbarVisibilityChange(visible: Boolean) {
if (!userVisibleHint) return if (!userVisibleHint) return
@ -228,11 +208,7 @@ class ViewImageFragment : ViewMediaFragment() {
Glide.with(this).clear(binding.photoView) Glide.with(this).clear(binding.photoView)
} }
override fun onDestroyView() { @SuppressLint("CheckResult")
transition.onComplete()
super.onDestroyView()
}
private fun loadImageFromNetwork(url: String, previewUrl: String?, photoView: ImageView) { private fun loadImageFromNetwork(url: String, previewUrl: String?, photoView: ImageView) {
val glide = Glide.with(this) val glide = Glide.with(this)
// Request image from the any cache // Request image from the any cache
@ -240,18 +216,16 @@ class ViewImageFragment : ViewMediaFragment() {
.load(url) .load(url)
.dontAnimate() .dontAnimate()
.onlyRetrieveFromCache(true) .onlyRetrieveFromCache(true)
.let { .apply {
if (previewUrl != null) { previewUrl?.let {
it.thumbnail( thumbnail(
glide glide
.load(previewUrl) .load(it)
.dontAnimate() .dontAnimate()
.onlyRetrieveFromCache(true) .onlyRetrieveFromCache(true)
.centerInside() .centerInside()
.addListener(ImageRequestListener(true, isThumbnailRequest = true)), .addListener(ImageRequestListener(true, isThumbnailRequest = true)),
) )
} else {
it
} }
} }
// Request image from the network on fail load image from cache // Request image from the network on fail load image from cache
@ -297,10 +271,10 @@ class ViewImageFragment : ViewMediaFragment() {
isFirstResource: Boolean, isFirstResource: Boolean,
): Boolean { ): Boolean {
// If cache for full image failed complete transition // If cache for full image failed complete transition
if (isCacheRequest && !isThumbnailRequest && shouldStartTransition && if (isCacheRequest && !isThumbnailRequest && shouldCallMediaReady &&
!startedTransition !startedTransition
) { ) {
photoActionsListener.onBringUp() mediaActionsListener.onMediaReady()
} }
// Hide progress bar only on fail request from internet // Hide progress bar only on fail request from internet
if (!isCacheRequest) binding.progressBar.hide() if (!isCacheRequest) binding.progressBar.hide()
@ -318,29 +292,26 @@ class ViewImageFragment : ViewMediaFragment() {
): Boolean { ): Boolean {
binding.progressBar.hide() // Always hide the progress bar on success binding.progressBar.hide() // Always hide the progress bar on success
if (!startedTransition || !shouldStartTransition) { if (!startedTransition || !shouldCallMediaReady) {
// Set this right away so that we don't have to concurrent post() requests // Set this right away so that we don't have to concurrent post() requests
startedTransition = true startedTransition = true
// post() because load() replaces image with null. Sometimes after we set // post() because load() replaces image with null. Sometimes after we set
// the thumbnail. // the thumbnail.
binding.photoView.post { binding.photoView.post {
target.onResourceReady(resource, null) target.onResourceReady(resource, null)
if (shouldStartTransition) photoActionsListener.onBringUp() if (shouldCallMediaReady) mediaActionsListener.onMediaReady()
} }
} else { } else {
// This wait for transition. If there's no transition then we should hit // Wait for the fragment transition to complete before signalling to
// another branch. take() will unsubscribe after we have it to not leak memory // Glide that the resource is ready.
transition transitionComplete?.let {
.take(1) viewLifecycleOwner.lifecycleScope.launch {
.subscribe { it.await()
target.onResourceReady(resource, null) target.onResourceReady(resource, null)
} }
}
} }
return true return true
} }
} }
override fun onTransitionEnd() {
this.transition.onNext(Unit)
}
} }

View File

@ -16,6 +16,7 @@
package app.pachli.fragment package app.pachli.fragment
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.view.View import android.view.View
@ -24,23 +25,65 @@ import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import app.pachli.ViewMediaActivity import app.pachli.ViewMediaActivity
import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.Attachment
import kotlinx.coroutines.CompletableDeferred
/** Interface for actions that may happen while media is being displayed */
interface MediaActionsListener {
/**
* The media fragment is ready for the hosting activity to complete the
* fragment transition; typically because any media to be displayed has
* been loaded.
*/
fun onMediaReady()
/** The user is dismissing the media (e.g., by flinging up) */
fun onMediaDismiss()
/** The user has tapped on the media (typically to show/hide UI controls) */
fun onMediaTap()
}
abstract class ViewMediaFragment : Fragment() { abstract class ViewMediaFragment : Fragment() {
private var toolbarVisibilityDisposable: Function0<Boolean>? = null /** Function to remove the toolbar listener */
private var removeToolbarListener: Function0<Boolean>? = null
abstract fun setupMediaView( /**
showingDescription: Boolean, * Called after [onResume], subclasses should override this and update
) * the contents of views (including loading any media).
*
* @param showingDescription True if the media's description should be shown
*/
abstract fun setupMediaView(showingDescription: Boolean)
/**
* Called when the visibility of the toolbar changes.
*
* @param visible True if the toolbar is visible
*/
abstract fun onToolbarVisibilityChange(visible: Boolean) abstract fun onToolbarVisibilityChange(visible: Boolean)
protected var showingDescription = false protected var showingDescription = false
protected var isDescriptionVisible = false protected var isDescriptionVisible = false
/** The attachment to show. Set in [onViewCreated] */ /** The attachment to show */
protected lateinit var attachment: Attachment protected lateinit var attachment: Attachment
protected var shouldStartTransition = false /** Listener to call as media is loaded or on user interaction */
protected lateinit var mediaActionsListener: MediaActionsListener
/**
* True if the fragment should call [MediaActionsListener.onMediaReady]
* when the media is loaded.
*/
protected var shouldCallMediaReady = false
/** Awaitable signal that the transition has completed */
var transitionComplete: CompletableDeferred<Unit>? = CompletableDeferred()
override fun onAttach(context: Context) {
super.onAttach(context)
mediaActionsListener = context as MediaActionsListener
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -48,23 +91,60 @@ abstract class ViewMediaFragment : Fragment() {
attachment = arguments?.getParcelable<Attachment>(ARG_ATTACHMENT) attachment = arguments?.getParcelable<Attachment>(ARG_ATTACHMENT)
?: throw IllegalArgumentException("ARG_ATTACHMENT has to be set") ?: throw IllegalArgumentException("ARG_ATTACHMENT has to be set")
shouldStartTransition = arguments?.getBoolean(ARG_START_POSTPONED_TRANSITION) shouldCallMediaReady = arguments?.getBoolean(ARG_SHOULD_CALL_MEDIA_READY)
?: throw IllegalArgumentException("ARG_START_POSTPONED_TRANSITION has to be set") ?: throw IllegalArgumentException("ARG_START_POSTPONED_TRANSITION has to be set")
} }
/**
* Called by the fragment adapter to notify the fragment that the shared
* element transition has been completed.
*/
open fun onTransitionEnd() {
this.transitionComplete?.complete(Unit)
}
override fun onResume() {
super.onResume()
finalizeViewSetup()
}
private fun finalizeViewSetup() {
val mediaActivity = activity as ViewMediaActivity
showingDescription = !TextUtils.isEmpty(attachment.description)
isDescriptionVisible = showingDescription
setupMediaView(showingDescription && mediaActivity.isToolbarVisible)
removeToolbarListener = (activity as ViewMediaActivity)
.addToolbarVisibilityListener { isVisible ->
onToolbarVisibilityChange(isVisible)
}
}
override fun onDestroyView() {
removeToolbarListener?.invoke()
transitionComplete = null
super.onDestroyView()
}
companion object { companion object {
@JvmStatic protected const val ARG_SHOULD_CALL_MEDIA_READY = "shouldCallMediaReady"
protected val ARG_START_POSTPONED_TRANSITION = "startPostponedTransition"
@JvmStatic protected const val ARG_ATTACHMENT = "attach"
protected val ARG_ATTACHMENT = "attach"
/**
* @param attachment The media attachment to display in the fragment
* @param shouldCallMediaReady If true this fragment should call
* [MediaActionsListener.onMediaReady] when it has finished loading
* media, so the calling activity can perform any final actions.
* @return A fragment that shows [attachment]
*/
@JvmStatic @JvmStatic
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment { fun newInstance(attachment: Attachment, shouldCallMediaReady: Boolean): ViewMediaFragment {
val arguments = Bundle(2) val arguments = Bundle(2)
arguments.putParcelable(ARG_ATTACHMENT, attachment) arguments.putParcelable(ARG_ATTACHMENT, attachment)
arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, shouldStartPostponedTransition) arguments.putBoolean(ARG_SHOULD_CALL_MEDIA_READY, shouldCallMediaReady)
val fragment = when (attachment.type) { val fragment = when (attachment.type) {
Attachment.Type.IMAGE -> ViewImageFragment() Attachment.Type.IMAGE -> ViewImageFragment()
@ -94,30 +174,10 @@ abstract class ViewMediaFragment : Fragment() {
blurhash = null, blurhash = null,
), ),
) )
arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, true) arguments.putBoolean(ARG_SHOULD_CALL_MEDIA_READY, true)
fragment.arguments = arguments fragment.arguments = arguments
return fragment return fragment
} }
} }
abstract fun onTransitionEnd()
protected fun finalizeViewSetup() {
val mediaActivity = activity as ViewMediaActivity
showingDescription = !TextUtils.isEmpty(attachment.description)
isDescriptionVisible = showingDescription
setupMediaView(showingDescription && mediaActivity.isToolbarVisible)
toolbarVisibilityDisposable = (activity as ViewMediaActivity)
.addToolbarVisibilityListener { isVisible ->
onToolbarVisibilityChange(isVisible)
}
}
override fun onDestroyView() {
toolbarVisibilityDisposable?.invoke()
super.onDestroyView()
}
} }

View File

@ -23,8 +23,6 @@ import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
import android.view.GestureDetector import android.view.GestureDetector
import android.view.Gravity import android.view.Gravity
@ -36,6 +34,7 @@ import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
@ -50,10 +49,12 @@ import app.pachli.BuildConfig
import app.pachli.R import app.pachli.R
import app.pachli.ViewMediaActivity import app.pachli.ViewMediaActivity
import app.pachli.core.common.extensions.hide 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.viewBinding
import app.pachli.core.common.extensions.visible import app.pachli.core.common.extensions.visible
import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.Attachment
import app.pachli.databinding.FragmentViewVideoBinding import app.pachli.databinding.FragmentViewVideoBinding
import app.pachli.fragment.ViewVideoFragment.Companion.CONTROLS_TIMEOUT
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
@ -61,6 +62,10 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.abs 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 import okhttp3.OkHttpClient
/** /**
@ -69,31 +74,24 @@ import okhttp3.OkHttpClient
* UI behaviour: * UI behaviour:
* *
* - Fragment starts, media description is visible at top of screen, video starts playing * - Fragment starts, media description is visible at top of screen, video starts playing
* - Media description + toolbar disappears after CONTROLS_TIMEOUT_MS * - Media description + toolbar disappears after [CONTROLS_TIMEOUT]
* - Tapping shows controls + media description + toolbar, which fade after CONTROLS_TIMEOUT_MS * - Tapping shows controls + media description + toolbar, which fade after [CONTROLS_TIMEOUT]
* - Tapping pause, or the media description, pauses the video and the controls + media description * - Tapping pause, or the media description, pauses the video and the controls + media description
* remain visible * remain visible
*/ */
@UnstableApi @UnstableApi
@AndroidEntryPoint @AndroidEntryPoint
class ViewVideoFragment : ViewMediaFragment() { class ViewVideoFragment : ViewMediaFragment() {
interface VideoActionsListener {
fun onDismiss()
}
@Inject @Inject
lateinit var okHttpClient: OkHttpClient lateinit var okHttpClient: OkHttpClient
private val binding by viewBinding(FragmentViewVideoBinding::bind) private val binding by viewBinding(FragmentViewVideoBinding::bind)
private lateinit var videoActionsListener: VideoActionsListener
private lateinit var toolbar: View private lateinit var toolbar: View
private val handler = Handler(Looper.getMainLooper())
private val hideToolbar = Runnable { /** Hoist toolbar hiding to activity so it can track state across different fragments */
// Hoist toolbar hiding to activity so it can track state across different fragments private var hideToolbarJob: Job? = null
// This is explicitly stored as runnable so that we pass it to the handler later for cancellation
mediaActivity.onPhotoTap()
}
private lateinit var mediaActivity: ViewMediaActivity private lateinit var mediaActivity: ViewMediaActivity
private lateinit var mediaPlayerListener: Player.Listener private lateinit var mediaPlayerListener: Player.Listener
private var isAudio = false private var isAudio = false
@ -107,13 +105,13 @@ class ViewVideoFragment : ViewMediaFragment() {
private lateinit var mediaSourceFactory: DefaultMediaSourceFactory private lateinit var mediaSourceFactory: DefaultMediaSourceFactory
private var startedTransition = false
override fun onAttach(context: Context) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
mediaSourceFactory = DefaultMediaSourceFactory(context) mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient))) .setDataSourceFactory(DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient)))
videoActionsListener = context as VideoActionsListener
} }
@SuppressLint("PrivateResource", "MissingInflatedId") @SuppressLint("PrivateResource", "MissingInflatedId")
@ -137,13 +135,12 @@ class ViewVideoFragment : ViewMediaFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.videoView.controllerShowTimeoutMs = CONTROLS_TIMEOUT_MS binding.videoView.controllerShowTimeoutMs = CONTROLS_TIMEOUT.inWholeMilliseconds.toInt()
isAudio = attachment.type == Attachment.Type.AUDIO isAudio = attachment.type == Attachment.Type.AUDIO
binding.progressBar.show()
/** /** Handle single taps, flings, and dragging */
* Handle single taps, flings, and dragging
*/
val touchListener = object : View.OnTouchListener { val touchListener = object : View.OnTouchListener {
var lastY = 0f var lastY = 0f
@ -160,7 +157,7 @@ class ViewVideoFragment : ViewMediaFragment() {
/** A single tap should show/hide the media description */ /** A single tap should show/hide the media description */
override fun onSingleTapUp(e: MotionEvent): Boolean { override fun onSingleTapUp(e: MotionEvent): Boolean {
mediaActivity.onPhotoTap() mediaActivity.onMediaTap()
return false return false
} }
@ -172,7 +169,7 @@ class ViewVideoFragment : ViewMediaFragment() {
velocityY: Float, velocityY: Float,
): Boolean { ): Boolean {
if (abs(velocityY) > abs(velocityX)) { if (abs(velocityY) > abs(velocityX)) {
videoActionsListener.onDismiss() mediaActionsListener.onMediaDismiss()
return true return true
} }
return false return false
@ -196,7 +193,7 @@ class ViewVideoFragment : ViewMediaFragment() {
} }
} else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
if (abs(contentFrame.translationY) > 180) { if (abs(contentFrame.translationY) > 180) {
videoActionsListener.onDismiss() mediaActionsListener.onMediaDismiss()
} else { } else {
contentFrame.animate().translationY(0f).scaleX(1f).scaleY(1f).start() contentFrame.animate().translationY(0f).scaleX(1f).scaleY(1f).start()
} }
@ -222,6 +219,18 @@ class ViewVideoFragment : ViewMediaFragment() {
binding.progressBar.hide() binding.progressBar.hide()
binding.videoView.useController = true binding.videoView.useController = true
if (shouldCallMediaReady && !startedTransition) {
startedTransition = true
mediaActivity.onMediaReady()
}
transitionComplete?.let {
viewLifecycleOwner.lifecycleScope.launch {
it.await()
player?.play()
}
}
} }
else -> { /* do nothing */ } else -> { /* do nothing */ }
} }
@ -234,7 +243,7 @@ class ViewVideoFragment : ViewMediaFragment() {
if (isPlaying) { if (isPlaying) {
hideToolbarAfterDelay() hideToolbarAfterDelay()
} else { } else {
handler.removeCallbacks(hideToolbar) hideToolbarJob?.cancel()
} }
} }
@ -266,8 +275,6 @@ class ViewVideoFragment : ViewMediaFragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
finalizeViewSetup()
if (Build.VERSION.SDK_INT <= 23 || player == null) { if (Build.VERSION.SDK_INT <= 23 || player == null) {
initializePlayer() initializePlayer()
if (mediaActivity.isToolbarVisible && !isAudio) { if (mediaActivity.isToolbarVisible && !isAudio) {
@ -294,7 +301,7 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT <= 23) { if (Build.VERSION.SDK_INT <= 23) {
binding.videoView.onPause() binding.videoView.onPause()
releasePlayer() releasePlayer()
handler.removeCallbacks(hideToolbar) hideToolbarJob?.cancel()
} }
} }
@ -306,7 +313,7 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT > 23) { if (Build.VERSION.SDK_INT > 23) {
binding.videoView.onPause() binding.videoView.onPause()
releasePlayer() releasePlayer()
handler.removeCallbacks(hideToolbar) hideToolbarJob?.cancel()
} }
} }
@ -323,7 +330,10 @@ class ViewVideoFragment : ViewMediaFragment() {
setMediaItem(MediaItem.fromUri(mediaAttachment.url)) setMediaItem(MediaItem.fromUri(mediaAttachment.url))
addListener(mediaPlayerListener) addListener(mediaPlayerListener)
repeatMode = Player.REPEAT_MODE_ONE repeatMode = Player.REPEAT_MODE_ONE
playWhenReady = true
// Playback is automatically started in onPlaybackStateChanged when
// any transitions have completed.
playWhenReady = false
seekTo(savedSeekPosition) seekTo(savedSeekPosition)
prepare() prepare()
player = this player = this
@ -355,9 +365,9 @@ class ViewVideoFragment : ViewMediaFragment() {
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
override fun setupMediaView( override fun setupMediaView(showingDescription: Boolean) {
showingDescription: Boolean, startedTransition = false
) {
binding.mediaDescription.text = attachment.description binding.mediaDescription.text = attachment.description
binding.mediaDescription.visible(showingDescription) binding.mediaDescription.visible(showingDescription)
binding.mediaDescription.movementMethod = ScrollingMovementMethod() binding.mediaDescription.movementMethod = ScrollingMovementMethod()
@ -377,21 +387,18 @@ class ViewVideoFragment : ViewMediaFragment() {
} }
binding.videoView.requestFocus() binding.videoView.requestFocus()
if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) {
mediaActivity.onBringUp()
}
} }
private fun hideToolbarAfterDelay() { private fun hideToolbarAfterDelay() {
handler.postDelayed(hideToolbar, CONTROLS_TIMEOUT_MS.toLong()) hideToolbarJob?.cancel()
hideToolbarJob = lifecycleScope.launch {
delay(CONTROLS_TIMEOUT)
mediaActivity.onMediaTap()
}
} }
override fun onToolbarVisibilityChange(visible: Boolean) { override fun onToolbarVisibilityChange(visible: Boolean) {
if (!userVisibleHint) { if (!userVisibleHint) return
return
}
view ?: return view ?: return
isDescriptionVisible = showingDescription && visible isDescriptionVisible = showingDescription && visible
@ -417,14 +424,12 @@ class ViewVideoFragment : ViewMediaFragment() {
if (visible && (binding.videoView.player?.isPlaying == true) && !isAudio) { if (visible && (binding.videoView.player?.isPlaying == true) && !isAudio) {
hideToolbarAfterDelay() hideToolbarAfterDelay()
} else { } else {
handler.removeCallbacks(hideToolbar) hideToolbarJob?.cancel()
} }
} }
override fun onTransitionEnd() { }
companion object { companion object {
private const val CONTROLS_TIMEOUT_MS = 2000 // Consistent with YouTube player private val CONTROLS_TIMEOUT = 2.seconds // Consistent with YouTube player
private const val SEEK_POSITION = "seekPosition" private const val SEEK_POSITION = "seekPosition"
} }
} }

View File

@ -13,7 +13,8 @@ class ImagePagerAdapter(
private val initialPosition: Int, private val initialPosition: Int,
) : ViewMediaAdapter(activity) { ) : ViewMediaAdapter(activity) {
private var didTransition = false /** True if the animated transition to the fragment has completed */
private var transitionComplete = false
private val fragments = MutableList<WeakReference<ViewMediaFragment>?>(attachments.size) { null } private val fragments = MutableList<WeakReference<ViewMediaFragment>?>(attachments.size) { null }
override fun getItemCount() = attachments.size override fun getItemCount() = attachments.size
@ -26,7 +27,7 @@ class ImagePagerAdapter(
// transition and wait until it's over and it will never take place. // transition and wait until it's over and it will never take place.
val fragment = ViewMediaFragment.newInstance( val fragment = ViewMediaFragment.newInstance(
attachment = attachments[position], attachment = attachments[position],
shouldStartPostponedTransition = !didTransition && position == initialPosition, shouldCallMediaReady = !transitionComplete && position == initialPosition,
) )
fragments[position] = WeakReference(fragment) fragments[position] = WeakReference(fragment)
return fragment return fragment
@ -35,8 +36,14 @@ class ImagePagerAdapter(
} }
} }
/**
* Called by the hosting activity to notify the adapter that the shared element
* transition to the first displayed item in the adapter has completed.
*
* Forward the notification to the fragment.
*/
override fun onTransitionEnd(position: Int) { override fun onTransitionEnd(position: Int) {
this.didTransition = true this.transitionComplete = true
fragments[position]?.get()?.onTransitionEnd() fragments[position]?.get()?.onTransitionEnd()
} }
} }