Move ExoPlayer initialization to a Dagger module and optimize its dependencies (#4296)

Currently, ExoPlayer is initialized explicitly in `ViewMediaFragment`
with all its dependencies, including many that are not useful for
viewing Mastodon media attachments.

This pull request moves most ExoPlayer initialization and configuration
to a new Dagger module, and instead a `Provider<ExoPlayer>` factory is
injected in the Fragment so it can create new instances when needed.

The following ExoPlayer components will be configured:

- **Renderers**: all of them (audio, video, metadata, subtitles) except
for the `CameraMotionRenderer`.
- **Extractors**: FLAC, Wav, Mp4, Ogg, Matroska/WebM and MP3 containers,
to provide the same support as Firefox or Chrome browsers. Other
container formats that are either image formats (already covered by
Glide), not web-friendly or reserved for live streaming are skipped.
- **MediaSource**: only progressive download (through OkHttp) is
provided. Live streaming support using protocols like RTSP, MPEG/Dash or
HLS is skipped, because Mastodon servers don't use these protocols to
download attachments.

The Mastodon documentation mentions the [supported media formats for
attachments](https://docs.joinmastodon.org/user/posting/#media) and this
covers them and even more. The docs also mentions that the video and
audio files are transcoded to MP4 and MP3 upon upload but that was not
the case in the past (for example WebM was used) and it could change
again in the future.

Specifying these components manually allows reducing the APK size by
about 200 KB thanks to R8 shrinking.

There are also a few extra code changes:
- Remove the code specific to API < 24 since the min SDK of the app is
now 24.
- Add support for pausing a video when unplugging headphones.
- Specify the audio attributes according to content type to help the
Android audio mixer.
This commit is contained in:
Christophe Beyls 2024-03-09 11:04:04 +01:00 committed by GitHub
parent f09a5b00e0
commit 9901376d38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 179 additions and 74 deletions

View File

@ -36,7 +36,8 @@ import javax.inject.Singleton
ServicesModule::class,
BroadcastReceiverModule::class,
ViewModelModule::class,
WorkerModule::class
WorkerModule::class,
PlayerModule::class
]
)
interface AppComponent {

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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()
}
}

View File

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

View File

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

View File

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