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.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@ -109,6 +110,15 @@ fun MovieDetails(
-> LoadingPage() -> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
item?.let { movie -> item?.let { movie ->
LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
MovieDetailsContent( MovieDetailsContent(
preferences = preferences, preferences = preferences,
movie = movie, movie = movie,
@ -118,10 +128,10 @@ fun MovieDetails(
trailers = trailers, trailers = trailers,
similar = similar, similar = similar,
onClickItem = { onClickItem = {
viewModel.navigationManager.navigateTo(it.destination()) viewModel.navigateTo(it.destination())
}, },
onClickPerson = { onClickPerson = {
viewModel.navigationManager.navigateTo( viewModel.navigateTo(
Destination.MediaItem( Destination.MediaItem(
it.id, it.id,
BaseItemKind.PERSON, BaseItemKind.PERSON,
@ -129,7 +139,7 @@ fun MovieDetails(
) )
}, },
playOnClick = { playOnClick = {
viewModel.navigationManager.navigateTo( viewModel.navigateTo(
Destination.Playback( Destination.Playback(
movie.id, movie.id,
it.inWholeMilliseconds, it.inWholeMilliseconds,
@ -157,7 +167,7 @@ fun MovieDetails(
favorite = movie.data.userData?.isFavorite ?: false, favorite = movie.data.userData?.isFavorite ?: false,
series = null, series = null,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.sourceId,
navigateTo = viewModel.navigationManager::navigateTo, navigateTo = viewModel::navigateTo,
onClickWatch = viewModel::setWatched, onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite, onClickFavorite = viewModel::setFavorite,
onChooseVersion = { onChooseVersion = {
@ -203,7 +213,7 @@ fun MovieDetails(
trailerOnClick = { trailer -> trailerOnClick = { trailer ->
when (trailer) { when (trailer) {
is LocalTrailer -> is LocalTrailer ->
viewModel.navigationManager.navigateTo( viewModel.navigateTo(
Destination.Playback( Destination.Playback(
itemId = trailer.baseItem.id, itemId = trailer.baseItem.id,
item = trailer.baseItem, 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.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer
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.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty 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.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.ThemeSongPlayer
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -40,9 +43,10 @@ class MovieViewModel
@Inject @Inject
constructor( constructor(
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer,
) : LoadingItemViewModel(api) { ) : LoadingItemViewModel(api) {
private lateinit var itemId: UUID 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, modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
) { ) {
LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(preferences, destination.itemId, destination.item, null) viewModel.init(preferences, destination.itemId, destination.item, null)
} }
@ -113,6 +104,16 @@ fun SeriesDetails(
-> LoadingPage() -> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
item?.let { item -> item?.let { item ->
LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
val played = item.data.userData?.played ?: false val played = item.data.userData?.played ?: false
SeriesDetailsContent( SeriesDetailsContent(
preferences = preferences, preferences = preferences,

View file

@ -72,15 +72,6 @@ fun SeriesOverview(
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
initialSeasonEpisode: SeasonEpisodeIds? = null, initialSeasonEpisode: SeasonEpisodeIds? = null,
) { ) {
LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
val firstItemFocusRequester = remember { FocusRequester() } val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() }
@ -168,6 +159,15 @@ fun SeriesOverview(
LoadingState.Success -> { LoadingState.Success -> {
series?.let { series -> series?.let { series ->
LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() }
LifecycleStartEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
preferences.appPreferences.interfacePreferences.playThemeSongs,
)
onStopOrDispose {
viewModel.release()
}
}
fun buildMoreForEpisode( fun buildMoreForEpisode(
ep: BaseItem, ep: BaseItem,

View file

@ -2,10 +2,8 @@ package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository 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.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.ThemeSongPlayer 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers 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.libraryApi
import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi 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.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
@ -141,37 +137,17 @@ class SeriesViewModel
/** /**
* If the series has a theme song & app settings allow, play it * If the series has a theme song & app settings allow, play it
*/ */
@OptIn(UnstableApi::class)
fun maybePlayThemeSong( fun maybePlayThemeSong(
seriesId: UUID, seriesId: UUID,
playThemeSongs: ThemeSongVolume, playThemeSongs: ThemeSongVolume,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val themeSongs = api.libraryApi.getThemeSongs(seriesId).content themeSongPlayer.playThemeFor(seriesId, playThemeSongs)
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 { addCloseable {
themeSongPlayer.stop() themeSongPlayer.stop()
} }
} }
} }
}
}
}
fun release() { fun release() {
themeSongPlayer.stop() themeSongPlayer.stop()

View file

@ -10,31 +10,77 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Simple service to play theme song music * Simple service to play theme song music
*/ */
@OptIn(UnstableApi::class)
@Singleton @Singleton
class ThemeSongPlayer class ThemeSongPlayer
@Inject @Inject
constructor( constructor(
@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 var player: Player? = null private val player: Player by lazy {
ExoPlayer
.Builder(context)
.setMediaSourceFactory(
DefaultMediaSourceFactory(
OkHttpDataSource.Factory(authOkHttpClient),
),
).build()
}
@OptIn(UnstableApi::class) suspend fun playThemeFor(
fun play( itemId: UUID,
volume: ThemeSongVolume, 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, url: String,
) { ) {
stop() stop()
val volumeLevel = val volumeLevel =
when (volume) { when (volumeLevel) {
ThemeSongVolume.UNRECOGNIZED, ThemeSongVolume.UNRECOGNIZED,
ThemeSongVolume.DISABLED, ThemeSongVolume.DISABLED,
-> return -> return
@ -45,24 +91,16 @@ class ThemeSongPlayer
ThemeSongVolume.HIGH -> .5f ThemeSongVolume.HIGH -> .5f
ThemeSongVolume.HIGHEST -> 75f ThemeSongVolume.HIGHEST -> 75f
} }
val player = player.apply {
ExoPlayer volume = volumeLevel
.Builder(context) setMediaItem(MediaItem.fromUri(url))
.setMediaSourceFactory( prepare()
DefaultMediaSourceFactory( play()
OkHttpDataSource.Factory(authOkHttpClient),
),
).build()
.apply {
this.volume = volumeLevel
playWhenReady = true
} }
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
this.player = player
} }
fun stop() { fun stop() {
player?.release() Timber.v("Stopping theme song")
player.stop()
} }
} }