mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
cb704991f0
commit
086badf2e2
8 changed files with 105 additions and 97 deletions
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,12 +156,7 @@ class CollectionViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
listenForStateUpdates()
|
listenForStateUpdates()
|
||||||
themeSongPlayer.playThemeFor(
|
themeSongPlayer.playThemeFor(itemId)
|
||||||
itemId,
|
|
||||||
preferencesService
|
|
||||||
.getCurrent()
|
|
||||||
.appPreferences.interfacePreferences.playThemeSongs,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue