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 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<AppPreferences>,
) {
private val mutex = Mutex()
private val state = MutableStateFlow(ThemeSongState())
private val player: Player by lazy {
ExoPlayer
.Builder(context)
@ -45,44 +63,19 @@ 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,
),
)
Timber.v("Found theme song for $itemId")
withContext(Dispatchers.Main) {
play(volume, url)
}
true
} ?: false
}
private fun play(
volumeLevel: ThemeSongVolume,
url: String,
) {
fun playThemeFor(itemId: UUID) {
scope.launch {
mutex.withLock {
state.value.job?.join()
val volumeLevel =
when (volumeLevel) {
when (
preferences.data
.first()
.interfacePreferences.playThemeSongs
) {
ThemeSongVolume.UNRECOGNIZED,
ThemeSongVolume.DISABLED,
-> return
-> return@withLock
ThemeSongVolume.LOWEST -> .05f
@ -94,6 +87,22 @@ class ThemeSongPlayer
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
@ -102,11 +111,38 @@ class ThemeSongPlayer
play()
}
}
}
}
state.update {
it.copy(
itemId = itemId,
job = job,
)
}
}
}
}
fun stop() {
if (player.isPlaying) {
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,
)

View file

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

View file

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

View file

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

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.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)
}
}

View file

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

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

View file

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