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.getTemporaryMediaFilename
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
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.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
@ -67,10 +69,13 @@ import java.io.FileNotFoundException
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
|
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)
|
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
|
||||||
|
|
||||||
|
@ -337,6 +342,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
||||||
shareFile(file, mimeType)
|
shareFile(file, mimeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun androidInjector() = androidInjector
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val EXTRA_ATTACHMENTS = "attachments"
|
private const val EXTRA_ATTACHMENTS = "attachments"
|
||||||
private const val EXTRA_ATTACHMENT_INDEX = "index"
|
private const val EXTRA_ATTACHMENT_INDEX = "index"
|
||||||
|
|
|
@ -94,7 +94,7 @@ abstract class ActivitiesModule {
|
||||||
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
|
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
|
||||||
abstract fun contributesPreferencesActivity(): PreferencesActivity
|
abstract fun contributesPreferencesActivity(): PreferencesActivity
|
||||||
|
|
||||||
@ContributesAndroidInjector
|
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
|
||||||
abstract fun contributesViewMediaActivity(): ViewMediaActivity
|
abstract fun contributesViewMediaActivity(): ViewMediaActivity
|
||||||
|
|
||||||
@ContributesAndroidInjector
|
@ContributesAndroidInjector
|
||||||
|
|
|
@ -35,13 +35,10 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
||||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
||||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
||||||
|
import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.android.ContributesAndroidInjector
|
import dagger.android.ContributesAndroidInjector
|
||||||
|
|
||||||
/**
|
|
||||||
* Created by charlag on 3/24/18.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
abstract class FragmentBuildersModule {
|
abstract class FragmentBuildersModule {
|
||||||
@ContributesAndroidInjector
|
@ContributesAndroidInjector
|
||||||
|
@ -103,4 +100,7 @@ abstract class FragmentBuildersModule {
|
||||||
|
|
||||||
@ContributesAndroidInjector
|
@ContributesAndroidInjector
|
||||||
abstract fun trendingFragment(): TrendingFragment
|
abstract fun trendingFragment(): TrendingFragment
|
||||||
|
|
||||||
|
@ContributesAndroidInjector
|
||||||
|
abstract fun viewVideoFragment(): ViewVideoFragment
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,9 @@ package com.keylesspalace.tusky.fragment
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.keylesspalace.tusky.ViewMediaActivity
|
import com.keylesspalace.tusky.ViewMediaActivity
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
|
|
||||||
|
@ -47,6 +49,7 @@ abstract class ViewMediaFragment : Fragment() {
|
||||||
protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl"
|
protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl"
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment {
|
fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment {
|
||||||
val arguments = Bundle(2)
|
val arguments = Bundle(2)
|
||||||
arguments.putParcelable(ARG_ATTACHMENT, attachment)
|
arguments.putParcelable(ARG_ATTACHMENT, attachment)
|
||||||
|
|
|
@ -19,33 +19,60 @@ 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.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
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.KeyEvent
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
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.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.ViewMediaActivity
|
||||||
import com.keylesspalace.tusky.databinding.FragmentViewVideoBinding
|
import com.keylesspalace.tusky.databinding.FragmentViewVideoBinding
|
||||||
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
import com.keylesspalace.tusky.view.ExposedPlayPauseVideoView
|
import okhttp3.OkHttpClient
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
class ViewVideoFragment : ViewMediaFragment() {
|
@UnstableApi
|
||||||
|
class ViewVideoFragment : ViewMediaFragment(), Injectable {
|
||||||
interface VideoActionsListener {
|
interface VideoActionsListener {
|
||||||
fun onDismiss()
|
fun onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _binding: FragmentViewVideoBinding? = null
|
@Inject
|
||||||
private val binding get() = _binding!!
|
lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
||||||
|
private val binding by viewBinding(FragmentViewVideoBinding::bind)
|
||||||
|
|
||||||
private lateinit var videoActionsListener: VideoActionsListener
|
private lateinit var videoActionsListener: VideoActionsListener
|
||||||
private lateinit var toolbar: View
|
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
|
// 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
|
// This is explicitly stored as runnable so that we pass it to the handler later for cancellation
|
||||||
mediaActivity.onPhotoTap()
|
mediaActivity.onPhotoTap()
|
||||||
mediaController.hide()
|
|
||||||
}
|
}
|
||||||
private lateinit var mediaActivity: ViewMediaActivity
|
private lateinit var mediaActivity: ViewMediaActivity
|
||||||
private lateinit var mediaController: MediaController
|
private lateinit var mediaPlayerListener: Player.Listener
|
||||||
private var isAudio = false
|
private var isAudio = false
|
||||||
|
|
||||||
companion object {
|
private lateinit var mediaAttachment: Attachment
|
||||||
private const val TOOLBAR_HIDE_DELAY_MS = 3000L
|
|
||||||
}
|
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) {
|
override fun onAttach(context: Context) {
|
||||||
super.onAttach(context)
|
super.onAttach(context)
|
||||||
|
|
||||||
|
mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||||
|
.setDataSourceFactory(DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient)))
|
||||||
|
|
||||||
videoActionsListener = context as VideoActionsListener
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
if (_binding != null) {
|
if (Build.VERSION.SDK_INT <= 23 || player == null) {
|
||||||
|
initializePlayer()
|
||||||
if (mediaActivity.isToolbarVisible && !isAudio) {
|
if (mediaActivity.isToolbarVisible && !isAudio) {
|
||||||
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
|
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() {
|
override fun onPause() {
|
||||||
super.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)
|
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.mediaDescription.elevation = binding.videoView.elevation + 1
|
||||||
|
|
||||||
binding.videoView.transitionName = url
|
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.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)) {
|
if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) {
|
||||||
mediaActivity.onBringUp()
|
mediaActivity.onBringUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideToolbarAfterDelay(delayMilliseconds: Long) {
|
private fun hideToolbarAfterDelay(delayMilliseconds: Int) {
|
||||||
handler.postDelayed(hideToolbar, delayMilliseconds)
|
handler.postDelayed(hideToolbar, delayMilliseconds.toLong())
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onToolbarVisibilityChange(visible: Boolean) {
|
override fun onToolbarVisibilityChange(visible: Boolean) {
|
||||||
if (_binding == null || !userVisibleHint) {
|
if (!userVisibleHint) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,27 +386,27 @@ class ViewVideoFragment : ViewMediaFragment() {
|
||||||
|
|
||||||
binding.mediaDescription.animate().alpha(alpha)
|
binding.mediaDescription.animate().alpha(alpha)
|
||||||
.setListener(object : AnimatorListenerAdapter() {
|
.setListener(object : AnimatorListenerAdapter() {
|
||||||
|
@SuppressLint("SyntheticAccessor")
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
if (_binding != null) {
|
view ?: return
|
||||||
binding.mediaDescription.visible(isDescriptionVisible)
|
binding.mediaDescription.visible(isDescriptionVisible)
|
||||||
}
|
|
||||||
animation.removeListener(this)
|
animation.removeListener(this)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.start()
|
.start()
|
||||||
|
|
||||||
if (visible && binding.videoView.isPlaying && !isAudio) {
|
if (visible && (binding.videoView.player?.isPlaying == true) && !isAudio) {
|
||||||
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
|
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
|
||||||
} else {
|
} else {
|
||||||
handler.removeCallbacks(hideToolbar)
|
handler.removeCallbacks(hideToolbar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTransitionEnd() {
|
override fun onTransitionEnd() { }
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
companion object {
|
||||||
super.onDestroyView()
|
private const val TAG = "ViewVideoFragment"
|
||||||
_binding = null
|
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"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="Some media description" />
|
tools:text="Some media description" />
|
||||||
|
|
||||||
<com.keylesspalace.tusky.view.ExposedPlayPauseVideoView
|
<androidx.media3.ui.PlayerView
|
||||||
android:id="@+id/videoView"
|
android:id="@+id/videoView"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintRight_toRightOf="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
|
<ProgressBar
|
||||||
android:id="@+id/progressBar"
|
android:id="@+id/progressBar"
|
||||||
|
@ -42,4 +45,4 @@
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="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="load_newest_notifications">Load newest notifications</string>
|
||||||
<string name="about_copy">Copy version and device information</string>
|
<string name="about_copy">Copy version and device information</string>
|
||||||
<string name="about_copied">Copied 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>
|
</resources>
|
||||||
|
|
|
@ -10,6 +10,7 @@ androidx-exifinterface = "1.3.6"
|
||||||
androidx-fragment = "1.6.1"
|
androidx-fragment = "1.6.1"
|
||||||
androidx-junit = "1.1.5"
|
androidx-junit = "1.1.5"
|
||||||
androidx-lifecycle = "2.6.1"
|
androidx-lifecycle = "2.6.1"
|
||||||
|
androidx-media3 = "1.1.0"
|
||||||
androidx-paging = "3.2.0"
|
androidx-paging = "3.2.0"
|
||||||
androidx-preference = "1.2.0"
|
androidx-preference = "1.2.0"
|
||||||
androidx-recyclerview = "1.3.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-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-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-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-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-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
|
||||||
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
|
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-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-lifecycle-livedata-ktx", "androidx-lifecycle-common-java8", "androidx-lifecycle-reactivestreams-ktx",
|
||||||
"androidx-constraintlayout", "androidx-paging-runtime-ktx", "androidx-viewpager2", "androidx-work-runtime-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"]
|
autodispose = ["autodispose-core", "autodispose-android-lifecycle"]
|
||||||
dagger = ["dagger-core", "dagger-android-core", "dagger-android-support"]
|
dagger = ["dagger-core", "dagger-android-core", "dagger-android-support"]
|
||||||
dagger-processors = ["dagger-compiler", "dagger-android-processor"]
|
dagger-processors = ["dagger-compiler", "dagger-android-processor"]
|
||||||
|
|
Loading…
Reference in New Issue