From 086badf2e2cd5a0380c8c85ae85a078027da64e6 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 23 May 2026 13:38:59 -0400 Subject: [PATCH] Fix theme song playing over video (#1442) ## Description There was an issue if when opening a page with theme music and quickly changing pages, usually playback, the theme music might play on the new page. This is because the call to stop the theme music could potentially happen _before_ the song was loaded or had started playing. This PR fixes this race condition by tracking the job that's fetching the theme song and by adding a mutex to ensure the play function completes before the stop function can begin. ### Related issues Fixes #938 ### Testing Emulator w/ fake delay to increase the odds of the issue occurring ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/ThemeSongPlayer.kt | 146 +++++++++++------- .../ui/components/CollectionFolderGrid.kt | 8 +- .../detail/collection/CollectionViewModel.kt | 7 +- .../ui/detail/episode/EpisodeDetails.kt | 9 +- .../ui/detail/episode/EpisodeViewModel.kt | 11 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 5 +- .../ui/detail/movie/MovieViewModel.kt | 8 +- .../ui/detail/series/SeriesViewModel.kt | 8 +- 8 files changed, 105 insertions(+), 97 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt index e6bcb867..e1b6cf1b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt @@ -2,17 +2,30 @@ package com.github.damontecres.wholphin.services import android.content.Context import androidx.annotation.OptIn +import androidx.datastore.core.DataStore import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.api.client.ApiClient @@ -34,7 +47,12 @@ class ThemeSongPlayer @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, private val api: ApiClient, + @param:IoCoroutineScope private val scope: CoroutineScope, + private val preferences: DataStore, ) { + private val mutex = Mutex() + private val state = MutableStateFlow(ThemeSongState()) + private val player: Player by lazy { ExoPlayer .Builder(context) @@ -45,68 +63,86 @@ class ThemeSongPlayer ).build() } - suspend fun playThemeFor( - itemId: UUID, - volume: ThemeSongVolume, - ): Boolean = - withContext(Dispatchers.IO) { - if (volume == ThemeSongVolume.DISABLED || volume == ThemeSongVolume.UNRECOGNIZED) { - return@withContext false - } - val themeSongs by api.libraryApi.getThemeSongs(itemId) - return@withContext themeSongs.items.randomOrNull()?.let { theme -> - val url = - api.universalAudioApi.getUniversalAudioStreamUrl( - theme.id, - container = - listOf( - Codec.Audio.OPUS, - Codec.Audio.MP3, - Codec.Audio.AAC, - Codec.Audio.FLAC, - ), + fun playThemeFor(itemId: UUID) { + scope.launch { + mutex.withLock { + state.value.job?.join() + val volumeLevel = + when ( + preferences.data + .first() + .interfacePreferences.playThemeSongs + ) { + ThemeSongVolume.UNRECOGNIZED, + ThemeSongVolume.DISABLED, + -> return@withLock + + ThemeSongVolume.LOWEST -> .05f + + ThemeSongVolume.LOW -> .1f + + ThemeSongVolume.MEDIUM -> .25f + + ThemeSongVolume.HIGH -> .5f + + ThemeSongVolume.HIGHEST -> 75f + } + val job = + scope.launchIO { + val themeSongs by api.libraryApi.getThemeSongs(itemId) + themeSongs.items.randomOrNull()?.let { theme -> + val url = + api.universalAudioApi.getUniversalAudioStreamUrl( + theme.id, + container = + listOf( + Codec.Audio.OPUS, + Codec.Audio.MP3, + Codec.Audio.AAC, + Codec.Audio.FLAC, + ), + ) + withContext(Dispatchers.Main) { + player.apply { + stop() + volume = volumeLevel + setMediaItem(MediaItem.fromUri(url)) + prepare() + play() + } + } + } + } + state.update { + it.copy( + itemId = itemId, + job = job, ) - Timber.v("Found theme song for $itemId") - withContext(Dispatchers.Main) { - play(volume, url) } - true - } ?: false - } - - private fun play( - volumeLevel: ThemeSongVolume, - url: String, - ) { - val volumeLevel = - when (volumeLevel) { - ThemeSongVolume.UNRECOGNIZED, - ThemeSongVolume.DISABLED, - -> return - - ThemeSongVolume.LOWEST -> .05f - - ThemeSongVolume.LOW -> .1f - - ThemeSongVolume.MEDIUM -> .25f - - ThemeSongVolume.HIGH -> .5f - - ThemeSongVolume.HIGHEST -> 75f } - player.apply { - stop() - volume = volumeLevel - setMediaItem(MediaItem.fromUri(url)) - prepare() - play() } } fun stop() { - if (player.isPlaying) { - Timber.v("Stopping theme song") - player.stop() + scope.launch { + mutex.withLock { + state.value.job?.cancelAndJoin() + withContext(Dispatchers.Main) { + Timber.v("Stopping theme song") + player.stop() + } + state.update { + it.copy( + itemId = null, + job = null, + ) + } + } } } } + +data class ThemeSongState( + val itemId: UUID? = null, + val job: Job? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 8ed6acbe..ea052c99 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -509,15 +509,11 @@ class CollectionFolderViewModel } fun onResumePage() { - viewModelScope.launchIO { + viewModelScope.launchDefault { item.value?.let { Timber.v("onResumePage: %s", loading.value!!::class) if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { - val volume = - userPreferencesService - .getCurrent() - .appPreferences.interfacePreferences.playThemeSongs - themeSongPlayer.playThemeFor(it.id, volume) + themeSongPlayer.playThemeFor(it.id) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index 915471d7..b2876a09 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -156,12 +156,7 @@ class CollectionViewModel ) } listenForStateUpdates() - themeSongPlayer.playThemeFor( - itemId, - preferencesService - .getCurrent() - .appPreferences.interfacePreferences.playThemeSongs, - ) + themeSongPlayer.playThemeFor(itemId) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index fa39f919..803e18ce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -132,11 +132,10 @@ fun EpisodeDetails( LoadingState.Success -> { item?.let { ep -> - LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) + LifecycleResumeEffect(ep) { + ep.data.seriesId?.let { seriesId -> + viewModel.maybePlayThemeSong(seriesId) + } onPauseOrDispose { viewModel.release() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index 2110a3a5..ec09ded1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -10,7 +10,6 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService @@ -178,15 +177,9 @@ class EpisodeViewModel } } - fun maybePlayThemeSong( - seriesId: UUID, - playThemeSongs: ThemeSongVolume, - ) { + fun maybePlayThemeSong(seriesId: UUID) { viewModelScope.launchIO { - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) - addCloseable { - themeSongPlayer.stop() - } + themeSongPlayer.playThemeFor(seriesId) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index e198689d..e249a0d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -152,10 +152,7 @@ fun MovieDetails( val movie by rememberUpdatedState(s.data) val chosenStreams by rememberUpdatedState(state.chosenStreams) LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) + viewModel.maybePlayThemeSong(destination.itemId) onPauseOrDispose { viewModel.release() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index bd2cc373..1b5ab26e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager @@ -258,12 +257,9 @@ class MovieViewModel } } - fun maybePlayThemeSong( - seriesId: UUID, - playThemeSongs: ThemeSongVolume, - ) { + fun maybePlayThemeSong(itemId: UUID) { viewModelScope.launchIO { - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + themeSongPlayer.playThemeFor(itemId) addCloseable { themeSongPlayer.stop() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 4d0ce9e2..8852e89b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -282,12 +282,8 @@ class SeriesViewModel fun onResumePage() { item.value?.let { item -> viewModelScope.launchDefault { backdropService.submit(item) } - viewModelScope.launchIO { - val playThemeSongs = - userPreferencesService - .getCurrent() - .appPreferences.interfacePreferences.playThemeSongs - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + viewModelScope.launchDefault { + themeSongPlayer.playThemeFor(seriesId) } } }