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.
This commit is contained in:
damontecres 2025-11-03 11:50:47 -05:00 committed by GitHub
parent d658ec16f3
commit 08577b8a31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 121 additions and 71 deletions

View file

@ -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,

View file

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

View file

@ -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,

View file

@ -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,

View file

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

View file

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