From fbf77b937677fd623e411e6dbbc18f214e286b3f Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:55:58 -0500 Subject: [PATCH] Better playback lifecycle handling (#191) Refactors lifecycle event observers When the app is "paused", aka not actively focused, but still visible (such as when opening Android TV OS menu), playback will pause. When the app is "stopped", aka not visible (such as pressing Home button), playback will stop, report playback progress, and return to the previous screen. I think this will fix #181, but I don't have a controller to test with --- .../damontecres/wholphin/MainActivity.kt | 14 ++-- .../wholphin/ui/playback/PlaybackViewModel.kt | 28 +------- .../util/PlaybackLifecycleObserver.kt | 46 ++++++++++++ .../wholphin/util/PlayerFactory.kt | 72 +++++++++++++++++++ .../util/TrackActivityPlaybackListener.kt | 6 +- 5 files changed, 130 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/PlayerFactory.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 596168f8..c285294c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -37,6 +37,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.AppUpgradeHandler +import com.github.damontecres.wholphin.util.PlaybackLifecycleObserver import com.github.damontecres.wholphin.util.ServerEventListener import com.github.damontecres.wholphin.util.UpdateChecker import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -70,10 +71,14 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var serverEventListener: ServerEventListener + @Inject + lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) Timber.i("MainActivity.onCreate") + super.onCreate(savedInstanceState) + lifecycle.addObserver(playbackLifecycleObserver) setContent { CoilConfig(okHttpClient, false) val appPreferences by userPreferencesDataStore.data.collectAsState(null) @@ -175,11 +180,4 @@ class MainActivity : AppCompatActivity() { appUpgradeHandler.run() } } - - override fun onPause() { - if (navigationManager.backStack.lastOrNull() is Destination.Playback) { - navigationManager.goBack() - } - super.onPause() - } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index cb80363d..3cb73b14 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -17,8 +17,6 @@ import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.DefaultRenderersFactory -import androidx.media3.exoplayer.ExoPlayer import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackRepository @@ -33,7 +31,6 @@ import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.data.model.chooseStream import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences @@ -49,6 +46,7 @@ import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.PlayerFactory import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.TrackSupport import com.github.damontecres.wholphin.util.checkForSupport @@ -62,12 +60,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.mediaInfoApi @@ -118,29 +114,11 @@ class PlaybackViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val appPreferences: DataStore, + private val playerFactory: PlayerFactory, ) : ViewModel(), Player.Listener { val player by lazy { - val extensions = - runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled - Timber.v("extensions=$extensions") - val rendererMode = - when (extensions) { - MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF - else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - } - ExoPlayer - .Builder(context) - .setRenderersFactory( - DefaultRenderersFactory(context) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() - .apply { - playWhenReady = true - } + playerFactory.createVideoPlayer() } val loading = MutableLiveData(LoadingState.Loading) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt new file mode 100644 index 00000000..fcc49917 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt @@ -0,0 +1,46 @@ +package com.github.damontecres.wholphin.util + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.nav.NavigationManager +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject + +/** + * Observes the activity lifecycle in order to pause/resume/stop playback + */ +@ActivityRetainedScoped +class PlaybackLifecycleObserver + @Inject + constructor( + private val navigationManager: NavigationManager, + private val playerFactory: PlayerFactory, + ) : DefaultLifecycleObserver { + private var wasPlaying: Boolean? = null + + override fun onStart(owner: LifecycleOwner) { + wasPlaying = null + } + + override fun onResume(owner: LifecycleOwner) { + if (wasPlaying == true) { + playerFactory.currentPlayer?.let { + if (!it.isReleased) it.play() + } + } + } + + override fun onPause(owner: LifecycleOwner) { + playerFactory.currentPlayer?.let { + wasPlaying = it.isPlaying + it.pause() + } + } + + override fun onStop(owner: LifecycleOwner) { + if (navigationManager.backStack.lastOrNull() is Destination.Playback) { + navigationManager.goBack() + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlayerFactory.kt new file mode 100644 index 00000000..36efeefa --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/PlayerFactory.kt @@ -0,0 +1,72 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import android.content.Context +import androidx.annotation.OptIn +import androidx.datastore.core.DataStore +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.MediaExtensionStatus +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Constructs a [Player] instance for video playback + */ +@Singleton +class PlayerFactory + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val appPreferences: DataStore, + ) { + @Volatile + var currentPlayer: Player? = null + private set + + fun createVideoPlayer(): Player { + if (currentPlayer?.isReleased == true) { + throw IllegalStateException("Player was not released before trying to create a new one!") + } + + val extensions = + runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled + Timber.v("extensions=$extensions") + val rendererMode = + when (extensions) { + MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF + else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + } + val newPlayer = + ExoPlayer + .Builder(context) + .setRenderersFactory( + DefaultRenderersFactory(context) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode), + ).build() + .apply { + playWhenReady = true + } + currentPlayer = newPlayer + return newPlayer + } + } + +val Player.isReleased: Boolean + get() { + return when (this) { + is ExoPlayer -> isReleased + else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}") + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index f37dbb5f..1e50d4a0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -76,13 +76,13 @@ class TrackActivityPlaybackListener( fun release() { task.cancel() TIMER.purge() - val position = player.currentPosition.milliseconds.inWholeTicks + val position = player.currentPosition.milliseconds coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { - Timber.v("reportPlaybackStopped for ${itemPlayback.itemId}") + Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position") api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = itemPlayback.itemId, - positionTicks = position, + positionTicks = position.inWholeTicks, failed = false, playSessionId = playback.playSessionId, liveStreamId = playback.liveStreamId,