From 8529f309ffee52c5c38b220aa49b358adc3b6e54 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 10 Aug 2023 19:31:55 +0200 Subject: [PATCH] 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 --- .../keylesspalace/tusky/ViewMediaActivity.kt | 9 +- .../tusky/di/ActivitiesModule.kt | 2 +- .../tusky/di/FragmentBuildersModule.kt | 8 +- .../tusky/fragment/ViewMediaFragment.kt | 3 + .../tusky/fragment/ViewVideoFragment.kt | 443 +++++++++++------- .../tusky/view/ExposedPlayPauseVideoView.kt | 41 -- .../main/res/layout/fragment_view_video.xml | 9 +- app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 10 +- 9 files changed, 314 insertions(+), 212 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 1e017827c..88b330c84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -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 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" diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 2ceb97213..214ddcb48 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index aee1feab4..a5c24456c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt index 2f8aaf1d9..3125e556e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -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) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index 68dc6687a..6cd13c076 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -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(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(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(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() { + @SuppressLint("SyntheticAccessor") + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + 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(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" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt deleted file mode 100644 index 95605b18f..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/res/layout/fragment_view_video.xml b/app/src/main/res/layout/fragment_view_video.xml index 291361371..7e7fdcb35 100644 --- a/app/src/main/res/layout/fragment_view_video.xml +++ b/app/src/main/res/layout/fragment_view_video.xml @@ -24,14 +24,17 @@ app:layout_constraintTop_toTopOf="parent" tools:text="Some media description" /> - + app:layout_constraintTop_toTopOf="parent" + app:use_controller="false" + app:show_previous_button="false" + app:show_next_button="false" /> - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c00363661..06d25d4e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -817,4 +817,5 @@ Load newest notifications Copy version and device information Copied version and device information + Playback failed: %s diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 447d295b7..36ff558ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"]