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
This commit is contained in:
damontecres 2025-11-10 17:55:58 -05:00 committed by GitHub
parent ce8cab0d1f
commit fbf77b9376
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 130 additions and 36 deletions

View file

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

View file

@ -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<AppPreferences>,
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>(LoadingState.Loading)

View file

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

View file

@ -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<AppPreferences>,
) {
@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}")
}
}

View file

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