Play theme songs for series

This commit is contained in:
Damontecres 2025-10-03 15:11:26 -04:00
parent ba92001412
commit 1816317c68
No known key found for this signature in database
5 changed files with 102 additions and 17 deletions

View file

@ -25,6 +25,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import com.github.damontecres.dolphin.data.ServerRepository import com.github.damontecres.dolphin.data.ServerRepository
import com.github.damontecres.dolphin.hilt.AuthOkHttpClient
import com.github.damontecres.dolphin.preferences.AppPreferences import com.github.damontecres.dolphin.preferences.AppPreferences
import com.github.damontecres.dolphin.preferences.DefaultUserConfiguration import com.github.damontecres.dolphin.preferences.DefaultUserConfiguration
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
@ -48,6 +49,7 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var userPreferencesDataStore: DataStore<AppPreferences> lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@AuthOkHttpClient
@Inject @Inject
lateinit var okHttpClient: OkHttpClient lateinit var okHttpClient: OkHttpClient
@ -64,7 +66,7 @@ class MainActivity : AppCompatActivity() {
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
shape = RectangleShape, shape = RectangleShape,
) { ) {
CoilConfig(serverRepository, okHttpClient, false) CoilConfig(okHttpClient, false)
var isRestoringSession by remember { mutableStateOf(true) } var isRestoringSession by remember { mutableStateOf(true) }
val appPreferences by userPreferencesDataStore.data.collectAsState(null) val appPreferences by userPreferencesDataStore.data.collectAsState(null)

View file

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.provider.Settings import android.provider.Settings
import com.github.damontecres.dolphin.BuildConfig import com.github.damontecres.dolphin.BuildConfig
import com.github.damontecres.dolphin.data.ServerRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -15,11 +16,21 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.DeviceInfo
import javax.inject.Qualifier
import javax.inject.Singleton import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthOkHttpClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class StandardOkHttpClient
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object AppModule { object AppModule {
@StandardOkHttpClient
@Provides @Provides
@Singleton @Singleton
fun okHttpClient() = fun okHttpClient() =
@ -29,9 +40,33 @@ object AppModule {
// TODO user agent, timeouts, logging, etc // TODO user agent, timeouts, logging, etc
}.build() }.build()
@AuthOkHttpClient
@Provides @Provides
@Singleton @Singleton
fun okHttpFactory(okHttpClient: OkHttpClient) = OkHttpFactory(okHttpClient) fun authOkHttpClient(
serverRepository: ServerRepository,
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = okHttpClient
.newBuilder()
.addInterceptor {
val request = it.request()
val newRequest =
serverRepository.currentUser?.accessToken?.let { token ->
request
.newBuilder()
.addHeader(
"Authorization",
"MediaBrowser Token=\"$token\"",
).build()
}
it.proceed(newRequest ?: request)
}.build()
@Provides
@Singleton
fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = OkHttpFactory(okHttpClient)
@Provides @Provides
@Singleton @Singleton

View file

@ -10,15 +10,12 @@ import coil3.network.cachecontrol.CacheControlCacheStrategy
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import coil3.util.DebugLogger import coil3.util.DebugLogger
import com.github.damontecres.dolphin.data.ServerRepository
import okhttp3.Call
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class, ExperimentalCoilApi::class) @OptIn(ExperimentalTime::class, ExperimentalCoilApi::class)
@Composable @Composable
fun CoilConfig( fun CoilConfig(
serverRepository: ServerRepository,
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
debugLogging: Boolean, debugLogging: Boolean,
) { ) {
@ -37,18 +34,7 @@ fun CoilConfig(
add( add(
OkHttpNetworkFetcherFactory( OkHttpNetworkFetcherFactory(
cacheStrategy = { CacheControlCacheStrategy() }, cacheStrategy = { CacheControlCacheStrategy() },
callFactory = { callFactory = { okHttpClient },
Call.Factory { request ->
// Ref: https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f
val token = serverRepository.currentUser?.accessToken
okHttpClient.newCall(
request
.newBuilder()
.addHeader("Authorization", "MediaBrowser Token=\"$token\"")
.build(),
)
}
},
), ),
) )
}.build() }.build()

View file

@ -1,5 +1,7 @@
package com.github.damontecres.dolphin.ui.detail package com.github.damontecres.dolphin.ui.detail
import android.content.Context
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -10,9 +12,16 @@ import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Video import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.hilt.AuthOkHttpClient
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.ErrorMessage
@ -26,12 +35,16 @@ import com.github.damontecres.dolphin.util.GetItemsRequestHandler
import com.github.damontecres.dolphin.util.LoadingExceptionHandler import com.github.damontecres.dolphin.util.LoadingExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState import com.github.damontecres.dolphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.client.ApiClient 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.playStateApi
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
@ -48,7 +61,10 @@ class SeriesViewModel
@Inject @Inject
constructor( constructor(
api: ApiClient, api: ApiClient,
@param:ApplicationContext val context: Context,
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
) : ItemViewModel<Video>(api) { ) : ItemViewModel<Video>(api) {
private var player: Player? = null
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem?>>(listOf()) val seasons = MutableLiveData<List<BaseItem?>>(listOf())
@ -80,6 +96,7 @@ class SeriesViewModel
episodes.value = episodePager.orEmpty() episodes.value = episodePager.orEmpty()
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
maybePlayThemeSong()
} else { } else {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
seasons.value = listOf() seasons.value = listOf()
@ -90,6 +107,49 @@ class SeriesViewModel
} }
} }
@OptIn(UnstableApi::class)
private fun maybePlayThemeSong() {
// TODO user preference to enable/disable this
viewModelScope.launch(ExceptionHandler()) {
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("opus", "mp3", "aaa", "flac"),
)
Timber.v("Found theme song for series $seriesId")
withContext(Dispatchers.Main) {
val player =
ExoPlayer
.Builder(context)
.setMediaSourceFactory(
DefaultMediaSourceFactory(
OkHttpDataSource.Factory(okHttpClient),
),
).build()
.apply {
volume = .1f
playWhenReady = true
this@SeriesViewModel.player = this
}
addCloseable {
player.release()
}
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
}
}
}
}
}
fun release() {
player?.release()
player = null
}
private suspend fun getSeasons(item: BaseItem): ApiRequestPager<GetItemsRequest>? { private suspend fun getSeasons(item: BaseItem): ApiRequestPager<GetItemsRequest>? {
val request = val request =
GetItemsRequest( GetItemsRequest(

View file

@ -126,6 +126,7 @@ fun SeriesOverview(
position = it position = it
}, },
onClick = { onClick = {
viewModel.release()
val resumePosition = val resumePosition =
it.data.userData it.data.userData
?.playbackPositionTicks ?.playbackPositionTicks
@ -143,6 +144,7 @@ fun SeriesOverview(
}, },
playOnClick = { resume -> playOnClick = { resume ->
episodes.getOrNull(position.episodeRowIndex)?.let { episodes.getOrNull(position.episodeRowIndex)?.let {
viewModel.release()
navigationManager.navigateTo( navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
it.id, it.id,