mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
d658ec16f3
commit
08577b8a31
6 changed files with 121 additions and 71 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue