Merge branch 'main' into develop/jellyseerr

This commit is contained in:
Damontecres 2025-12-27 17:13:20 -05:00
commit 8f02dab6ed
No known key found for this signature in database
38 changed files with 1964 additions and 646 deletions

View file

@ -43,7 +43,7 @@ jobs:
- name: Build app
id: buildapp
run: |
./gradlew clean assembleDebug --no-daemon
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Tar build dirs

View file

@ -59,6 +59,7 @@ android {
debug {
isMinifyEnabled = false
isDebuggable = true
applicationIdSuffix = ".debug"
}
}
compileOptions {
@ -230,6 +231,9 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore)
implementation(libs.protobuf.kotlin.lite)
implementation(libs.androidx.tvprovider)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.datasource.okhttp)
@ -266,6 +270,7 @@ dependencies {
implementation(libs.androidx.palette.ktx)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler)
implementation(libs.timber)
implementation(libs.slf4j2.timber)
@ -291,5 +296,6 @@ dependencies {
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
}
// implementation(project(":seerr-api"))
testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent)
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Wholphin (Debug)</string>
</resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -11,6 +12,7 @@
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-feature
android:name="android.hardware.touchscreen"
@ -53,6 +55,10 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
</application>
</manifest>

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@ -13,13 +14,16 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
@ -32,7 +36,6 @@ import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
@ -47,10 +50,13 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@ -58,11 +64,9 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
@ -105,12 +109,15 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var userSwitchListener: UserSwitchListener
@Inject
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.i("MainActivity.onCreate")
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false)
@ -124,6 +131,7 @@ class MainActivity : AppCompatActivity() {
}
}
viewModel.appStart()
val requestedDestination = this.intent?.let(::extractDestination)
setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
appPreferences?.let { appPreferences ->
@ -217,20 +225,41 @@ class MainActivity : AppCompatActivity() {
appPreferences,
)
val preferences =
remember(appPreferences, current) {
UserPreferences(
appPreferences,
current.userDto.configuration
?: DefaultUserConfiguration,
remember(appPreferences) {
UserPreferences(appPreferences)
}
var showContent by remember {
mutableStateOf(true)
}
if (!preferences.appPreferences.signInAutomatically) {
LifecycleStartEffect(Unit) {
onStopOrDispose {
showContent = false
}
}
}
if (showContent) {
ApplicationContent(
user = current.user,
server = current.server,
startDestination =
requestedDestination
?: Destination.Home(),
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
ApplicationContent(
user = current.user,
server = current.server,
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@ -246,6 +275,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
Timber.d("onResume")
lifecycleScope.launchIO {
appUpgradeHandler.run()
}
@ -253,7 +283,7 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() {
super.onRestart()
Timber.i("onRestart")
Timber.d("onRestart")
viewModel.appStart()
// val signInAutomatically =
// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true
@ -264,6 +294,85 @@ class MainActivity : AppCompatActivity() {
// serverRepository.closeSession()
// }
}
override fun onStop() {
super.onStop()
Timber.d("onStop")
tvProviderSchedulerService.launchOneTimeRefresh()
}
override fun onPause() {
super.onPause()
Timber.d("onPause")
}
override fun onStart() {
super.onStart()
Timber.d("onStart")
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState")
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
Timber.d("onRestoreInstanceState")
}
override fun onDestroy() {
super.onDestroy()
Timber.d("onDestroy")
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Timber.v("onNewIntent")
extractDestination(intent)?.let {
navigationManager.replace(it)
}
}
private fun extractDestination(intent: Intent): Destination? =
intent.let {
val itemId =
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
val type =
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
if (itemId != null && type != null) {
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
Destination.SeriesOverview(
itemId = seriesId,
type = BaseItemKind.SERIES,
seasonEpisode =
SeasonEpisodeIds(
seasonId = seasonId,
seasonNumber = seasonNumber,
episodeId = itemId,
episodeNumber = episodeNumber,
),
)
} else {
Destination.MediaItem(itemId, type)
}
} else {
null
}
}
companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
const val INTENT_SERIES_ID = "seriesId"
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
}
}
@HiltViewModel
@ -277,40 +386,42 @@ class MainActivityViewModel
private val backdropService: BackdropService,
) : ViewModel() {
fun appStart() {
viewModelScope.launch {
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
if (prefs.signInAutomatically) {
val current =
withContext(Dispatchers.IO) {
viewModelScope.launchIO {
try {
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
if (prefs.signInAutomatically) {
val current =
serverRepository.restoreSession(
prefs.currentServerId?.toUUIDOrNull(),
prefs.currentUserId?.toUUIDOrNull(),
)
}
if (current != null) {
// Restored
navigationManager.navigateTo(SetupDestination.AppContent(current))
} else {
// Did not restore
navigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
navigationManager.navigateTo(SetupDestination.Loading)
backdropService.clearBackdrop()
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
if (currentServerId != null) {
val currentServer =
withContext(Dispatchers.IO) {
serverRepository.serverDao.getServer(currentServerId)?.server
}
if (currentServer != null) {
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
if (current != null) {
// Restored
navigationManager.navigateTo(SetupDestination.AppContent(current))
} else {
// Did not restore
navigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
navigationManager.navigateTo(SetupDestination.ServerList)
navigationManager.navigateTo(SetupDestination.Loading)
backdropService.clearBackdrop()
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
if (currentServerId != null) {
val currentServer =
serverRepository.serverDao.getServer(currentServerId)?.server
if (currentServer != null) {
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
} else {
navigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
navigationManager.navigateTo(SetupDestination.ServerList)
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
navigationManager.navigateTo(SetupDestination.ServerList)
}
}
viewModelScope.launchIO {

View file

@ -7,6 +7,8 @@ import android.os.StrictMode.ThreadPolicy
import android.util.Log
import androidx.compose.runtime.Composer
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import org.acra.ACRA
import org.acra.ReportField
@ -14,10 +16,13 @@ import org.acra.config.dialog
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import timber.log.Timber
import javax.inject.Inject
@OptIn(ExperimentalComposeRuntimeApi::class)
@HiltAndroidApp
class WholphinApplication : Application() {
class WholphinApplication :
Application(),
Configuration.Provider {
init {
instance = this
@ -94,6 +99,16 @@ class WholphinApplication : Application() {
ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString())
}
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() =
Configuration
.Builder()
.setWorkerFactory(workerFactory)
.build()
companion object {
lateinit var instance: WholphinApplication
private set

View file

@ -61,7 +61,7 @@ class ItemPlaybackRepository
)
val subtitleStream =
streamChoiceService.chooseSubtitleStream(
audioStream = audioStream,
audioStreamLang = audioStream?.language,
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }

View file

@ -46,9 +46,11 @@ class ServerRepository
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current
private var _currentUserDto = EqualityMutableLiveData<UserDto?>(null)
val currentUserDto: LiveData<UserDto?> = _currentUserDto
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }
val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user }
val currentUserDto: LiveData<UserDto?> get() = _current.map { it?.userDto }
/**
* Adds a server to the app database and updated the [ApiClient] to the server's URL
@ -101,7 +103,8 @@ class ServerRepository
}.build()
}
withContext(Dispatchers.Main) {
_current.value = CurrentUser(updatedServer, updatedUser, userDto)
_current.value = CurrentUser(updatedServer, updatedUser)
_currentUserDto.value = userDto
}
sharedPreferences.edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
@ -128,11 +131,21 @@ class ServerRepository
serverDao.getServer(serverId)
}
if (serverAndUsers != null) {
val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null) {
// TODO pin-related
val current = _current.value
if (current != null && current.server.id == serverId && current.user.id == userId) {
Timber.v("Restoring session for current user, so shortcut")
apiClient.update(
baseUrl = current.server.url,
accessToken = current.user.accessToken,
)
return current
} else {
val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null) {
// TODO pin-related
// if (user != null && !user.hasPin) {
return changeUser(serverAndUsers.server, user)
return changeUser(serverAndUsers.server, user)
}
}
}
return null
@ -229,7 +242,6 @@ class ServerRepository
}
suspend fun switchServerOrUser() {
apiClient.update(baseUrl = null, accessToken = null)
userPreferencesDataStore.updateData {
it
.toBuilder()
@ -271,5 +283,4 @@ class ServerRepository
data class CurrentUser(
val server: JellyfinServer,
val user: JellyfinUser,
val userDto: UserDto,
)

View file

@ -8,7 +8,6 @@ import org.jellyfin.sdk.model.api.UserConfiguration
*/
data class UserPreferences(
val appPreferences: AppPreferences,
val userConfig: UserConfiguration,
)
val DefaultUserConfiguration =

View file

@ -31,7 +31,7 @@ class ImageUrlService
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
): String =
): String? =
when (imageType) {
ImageType.BACKDROP,
ImageType.LOGO,
@ -124,8 +124,9 @@ class ImageUrlService
backgroundColor: String? = null,
foregroundLayer: String? = null,
imageIndex: Int? = null,
): String =
api.imageApi.getItemImageUrl(
): String? {
if (api.baseUrl.isNullOrBlank()) return null
return api.imageApi.getItemImageUrl(
itemId = itemId,
imageType = imageType,
maxWidth = maxWidth,
@ -144,6 +145,7 @@ class ImageUrlService
foregroundLayer = foregroundLayer,
imageIndex = imageIndex,
)
}
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)

View file

@ -0,0 +1,190 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.main.LatestData
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber
import java.time.LocalDateTime
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@Singleton
class LatestNextUpService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val datePlayedService: DatePlayedService,
) {
suspend fun getResume(
userId: UUID,
limit: Int,
includeEpisodes: Boolean,
): List<BaseItem> {
val request =
GetResumeItemsRequest(
userId = userId,
fields = SlimItemFields,
limit = limit,
includeItemTypes =
if (includeEpisodes) {
supportItemKinds
} else {
supportItemKinds
.toMutableSet()
.apply {
remove(BaseItemKind.EPISODE)
}
},
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.from(it, api, true) }
return items
}
suspend fun getNextUp(
userId: UUID,
limit: Int,
enableRewatching: Boolean,
enableResumable: Boolean,
): List<BaseItem> {
val request =
GetNextUpRequest(
userId = userId,
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = null,
limit = limit,
enableResumable = enableResumable,
enableUserData = true,
enableRewatching = enableRewatching,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
.map { BaseItem.from(it, api, true) }
return nextUp
}
suspend fun getLatest(
user: UserDto,
limit: Int,
includedIds: List<UUID>,
): List<LatestData> {
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
val views by api.userViewsApi.getUserViews()
val latestData =
views.items
.filter {
it.id in includedIds && it.id !in excluded &&
it.collectionType in supportedLatestCollectionTypes
}.map { view ->
val title =
view.name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
val request =
GetLatestMediaRequest(
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = view.id,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
LatestData(title, request)
}
return latestData
}
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
val rows =
latestData.mapNotNull { (title, request) ->
try {
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.from(it, api, true) }
if (latest.isNotEmpty()) {
HomeRowLoadingState.Success(
title = title,
items = latest,
)
} else {
null
}
} catch (ex: Exception) {
Timber.e(ex, "Exception fetching %s", title)
HomeRowLoadingState.Error(
title = title,
exception = ex,
)
}
}
return rows
}
suspend fun buildCombined(
resume: List<BaseItem>,
nextUp: List<BaseItem>,
): List<BaseItem> =
withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
val semaphore = Semaphore(3)
val deferred =
nextUp
.filter { it.data.seriesId != null }
.map { item ->
async(Dispatchers.IO) {
try {
semaphore.withPermit {
datePlayedService.getLastPlayed(item)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", item.id)
null
}
}
}
val nextUpLastPlayed = deferred.awaitAll()
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
val duration = (System.currentTimeMillis() - start).milliseconds
Timber.v("buildCombined took %s", duration)
return@withContext result
}
}

View file

@ -68,9 +68,20 @@ class NavigationManager
log()
}
/**
* Resets the backstack to the specified destination
*/
fun replace(destination: Destination) {
while (backStack.size > 1) {
backStack.removeLastOrNull()
}
backStack[0] = destination
log()
}
private fun log() {
val dest = backStack.lastOrNull().toString()
Timber.Forest.i("Current Destination: %s", dest)
Timber.i("Current Destination: %s", dest)
ACRA.errorReporter.putCustomData("destination", dest)
}
}

View file

@ -13,6 +13,7 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.UserConfiguration
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
@ -26,6 +27,8 @@ class StreamChoiceService
private val serverRepository: ServerRepository,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
) {
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto.value?.configuration
suspend fun updateAudio(
dto: BaseItemDto,
audioLang: String,
@ -117,7 +120,7 @@ class StreamChoiceService
val seriesLang =
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
// If the user has chosen a different language for the series, prefer that
val audioLanguage = seriesLang ?: prefs.userConfig.audioLanguagePreference
val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank()) {
val sorted =
@ -150,7 +153,7 @@ class StreamChoiceService
return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
chooseSubtitleStream(
audioStream,
audioStream?.language,
candidates,
itemPlayback,
plc,
@ -160,7 +163,7 @@ class StreamChoiceService
}
fun chooseSubtitleStream(
audioStream: MediaStream?,
audioStreamLang: String?,
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
playbackLanguageChoice: PlaybackLanguageChoice?,
@ -174,7 +177,7 @@ class StreamChoiceService
val seriesLang =
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
val subtitleLanguage =
(seriesLang ?: prefs.userConfig.subtitleLanguagePreference)
(seriesLang ?: userConfig?.subtitleLanguagePreference)
?.takeIf { it.isNotNullOrBlank() }
val subtitleMode =
@ -192,51 +195,62 @@ class StreamChoiceService
else -> {
// Fallback to the user's preference
prefs.userConfig.subtitleMode
userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
}
}
val candidates =
candidates.sortedWith(
compareBy<MediaStream>(
{ it.isDefault },
{ !it.isForced && it.language.equals(subtitleLanguage, true) },
{ it.isForced && it.language.equals(subtitleLanguage, true) },
{ it.isForced && it.language.isUnknown },
{ it.isForced },
),
)
return when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage != null) {
candidates.firstOrNull { it.language == subtitleLanguage }
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull {
it.language.equals(subtitleLanguage, true) ||
it.language.isUnknown
}
} else {
candidates.firstOrNull()
}
}
SubtitlePlaybackMode.ONLY_FORCED -> {
if (subtitleLanguage != null) {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
} else {
candidates.firstOrNull { it.isForced }
}
}
SubtitlePlaybackMode.SMART -> {
val audioLanguage = prefs.userConfig.audioLanguagePreference
val audioStreamLang = audioStream?.language
if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
candidates.firstOrNull { it.language == subtitleLanguage }
if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown }
} else {
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
}
} else {
null
candidates.firstOrNull { it.isForced }
}
}
SubtitlePlaybackMode.DEFAULT -> {
subtitleLanguage?.let { lang ->
// Find best track that is in the preferred language
(
candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang }
?: candidates.firstOrNull { it.isDefault && it.language == lang }
?: candidates.firstOrNull { it.isForced && it.language == lang }
)
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
?: candidates.firstOrNull { it.isDefault || it.isForced }
} else {
candidates.firstOrNull { it.isDefault || it.isForced }
}
?: (
// If none in preferred language, just find the best track
candidates.firstOrNull { it.isDefault && it.isForced }
?: candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull { it.isForced }
)
}
SubtitlePlaybackMode.NONE -> {
@ -246,3 +260,12 @@ class StreamChoiceService
}
}
}
private val String?.isUnknown: Boolean
get() =
this == null ||
this.equals("unknown", true) ||
this.equals("und", true) ||
this.equals("undetermined", true) ||
this.equals("mul", true) ||
this.equals("zxx", true)

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.services
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
@ -21,7 +20,6 @@ class UserPreferencesService
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
UserPreferences(
appPrefs,
userConfig ?: DefaultUserConfiguration,
)
}
}

View file

@ -0,0 +1,89 @@
package com.github.damontecres.wholphin.services.tvprovider
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.await
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
@ActivityScoped
class TvProviderSchedulerService
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
) {
private val activity = (context as AppCompatActivity)
private val workManager = WorkManager.getInstance(context)
private val supportsTvProvider =
// TODO <=25 has limited support
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
init {
serverRepository.current.observe(activity) { user ->
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
if (supportsTvProvider) {
if (user != null) {
activity.lifecycleScope.launchIO(ExceptionHandler()) {
Timber.i("Scheduling TvProviderWorker for ${user.user}")
workManager
.enqueueUniquePeriodicWork(
uniqueWorkName = TvProviderWorker.WORK_NAME,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
request =
PeriodicWorkRequestBuilder<TvProviderWorker>(
repeatInterval = 1.hours.toJavaDuration(),
).setBackoffCriteria(
BackoffPolicy.LINEAR,
15.minutes.toJavaDuration(),
).setInputData(
workDataOf(
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
),
).build(),
).await()
}
}
}
}
}
fun launchOneTimeRefresh() {
if (supportsTvProvider) {
activity.lifecycleScope.launchIO(ExceptionHandler()) {
serverRepository.current.value?.let { user ->
Timber.i("Scheduling on-time TvProviderWorker for ${user.user}")
workManager.enqueue(
OneTimeWorkRequestBuilder<TvProviderWorker>()
.setInputData(
workDataOf(
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
),
).build(),
)
}
}
}
}
}

View file

@ -0,0 +1,245 @@
package com.github.damontecres.wholphin.services.tvprovider
import android.content.Context
import android.content.Intent
import android.database.Cursor
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
import androidx.tvprovider.media.tv.WatchNextProgram
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LatestNextUpService
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.time.ZoneId
import java.util.Date
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
@HiltWorker
class TvProviderWorker
@AssistedInject
constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val serverRepository: ServerRepository,
private val preferences: DataStore<AppPreferences>,
private val api: ApiClient,
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Timber.d("Start")
val serverId =
inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
val userId =
inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
var currentUser = serverRepository.current.value
if (currentUser == null) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
Timber.w("No user found during run")
return Result.failure()
}
}
try {
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val potentialItemsToAdd =
getPotentialItems(
userId,
prefs.homePagePreferences.enableRewatchingNextUp,
prefs.homePagePreferences.combineContinueNext,
)
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds)
val currentItems = getCurrentTvChannelNextUp()
val currentItemIds = currentItems.map { it.internalProviderId }
val toRemove =
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
val userRemoved = currentItems.filterNot { it.isBrowsable }
val userRemovedIds = userRemoved.map { it.internalProviderId }
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
val toAdd =
potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds }
Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id })
// Remove existing items if they are no longer in the next up from server
(toRemove + userRemoved)
.map { TvContractCompat.buildWatchNextProgramUri(it.id) }
.forEach {
context.contentResolver.delete(it, null, null)
}
// Add new ones
val addedCount =
context.contentResolver.bulkInsert(
WatchNextPrograms.CONTENT_URI,
toAdd
.map { convert(it).toContentValues() }
.toTypedArray(),
)
Timber.v("Added %s", addedCount)
Timber.d("Completed successfully")
} catch (_: ApiClientException) {
return Result.retry()
} catch (_: Exception) {
return Result.failure()
}
return Result.success()
}
private suspend fun getPotentialItems(
userId: UUID,
enableRewatching: Boolean,
combineContinueNext: Boolean,
): List<BaseItem> {
val resumeItems = latestNextUpService.getResume(userId, 10, true)
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
val nextUpItems =
latestNextUpService
.getNextUp(userId, 10, enableRewatching, false)
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
return if (combineContinueNext) {
latestNextUpService.buildCombined(resumeItems, nextUpItems)
} else {
resumeItems + nextUpItems
}
}
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
context.contentResolver
.query(
WatchNextPrograms.CONTENT_URI,
WatchNextProgram.PROJECTION,
null,
null,
null,
)?.map(WatchNextProgram::fromCursor)
.orEmpty()
private fun convert(item: BaseItem): WatchNextProgram =
WatchNextProgram
.Builder()
.apply {
val dto = item.data
setInternalProviderId(item.id.toString())
val type =
when (item.type) {
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
else -> WatchNextPrograms.TYPE_CLIP
}
setType(type)
val resumePosition = dto.userData?.playbackPositionTicks?.ticks
if (resumePosition != null && resumePosition >= 2.minutes) {
// https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt())
} else {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
}
dto.runTimeTicks
?.ticks
?.inWholeMilliseconds
?.toInt()
?.let(::setDurationMillis)
setLastEngagementTimeUtcMillis(
dto.userData
?.lastPlayedDate
?.atZone(ZoneId.systemDefault())
?.toEpochSecond()
?: Date().time, // TODO
)
setTitle(item.title)
setDescription(dto.overview)
if (item.type == BaseItemKind.EPISODE) {
setEpisodeTitle(item.name)
dto.indexNumber?.let(::setEpisodeNumber)
dto.parentIndexNumber?.let(::setSeasonNumber)
}
setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9)
val imageType =
when (item.type) {
BaseItemKind.EPISODE -> ImageType.THUMB
else -> ImageType.PRIMARY
}
setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri())
setIntent(
Intent(context, MainActivity::class.java)
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
MainActivity.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
MainActivity.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
MainActivity.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
MainActivity.INTENT_EPISODE_NUMBER,
it,
)
}
}
},
)
}.build()
companion object {
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId"
}
}
fun <T> Cursor.map(transform: (Cursor) -> T): List<T> =
this.use {
buildList {
if (moveToFirst()) {
do {
add(transform.invoke(this@map))
} while (moveToNext())
}
}
}

View file

@ -28,7 +28,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.media3.common.Player
import coil3.request.ErrorResult
import com.github.damontecres.wholphin.data.model.BaseItem
@ -167,6 +169,26 @@ fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) {
}
}
/**
* Calls [tryRequestFocus] on the provided [FocusRequester] when this composable launches or resumes
*/
@Composable
fun RequestOrRestoreFocus(
focusRequester: FocusRequester?,
debugKey: String? = null,
) {
if (focusRequester != null) {
LaunchedEffect(Unit) {
debugKey?.let { Timber.v("RequestOrRestoreFocus: %s", it) }
focusRequester.tryRequestFocus()
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
debugKey?.let { Timber.v("RequestOrRestoreFocus onResume: %s", it) }
focusRequester.tryRequestFocus()
}
}
}
fun Modifier.enableMarquee(focused: Boolean) =
if (focused) {
basicMarquee(

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -77,7 +78,7 @@ fun GenreCard(
contentDescription = null,
modifier =
Modifier
.alpha(.6f)
.alpha(.75f)
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize(),
)

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -67,7 +68,7 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
@ -85,17 +86,18 @@ import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -114,13 +116,13 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.TreeSet
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
@HiltViewModel
@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class)
class CollectionFolderViewModel
@Inject
@AssistedInject
constructor(
private val savedStateHandle: SavedStateHandle,
api: ApiClient,
@param:ApplicationContext private val context: Context,
private val serverRepository: ServerRepository,
@ -128,7 +130,25 @@ class CollectionFolderViewModel
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
val navigationManager: NavigationManager,
@Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@Assisted("recursive") private val recursive: Boolean,
@Assisted private val collectionFilter: CollectionFolderFilter,
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
@Assisted defaultViewOptions: ViewOptions,
) : ItemViewModel(api) {
@AssistedFactory
interface Factory {
fun create(
itemId: String,
initialSortAndDirection: SortAndDirection?,
@Assisted("recursive") recursive: Boolean,
collectionFilter: CollectionFolderFilter,
@Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean,
defaultViewOptions: ViewOptions,
): CollectionFolderViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf())
@ -136,26 +156,19 @@ class CollectionFolderViewModel
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
val viewOptions = MutableLiveData<ViewOptions>()
private var useSeriesForPrimary: Boolean = true
private lateinit var collectionFilter: CollectionFolderFilter
var position: Int
get() = savedStateHandle.get<Int>("position") ?: 0
set(value) {
savedStateHandle["position"] = value
}
fun init(
itemId: String,
initialSortAndDirection: SortAndDirection?,
recursive: Boolean,
collectionFilter: CollectionFolderFilter,
useSeriesForPrimary: Boolean,
defaultViewOptions: ViewOptions,
): Job =
init {
viewModelScope.launch(
LoadingExceptionHandler(
loading,
context.getString(R.string.error_loading_collection, itemId),
) + Dispatchers.IO,
) {
this@CollectionFolderViewModel.collectionFilter = collectionFilter
this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary
this@CollectionFolderViewModel.itemId = itemId
itemId.toUUIDOrNull()?.let {
fetchItem(it)
}
@ -184,6 +197,7 @@ class CollectionFolderViewModel
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
}
}
private fun saveLibraryDisplayInfo(
newFilter: GetItemsFilter = this.filter.value!!,
@ -537,25 +551,27 @@ fun CollectionFolderGrid(
playEnabled: Boolean,
defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
viewModel: CollectionFolderViewModel =
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
key = itemId,
) {
it.create(
itemId = itemId,
initialSortAndDirection = initialSortAndDirection,
recursive = recursive,
collectionFilter = initialFilter,
useSeriesForPrimary = useSeriesForPrimary,
defaultViewOptions = defaultViewOptions,
)
},
) {
val context = LocalContext.current
OneTimeLaunchedEffect {
viewModel.init(
itemId,
initialSortAndDirection,
recursive,
initialFilter,
useSeriesForPrimary,
defaultViewOptions,
)
}
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
val filter by viewModel.filter.observeAsState(initialFilter.filter)
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
@ -589,6 +605,7 @@ fun CollectionFolderGrid(
Box(modifier = modifier) {
CollectionFolderGridContent(
preferences = preferences,
initialPosition = viewModel.position,
item = item,
title = title,
pager = pager,
@ -611,7 +628,10 @@ fun CollectionFolderGrid(
},
showTitle = showTitle,
sortOptions = sortOptions,
positionCallback = positionCallback,
positionCallback = { columns, position ->
viewModel.position = position
positionCallback?.invoke(columns, position)
},
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
@ -728,6 +748,7 @@ fun CollectionFolderGridContent(
onClickPlayAll: (shuffle: Boolean) -> Unit,
onClickPlay: (Int, BaseItem) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
initialPosition: Int,
modifier: Modifier = Modifier,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
@ -742,10 +763,10 @@ fun CollectionFolderGridContent(
var viewOptions by remember { mutableStateOf(viewOptions) }
val gridFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
RequestOrRestoreFocus(gridFocusRequester)
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
var position by rememberInt(0)
var position by rememberInt(initialPosition)
val focusedItem = pager.getOrNull(position)
if (viewOptions.showDetails) {
LaunchedEffect(focusedItem) {
@ -861,7 +882,7 @@ fun CollectionFolderGridContent(
showJumpButtons = false, // TODO add preference
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
modifier = Modifier.fillMaxSize(),
initialPosition = 0,
initialPosition = initialPosition,
positionCallback = { columns, newPosition ->
showHeader = newPosition < columns
position = newPosition

View file

@ -11,6 +11,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
@ -68,7 +72,10 @@ class GenreViewModel
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val genres = MutableLiveData<List<Genre>>(listOf())
fun init(itemId: UUID) {
fun init(
itemId: UUID,
cardWidthPx: Int,
) {
loading.value = LoadingState.Loading
this.itemId = itemId
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
@ -96,7 +103,7 @@ class GenreViewModel
loading.value = LoadingState.Success
}
// val excludeItemIds = mutableSetOf<UUID>()
val genreToUrl = ConcurrentHashMap<UUID, String>()
val genreToUrl = ConcurrentHashMap<UUID, String?>()
val semaphore = Semaphore(4)
genres
.map { genre ->
@ -133,7 +140,8 @@ class GenreViewModel
item.type,
null,
false,
ImageType.THUMB,
ImageType.BACKDROP,
fillWidth = cardWidthPx,
)
}
}
@ -179,8 +187,23 @@ fun GenreCardGrid(
modifier: Modifier = Modifier,
viewModel: GenreViewModel = hiltViewModel(),
) {
val columns = 4
val spacing = 16.dp
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val cardWidthPx =
remember {
with(density) {
// Grid has 16dp padding on either side & 16dp spacing between 4 cards
// This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster
// E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required
(configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing))
.div(columns)
.roundToPx()
}
}
OneTimeLaunchedEffect {
viewModel.init(itemId)
viewModel.init(itemId, cardWidthPx)
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val genres by viewModel.genres.observeAsState(listOf())
@ -231,7 +254,8 @@ fun GenreCardGrid(
initialPosition = 0,
positionCallback = { columns, position ->
},
columns = 4,
columns = columns,
spacing = spacing,
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
GenreCard(
genre = item,

View file

@ -12,12 +12,11 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.main.buildCombinedNextUp
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -58,7 +57,7 @@ class RecommendedTvShowViewModel
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
private val datePlayedService: DatePlayedService,
private val lastestNextUpService: LatestNextUpService,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
@ -126,9 +125,7 @@ class RecommendedTvShowViewModel
val nextUpItems = nextUpItemsDeferred.await()
if (combineNextUp) {
val combined =
buildCombinedNextUp(
viewModelScope,
datePlayedService,
lastestNextUpService.buildCombined(
resumeItems,
nextUpItems,
)

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -49,8 +50,8 @@ fun TabRow(
modifier: Modifier = Modifier,
) {
val state = rememberLazyListState()
LaunchedEffect(Unit) {
state.animateScrollToItem(selectedTabIndex)
LaunchedEffect(selectedTabIndex) {
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
}
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
var rowHasFocus by remember { mutableStateOf(false) }

View file

@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -30,6 +32,7 @@ import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
data class ItemDetailsDialogInfo(
@ -54,33 +57,32 @@ fun ItemDetailsDialog(
val subtitleLabel = stringResource(R.string.subtitle)
val bitrateLabel = stringResource(R.string.bitrate)
val unknown = stringResource(R.string.unknown)
val runtimeLabel = stringResource(R.string.runtime_sort)
ScrollableDialog(
onDismissRequest = onDismissRequest,
width = 720.dp,
maxHeight = 480.dp,
itemSpacing = 4.dp,
width = 680.dp,
maxHeight = 440.dp,
itemSpacing = 8.dp,
) {
item {
Text(
text = info.title,
style = MaterialTheme.typography.titleLarge,
)
}
if (info.genres.isNotEmpty()) {
item {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = info.genres.joinToString(", "),
style = MaterialTheme.typography.titleSmall,
)
}
}
if (info.overview.isNotNullOrBlank()) {
item {
Text(
text = info.overview,
style = MaterialTheme.typography.bodyLarge,
text = info.title,
style = MaterialTheme.typography.headlineSmall,
)
if (info.genres.isNotEmpty()) {
Text(
text = info.genres.joinToString(", "),
style = MaterialTheme.typography.titleSmall,
)
}
if (info.overview.isNotNullOrBlank()) {
Text(
text = info.overview,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@ -89,13 +91,19 @@ fun ItemDetailsDialog(
source.mediaStreams?.letNotEmpty { mediaStreams ->
item {
Spacer(Modifier.height(8.dp))
HorizontalDivider()
}
// General file information
item {
val containerLabel = stringResource(R.string.container)
MediaInfoSection(
title = stringResource(R.string.general),
title =
titleIndex(
stringResource(R.string.general),
index,
info.files.size,
),
items =
buildList {
source.container?.let { add(containerLabel to it) }
@ -111,31 +119,38 @@ fun ItemDetailsDialog(
bitrateLabel to formatBytes(it, byteRateSuffixes),
)
}
source.runTimeTicks?.let {
add(runtimeLabel to it.ticks.toString())
}
},
)
}
// Video streams
items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream ->
val videoStreams = mediaStreams.filter { it.type == MediaStreamType.VIDEO }
itemsIndexed(videoStreams) { index, stream ->
MediaInfoSection(
title = videoLabel,
items = buildVideoStreamInfo(context, stream),
title = titleIndex(videoLabel, index, videoStreams.size),
items = remember { buildVideoStreamInfo(context, stream) },
additional = remember { buildVideoStreamInfoAdditional(context, stream) },
)
}
// Audio streams - display multiple per row
items(
mediaStreams
.filter { it.type == MediaStreamType.AUDIO }
.chunked(3),
) { streamGroup ->
val audioStreams = mediaStreams.filter { it.type == MediaStreamType.AUDIO }
itemsIndexed(audioStreams.chunked(3)) { groupIndex, streamGroup ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
streamGroup.forEach { stream ->
streamGroup.forEachIndexed { index, stream ->
MediaInfoSection(
title = audioLabel,
title =
titleIndex(
audioLabel,
groupIndex * 3 + index,
audioStreams.size,
),
items = buildAudioStreamInfo(context, stream),
modifier = Modifier.weight(1f),
)
@ -148,19 +163,21 @@ fun ItemDetailsDialog(
}
// Subtitle streams - display multiple per row
items(
mediaStreams
.filter { it.type == MediaStreamType.SUBTITLE }
.chunked(3),
) { streamGroup ->
val subtitleStreams = mediaStreams.filter { it.type == MediaStreamType.SUBTITLE }
itemsIndexed(subtitleStreams.chunked(3)) { groupIndex, streamGroup ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
streamGroup.forEach { stream ->
streamGroup.forEachIndexed { index, stream ->
MediaInfoSection(
title = subtitleLabel,
items = buildSubtitleStreamInfo(context, stream),
title =
titleIndex(
subtitleLabel,
groupIndex * 3 + index,
subtitleStreams.size,
),
items = buildSubtitleStreamInfo(context, stream, showFilePath),
modifier = Modifier.weight(1f),
)
}
@ -185,6 +202,7 @@ private fun MediaInfoSection(
title: String,
items: List<Pair<String, String>>,
modifier: Modifier = Modifier,
additional: List<Pair<String, String>> = listOf(),
) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
@ -192,66 +210,86 @@ private fun MediaInfoSection(
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
items.forEach { (label, value) ->
Row(
modifier = Modifier.padding(start = 12.dp),
) {
Text(
text = "$label: ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
)
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(start = 12.dp),
) {
Column(modifier = Modifier.weight(1f, fill = false)) {
items.forEach { (label, value) ->
Row(
modifier = Modifier,
) {
Text(
text = "$label: ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
if (additional.isNotEmpty()) {
Column(modifier = Modifier.weight(1f, fill = false)) {
additional.forEach { (label, value) ->
Row(
modifier = Modifier,
) {
Text(
text = "$label: ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
}
fun titleIndex(
title: String,
index: Int,
total: Int,
) = if (total > 1) {
"$title (${index + 1})"
} else {
title
}
private fun buildVideoStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
val titleLabel = context.getString(R.string.title)
val codecLabel = context.getString(R.string.codec)
val avcLabel = context.getString(R.string.avc)
val profileLabel = context.getString(R.string.profile)
val levelLabel = context.getString(R.string.level)
val resolutionLabel = context.getString(R.string.resolution)
val aspectRatioLabel = context.getString(R.string.aspect_ratio)
val anamorphicLabel = context.getString(R.string.anamorphic)
val interlacedLabel = context.getString(R.string.interlaced)
val framerateLabel = context.getString(R.string.framerate)
val bitrateLabel = context.getString(R.string.bitrate)
val bitDepthLabel = context.getString(R.string.bit_depth)
val videoRangeLabel = context.getString(R.string.video_range)
val videoRangeTypeLabel = context.getString(R.string.video_range_type)
val colorSpaceLabel = context.getString(R.string.color_space)
val colorTransferLabel = context.getString(R.string.color_transfer)
val colorPrimariesLabel = context.getString(R.string.color_primaries)
val pixelFormatLabel = context.getString(R.string.pixel_format)
val refFramesLabel = context.getString(R.string.ref_frames)
val nalLabel = context.getString(R.string.nal)
val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no)
val titleLabel = context.getString(R.string.title)
val codecLabel = context.getString(R.string.codec)
val resolutionLabel = context.getString(R.string.resolution)
val aspectRatioLabel = context.getString(R.string.aspect_ratio)
val framerateLabel = context.getString(R.string.framerate)
val bitrateLabel = context.getString(R.string.bitrate)
val profileLabel = context.getString(R.string.profile)
val levelLabel = context.getString(R.string.level)
val interlacedLabel = context.getString(R.string.interlaced)
val videoRangeLabel = context.getString(R.string.video_range)
val sdrStr = context.getString(R.string.sdr)
val hdrStr = context.getString(R.string.hdr)
val hdr10Str = context.getString(R.string.hdr10)
val hdr10PlusStr = context.getString(R.string.hdr10_plus)
val hlgStr = context.getString(R.string.hlg)
val bitUnit = context.getString(R.string.bit_unit)
stream.title?.let { add(titleLabel to it) }
stream.codec?.let { add(codecLabel to it.uppercase()) }
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
stream.profile?.let { add(profileLabel to it) }
stream.level?.let { add(levelLabel to it.toString()) }
if (stream.width != null && stream.height != null) {
add(resolutionLabel to "${stream.width}x${stream.height}")
}
@ -259,23 +297,55 @@ private fun buildVideoStreamInfo(
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
add(aspectRatioLabel to aspectRatio)
}
stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) }
stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) }
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
stream.averageFrameRate?.let {
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
}
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") }
stream.videoRange?.let {
stream.videoRange.let {
val rangeStr =
when (it) {
VideoRange.SDR -> sdrStr
VideoRange.HDR -> hdrStr
VideoRange.UNKNOWN -> null
else -> null
}
rangeStr?.let { add(videoRangeLabel to it) }
}
stream.profile?.let { add(profileLabel to it) }
stream.level?.let { add(levelLabel to it.toString()) }
stream.isInterlaced.let { add(interlacedLabel to if (it) yesStr else noStr) }
}
private fun buildVideoStreamInfoAdditional(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no)
val avcLabel = context.getString(R.string.avc)
val anamorphicLabel = context.getString(R.string.anamorphic)
val bitDepthLabel = context.getString(R.string.bit_depth)
val videoRangeTypeLabel = context.getString(R.string.video_range_type)
val colorSpaceLabel = context.getString(R.string.color_space)
val colorTransferLabel = context.getString(R.string.color_transfer)
val colorPrimariesLabel = context.getString(R.string.color_primaries)
val pixelFormatLabel = context.getString(R.string.pixel_format)
val refFramesLabel = context.getString(R.string.ref_frames)
val nalLabel = context.getString(R.string.nal)
val dolbyVisionLabel = context.getString(R.string.dolby_vision)
val sdrStr = context.getString(R.string.sdr)
val hdr10Str = context.getString(R.string.hdr10)
val hdr10PlusStr = context.getString(R.string.hdr10_plus)
val hlgStr = context.getString(R.string.hlg)
val bitUnit = context.getString(R.string.bit_unit)
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) }
stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") }
stream.videoRangeType?.let {
val rangeTypeStr =
when (it) {
@ -304,7 +374,8 @@ private fun buildVideoStreamInfo(
stream.colorPrimaries?.let { add(colorPrimariesLabel to it) }
stream.pixelFormat?.let { add(pixelFormatLabel to it) }
stream.refFrames?.let { add(refFramesLabel to it.toString()) }
stream.nalLengthSize?.let { add(nalLabel to it.toString()) }
stream.nalLengthSize?.let { add(nalLabel to it) }
stream.videoDoViTitle?.let { add(dolbyVisionLabel to it) }
}
private fun buildAudioStreamInfo(
@ -341,17 +412,18 @@ private fun buildAudioStreamInfo(
private fun buildSubtitleStreamInfo(
context: Context,
stream: MediaStream,
showPath: Boolean,
): List<Pair<String, String>> =
buildList {
val titleLabel = context.getString(R.string.title)
val languageLabel = context.getString(R.string.language)
val codecLabel = context.getString(R.string.codec)
val avcLabel = context.getString(R.string.avc)
val defaultLabel = context.getString(R.string.default_track)
val forcedLabel = context.getString(R.string.forced_track)
val externalLabel = context.getString(R.string.external_track)
val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no)
val pathLabel = context.getString(R.string.path)
stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) }
@ -359,10 +431,15 @@ private fun buildSubtitleStreamInfo(
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase()
add(codecLabel to formattedCodec)
}
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) }
stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) }
stream.isHearingImpaired?.let {
add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr)
}
if (showPath) {
stream.path?.let { pathLabel to it }
}
}
private fun calculateAspectRatio(

View file

@ -117,12 +117,16 @@ fun <T : CardGridItem> CardGrid(
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow)
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
val gridState =
rememberLazyGridState(
cacheWindow = fractionCacheWindow,
initialFirstVisibleItemIndex = focusedIndex,
)
val scope = rememberCoroutineScope()
val firstFocus = remember { FocusRequester() }
val zeroFocus = remember { FocusRequester() }
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
var alphabetFocus by remember { mutableStateOf(false) }
val focusOn = { index: Int ->

View file

@ -302,7 +302,7 @@ fun DebugPage(
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "User server settings: ${preferences.userConfig}",
text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -31,6 +30,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -48,7 +48,6 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@ -291,9 +290,7 @@ fun EpisodeDetailsContent(
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val bringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),

View file

@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -49,6 +48,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.cards.ChapterRow
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
@ -73,7 +73,6 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@ -391,9 +390,9 @@ fun MovieDetailsContent(
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val bringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),

View file

@ -16,7 +16,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -46,6 +45,7 @@ import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
@ -74,7 +74,6 @@ import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@ -88,13 +87,15 @@ fun SeriesDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(),
viewModel: SeriesViewModel =
hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(
creationCallback = {
it.create(destination.itemId, null, SeriesPageType.DETAILS)
},
),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.init(preferences, destination.itemId, null, true)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
@ -285,7 +286,7 @@ private const val SIMILAR_ROW = EXTRAS_ROW + 1
fun SeriesDetailsContent(
preferences: UserPreferences,
series: BaseItem,
seasons: List<BaseItem>,
seasons: List<BaseItem?>,
similar: List<BaseItem>,
trailers: List<Trailer>,
extras: List<ExtrasItem>,
@ -311,9 +312,7 @@ fun SeriesDetailsContent(
var position by rememberInt()
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
Box(

View file

@ -4,12 +4,11 @@ package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -22,7 +21,6 @@ import androidx.lifecycle.map
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -36,13 +34,12 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
@ -52,7 +49,6 @@ import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration
@ -80,10 +76,15 @@ data class SeriesOverviewPosition(
fun SeriesOverview(
preferences: UserPreferences,
destination: Destination.SeriesOverview,
initialSeasonEpisode: SeasonEpisodeIds?,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(),
viewModel: SeriesViewModel =
hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(
creationCallback = {
it.create(destination.itemId, initialSeasonEpisode, SeriesPageType.OVERVIEW)
},
),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
initialSeasonEpisode: SeasonEpisodeIds? = null,
) {
val context = LocalContext.current
val firstItemFocusRequester = remember { FocusRequester() }
@ -91,18 +92,6 @@ fun SeriesOverview(
val castCrewRowFocusRequester = remember { FocusRequester() }
val guestStarRowFocusRequester = remember { FocusRequester() }
var initialLoadDone by rememberSaveable { mutableStateOf(false) }
OneTimeLaunchedEffect {
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
viewModel.init(
preferences,
destination.itemId,
initialSeasonEpisode,
false,
)
initialLoadDone = true
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val series by viewModel.item.observeAsState(null)
@ -111,27 +100,9 @@ fun SeriesOverview(
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf())
val episodeList = (episodes as? EpisodeList.Success)?.episodes
var position by rememberSaveable(
destination,
loading,
stateSaver =
Saver(
save = { listOf(it.seasonTabIndex, it.episodeRowIndex) },
restore = { SeriesOverviewPosition(it[0], it[1]) },
),
) {
mutableStateOf(
SeriesOverviewPosition(
seasons.indexOfFirstOrNull {
equalsNotNull(it.id, initialSeasonEpisode?.seasonId) ||
equalsNotNull(it.indexNumber, initialSeasonEpisode?.seasonNumber)
} ?: 0,
(episodes as? EpisodeList.Success)?.initialIndex ?: 0,
),
)
}
if (initialLoadDone) {
LaunchedEffect(Unit) {
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
LaunchedEffect(Unit) {
if (seasons.isNotEmpty()) {
seasons.getOrNull(position.seasonTabIndex)?.let {
viewModel.loadEpisodes(it.id)
}
@ -301,13 +272,20 @@ fun SeriesOverview(
episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester,
guestStarRowFocusRequester = guestStarRowFocusRequester,
onFocus = {
if (it.seasonTabIndex != position.seasonTabIndex) {
seasons.getOrNull(it.seasonTabIndex)?.let { season ->
onChangeSeason = { index ->
if (index != position.seasonTabIndex) {
seasons.getOrNull(index)?.let { season ->
viewModel.loadEpisodes(season.id)
viewModel.position.update {
SeriesOverviewPosition(index, 0)
}
}
}
position = it
},
onFocusEpisode = { episodeIndex ->
viewModel.position.update {
it.copy(episodeRowIndex = episodeIndex)
}
},
onClick = {
rowFocused = EPISODE_ROW

View file

@ -26,7 +26,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -50,7 +49,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -80,7 +79,8 @@ fun SeriesOverviewContent(
episodeRowFocusRequester: FocusRequester,
castCrewRowFocusRequester: FocusRequester,
guestStarRowFocusRequester: FocusRequester,
onFocus: (SeriesOverviewPosition) -> Unit,
onChangeSeason: (Int) -> Unit,
onFocusEpisode: (Int) -> Unit,
onClick: (BaseItem) -> Unit,
onLongClick: (BaseItem) -> Unit,
playOnClick: (Duration) -> Unit,
@ -141,11 +141,12 @@ fun SeriesOverviewContent(
tabs =
seasons.mapNotNull {
it?.name
?: (stringResource(R.string.tv_season) + " ${it?.data?.indexNumber}")
?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" }
?: ""
},
onClick = {
selectedTabIndex = it
onFocus.invoke(SeriesOverviewPosition(it, 0))
onChangeSeason.invoke(it)
},
modifier =
Modifier
@ -169,102 +170,93 @@ fun SeriesOverviewContent(
modifier = Modifier.fillMaxWidth(.6f),
)
key(position.seasonTabIndex) {
when (val eps = episodes) {
EpisodeList.Loading -> {
LoadingPage()
}
// key(position.seasonTabIndex) {
when (val eps = episodes) {
EpisodeList.Loading -> {
LoadingPage()
}
is EpisodeList.Error -> {
ErrorMessage(eps.message, eps.exception)
}
is EpisodeList.Error -> {
ErrorMessage(eps.message, eps.exception)
}
is EpisodeList.Success -> {
val state = rememberLazyListState()
OneTimeLaunchedEffect {
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
state.scrollToItem(position.episodeRowIndex)
is EpisodeList.Success -> {
val state = rememberLazyListState(position.episodeRowIndex)
RequestOrRestoreFocus(firstItemFocusRequester)
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
modifier =
Modifier
.focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester)
.onFocusChanged {
cardRowHasFocus = it.hasFocus
},
) {
itemsIndexed(eps.episodes) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() }
if (interactionSource.collectIsFocusedAsState().value) {
onFocusEpisode.invoke(episodeIndex)
}
firstItemFocusRequester.tryRequestFocus()
}
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
modifier =
Modifier
.focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester)
.onFocusChanged {
cardRowHasFocus = it.hasFocus
},
) {
itemsIndexed(eps.episodes) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() }
if (interactionSource.collectIsFocusedAsState().value) {
onFocus.invoke(
SeriesOverviewPosition(
selectedTabIndex,
episodeIndex,
),
)
}
val cornerText =
episode?.data?.indexNumber?.let { "E$it" }
?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard(
name = episode?.name,
item = episode,
aspectRatio =
episode
?.aspectRatio
?.coerceAtLeast(AspectRatios.FOUR_THREE)
?: (AspectRatios.WIDE),
cornerText = cornerText,
played = episode?.data?.userData?.played ?: false,
playPercent =
episode?.data?.userData?.playedPercentage
?: 0.0,
onClick = { if (episode != null) onClick.invoke(episode) },
onLongClick = {
if (episode != null) {
onLongClick.invoke(
episode,
)
}
},
modifier =
Modifier
.ifElse(
episodeIndex == position.episodeRowIndex,
Modifier.focusRequester(firstItemFocusRequester),
).ifElse(
episodeIndex != position.episodeRowIndex,
Modifier
.background(
Color.Black,
shape = RoundedCornerShape(8.dp),
).alpha(dimming),
).onFocusChanged {
if (it.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
val cornerText =
episode?.data?.indexNumber?.let { "E$it" }
?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard(
name = episode?.name,
item = episode,
aspectRatio =
episode
?.aspectRatio
?.coerceAtLeast(AspectRatios.FOUR_THREE)
?: (AspectRatios.WIDE),
cornerText = cornerText,
played = episode?.data?.userData?.played ?: false,
playPercent =
episode?.data?.userData?.playedPercentage
?: 0.0,
onClick = { if (episode != null) onClick.invoke(episode) },
onLongClick = {
if (episode != null) {
onLongClick.invoke(
episode,
)
}
},
modifier =
Modifier
.ifElse(
episodeIndex == position.episodeRowIndex,
Modifier.focusRequester(firstItemFocusRequester),
).ifElse(
episodeIndex != position.episodeRowIndex,
Modifier
.background(
Color.Black,
shape = RoundedCornerShape(8.dp),
).alpha(dimming),
).onFocusChanged {
if (it.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
}.onKeyEvent {
if (episode != null && isPlayKeyUp(it)) {
onClick.invoke(episode)
return@onKeyEvent true
}
return@onKeyEvent false
},
interactionSource = interactionSource,
cardHeight = 120.dp,
)
}
}
}.onKeyEvent {
if (episode != null && isPlayKeyUp(it)) {
onClick.invoke(episode)
return@onKeyEvent true
}
return@onKeyEvent false
},
interactionSource = interactionSource,
cardHeight = 120.dp,
)
}
}
}
// }
}
focusedEpisode?.let { ep ->

View file

@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
@ -26,8 +25,10 @@ import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.lt
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
@ -37,11 +38,19 @@ import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -57,11 +66,10 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class)
class SeriesViewModel
@Inject
@AssistedInject
constructor(
api: ApiClient,
@param:ApplicationContext val context: Context,
@ -76,11 +84,21 @@ class SeriesViewModel
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
@Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@Assisted val seriesPageType: SeriesPageType,
) : ItemViewModel(api) {
private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences
@AssistedFactory
interface Factory {
fun create(
seriesId: UUID,
seasonEpisodeIds: SeasonEpisodeIds?,
seriesPageType: SeriesPageType,
): SeriesViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem>>(listOf())
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val trailers = MutableLiveData<List<Trailer>>(listOf())
@ -90,48 +108,76 @@ class SeriesViewModel
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
fun init(
prefs: UserPreferences,
itemId: UUID,
seasonEpisodeIds: SeasonEpisodeIds?,
loadAdditionalDetails: Boolean,
) {
this.seriesId = itemId
this.prefs = prefs
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
init {
viewModelScope.launch(
LoadingExceptionHandler(
loading,
"Error loading series $seriesId",
) + Dispatchers.IO,
) {
Timber.v("Start")
val item = fetchItem(seriesId)
backdropService.submit(item)
val seasons = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season
val initialSeason =
if (seasonEpisodeIds != null) {
seasons.firstOrNull {
equalsNotNull(it.id, seasonEpisodeIds.seasonId) ||
equalsNotNull(it.indexNumber, seasonEpisodeIds.seasonNumber)
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
val episodeListDeferred =
if (seriesPageType == SeriesPageType.OVERVIEW) {
viewModelScope.async(Dispatchers.IO) {
if (seasonEpisodeIds != null) {
loadEpisodesInternal(
seasonEpisodeIds.seasonId,
seasonEpisodeIds.episodeId,
seasonEpisodeIds.episodeNumber,
)
} else {
seasonsDeferred.await().firstOrNull()?.let {
loadEpisodesInternal(
it.id,
null,
null,
)
} ?: EpisodeList.Error(message = "Could not determine season")
}
}
} else {
seasons.firstOrNull()
CompletableDeferred(value = EpisodeList.Loading)
}
val episodeInfo =
initialSeason?.let {
loadEpisodesInternal(
it.id,
seasonEpisodeIds?.episodeId,
seasonEpisodeIds?.episodeNumber,
)
} ?: EpisodeList.Error("Could not determine season for selected episode")
val seasons = seasonsDeferred.await()
val episodes = episodeListDeferred.await()
Timber.v("Done")
if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) {
viewModelScope.launchIO {
val index =
(seasons as? ApiRequestPager<*>)?.let {
findIndexOf(
seasonEpisodeIds.seasonNumber,
seasonEpisodeIds.seasonId,
it,
)
} ?: 0
Timber.v("Got initial season index: $index")
position.update {
it.copy(seasonTabIndex = index)
}
}
}
withContext(Dispatchers.Main) {
this@SeriesViewModel.position.update {
it.copy(
episodeRowIndex =
(episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0,
)
}
this@SeriesViewModel.seasons.value = seasons
episodes.value = episodeInfo
this@SeriesViewModel.episodes.value = episodes
loading.value = LoadingState.Success
}
if (loadAdditionalDetails) {
if (seriesPageType == SeriesPageType.DETAILS) {
viewModelScope.launchIO {
val trailers = trailerService.getTrailers(item)
withContext(Dispatchers.Main) {
@ -153,7 +199,7 @@ class SeriesViewModel
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser.value?.id,
itemId = itemId,
itemId = seriesId,
fields = SlimItemFields,
limit = 25,
),
@ -185,31 +231,45 @@ class SeriesViewModel
themeSongPlayer.stop()
}
private suspend fun getSeasons(series: BaseItem): List<BaseItem> {
val request =
GetItemsRequest(
parentId = series.id,
recursive = false,
includeItemTypes = listOf(BaseItemKind.SEASON),
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING),
fields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
ItemFields.SEASON_USER_DATA,
),
)
val seasons =
GetItemsRequestHandler.execute(api, request).content.items.map {
BaseItem.from(
it,
api,
private fun getSeasons(
series: BaseItem,
seasonNum: Int?,
): Deferred<List<BaseItem?>> =
viewModelScope.async(Dispatchers.IO) {
val request =
GetItemsRequest(
parentId = series.id,
recursive = false,
includeItemTypes = listOf(BaseItemKind.SEASON),
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING),
fields =
if (seriesPageType == SeriesPageType.DETAILS) {
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
)
} else {
null
},
)
}
Timber.v("Loaded ${seasons.size} seasons for series ${series.id}")
return seasons
}
val pager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
pageSize = 10,
).init(seasonNum ?: 0)
// val seasons =
// GetItemsRequestHandler.execute(api, request).content.items.map {
// BaseItem.from(
// it,
// api,
// )
// }
// Timber.v("Loaded ${seasons.size} seasons for series ${series.id}")
pager
}
private suspend fun loadEpisodesInternal(
seasonId: UUID,
@ -233,14 +293,11 @@ class SeriesViewModel
),
)
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init()
pager.init(episodeNumber ?: 0)
val initialIndex =
if (episodeId != null || episodeNumber != null) {
pager
.indexOfBlocking {
equalsNotNull(it?.id, episodeId) ||
equalsNotNull(it?.indexNumber, episodeNumber)
}.coerceAtLeast(0)
findIndexOf(episodeNumber, episodeId, pager)
.coerceAtLeast(0)
} else {
// Force the first page to to be fetched
if (pager.isNotEmpty()) {
@ -272,7 +329,7 @@ class SeriesViewModel
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
(episodes as? EpisodeList.Success)
?.let {
it.episodes.getOrNull(it.initialIndex)
it.episodes.getOrNull(it.initialEpisodeIndex)
}?.let { lookupPeopleInEpisode(it) }
}
}
@ -312,7 +369,7 @@ class SeriesViewModel
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
setWatched(seasonId, played, null)
val series = fetchItem(seriesId)
val seasons = getSeasons(series)
val seasons = getSeasons(series, null).await()
this@SeriesViewModel.seasons.setValueOnMain(seasons)
}
@ -320,7 +377,7 @@ class SeriesViewModel
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(seriesId, played)
val series = fetchItem(seriesId)
val seasons = getSeasons(series)
val seasons = getSeasons(series, null).await()
this@SeriesViewModel.seasons.setValueOnMain(seasons)
}
@ -469,7 +526,7 @@ sealed interface EpisodeList {
data class Success(
val seasonId: UUID,
val episodes: ApiRequestPager<GetEpisodesRequest>,
val initialIndex: Int,
val initialEpisodeIndex: Int,
) : EpisodeList
}
@ -477,3 +534,49 @@ data class PeopleInItem(
val itemId: UUID? = null,
val people: List<Person> = listOf(),
)
enum class SeriesPageType {
DETAILS,
OVERVIEW,
}
private suspend fun findIndexOf(
targetNum: Int?,
targetId: UUID?,
pager: ApiRequestPager<*>,
): Int {
val index =
if (targetId != null && (targetNum == null || targetNum !in pager.indices)) {
// No hint info, so have to check everything
pager.indexOfBlocking { equalsNotNull(it?.id, targetId) }
} else if (targetNum != null && targetNum in pager.indices) {
// Start searching from the season number and choose direction from there
val num = pager.getBlocking(targetNum)?.indexNumber
if (num.lt(targetNum)) {
for (i in targetNum + 1 until pager.lastIndex) {
val season = pager.getBlocking(i)
if (equalsNotNull(season?.indexNumber, targetNum) ||
equalsNotNull(season?.id, targetId)
) {
return i
}
}
return 0
} else if (num.gt(targetNum)) {
for (i in targetNum - 1 downTo 0) {
val season = pager.getBlocking(i)
if (equalsNotNull(season?.indexNumber, targetNum) ||
equalsNotNull(season?.id, targetId)
) {
return i
}
}
return 0
} else {
targetNum
}
} else {
0
}
return index
}

View file

@ -48,6 +48,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress
@ -360,13 +361,21 @@ fun HomePageContent(
.fillMaxWidth()
.animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick ->
val cornerText =
remember {
item?.data?.indexNumber?.let { "E$it" }
?: item
?.data
?.userData
?.unplayedItemCount
?.takeIf { it > 0 }
?.let { abbreviateNumber(it) }
}
BannerCard(
name = item?.data?.seriesName ?: item?.name,
item = item,
aspectRatio = AspectRatios.TALL,
cornerText =
item?.data?.indexNumber?.let { "E$it" }
?: item?.data?.childCount?.let { if (it > 0) it.toString() else null },
cornerText = cornerText,
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =

View file

@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain
@ -21,34 +21,18 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber
import java.time.LocalDateTime
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel
class HomeViewModel
@ -61,6 +45,7 @@ class HomeViewModel
val navDrawerItemRepository: NavDrawerItemRepository,
private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService,
private val latestNextUpService: LatestNextUpService,
private val backdropService: BackdropService,
) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -91,7 +76,9 @@ class HomeViewModel
),
) {
Timber.d("init HomeViewModel")
backdropService.clearBackdrop()
if (reload) {
backdropService.clearBackdrop()
}
serverRepository.currentUserDto.value?.let { userDto ->
val includedIds =
@ -99,10 +86,9 @@ class HomeViewModel
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
.filter { it is ServerNavDrawerItem }
.map { (it as ServerNavDrawerItem).itemId }
// TODO data is fetched all together which may be slow for large servers
val resume = getResume(userDto.id, limit, true)
val resume = latestNextUpService.getResume(userDto.id, limit, true)
val nextUp =
getNextUp(
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
@ -111,13 +97,7 @@ class HomeViewModel
val watching =
buildList {
if (prefs.combineContinueNext) {
val items =
buildCombinedNextUp(
viewModelScope,
datePlayedService,
resume,
nextUp,
)
val items = latestNextUpService.buildCombined(resume, nextUp)
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching),
@ -144,7 +124,7 @@ class HomeViewModel
}
}
val latest = getLatest(userDto, limit, includedIds)
val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
withContext(Dispatchers.Main) {
@ -154,127 +134,13 @@ class HomeViewModel
}
loadingState.value = LoadingState.Success
}
loadLatest(latest)
refreshState.setValueOnMain(LoadingState.Success)
val loadedLatest = latestNextUpService.loadLatest(latest)
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
}
}
}
private suspend fun getResume(
userId: UUID,
limit: Int,
includeEpisodes: Boolean,
): List<BaseItem> {
val request =
GetResumeItemsRequest(
userId = userId,
fields = SlimItemFields,
limit = limit,
includeItemTypes =
if (includeEpisodes) {
supportItemKinds
} else {
supportItemKinds
.toMutableSet()
.apply {
remove(BaseItemKind.EPISODE)
}
},
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.from(it, api, true) }
return items
}
private suspend fun getNextUp(
userId: UUID,
limit: Int,
enableRewatching: Boolean,
enableResumable: Boolean,
): List<BaseItem> {
val request =
GetNextUpRequest(
userId = userId,
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = null,
limit = limit,
enableResumable = enableResumable,
enableUserData = true,
enableRewatching = enableRewatching,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
.map { BaseItem.from(it, api, true) }
return nextUp
}
private suspend fun getLatest(
user: UserDto,
limit: Int,
includedIds: List<UUID>,
): List<LatestData> {
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
val views by api.userViewsApi.getUserViews()
val latestData =
views.items
.filter {
it.id in includedIds && it.id !in excluded &&
it.collectionType in supportedLatestCollectionTypes
}.map { view ->
val title =
view.name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
val request =
GetLatestMediaRequest(
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = view.id,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
LatestData(title, request)
}
return latestData
}
private suspend fun loadLatest(latestData: List<LatestData>) {
val rows =
latestData.mapNotNull { (title, request) ->
try {
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.from(it, api, true) }
if (latest.isNotEmpty()) {
HomeRowLoadingState.Success(
title = title,
items = latest,
)
} else {
null
}
} catch (ex: Exception) {
Timber.e(ex, "Exception fetching %s", title)
HomeRowLoadingState.Error(
title = title,
exception = ex,
)
}
}
latestRows.setValueOnMain(rows)
}
fun setWatched(
itemId: UUID,
played: Boolean,
@ -315,38 +181,3 @@ data class LatestData(
val title: String,
val request: GetLatestMediaRequest,
)
suspend fun buildCombinedNextUp(
scope: CoroutineScope,
datePlayedService: DatePlayedService,
resume: List<BaseItem>,
nextUp: List<BaseItem>,
): List<BaseItem> =
withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
val semaphore = Semaphore(3)
val deferred =
nextUp
.filter { it.data.seriesId != null }
.map { item ->
scope.async(Dispatchers.IO) {
try {
semaphore.withPermit {
datePlayedService.getLastPlayed(item)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", item.id)
null
}
}
}
val nextUpLastPlayed = deferred.awaitAll()
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
val duration = (System.currentTimeMillis() - start).milliseconds
Timber.v("buildCombined took %s", duration)
return@withContext result
}

View file

@ -69,6 +69,7 @@ class ApplicationContentViewModel
fun ApplicationContent(
server: JellyfinServer,
user: JellyfinUser,
startDestination: Destination,
navigationManager: NavigationManager,
preferences: UserPreferences,
modifier: Modifier = Modifier,
@ -80,7 +81,7 @@ fun ApplicationContent(
user,
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
) {
NavBackStack(Destination.Home())
NavBackStack(startDestination)
}
navigationManager.backStack = backStack
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()

View file

@ -79,10 +79,10 @@ fun DestinationContent(
is Destination.SeriesOverview -> {
SeriesOverview(
preferences,
destination,
modifier,
preferences = preferences,
destination = destination,
initialSeasonEpisode = destination.seasonEpisode,
modifier = modifier,
)
}

View file

@ -483,6 +483,7 @@ fun PlaybackPage(
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
onClick = {
viewModel.reportInteraction()
controllerViewState.hideControls()
viewModel.playNextUp()
},
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,

View file

@ -0,0 +1,540 @@
package com.github.damontecres.wholphin.test
import androidx.lifecycle.MutableLiveData
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceService
import io.mockk.every
import io.mockk.mockk
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.UserDto
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class TestStreamChoiceServiceBasic(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
null,
SubtitlePlaybackMode.NONE,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
),
TestInput(
1,
SubtitlePlaybackMode.NONE,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
plc = plc(subtitleLang = "spa"),
),
TestInput(
0,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
),
TestInput(
1,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
userSubtitleLang = "spa",
),
TestInput(
null,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
plc = plc(subtitlesDisabled = true),
),
TestInput(
null,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.DISABLED),
),
TestInput(
0,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED),
),
)
}
}
@RunWith(Parameterized::class)
class TestStreamChoiceServiceDefault(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
userSubtitleLang = null,
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", true),
),
userSubtitleLang = null,
),
TestInput(
1,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", true),
),
userSubtitleLang = null,
),
TestInput(
null,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", false),
),
),
TestInput(
1,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
itemPlayback = itemPlayback(subtitleIndex = 1),
),
TestInput(
1,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", false),
),
plc = plc(subtitleLang = "spa"),
),
TestInput(
1,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
plc = plc(subtitleLang = "spa"),
),
TestInput(
null,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
plc = plc(subtitlesDisabled = true),
),
)
}
}
@RunWith(Parameterized::class)
class TestStreamChoiceServiceSmart(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
streamAudioLang = null,
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
),
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "spa",
),
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
),
TestInput(
1,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "spa",
plc = plc(subtitleLang = "spa"),
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "spa",
plc = plc(subtitlesDisabled = true),
),
TestInput(
1,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
userSubtitleLang = "spa",
userAudioLang = "spa",
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
userSubtitleLang = "spa",
userAudioLang = "eng",
),
)
}
}
@RunWith(Parameterized::class)
class TestStreamChoiceServiceOnlyForced(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
null,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
),
TestInput(
1,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = 1),
),
TestInput(
0,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitleLang = "eng"),
),
TestInput(
null,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitlesDisabled = true),
),
TestInput(
0,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
),
TestInput(
null,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
),
TestInput(
1,
SubtitlePlaybackMode.ONLY_FORCED,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
),
)
}
}
data class TestInput(
val expectedIndex: Int?,
val userSubtitleMode: SubtitlePlaybackMode?,
val userAudioLang: String? = "eng",
val userSubtitleLang: String? = "eng",
val streamAudioLang: String? = "eng",
val subtitles: List<MediaStream>,
val itemPlayback: ItemPlayback? = null,
val plc: PlaybackLanguageChoice? = null,
) {
override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})"
}
private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)"
private fun serverRepo(
audioLang: String?,
subtitleMode: SubtitlePlaybackMode?,
subtitleLang: String?,
): ServerRepository {
val mocked = mockk<ServerRepository>()
every { mocked.currentUserDto } returns
MutableLiveData(
UserDto(
id = UUID.randomUUID(),
hasPassword = true,
hasConfiguredPassword = true,
hasConfiguredEasyPassword = true,
configuration =
DefaultUserConfiguration.copy(
audioLanguagePreference = audioLang,
subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT,
subtitleLanguagePreference = subtitleLang,
),
),
)
return mocked
}
private fun runTest(input: TestInput) {
val service =
StreamChoiceService(
serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
mockk<PlaybackLanguageChoiceDao>(),
)
val result =
service.chooseSubtitleStream(
audioStreamLang = input.streamAudioLang,
candidates = input.subtitles,
itemPlayback = input.itemPlayback,
playbackLanguageChoice = input.plc,
prefs = UserPreferences(AppPreferences.getDefaultInstance()),
)
Assert.assertEquals(input.expectedIndex, result?.index)
}
fun subtitle(
index: Int,
lang: String?,
default: Boolean = false,
forced: Boolean = false,
): MediaStream =
MediaStream(
type = MediaStreamType.SUBTITLE,
language = lang,
isDefault = default,
isForced = forced,
isHearingImpaired = false,
isInterlaced = false,
index = index,
isExternal = false,
isTextSubtitleStream = true,
supportsExternalStream = true,
)
private fun itemPlayback(
audioIndex: Int = TrackIndex.UNSPECIFIED,
subtitleIndex: Int = TrackIndex.UNSPECIFIED,
): ItemPlayback =
ItemPlayback(
rowId = 1,
userId = 1,
itemId = UUID.randomUUID(),
sourceId = UUID.randomUUID(),
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
)
private fun plc(
audioLang: String? = null,
subtitleLang: String? = null,
subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null,
): PlaybackLanguageChoice =
PlaybackLanguageChoice(
userId = 1,
seriesId = UUID.randomUUID(),
audioLanguage = audioLang,
subtitleLanguage = subtitleLang,
subtitlesDisabled = subtitlesDisabled,
)

View file

@ -5,13 +5,16 @@ agp = "8.13.2"
auto-service = "1.1.1"
autoServiceKsp = "1.2.0"
desugar_jdk_libs = "2.1.5"
hiltCompiler = "1.3.0"
hiltNavigationCompose = "1.3.0"
hiltWork = "1.3.0"
kotlin = "2.2.21"
kotlinxCoroutinesCore = "1.10.2"
ksp = "2.3.0"
coreKtx = "1.17.0"
appcompat = "1.7.1"
composeBom = "2025.12.01"
mockk = "1.14.7"
multiplatformMarkdownRenderer = "0.38.1"
okhttpBom = "5.3.2"
programguide = "1.6.0"
@ -34,6 +37,8 @@ protobuf-javalite = "4.33.2"
hilt = "2.57.2"
room = "2.8.4"
preferenceKtx = "1.2.1"
tvprovider = "1.1.0"
workRuntimeKtx = "2.11.0"
paletteKtx = "1.0.0"
[libraries]
@ -55,18 +60,24 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" }
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" }
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" }
auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
okhttp = { module = "com.squareup.okhttp3:okhttp" }