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
This commit is contained in:
Damontecres 2026-05-23 13:38:59 -04:00 committed by GitHub
parent cb704991f0
commit 086badf2e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 105 additions and 97 deletions

View file

@ -2,17 +2,30 @@ package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.datastore.core.DataStore
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory 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.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient 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 com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -34,7 +47,12 @@ class ThemeSongPlayer
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
private val api: ApiClient, private val api: ApiClient,
@param:IoCoroutineScope private val scope: CoroutineScope,
private val preferences: DataStore<AppPreferences>,
) { ) {
private val mutex = Mutex()
private val state = MutableStateFlow(ThemeSongState())
private val player: Player by lazy { private val player: Player by lazy {
ExoPlayer ExoPlayer
.Builder(context) .Builder(context)
@ -45,44 +63,19 @@ class ThemeSongPlayer
).build() ).build()
} }
suspend fun playThemeFor( fun playThemeFor(itemId: UUID) {
itemId: UUID, scope.launch {
volume: ThemeSongVolume, mutex.withLock {
): Boolean = state.value.job?.join()
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,
),
)
Timber.v("Found theme song for $itemId")
withContext(Dispatchers.Main) {
play(volume, url)
}
true
} ?: false
}
private fun play(
volumeLevel: ThemeSongVolume,
url: String,
) {
val volumeLevel = val volumeLevel =
when (volumeLevel) { when (
preferences.data
.first()
.interfacePreferences.playThemeSongs
) {
ThemeSongVolume.UNRECOGNIZED, ThemeSongVolume.UNRECOGNIZED,
ThemeSongVolume.DISABLED, ThemeSongVolume.DISABLED,
-> return -> return@withLock
ThemeSongVolume.LOWEST -> .05f ThemeSongVolume.LOWEST -> .05f
@ -94,6 +87,22 @@ class ThemeSongPlayer
ThemeSongVolume.HIGHEST -> 75f 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 { player.apply {
stop() stop()
volume = volumeLevel volume = volumeLevel
@ -102,11 +111,38 @@ class ThemeSongPlayer
play() play()
} }
} }
}
}
state.update {
it.copy(
itemId = itemId,
job = job,
)
}
}
}
}
fun stop() { fun stop() {
if (player.isPlaying) { scope.launch {
mutex.withLock {
state.value.job?.cancelAndJoin()
withContext(Dispatchers.Main) {
Timber.v("Stopping theme song") Timber.v("Stopping theme song")
player.stop() player.stop()
} }
state.update {
it.copy(
itemId = null,
job = null,
)
} }
} }
}
}
}
data class ThemeSongState(
val itemId: UUID? = null,
val job: Job? = null,
)

View file

@ -509,15 +509,11 @@ class CollectionFolderViewModel
} }
fun onResumePage() { fun onResumePage() {
viewModelScope.launchIO { viewModelScope.launchDefault {
item.value?.let { item.value?.let {
Timber.v("onResumePage: %s", loading.value!!::class) Timber.v("onResumePage: %s", loading.value!!::class)
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
val volume = themeSongPlayer.playThemeFor(it.id)
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.playThemeSongs
themeSongPlayer.playThemeFor(it.id, volume)
} }
} }
} }

View file

@ -156,12 +156,7 @@ class CollectionViewModel
) )
} }
listenForStateUpdates() listenForStateUpdates()
themeSongPlayer.playThemeFor( themeSongPlayer.playThemeFor(itemId)
itemId,
preferencesService
.getCurrent()
.appPreferences.interfacePreferences.playThemeSongs,
)
} }
} }

View file

@ -132,11 +132,10 @@ fun EpisodeDetails(
LoadingState.Success -> { LoadingState.Success -> {
item?.let { ep -> item?.let { ep ->
LifecycleResumeEffect(destination.itemId) { LifecycleResumeEffect(ep) {
viewModel.maybePlayThemeSong( ep.data.seriesId?.let { seriesId ->
destination.itemId, viewModel.maybePlayThemeSong(seriesId)
preferences.appPreferences.interfacePreferences.playThemeSongs, }
)
onPauseOrDispose { onPauseOrDispose {
viewModel.release() viewModel.release()
} }

View file

@ -10,7 +10,6 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback 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.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaManagementService
@ -178,15 +177,9 @@ class EpisodeViewModel
} }
} }
fun maybePlayThemeSong( fun maybePlayThemeSong(seriesId: UUID) {
seriesId: UUID,
playThemeSongs: ThemeSongVolume,
) {
viewModelScope.launchIO { viewModelScope.launchIO {
themeSongPlayer.playThemeFor(seriesId, playThemeSongs) themeSongPlayer.playThemeFor(seriesId)
addCloseable {
themeSongPlayer.stop()
}
} }
} }

View file

@ -152,10 +152,7 @@ fun MovieDetails(
val movie by rememberUpdatedState(s.data) val movie by rememberUpdatedState(s.data)
val chosenStreams by rememberUpdatedState(state.chosenStreams) val chosenStreams by rememberUpdatedState(state.chosenStreams)
LifecycleResumeEffect(destination.itemId) { LifecycleResumeEffect(destination.itemId) {
viewModel.maybePlayThemeSong( viewModel.maybePlayThemeSong(destination.itemId)
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onPauseOrDispose { onPauseOrDispose {
viewModel.release() viewModel.release()
} }

View file

@ -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.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer 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.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
@ -258,12 +257,9 @@ class MovieViewModel
} }
} }
fun maybePlayThemeSong( fun maybePlayThemeSong(itemId: UUID) {
seriesId: UUID,
playThemeSongs: ThemeSongVolume,
) {
viewModelScope.launchIO { viewModelScope.launchIO {
themeSongPlayer.playThemeFor(seriesId, playThemeSongs) themeSongPlayer.playThemeFor(itemId)
addCloseable { addCloseable {
themeSongPlayer.stop() themeSongPlayer.stop()
} }

View file

@ -282,12 +282,8 @@ class SeriesViewModel
fun onResumePage() { fun onResumePage() {
item.value?.let { item -> item.value?.let { item ->
viewModelScope.launchDefault { backdropService.submit(item) } viewModelScope.launchDefault { backdropService.submit(item) }
viewModelScope.launchIO { viewModelScope.launchDefault {
val playThemeSongs = themeSongPlayer.playThemeFor(seriesId)
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.playThemeSongs
themeSongPlayer.playThemeFor(seriesId, playThemeSongs)
} }
} }
} }