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:
Nik Clayton 2023-08-10 19:31:55 +02:00 committed by GitHub
parent 09d4f62004
commit 8529f309ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 314 additions and 212 deletions

View File

@ -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"

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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"
}
}

View File

@ -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()
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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"]