Migrate to androidx-media3 video player (#3857)
Behaviour is consistent with previous player except that: - Swapping apps while a video is playing, and then returning to Tusky, will keep the seek position in the video instead of returning to the start - The controls/media description can be shown by tapping anywhere, not just on the video itself - The media description is on-screen for the same duration as the player controls (5 seconds here, 3 seconds in the previous code) - The user has options to control the playback speed - Rotating the device does not squash/stretch the video - Show the media preview when playing audio-only files Fixes https://github.com/tuskyapp/Tusky/issues/3329, https://github.com/tuskyapp/Tusky/issues/3141, https://github.com/tuskyapp/Tusky/issues/3126, https://github.com/tuskyapp/Tusky/issues/2753, https://github.com/tuskyapp/Tusky/issues/3508, https://github.com/tuskyapp/Tusky/issues/3291 --------- Co-authored-by: mcc <andi.m.mcclure@gmail.com>
This commit is contained in:
parent
09d4f62004
commit
8529f309ff
|
@ -59,6 +59,8 @@ import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
|
|||
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
|
@ -67,10 +69,13 @@ import java.io.FileNotFoundException
|
|||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
|
||||
|
||||
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
|
||||
class ViewMediaActivity : BaseActivity(), HasAndroidInjector, ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
|
||||
|
||||
|
@ -337,6 +342,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
shareFile(file, mimeType)
|
||||
}
|
||||
|
||||
override fun androidInjector() = androidInjector
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_ATTACHMENTS = "attachments"
|
||||
private const val EXTRA_ATTACHMENT_INDEX = "index"
|
||||
|
|
|
@ -94,7 +94,7 @@ abstract class ActivitiesModule {
|
|||
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
|
||||
abstract fun contributesPreferencesActivity(): PreferencesActivity
|
||||
|
||||
@ContributesAndroidInjector
|
||||
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
|
||||
abstract fun contributesViewMediaActivity(): ViewMediaActivity
|
||||
|
||||
@ContributesAndroidInjector
|
||||
|
|
|
@ -35,13 +35,10 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
|||
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
||||
import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
/**
|
||||
* Created by charlag on 3/24/18.
|
||||
*/
|
||||
|
||||
@Module
|
||||
abstract class FragmentBuildersModule {
|
||||
@ContributesAndroidInjector
|
||||
|
@ -103,4 +100,7 @@ abstract class FragmentBuildersModule {
|
|||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun trendingFragment(): TrendingFragment
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun viewVideoFragment(): ViewVideoFragment
|
||||
}
|
||||
|
|
|
@ -17,7 +17,9 @@ package com.keylesspalace.tusky.fragment
|
|||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
|
||||
|
@ -47,6 +49,7 @@ abstract class ViewMediaFragment : Fragment() {
|
|||
protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl"
|
||||
|
||||
@JvmStatic
|
||||
@OptIn(UnstableApi::class)
|
||||
fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment {
|
||||
val arguments = Bundle(2)
|
||||
arguments.putParcelable(ARG_ATTACHMENT, attachment)
|
||||
|
|
|
@ -19,33 +19,60 @@ import android.animation.Animator
|
|||
import android.animation.AnimatorListenerAdapter
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.method.ScrollingMovementMethod
|
||||
import android.view.GestureDetector
|
||||
import android.view.KeyEvent
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.MediaController
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.view.GestureDetectorCompat
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.util.EventLogger
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerControlView
|
||||
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 com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.databinding.FragmentViewVideoBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.view.ExposedPlayPauseVideoView
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
class ViewVideoFragment : ViewMediaFragment() {
|
||||
@UnstableApi
|
||||
class ViewVideoFragment : ViewMediaFragment(), Injectable {
|
||||
interface VideoActionsListener {
|
||||
fun onDismiss()
|
||||
}
|
||||
|
||||
private var _binding: FragmentViewVideoBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
@Inject
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
|
||||
private val binding by viewBinding(FragmentViewVideoBinding::bind)
|
||||
|
||||
private lateinit var videoActionsListener: VideoActionsListener
|
||||
private lateinit var toolbar: View
|
||||
|
@ -54,39 +81,266 @@ class ViewVideoFragment : ViewMediaFragment() {
|
|||
// Hoist toolbar hiding to activity so it can track state across different fragments
|
||||
// This is explicitly stored as runnable so that we pass it to the handler later for cancellation
|
||||
mediaActivity.onPhotoTap()
|
||||
mediaController.hide()
|
||||
}
|
||||
private lateinit var mediaActivity: ViewMediaActivity
|
||||
private lateinit var mediaController: MediaController
|
||||
private lateinit var mediaPlayerListener: Player.Listener
|
||||
private var isAudio = false
|
||||
|
||||
companion object {
|
||||
private const val TOOLBAR_HIDE_DELAY_MS = 3000L
|
||||
}
|
||||
private lateinit var mediaAttachment: Attachment
|
||||
|
||||
private var player: ExoPlayer? = null
|
||||
|
||||
/** The saved seek position, if the fragment is being resumed */
|
||||
private var savedSeekPosition: Long = 0
|
||||
|
||||
private lateinit var mediaSourceFactory: DefaultMediaSourceFactory
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||
.setDataSourceFactory(DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient)))
|
||||
|
||||
videoActionsListener = context as VideoActionsListener
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
// Move the controls to the bottom of the screen, with enough bottom margin to clear the seekbar
|
||||
val controls = rootView.findViewById<LinearLayout>(androidx.media3.ui.R.id.exo_center_controls)
|
||||
val layoutParams = controls.layoutParams as FrameLayout.LayoutParams
|
||||
layoutParams.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM
|
||||
layoutParams.bottomMargin = rootView.context.resources.getDimension(androidx.media3.ui.R.dimen.exo_styled_bottom_bar_height)
|
||||
.toInt()
|
||||
controls.layoutParams = layoutParams
|
||||
|
||||
return rootView
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val attachment = arguments?.getParcelable<Attachment>(ARG_ATTACHMENT)
|
||||
?: throw IllegalArgumentException("attachment has to be set")
|
||||
|
||||
val url = attachment.url
|
||||
isAudio = attachment.type == Attachment.Type.AUDIO
|
||||
|
||||
/**
|
||||
* Handle single taps, flings, and dragging
|
||||
*/
|
||||
val touchListener = object : View.OnTouchListener {
|
||||
var lastY = 0f
|
||||
|
||||
/** The view that contains the playing content */
|
||||
// binding.videoView is fullscreen, and includes the controls, so don't use that
|
||||
// when scaling in response to the user dragging on the screen
|
||||
val contentFrame = binding.videoView.findViewById<AspectRatioFrameLayout>(androidx.media3.ui.R.id.exo_content_frame)
|
||||
|
||||
/** Handle taps and flings */
|
||||
val simpleGestureDetector = GestureDetectorCompat(
|
||||
requireContext(),
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDown(e: MotionEvent) = true
|
||||
|
||||
/** A single tap should show/hide the media description */
|
||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||
mediaActivity.onPhotoTap()
|
||||
return false
|
||||
}
|
||||
|
||||
/** A fling up/down should dismiss the fragment */
|
||||
override fun onFling(
|
||||
e1: MotionEvent,
|
||||
e2: MotionEvent,
|
||||
velocityX: Float,
|
||||
velocityY: Float
|
||||
): Boolean {
|
||||
if (abs(velocityY) > abs(velocityX)) {
|
||||
videoActionsListener.onDismiss()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouch(v: View?, event: MotionEvent): Boolean {
|
||||
// Track movement, and scale / translate the video display accordingly
|
||||
if (event.action == MotionEvent.ACTION_DOWN) {
|
||||
lastY = event.rawY
|
||||
} else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) {
|
||||
val diff = event.rawY - lastY
|
||||
if (contentFrame.translationY != 0f || abs(diff) > 40) {
|
||||
contentFrame.translationY += diff
|
||||
val scale = (-abs(contentFrame.translationY) / 720 + 1).coerceAtLeast(0.5f)
|
||||
contentFrame.scaleY = scale
|
||||
contentFrame.scaleX = scale
|
||||
lastY = event.rawY
|
||||
}
|
||||
} else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
||||
if (abs(contentFrame.translationY) > 180) {
|
||||
videoActionsListener.onDismiss()
|
||||
} else {
|
||||
contentFrame.animate().translationY(0f).scaleX(1f).scaleY(1f).start()
|
||||
}
|
||||
}
|
||||
|
||||
simpleGestureDetector.onTouchEvent(event)
|
||||
|
||||
// Allow the player's normal onTouch handler to run as well (e.g., to show the
|
||||
// player controls on tap)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
mediaPlayerListener = object : Player.Listener {
|
||||
@SuppressLint("ClickableViewAccessibility", "SyntheticAccessor")
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
// Wait until the media is loaded before accepting taps as we don't want toolbar to
|
||||
// be hidden until then.
|
||||
binding.videoView.setOnTouchListener(touchListener)
|
||||
|
||||
binding.progressBar.hide()
|
||||
binding.videoView.useController = true
|
||||
binding.videoView.showController()
|
||||
}
|
||||
else -> { /* do nothing */ }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isAudio) return
|
||||
if (isPlaying) {
|
||||
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
|
||||
} else {
|
||||
handler.removeCallbacks(hideToolbar)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SyntheticAccessor")
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
binding.progressBar.hide()
|
||||
val message = getString(
|
||||
R.string.error_media_playback,
|
||||
error.cause?.message ?: error.message
|
||||
)
|
||||
Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE)
|
||||
.setTextMaxLines(10)
|
||||
.setAction(R.string.action_retry) { player?.prepare() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
savedSeekPosition = savedInstanceState?.getLong(SEEK_POSITION) ?: 0
|
||||
|
||||
mediaAttachment = attachment
|
||||
|
||||
finalizeViewSetup(url, attachment.previewUrl, attachment.description)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (Build.VERSION.SDK_INT > 23) {
|
||||
initializePlayer()
|
||||
binding.videoView.onResume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (_binding != null) {
|
||||
if (Build.VERSION.SDK_INT <= 23 || player == null) {
|
||||
initializePlayer()
|
||||
if (mediaActivity.isToolbarVisible && !isAudio) {
|
||||
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
|
||||
}
|
||||
binding.videoView.start()
|
||||
binding.videoView.onResume()
|
||||
}
|
||||
}
|
||||
|
||||
private fun releasePlayer() {
|
||||
player?.let {
|
||||
savedSeekPosition = it.currentPosition
|
||||
it.release()
|
||||
player = null
|
||||
binding.videoView.player = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
if (_binding != null) {
|
||||
// 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) {
|
||||
binding.videoView.onPause()
|
||||
releasePlayer()
|
||||
handler.removeCallbacks(hideToolbar)
|
||||
binding.videoView.pause()
|
||||
mediaController.hide()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
binding.videoView.onPause()
|
||||
releasePlayer()
|
||||
handler.removeCallbacks(hideToolbar)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putLong(SEEK_POSITION, savedSeekPosition)
|
||||
}
|
||||
|
||||
private fun initializePlayer() {
|
||||
ExoPlayer.Builder(requireContext())
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.build().apply {
|
||||
if (BuildConfig.DEBUG) addAnalyticsListener(EventLogger("$TAG:ExoPlayer"))
|
||||
setMediaItem(MediaItem.fromUri(mediaAttachment.url))
|
||||
addListener(mediaPlayerListener)
|
||||
repeatMode = Player.REPEAT_MODE_ONE
|
||||
playWhenReady = true
|
||||
seekTo(savedSeekPosition)
|
||||
prepare()
|
||||
player = this
|
||||
}
|
||||
|
||||
binding.videoView.player = player
|
||||
|
||||
// Audio-only files might have a preview image. If they do, set it as the artwork
|
||||
if (isAudio) {
|
||||
mediaAttachment.previewUrl?.let { url ->
|
||||
Glide.with(this).load(url).into(object : CustomTarget<Drawable>() {
|
||||
@SuppressLint("SyntheticAccessor")
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
transition: Transition<in Drawable>?
|
||||
) {
|
||||
view ?: return
|
||||
binding.videoView.defaultArtwork = resource
|
||||
}
|
||||
|
||||
@SuppressLint("SyntheticAccessor")
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
view ?: return
|
||||
binding.videoView.defaultArtwork = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -105,153 +359,20 @@ class ViewVideoFragment : ViewMediaFragment() {
|
|||
binding.mediaDescription.elevation = binding.videoView.elevation + 1
|
||||
|
||||
binding.videoView.transitionName = url
|
||||
binding.videoView.setVideoPath(url)
|
||||
mediaController = object : MediaController(mediaActivity) {
|
||||
override fun show(timeout: Int) {
|
||||
// We're doing manual auto-close management.
|
||||
// Also, take focus back from the pause button so we can use the back button.
|
||||
super.show(0)
|
||||
mediaController.requestFocus()
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
|
||||
if (event?.keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
if (event.action == KeyEvent.ACTION_UP) {
|
||||
hide()
|
||||
activity?.supportFinishAfterTransition()
|
||||
}
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
mediaController.setMediaPlayer(binding.videoView)
|
||||
binding.videoView.setMediaController(mediaController)
|
||||
binding.videoView.requestFocus()
|
||||
binding.videoView.setPlayPauseListener(object : ExposedPlayPauseVideoView.PlayPauseListener {
|
||||
override fun onPlay() {
|
||||
if (!isAudio) {
|
||||
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (!isAudio) {
|
||||
handler.removeCallbacks(hideToolbar)
|
||||
}
|
||||
}
|
||||
})
|
||||
binding.videoView.setOnPreparedListener { mp ->
|
||||
val containerWidth = binding.videoContainer.measuredWidth.toFloat()
|
||||
val containerHeight = binding.videoContainer.measuredHeight.toFloat()
|
||||
val videoWidth = mp.videoWidth.toFloat()
|
||||
val videoHeight = mp.videoHeight.toFloat()
|
||||
|
||||
if (isAudio) {
|
||||
binding.videoView.layoutParams.height = 1
|
||||
binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
} else if (containerWidth / containerHeight > videoWidth / videoHeight) {
|
||||
binding.videoView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
binding.videoView.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
} else {
|
||||
binding.videoView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
|
||||
// Wait until the media is loaded before accepting taps as we don't want toolbar to
|
||||
// be hidden until then.
|
||||
binding.videoView.setOnTouchListener { _, _ ->
|
||||
mediaActivity.onPhotoTap()
|
||||
false
|
||||
}
|
||||
|
||||
// Audio doesn't cause the controller to show automatically
|
||||
if (isAudio) {
|
||||
mediaController.show()
|
||||
}
|
||||
|
||||
binding.progressBar.hide()
|
||||
mp.isLooping = true
|
||||
}
|
||||
|
||||
if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) {
|
||||
mediaActivity.onBringUp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideToolbarAfterDelay(delayMilliseconds: Long) {
|
||||
handler.postDelayed(hideToolbar, delayMilliseconds)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
mediaActivity = activity as ViewMediaActivity
|
||||
toolbar = mediaActivity.toolbar
|
||||
_binding = FragmentViewVideoBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val attachment = arguments?.getParcelable<Attachment>(ARG_ATTACHMENT)
|
||||
?: throw IllegalArgumentException("attachment has to be set")
|
||||
|
||||
val url = attachment.url
|
||||
isAudio = attachment.type == Attachment.Type.AUDIO
|
||||
|
||||
val gestureDetector = GestureDetectorCompat(
|
||||
requireContext(),
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDown(event: MotionEvent): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onFling(
|
||||
e1: MotionEvent,
|
||||
e2: MotionEvent,
|
||||
velocityX: Float,
|
||||
velocityY: Float
|
||||
): Boolean {
|
||||
if (abs(velocityY) > abs(velocityX)) {
|
||||
videoActionsListener.onDismiss()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
var lastY = 0f
|
||||
binding.root.setOnTouchListener { _, event ->
|
||||
if (event.action == MotionEvent.ACTION_DOWN) {
|
||||
lastY = event.rawY
|
||||
} else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) {
|
||||
val diff = event.rawY - lastY
|
||||
if (binding.videoView.translationY != 0f || abs(diff) > 40) {
|
||||
binding.videoView.translationY += diff
|
||||
val scale = (-abs(binding.videoView.translationY) / 720 + 1).coerceAtLeast(0.5f)
|
||||
binding.videoView.scaleY = scale
|
||||
binding.videoView.scaleX = scale
|
||||
lastY = event.rawY
|
||||
return@setOnTouchListener true
|
||||
}
|
||||
} else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
||||
if (abs(binding.videoView.translationY) > 180) {
|
||||
videoActionsListener.onDismiss()
|
||||
} else {
|
||||
binding.videoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start()
|
||||
}
|
||||
}
|
||||
|
||||
gestureDetector.onTouchEvent(event)
|
||||
}
|
||||
|
||||
finalizeViewSetup(url, attachment.previewUrl, attachment.description)
|
||||
private fun hideToolbarAfterDelay(delayMilliseconds: Int) {
|
||||
handler.postDelayed(hideToolbar, delayMilliseconds.toLong())
|
||||
}
|
||||
|
||||
override fun onToolbarVisibilityChange(visible: Boolean) {
|
||||
if (_binding == null || !userVisibleHint) {
|
||||
if (!userVisibleHint) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -265,27 +386,27 @@ class ViewVideoFragment : ViewMediaFragment() {
|
|||
|
||||
binding.mediaDescription.animate().alpha(alpha)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
@SuppressLint("SyntheticAccessor")
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
if (_binding != null) {
|
||||
binding.mediaDescription.visible(isDescriptionVisible)
|
||||
}
|
||||
view ?: return
|
||||
binding.mediaDescription.visible(isDescriptionVisible)
|
||||
animation.removeListener(this)
|
||||
}
|
||||
})
|
||||
.start()
|
||||
|
||||
if (visible && binding.videoView.isPlaying && !isAudio) {
|
||||
if (visible && (binding.videoView.player?.isPlaying == true) && !isAudio) {
|
||||
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
|
||||
} else {
|
||||
handler.removeCallbacks(hideToolbar)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTransitionEnd() {
|
||||
}
|
||||
override fun onTransitionEnd() { }
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
companion object {
|
||||
private const val TAG = "ViewVideoFragment"
|
||||
private const val TOOLBAR_HIDE_DELAY_MS = PlayerControlView.DEFAULT_SHOW_TIMEOUT_MS
|
||||
private const val SEEK_POSITION = "seekPosition"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
package com.keylesspalace.tusky.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.VideoView
|
||||
|
||||
class ExposedPlayPauseVideoView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) :
|
||||
VideoView(context, attrs, defStyleAttr) {
|
||||
|
||||
private var listener: PlayPauseListener? = null
|
||||
private var playing = false
|
||||
|
||||
fun setPlayPauseListener(listener: PlayPauseListener) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
if (!playing) {
|
||||
playing = true
|
||||
listener?.onPlay()
|
||||
}
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
super.pause()
|
||||
if (playing) {
|
||||
playing = false
|
||||
listener?.onPause()
|
||||
}
|
||||
}
|
||||
|
||||
interface PlayPauseListener {
|
||||
fun onPlay()
|
||||
fun onPause()
|
||||
}
|
||||
}
|
|
@ -24,14 +24,17 @@
|
|||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Some media description" />
|
||||
|
||||
<com.keylesspalace.tusky.view.ExposedPlayPauseVideoView
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/videoView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:use_controller="false"
|
||||
app:show_previous_button="false"
|
||||
app:show_next_button="false" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
|
@ -42,4 +45,4 @@
|
|||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
@ -817,4 +817,5 @@
|
|||
<string name="load_newest_notifications">Load newest notifications</string>
|
||||
<string name="about_copy">Copy version and device information</string>
|
||||
<string name="about_copied">Copied version and device information</string>
|
||||
<string name="error_media_playback">Playback failed: %s</string>
|
||||
</resources>
|
||||
|
|
|
@ -10,6 +10,7 @@ androidx-exifinterface = "1.3.6"
|
|||
androidx-fragment = "1.6.1"
|
||||
androidx-junit = "1.1.5"
|
||||
androidx-lifecycle = "2.6.1"
|
||||
androidx-media3 = "1.1.0"
|
||||
androidx-paging = "3.2.0"
|
||||
androidx-preference = "1.2.0"
|
||||
androidx-recyclerview = "1.3.0"
|
||||
|
@ -81,6 +82,12 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo
|
|||
androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-reactivestreams-ktx = { module = "androidx.lifecycle:lifecycle-reactivestreams-ktx", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
|
||||
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
|
||||
androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" }
|
||||
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
|
||||
androidx-media3-exoplayer-rtsp = { module = "androidx.media3:media3-exoplayer-rtsp", version.ref = "androidx-media3" }
|
||||
androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidx-media3" }
|
||||
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||
androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" }
|
||||
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
|
||||
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
|
||||
|
@ -146,7 +153,8 @@ androidx = ["androidx-core-ktx", "androidx-appcompat", "androidx-fragment-ktx",
|
|||
"androidx-emoji2-core", "androidx-emoji2-views-core", "androidx-emoji2-view-helper", "androidx-lifecycle-viewmodel-ktx",
|
||||
"androidx-lifecycle-livedata-ktx", "androidx-lifecycle-common-java8", "androidx-lifecycle-reactivestreams-ktx",
|
||||
"androidx-constraintlayout", "androidx-paging-runtime-ktx", "androidx-viewpager2", "androidx-work-runtime-ktx",
|
||||
"androidx-core-splashscreen", "androidx-activity"]
|
||||
"androidx-core-splashscreen", "androidx-activity", "androidx-media3-exoplayer", "androidx-media3-exoplayer-dash",
|
||||
"androidx-media3-exoplayer-hls", "androidx-media3-exoplayer-rtsp", "androidx-media3-datasource-okhttp", "androidx-media3-ui"]
|
||||
autodispose = ["autodispose-core", "autodispose-android-lifecycle"]
|
||||
dagger = ["dagger-core", "dagger-android-core", "dagger-android-support"]
|
||||
dagger-processors = ["dagger-compiler", "dagger-android-processor"]
|
||||
|
|
Loading…
Reference in New Issue