From 08577b8a31642850a32a5b46c5cf0fa17873f9bd Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:50:47 -0500 Subject: [PATCH] Play theme song for movies (#149) Closes #133 Fixes #142 Theme songs will play for both TV shows and movies, if available. Also, if there are multiple theme songs, one will be picked randomly instead of always playing the first. Additionally, the music now waits to start until the UI has loaded. --- .../wholphin/ui/detail/movie/MovieDetails.kt | 20 +++-- .../ui/detail/movie/MovieViewModel.kt | 27 ++++++- .../ui/detail/series/SeriesDetails.kt | 19 ++--- .../ui/detail/series/SeriesOverview.kt | 18 ++--- .../ui/detail/series/SeriesViewModel.kt | 30 +------ .../wholphin/util/ThemeSongPlayer.kt | 78 ++++++++++++++----- 6 files changed, 121 insertions(+), 71 deletions(-) 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 301363d1..11ce4ba4 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage @@ -109,6 +110,15 @@ fun MovieDetails( -> LoadingPage() LoadingState.Success -> { item?.let { movie -> + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() + } + } MovieDetailsContent( preferences = preferences, movie = movie, @@ -118,10 +128,10 @@ fun MovieDetails( trailers = trailers, similar = similar, onClickItem = { - viewModel.navigationManager.navigateTo(it.destination()) + viewModel.navigateTo(it.destination()) }, onClickPerson = { - viewModel.navigationManager.navigateTo( + viewModel.navigateTo( Destination.MediaItem( it.id, BaseItemKind.PERSON, @@ -129,7 +139,7 @@ fun MovieDetails( ) }, playOnClick = { - viewModel.navigationManager.navigateTo( + viewModel.navigateTo( Destination.Playback( movie.id, it.inWholeMilliseconds, @@ -157,7 +167,7 @@ fun MovieDetails( favorite = movie.data.userData?.isFavorite ?: false, series = null, sourceId = chosenStreams?.sourceId, - navigateTo = viewModel.navigationManager::navigateTo, + navigateTo = viewModel::navigateTo, onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onChooseVersion = { @@ -203,7 +213,7 @@ fun MovieDetails( trailerOnClick = { trailer -> when (trailer) { is LocalTrailer -> - viewModel.navigationManager.navigateTo( + viewModel.navigateTo( Destination.Playback( itemId = trailer.baseItem.id, item = trailer.baseItem, 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 d51d9523..82671199 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 @@ -12,15 +12,18 @@ import com.github.damontecres.wholphin.data.model.LocalTrailer import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.setValueOnMain 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.ThemeSongPlayer import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -40,9 +43,10 @@ class MovieViewModel @Inject constructor( api: ApiClient, - val navigationManager: NavigationManager, + private val navigationManager: NavigationManager, val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, + private val themeSongPlayer: ThemeSongPlayer, ) : LoadingItemViewModel(api) { private lateinit var itemId: UUID @@ -181,4 +185,25 @@ class MovieViewModel } } } + + fun maybePlayThemeSong( + seriesId: UUID, + playThemeSongs: ThemeSongVolume, + ) { + viewModelScope.launchIO { + themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + addCloseable { + themeSongPlayer.stop() + } + } + } + + fun release() { + themeSongPlayer.stop() + } + + fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 85ff5a9d..fb2f7a04 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -83,15 +83,6 @@ fun SeriesDetails( modifier: Modifier = Modifier, viewModel: SeriesViewModel = hiltViewModel(), ) { - LifecycleStartEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onStopOrDispose { - viewModel.release() - } - } LaunchedEffect(Unit) { viewModel.init(preferences, destination.itemId, destination.item, null) } @@ -113,6 +104,16 @@ fun SeriesDetails( -> LoadingPage() LoadingState.Success -> { item?.let { item -> + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() + } + } + val played = item.data.userData?.played ?: false SeriesDetailsContent( preferences = preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 4502ef07..75ea4555 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -72,15 +72,6 @@ fun SeriesOverview( viewModel: SeriesViewModel = hiltViewModel(), initialSeasonEpisode: SeasonEpisodeIds? = null, ) { - LifecycleStartEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onStopOrDispose { - viewModel.release() - } - } val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } @@ -168,6 +159,15 @@ fun SeriesOverview( LoadingState.Success -> { series?.let { series -> LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() + } + } fun buildMoreForEpisode( ep: BaseItem, 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 04da9868..0937006d 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 @@ -2,10 +2,8 @@ package com.github.damontecres.wholphin.ui.detail.series import android.content.Context import android.widget.Toast -import androidx.annotation.OptIn import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository @@ -30,7 +28,6 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.ThemeSongPlayer -import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -41,7 +38,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemFields @@ -141,34 +137,14 @@ class SeriesViewModel /** * If the series has a theme song & app settings allow, play it */ - @OptIn(UnstableApi::class) fun maybePlayThemeSong( seriesId: UUID, playThemeSongs: ThemeSongVolume, ) { viewModelScope.launchIO { - val themeSongs = api.libraryApi.getThemeSongs(seriesId).content - themeSongs.items.firstOrNull()?.let { theme -> - theme.mediaSources?.firstOrNull()?.let { source -> - 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 series $seriesId") - withContext(Dispatchers.Main) { - themeSongPlayer.play(playThemeSongs, url) - addCloseable { - themeSongPlayer.stop() - } - } - } + themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + addCloseable { + themeSongPlayer.stop() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt index 02160e65..1327b6f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt @@ -10,31 +10,77 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import com.github.damontecres.wholphin.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.preferences.ThemeSongVolume +import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.client.extensions.universalAudioApi +import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton /** * Simple service to play theme song music */ +@OptIn(UnstableApi::class) @Singleton class ThemeSongPlayer @Inject constructor( @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val api: ApiClient, ) { - private var player: Player? = null + private val player: Player by lazy { + ExoPlayer + .Builder(context) + .setMediaSourceFactory( + DefaultMediaSourceFactory( + OkHttpDataSource.Factory(authOkHttpClient), + ), + ).build() + } - @OptIn(UnstableApi::class) - fun play( + 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, ) { stop() val volumeLevel = - when (volume) { + when (volumeLevel) { ThemeSongVolume.UNRECOGNIZED, ThemeSongVolume.DISABLED, -> return @@ -45,24 +91,16 @@ class ThemeSongPlayer ThemeSongVolume.HIGH -> .5f ThemeSongVolume.HIGHEST -> 75f } - val player = - ExoPlayer - .Builder(context) - .setMediaSourceFactory( - DefaultMediaSourceFactory( - OkHttpDataSource.Factory(authOkHttpClient), - ), - ).build() - .apply { - this.volume = volumeLevel - playWhenReady = true - } - player.setMediaItem(MediaItem.fromUri(url)) - player.prepare() - this.player = player + player.apply { + volume = volumeLevel + setMediaItem(MediaItem.fromUri(url)) + prepare() + play() + } } fun stop() { - player?.release() + Timber.v("Stopping theme song") + player.stop() } }