diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index d81c5e18..56513c4b 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7a8cea71..e997870d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
}
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
new file mode 100644
index 00000000..606329c9
--- /dev/null
+++ b/app/src/debug/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Wholphin (Debug)
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6d84299e..9edf3067 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,5 +1,6 @@
-
+
@@ -11,6 +12,7 @@
+
+
diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
index 638ebc0f..92398cb5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
@@ -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 {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
index 3368091a..ae4b5d28 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
@@ -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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
index 00bc0fb7..17ad9f0f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
@@ -61,7 +61,7 @@ class ItemPlaybackRepository
)
val subtitleStream =
streamChoiceService.chooseSubtitleStream(
- audioStream = audioStream,
+ audioStreamLang = audioStream?.language,
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
index 539c8ac4..1e5cade5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
@@ -46,9 +46,11 @@ class ServerRepository
private var _current = EqualityMutableLiveData(null)
val current: LiveData = _current
+ private var _currentUserDto = EqualityMutableLiveData(null)
+ val currentUserDto: LiveData = _currentUserDto
+
val currentServer: LiveData get() = _current.map { it?.server }
val currentUser: LiveData get() = _current.map { it?.user }
- val currentUserDto: LiveData 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,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt
index 520a12e8..35da376a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt
@@ -8,7 +8,6 @@ import org.jellyfin.sdk.model.api.UserConfiguration
*/
data class UserPreferences(
val appPreferences: AppPreferences,
- val userConfig: UserConfiguration,
)
val DefaultUserConfiguration =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
index 164035e7..cab8609e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
@@ -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)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
new file mode 100644
index 00000000..44ed1a58
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
@@ -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 {
+ 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 {
+ 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,
+ ): List {
+ 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): List {
+ 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,
+ nextUp: List,
+ ): List =
+ 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()
+ 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
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt
index 136cb746..63a85cef 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt
@@ -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)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
index e1490ac0..ada96c57 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
@@ -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,
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(
+ { 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)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
index a59f287b..90d68e52 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
@@ -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,
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt
new file mode 100644
index 00000000..05ce1d2a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt
@@ -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(
+ 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()
+ .setInputData(
+ workDataOf(
+ TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
+ TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
+ ),
+ ).build(),
+ )
+ }
+ }
+ }
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
new file mode 100644
index 00000000..1d54f256
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
@@ -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,
+ 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 {
+ 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 =
+ 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 Cursor.map(transform: (Cursor) -> T): List =
+ this.use {
+ buildList {
+ if (moveToFirst()) {
+ do {
+ add(transform.invoke(this@map))
+ } while (moveToNext())
+ }
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
index bc39c48e..4eba1b32 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
@@ -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(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
index c2e863c3..4340466c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
@@ -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(),
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
index 8692d447..a2e99ffe 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
@@ -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.Loading)
val backgroundLoading = MutableLiveData(LoadingState.Loading)
val pager = MutableLiveData>(listOf())
@@ -136,26 +156,19 @@ class CollectionFolderViewModel
val filter = MutableLiveData(GetItemsFilter())
val viewOptions = MutableLiveData()
- private var useSeriesForPrimary: Boolean = true
- private lateinit var collectionFilter: CollectionFolderFilter
+ var position: Int
+ get() = savedStateHandle.get("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> = DefaultFilterOptions,
+ playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
+ viewModel: CollectionFolderViewModel =
+ hiltViewModel(
+ 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(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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
index 87fc1da6..c84045b3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
@@ -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.Pending)
val genres = MutableLiveData>(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()
- val genreToUrl = ConcurrentHashMap()
+ val genreToUrl = ConcurrentHashMap()
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,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
index 6e48692a..fa4071b6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
@@ -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,
- 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,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt
index 89323bdd..16e26649 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt
@@ -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) }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
index 4947fda6..8e6eb07f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
@@ -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>,
modifier: Modifier = Modifier,
+ additional: List> = 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> =
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> =
+ 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> =
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(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
index ba74641a..19f8f6fd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
@@ -117,12 +117,16 @@ fun 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 ->
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
index b65f8624..60ad839e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
@@ -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,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
index bca08e7b..493093da 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
@@ -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),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
index 24d2f98e..78876e38 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
@@ -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),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
index 0ab81b7e..227f13bd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
@@ -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(
+ 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,
+ seasons: List,
similar: List,
trailers: List,
extras: List,
@@ -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(null) }
Box(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
index 73f489c1..9377aa1d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
@@ -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(
+ 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
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
index 9a2d4dcf..75d8344b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
@@ -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 ->
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
index 5dbe62f8..a1a4edf6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
@@ -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.Loading)
- val seasons = MutableLiveData>(listOf())
+ val seasons = MutableLiveData>(listOf())
val episodes = MutableLiveData(EpisodeList.Loading)
val trailers = MutableLiveData>(listOf())
@@ -90,48 +108,76 @@ class SeriesViewModel
val peopleInEpisode = MutableLiveData(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 {
- 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> =
+ 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,
- val initialIndex: Int,
+ val initialEpisodeIndex: Int,
) : EpisodeList
}
@@ -477,3 +534,49 @@ data class PeopleInItem(
val itemId: UUID? = null,
val people: List = 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
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
index e19f44aa..32101cbf 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
@@ -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 =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
index ad1c2970..e2704ad8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
@@ -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.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 {
- 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 {
- 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,
- ): List {
- 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) {
- 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,
- nextUp: List,
-): List =
- 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()
- 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
- }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
index 7e7d0d2b..14f34747 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
@@ -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()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
index 4cc50eec..a2b2a764 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
@@ -79,10 +79,10 @@ fun DestinationContent(
is Destination.SeriesOverview -> {
SeriesOverview(
- preferences,
- destination,
- modifier,
+ preferences = preferences,
+ destination = destination,
initialSeasonEpisode = destination.seasonEpisode,
+ modifier = modifier,
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
index e27ea11f..1bf8ec56 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
@@ -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,
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt
new file mode 100644
index 00000000..fa16c5ab
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt
@@ -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 =
+ 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 =
+ 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 =
+ 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 =
+ 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,
+ 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()
+ 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(),
+ )
+ 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,
+ )
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 467bc93a..61c5e8cf 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }