diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index d922ab370..91045baef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -36,7 +36,8 @@ import javax.inject.Singleton ServicesModule::class, BroadcastReceiverModule::class, ViewModelModule::class, - WorkerModule::class + WorkerModule::class, + PlayerModule::class ] ) interface AppComponent { diff --git a/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt new file mode 100644 index 000000000..4d835cb9e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.di + +import android.content.Context +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.metadata.MetadataRenderer +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.text.TextRenderer +import androidx.media3.exoplayer.video.MediaCodecVideoRenderer +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.flac.FlacExtractor +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.mp3.Mp3Extractor +import androidx.media3.extractor.mp4.FragmentedMp4Extractor +import androidx.media3.extractor.mp4.Mp4Extractor +import androidx.media3.extractor.ogg.OggExtractor +import androidx.media3.extractor.wav.WavExtractor +import dagger.Module +import dagger.Provides +import okhttp3.OkHttpClient + +@Module +@OptIn(UnstableApi::class) +object PlayerModule { + @Provides + fun provideAudioSink(context: Context): AudioSink { + return DefaultAudioSink.Builder(context) + .build() + } + + @Provides + fun provideRenderersFactory(context: Context, audioSink: AudioSink): RenderersFactory { + return RenderersFactory { eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput -> + arrayOf( + MediaCodecVideoRenderer( + context, + MediaCodecSelector.DEFAULT, + DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS, + eventHandler, + videoRendererEventListener, + DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY + ), + MediaCodecAudioRenderer( + context, + MediaCodecSelector.DEFAULT, + eventHandler, + audioRendererEventListener, + audioSink + ), + TextRenderer( + textRendererOutput, + eventHandler.looper + ), + MetadataRenderer( + metadataRendererOutput, + eventHandler.looper + ) + ) + } + } + + @Provides + fun provideExtractorsFactory(): ExtractorsFactory { + // Extractors order is optimized according to + // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ + return ExtractorsFactory { + arrayOf( + FlacExtractor(), + WavExtractor(), + Mp4Extractor(), + FragmentedMp4Extractor(), + OggExtractor(), + MatroskaExtractor(), + Mp3Extractor() + ) + } + } + + @Provides + fun provideDataSourceFactory(context: Context, okHttpClient: OkHttpClient): DataSource.Factory { + return DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient)) + } + + @Provides + fun provideMediaSourceFactory( + dataSourceFactory: DataSource.Factory, + extractorsFactory: ExtractorsFactory + ): MediaSource.Factory { + // Only progressive download is supported for Mastodon attachments + return ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + } + + @Provides + fun provideExoPlayer( + context: Context, + renderersFactory: RenderersFactory, + mediaSourceFactory: MediaSource.Factory + ): ExoPlayer { + return ExoPlayer.Builder(context, renderersFactory, mediaSourceFactory) + .setLooper(Looper.getMainLooper()) + .setHandleAudioBecomingNoisy(true) // automatically pause when unplugging headphones + .setWakeMode(C.WAKE_MODE_NONE) // playback is always in the foreground + .build() + } +} 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 2f37b422d..a3f880cb7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -17,9 +17,7 @@ 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 @@ -49,7 +47,6 @@ abstract class ViewMediaFragment : Fragment() { protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl" @JvmStatic - @OptIn(UnstableApi::class) fun newInstance( attachment: Attachment, shouldStartPostponedTransition: Boolean 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 36c663c45..24dd51353 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -20,7 +20,6 @@ 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 @@ -35,14 +34,13 @@ import android.widget.FrameLayout import android.widget.LinearLayout import androidx.annotation.OptIn import androidx.core.view.GestureDetectorCompat +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C 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 com.bumptech.glide.Glide @@ -59,17 +57,17 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import javax.inject.Inject +import javax.inject.Provider import kotlin.math.abs -import okhttp3.OkHttpClient -@UnstableApi +@OptIn(UnstableApi::class) class ViewVideoFragment : ViewMediaFragment(), Injectable { interface VideoActionsListener { fun onDismiss() } @Inject - lateinit var okHttpClient: OkHttpClient + lateinit var playerProvider: Provider private val binding by viewBinding(FragmentViewVideoBinding::bind) @@ -92,8 +90,6 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { /** The saved seek position, if the fragment is being resumed */ private var savedSeekPosition: Long = 0 - private lateinit var mediaSourceFactory: DefaultMediaSourceFactory - /** Have we received at least one "READY" event? */ private var haveStarted = false @@ -106,9 +102,6 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { override fun onAttach(context: Context) { super.onAttach(context) - mediaSourceFactory = DefaultMediaSourceFactory(context) - .setDataSourceFactory(DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient))) - videoActionsListener = context as VideoActionsListener } @@ -285,53 +278,18 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { override fun onStart() { super.onStart() - if (Build.VERSION.SDK_INT > 23) { - initializePlayer() - binding.videoView.onResume() - } - } - override fun onResume() { - super.onResume() - - if (Build.VERSION.SDK_INT <= 23 || player == null) { - initializePlayer() - - 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 <= 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) - } + initializePlayer() + binding.videoView.onResume() } 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) - } + // This might be multi-window, so pause everything now. + binding.videoView.onPause() + releasePlayer() + handler.removeCallbacks(hideToolbar) } override fun onSaveInstanceState(outState: Bundle) { @@ -340,18 +298,22 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { } 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 - } + player = playerProvider.get().apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(if (isAudio) C.AUDIO_CONTENT_TYPE_UNKNOWN else C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) + .build(), + true + ) + if (BuildConfig.DEBUG) addAnalyticsListener(EventLogger("$TAG:ExoPlayer")) + setMediaItem(MediaItem.fromUri(mediaAttachment.url)) + addListener(mediaPlayerListener) + repeatMode = Player.REPEAT_MODE_ONE + playWhenReady = true + seekTo(savedSeekPosition) + prepare() + } binding.videoView.player = player @@ -378,6 +340,15 @@ class ViewVideoFragment : ViewMediaFragment(), Injectable { } } + private fun releasePlayer() { + player?.let { + savedSeekPosition = it.currentPosition + it.release() + player = null + binding.videoView.player = null + } + } + @SuppressLint("ClickableViewAccessibility") override fun setupMediaView( url: String, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9429ac7d8..35919d398 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -79,9 +79,6 @@ androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-lived 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" } @@ -142,8 +139,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-media3-exoplayer", "androidx-media3-exoplayer-dash", - "androidx-media3-exoplayer-hls", "androidx-media3-exoplayer-rtsp", "androidx-media3-datasource-okhttp", "androidx-media3-ui"] + "androidx-core-splashscreen", "androidx-activity", "androidx-media3-exoplayer", "androidx-media3-datasource-okhttp", + "androidx-media3-ui"] dagger = ["dagger-core", "dagger-android-core", "dagger-android-support"] dagger-processors = ["dagger-compiler", "dagger-android-processor"] filemojicompat = ["filemojicompat-core", "filemojicompat-ui", "filemojicompat-defaults"]