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