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

View file

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context
import android.provider.Settings
import com.github.damontecres.dolphin.BuildConfig
import com.github.damontecres.dolphin.data.ServerRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -15,11 +16,21 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthOkHttpClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class StandardOkHttpClient
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@StandardOkHttpClient
@Provides
@Singleton
fun okHttpClient() =
@ -29,9 +40,33 @@ object AppModule {
// TODO user agent, timeouts, logging, etc
}.build()
@AuthOkHttpClient
@Provides
@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
@Singleton

View file

@ -10,15 +10,12 @@ import coil3.network.cachecontrol.CacheControlCacheStrategy
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import coil3.util.DebugLogger
import com.github.damontecres.dolphin.data.ServerRepository
import okhttp3.Call
import okhttp3.OkHttpClient
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class, ExperimentalCoilApi::class)
@Composable
fun CoilConfig(
serverRepository: ServerRepository,
okHttpClient: OkHttpClient,
debugLogging: Boolean,
) {
@ -37,18 +34,7 @@ fun CoilConfig(
add(
OkHttpNetworkFetcherFactory(
cacheStrategy = { CacheControlCacheStrategy() },
callFactory = {
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(),
)
}
},
callFactory = { okHttpClient },
),
)
}.build()

View file

@ -1,5 +1,7 @@
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.lazy.LazyColumn
import androidx.compose.runtime.Composable
@ -10,9 +12,16 @@ import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
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 com.github.damontecres.dolphin.data.model.BaseItem
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.ui.cards.ItemRow
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.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
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.playStateApi
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
@ -48,7 +61,10 @@ class SeriesViewModel
@Inject
constructor(
api: ApiClient,
@param:ApplicationContext val context: Context,
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
) : ItemViewModel<Video>(api) {
private var player: Player? = null
private lateinit var seriesId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
@ -80,6 +96,7 @@ class SeriesViewModel
episodes.value = episodePager.orEmpty()
loading.value = LoadingState.Success
}
maybePlayThemeSong()
} else {
withContext(Dispatchers.Main) {
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>? {
val request =
GetItemsRequest(

View file

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