Merge branch 'main' into develop/customize-home

This commit is contained in:
Damontecres 2026-02-09 18:01:54 -05:00
commit 9fa169a712
No known key found for this signature in database
26 changed files with 368 additions and 264 deletions

View file

@ -285,6 +285,8 @@ dependencies {
ksp(libs.auto.service.ksp)
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp)
implementation(libs.kache)
implementation(libs.kache.file)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -186,6 +186,45 @@ sealed interface AppPreference<Pref, T> {
summarizer = { value -> value?.toString() },
)
val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365)
val MaxDaysNextUp =
AppSliderPreference<AppPreferences>(
title = R.string.max_days_next_up,
defaultValue = -1,
min = 0,
// Max is "no limit" stored as -1
max = MaxDaysNextUpOptions.lastIndex + 1L,
interval = 1,
getter = {
MaxDaysNextUpOptions
.indexOf(it.homePagePreferences.maxDaysNextUp)
.takeIf { it in MaxDaysNextUpOptions.indices }
?.toLong()
?: MaxDaysNextUpOptions.size.toLong()
},
setter = { prefs, index ->
prefs.updateHomePagePreferences {
maxDaysNextUp = MaxDaysNextUpOptions.getOrNull(index.toInt()) ?: -1
}
},
summarizer = { value ->
if (value != null) {
val v = MaxDaysNextUpOptions.getOrNull(value.toInt()) ?: -1
if (v == -1) {
WholphinApplication.instance.getString(R.string.no_limit)
} else {
WholphinApplication.instance.resources.getQuantityString(
R.plurals.days,
v,
v.toString(),
)
}
} else {
null
}
},
)
val CombineContinueNext =
AppSwitchPreference<AppPreferences>(
title = R.string.combine_continue_next,

View file

@ -82,6 +82,7 @@ class AppPreferencesSerializer
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
combineContinueNext = AppPreference.CombineContinueNext.defaultValue
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
}.build()
interfacePreferences =
InterfacePreferences

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
@ -237,4 +238,12 @@ suspend fun upgradeApp(
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) {
appPreferences.updateData {
it.updateHomePagePreferences {
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
}
}
}
}

View file

@ -555,6 +555,7 @@ class HomeSettingsService
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
@ -579,6 +580,7 @@ class HomeSettingsService
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)

View file

@ -75,8 +75,11 @@ class LatestNextUpService
limit: Int,
enableRewatching: Boolean,
enableResumable: Boolean,
maxDays: Int,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> {
val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
val request =
GetNextUpRequest(
userId = userId,
@ -87,6 +90,7 @@ class LatestNextUpService
enableResumable = enableResumable,
enableUserData = true,
enableRewatching = enableRewatching,
nextUpDateCutoff = nextUpDateCutoff,
)
val nextUp =
api.tvShowsApi

View file

@ -4,9 +4,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@ -19,7 +16,6 @@ class PlaybackLifecycleObserver
private val navigationManager: NavigationManager,
private val playerFactory: PlayerFactory,
private val themeSongPlayer: ThemeSongPlayer,
private val suggestionsCache: SuggestionsCache,
) : DefaultLifecycleObserver {
private var wasPlaying: Boolean? = null
@ -52,6 +48,5 @@ class PlaybackLifecycleObserver
override fun onStop(owner: LifecycleOwner) {
themeSongPlayer.stop()
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
}
}

View file

@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
@ -50,32 +49,31 @@ class SuggestionService
.asFlow()
.flatMapLatest { user ->
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
cache.cacheVersion
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() }
.distinctUntilChanged()
.flatMapLatest { cachedIds ->
if (cachedIds.isNotEmpty()) {
flow {
try {
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind)))
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
} else {
workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.map { workInfos ->
val isActive =
workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
}
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
if (cachedIds.isNotEmpty()) {
flow {
try {
emit(
SuggestionsResource.Success(
fetchItemsByIds(cachedIds, itemKind),
),
)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
} else {
workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.map { workInfos ->
val isActive =
workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
}
}
}
}

View file

@ -4,15 +4,13 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.ui.toServerString
import com.mayakapps.kache.InMemoryKache
import com.mayakapps.kache.ObjectKache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
@ -38,39 +36,15 @@ class SuggestionsCache
constructor(
@param:ApplicationContext private val context: Context,
) {
private val cacheDir: File
get() = File(context.cacheDir, "suggestions")
private val json = Json { ignoreUnknownKeys = true }
private val _cacheVersion = MutableStateFlow(0L)
val cacheVersion: StateFlow<Long> = _cacheVersion.asStateFlow()
private val memoryCache: MutableMap<String, CachedSuggestions> =
LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true)
@Volatile
private var diskCacheLoadedUserId: UUID? = null
private val dirtyKeys: MutableSet<String> = mutableSetOf()
private val mutex = Mutex()
@OptIn(ExperimentalSerializationApi::class)
private fun writeEntryToDisk(
key: String,
cached: CachedSuggestions,
) {
runCatching {
val suggestionsDir = cacheDir.apply { mkdirs() }
File(suggestionsDir, "$key.json")
.outputStream()
.use { json.encodeToStream(cached, it) }
}.onFailure { Timber.w(it, "Failed to write evicted cache: $key") }
}
private fun checkForEviction(newKey: String): Pair<String, CachedSuggestions>? {
if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) {
return null
private val memoryCache =
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
creationScope = CoroutineScope(Dispatchers.IO)
}
val eldest = memoryCache.entries.firstOrNull() ?: return null
memoryCache.remove(eldest.key)
return if (dirtyKeys.remove(eldest.key)) eldest.key to eldest.value else null
}
private fun cacheKey(
userId: UUID,
@ -78,63 +52,28 @@ class SuggestionsCache
itemKind: BaseItemKind,
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
private val cacheDir: File
get() = File(context.cacheDir, "suggestions")
@OptIn(ExperimentalSerializationApi::class)
private suspend fun loadFromDisk(userId: UUID) {
if (diskCacheLoadedUserId == userId) return
mutex.withLock {
if (diskCacheLoadedUserId == userId) return@withLock
withContext(Dispatchers.IO) {
val suggestionsDir = cacheDir
if (!suggestionsDir.exists()) {
diskCacheLoadedUserId = userId
return@withContext
}
memoryCache.clear()
suggestionsDir
.listFiles {
it.name.startsWith(userId.toServerString())
}.orEmpty()
.take(MAX_MEMORY_CACHE_SIZE)
.forEach { file ->
runCatching {
val key = file.nameWithoutExtension
val cached =
file
.inputStream()
.use { json.decodeFromStream<CachedSuggestions>(it) }
memoryCache[key] = cached
}.onFailure { Timber.w(it, "Failed to read cache file: ${file.name}") }
}
diskCacheLoadedUserId = userId
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun get(
userId: UUID,
libraryId: UUID,
itemKind: BaseItemKind,
): CachedSuggestions? {
loadFromDisk(userId)
val key = cacheKey(userId, libraryId, itemKind)
memoryCache[key]?.let { return it }
return withContext(Dispatchers.IO) {
runCatching {
File(cacheDir, "$key.json")
.takeIf { it.exists() }
?.inputStream()
?.use {
return memoryCache.getOrPut(key) {
try {
mutex.withLock {
File(cacheDir, "$key.json").inputStream().use {
json.decodeFromStream<CachedSuggestions>(it)
}?.also { memoryCache[key] = it }
}.onFailure { Timber.w(it, "Failed to read cache: $key") }
.getOrNull()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception reading from disk cache")
null
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun put(
userId: UUID,
libraryId: UUID,
@ -142,75 +81,23 @@ class SuggestionsCache
ids: List<UUID>,
) {
val key = cacheKey(userId, libraryId, itemKind)
val cached = CachedSuggestions(ids)
val evictedEntry =
val suggestions = CachedSuggestions(ids)
memoryCache.put(key, suggestions)
try {
cacheDir.mkdirs()
mutex.withLock {
val evicted = checkForEviction(key)
memoryCache[key] = cached
dirtyKeys.add(key)
_cacheVersion.update { it + 1 }
evicted
}
evictedEntry?.let { (evictedKey, evictedValue) ->
withContext(Dispatchers.IO) {
writeEntryToDisk(evictedKey, evictedValue)
}
}
}
suspend fun isEmpty(): Boolean =
mutex.withLock {
if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) {
return@withLock false
}
withContext(Dispatchers.IO) {
val files = cacheDir.listFiles()
files == null || files.isEmpty()
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun save() {
val entriesToSave =
mutex.withLock {
if (dirtyKeys.isEmpty()) return
val entries =
dirtyKeys.mapNotNull { key ->
memoryCache[key]?.let { key to it }
}
dirtyKeys.clear()
entries
}
withContext(Dispatchers.IO) {
val suggestionsDir =
cacheDir.apply {
if (!mkdirs() && !exists()) Timber.w("Failed to create suggestions cache directory")
File(cacheDir, "$key.json").outputStream().use {
json.encodeToStream(suggestions, it)
}
entriesToSave.forEach { (key, value) ->
runCatching {
File(suggestionsDir, "$key.json")
.outputStream()
.use { json.encodeToStream(value, it) }
}.onFailure { Timber.w(it, "Failed to write cache: $key") }
}
} catch (ex: Exception) {
Timber.e(ex, "Exception writing to disk cache")
}
}
suspend fun clear() {
mutex.withLock {
memoryCache.clear()
dirtyKeys.clear()
_cacheVersion.update { it + 1 }
diskCacheLoadedUserId = null
}
withContext(Dispatchers.IO) {
runCatching { cacheDir.deleteRecursively() }
.onFailure { Timber.w(it, "Failed to clear suggestions cache") }
}
}
companion object {
private const val MAX_MEMORY_CACHE_SIZE = 8
}
}
fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L
fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty()
suspend fun <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null

View file

@ -80,13 +80,7 @@ class SuggestionsSchedulerService
BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(),
).setInputData(inputData)
if (cache.isEmpty()) {
Timber.i("Suggestions cache empty, scheduling periodic fetch with 30s delay")
periodicWorkRequestBuilder.setInitialDelay(30.seconds.toJavaDuration())
} else {
Timber.i("Scheduling periodic SuggestionsWorker")
}
.setInitialDelay(60.seconds.toJavaDuration())
workManager.enqueueUniquePeriodicWork(
uniqueWorkName = SuggestionsWorker.WORK_NAME,

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -31,6 +32,7 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import kotlin.coroutines.CoroutineContext
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
@ -79,6 +81,7 @@ class SuggestionsWorker
}
val results =
supervisorScope {
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
views
.mapNotNull { view ->
val itemKind =
@ -87,7 +90,14 @@ class SuggestionsWorker
async(Dispatchers.IO) {
runCatching {
Timber.v("Fetching suggestions for view %s", view.id)
val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow)
val suggestions =
fetchSuggestions(
context,
view.id,
userId,
itemKind,
itemsPerRow,
)
ensureActive()
cache.put(
userId,
@ -107,7 +117,6 @@ class SuggestionsWorker
}
val successCount = results.count { it.isSuccess }
val failureCount = results.count { it.isFailure }
cache.save()
if (failureCount > 0 && successCount == 0) {
Timber.w("All attempts failed ($failureCount views), scheduling retry")
return Result.retry()
@ -124,6 +133,7 @@ class SuggestionsWorker
}
private suspend fun fetchSuggestions(
coroutineContext: CoroutineContext,
parentId: UUID,
userId: UUID,
itemKind: BaseItemKind,
@ -138,7 +148,7 @@ class SuggestionsWorker
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
val historyDeferred =
async(Dispatchers.IO) {
async(coroutineContext) {
fetchItems(
parentId = parentId,
userId = userId,
@ -160,7 +170,7 @@ class SuggestionsWorker
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
val contextualDeferred =
async(Dispatchers.IO) {
async(coroutineContext) {
if (allGenreIds.isEmpty()) {
emptyList()
} else {
@ -178,7 +188,7 @@ class SuggestionsWorker
}
val randomDeferred =
async(Dispatchers.IO) {
async(coroutineContext) {
fetchItems(
parentId = parentId,
userId = userId,
@ -190,7 +200,7 @@ class SuggestionsWorker
}
val freshDeferred =
async(Dispatchers.IO) {
async(coroutineContext) {
fetchItems(
parentId = parentId,
userId = userId,
@ -201,18 +211,19 @@ class SuggestionsWorker
limit = freshLimit,
)
}
withContext(Dispatchers.Default) {
val contextual = contextualDeferred.await()
val random = randomDeferred.await()
val fresh = freshDeferred.await()
val contextual = contextualDeferred.await()
val random = randomDeferred.await()
val fresh = freshDeferred.await()
(contextual + fresh + random)
.asSequence()
.distinctBy { it.id }
.filterNot { excludeIds.contains(it.relevantId) }
.toList()
.shuffled()
.take(itemsPerRow)
(contextual + fresh + random)
.asSequence()
.distinctBy { it.id }
.filterNot { excludeIds.contains(it.relevantId) }
.toList()
.shuffled()
.take(itemsPerRow)
}
}
private suspend fun fetchItems(

View file

@ -21,6 +21,7 @@ import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@ActivityScoped
@ -60,7 +61,8 @@ class TvProviderSchedulerService
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
),
).build(),
).setInitialDelay(60.seconds.toJavaDuration())
.build(),
).await()
}
}

View file

@ -80,6 +80,7 @@ class TvProviderWorker
getPotentialItems(
userId,
prefs.homePagePreferences.enableRewatchingNextUp,
prefs.homePagePreferences.maxDaysNextUp,
)
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
@ -143,12 +144,13 @@ class TvProviderWorker
private suspend fun getPotentialItems(
userId: UUID,
enableRewatching: Boolean,
maxDaysNextUp: Int,
): List<BaseItem> {
val resumeItems = latestNextUpService.getResume(userId, 10, true)
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
val nextUpItems =
latestNextUpService
.getNextUp(userId, 10, enableRewatching, false)
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
return latestNextUpService.buildCombined(resumeItems, nextUpItems)
}

View file

@ -81,6 +81,18 @@ fun HomeSettingsGlobal(
modifier = Modifier,
)
}
item {
ComposablePreference(
preference = AppPreference.MaxDaysNextUp,
value = AppPreference.MaxDaysNextUp.getter.invoke(preferences),
onValueChange = {
val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it)
onPreferenceChange.invoke(newPrefs)
},
onNavigate = {},
modifier = Modifier,
)
}
item { HorizontalDivider() }
item {
HomeSettingsListItem(

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSerializable
@ -231,6 +232,7 @@ fun ApplicationContent(
)
}
}
val navDrawerListState = rememberLazyListState()
NavDisplay(
backStack = navigationManager.backStack,
onBack = { navigationManager.goBack() },
@ -257,6 +259,7 @@ fun ApplicationContent(
user = user,
server = server,
drawerState = drawerState,
navDrawerListState = navDrawerListState,
onClearBackdrop = viewModel::clearBackdrop,
modifier = Modifier.fillMaxSize(),
)

View file

@ -18,8 +18,8 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Home
@ -41,7 +41,6 @@ import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
@ -88,12 +87,10 @@ import com.github.damontecres.wholphin.ui.spacedByWithFooter
import com.github.damontecres.wholphin.ui.theme.LocalTheme
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
@ -272,6 +269,7 @@ fun NavDrawer(
user: JellyfinUser,
server: JellyfinServer,
drawerState: DrawerState,
navDrawerListState: LazyListState,
onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel =
@ -320,8 +318,6 @@ fun NavDrawer(
visibilityThreshold = IntOffset.VisibilityThreshold,
),
)
val config = LocalConfiguration.current
val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } }
ModalNavigationDrawer(
modifier = modifier,
@ -329,28 +325,8 @@ fun NavDrawer(
drawerContent = { drawerValue ->
val isOpen = drawerValue.isOpen
val spacedBy = 4.dp
val listState = rememberLazyListState()
val searchFocusRequester = remember { FocusRequester() }
suspend fun scrollToSelected() {
val target = selectedIndex + 2
try {
if (target !in
listState.firstVisibleItemIndex..<listState.layoutInfo.visibleItemsInfo.lastIndex
) {
val mult =
if ((target - 2) < listState.layoutInfo.totalItemsCount / 2) -1 else 1
listState.animateScrollToItem(selectedIndex + 2, mult * (heightInPx / 2))
}
} catch (ex: Exception) {
Timber.w(ex, "Error scrolling to %s", target)
}
}
LaunchedEffect(selectedIndex) {
scrollToSelected()
}
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@ -374,7 +350,7 @@ fun NavDrawer(
modifier = Modifier,
)
LazyColumn(
state = listState,
state = navDrawerListState,
contentPadding = PaddingValues(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedByWithFooter(spacedBy),
@ -389,11 +365,6 @@ fun NavDrawer(
focusRequester.tryRequestFocus()
}
}
onExit = {
scope.launch(ExceptionHandler()) {
scrollToSelected()
}
}
}.fillMaxHeight(),
) {
item {

View file

@ -65,7 +65,7 @@ fun SliderPreference(
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier

View file

@ -82,6 +82,7 @@ message HomePagePreferences{
int32 max_items_per_row = 1;
bool enable_rewatching_next_up = 2;
bool combine_continue_next = 3;
int32 max_days_next_up = 4;
}
enum ThemeSongVolume {

View file

@ -260,6 +260,11 @@
<item quantity="one">%d hour</item>
<item quantity="other">%d hours</item>
</plurals>
<plurals name="days">
<item quantity="zero">%s days</item>
<item quantity="one">%s day</item>
<item quantity="other">%s days</item>
</plurals>
<plurals name="items">
<item quantity="zero">%d items</item>
<item quantity="one">%d item</item>
@ -485,6 +490,8 @@
<string name="slideshow_duration">Slideshow duration</string>
<string name="play_videos_during_slideshow">Play videos during slideshow</string>
<string name="send_media_info_log_to_server">Send media info log to server</string>
<string name="no_limit">No limit</string>
<string name="max_days_next_up">Max days in Next Up</string>
<string name="add_row">Add row</string>
<string name="genres_in">Genres in %1$s</string>

View file

@ -12,7 +12,6 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
@ -86,7 +85,6 @@ class SuggestionServiceTest {
runTest {
val currentUser = MutableLiveData<JellyfinUser?>(null)
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
val service = createService()
@ -104,7 +102,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
flowOf(listOf(mockWorkInfo(state)))
@ -125,7 +122,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
flowOf(listOf(mockWorkInfo(state)))
@ -145,7 +141,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
@ -164,7 +159,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
@ -199,7 +193,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
val cachedId = UUID.randomUUID()
coEvery {
@ -237,7 +230,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
val cachedId = UUID.randomUUID()
coEvery {

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.mayakapps.kache.ObjectKache
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@ -28,11 +29,11 @@ class SuggestionsCacheTest {
return SuggestionsCache(mockContext)
}
private fun memoryCacheOf(cache: SuggestionsCache): MutableMap<String, CachedSuggestions> {
private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache<String, CachedSuggestions> {
val field = SuggestionsCache::class.java.getDeclaredField("memoryCache")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
return field.get(cache) as MutableMap<String, CachedSuggestions>
return field.get(cache) as ObjectKache<String, CachedSuggestions>
}
@Test
@ -57,7 +58,6 @@ class SuggestionsCacheTest {
val libId = UUID.randomUUID()
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
cache1.save()
// Create a fresh instance which won't have the memory entry
val cache2 = testCacheWithTempDir()
@ -192,7 +192,6 @@ class SuggestionsCacheTest {
val cache1 = testCacheWithTempDir()
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
cache1.save()
// Read with fresh cache instance (empty memory cache, reads from disk)
val cache2 = testCacheWithTempDir()

View file

@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
@ -30,7 +29,6 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.UUID
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class)
class SuggestionsSchedulerServiceTest {
@ -65,7 +63,6 @@ class SuggestionsSchedulerServiceTest {
@Test
fun schedules_periodic_work_when_user_present() =
runTest {
coEvery { mockCache.isEmpty() } returns false
createService()
currentLiveData.value =
CurrentUser(
@ -79,7 +76,6 @@ class SuggestionsSchedulerServiceTest {
@Test
fun cancels_work_when_user_null() =
runTest {
coEvery { mockCache.isEmpty() } returns false
createService()
currentLiveData.value =
CurrentUser(
@ -95,7 +91,6 @@ class SuggestionsSchedulerServiceTest {
@Test
fun schedules_periodic_work_with_delay_when_cache_empty() =
runTest {
coEvery { mockCache.isEmpty() } returns true
val workRequestSlot = slot<PeriodicWorkRequest>()
every {
mockWorkManager.enqueueUniquePeriodicWork(
@ -114,13 +109,12 @@ class SuggestionsSchedulerServiceTest {
advanceUntilIdle()
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay)
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
}
@Test
fun schedules_periodic_work_without_delay_when_cache_not_empty() =
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
runTest {
coEvery { mockCache.isEmpty() } returns false
val workRequestSlot = slot<PeriodicWorkRequest>()
every {
mockWorkManager.enqueueUniquePeriodicWork(
@ -139,6 +133,6 @@ class SuggestionsSchedulerServiceTest {
advanceUntilIdle()
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
assertEquals(0L, workRequestSlot.captured.workSpec.initialDelay)
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
}
}

View file

@ -78,11 +78,6 @@ class SuggestionsWorkerTest {
every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 }
}
private fun mockQueryResult(items: List<BaseItemDto> = emptyList()) =
mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) {
every { content } returns mockk { every { this@mockk.items } returns items }
}
@Test
fun returns_failure_on_invalid_input() =
runTest {
@ -137,7 +132,6 @@ class SuggestionsWorkerTest {
assertEquals(ListenableWorker.Result.success(), result)
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
coVerify { mockCache.save() }
}
}
@ -231,3 +225,8 @@ class SuggestionsWorkerTest {
assertEquals(ListenableWorker.Result.retry(), result)
}
}
fun mockQueryResult(items: List<BaseItemDto> = emptyList()) =
mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) {
every { content } returns mockk { every { this@mockk.items } returns items }
}

View file

@ -0,0 +1,136 @@
package com.github.damontecres.wholphin.test
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.mockQueryResult
import io.mockk.CapturingSlot
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.operations.TvShowsApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.time.LocalDate
class NextUpTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val testDispatcher = StandardTestDispatcher()
private val mockTvShowsApi = mockk<TvShowsApi>()
private val mockApi = mockk<ApiClient>(relaxed = true)
private val mockContext = mockk<Context>()
private val mockDatePlayedService = mockk<DatePlayedService>()
private val latestNextUpService =
LatestNextUpService(mockContext, mockApi, mockDatePlayedService)
@Before
fun setUp() {
every { mockApi.tvShowsApi } returns mockTvShowsApi
}
@Test
fun `Test max 30 days in next up`() =
runTest {
val maxDays = 30
val nextUpSlot = CapturingSlot<GetNextUpRequest>()
coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult()
latestNextUpService.getNextUp(
userId = UUID.randomUUID(),
limit = 10,
enableRewatching = true,
enableResumable = true,
maxDays = maxDays,
)
Assert.assertEquals(10, nextUpSlot.captured.limit)
val expected = LocalDate.now().minusDays(maxDays.toLong())
Assert.assertEquals(expected, nextUpSlot.captured.nextUpDateCutoff?.toLocalDate())
}
@Test
fun `Test no limit in next up`() =
runTest {
val nextUpSlot = CapturingSlot<GetNextUpRequest>()
coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult()
latestNextUpService.getNextUp(
userId = UUID.randomUUID(),
limit = 10,
enableRewatching = true,
enableResumable = true,
maxDays = -1,
)
Assert.assertEquals(10, nextUpSlot.captured.limit)
Assert.assertNull(nextUpSlot.captured.nextUpDateCutoff)
}
@Test
fun `Test storing preference`() {
AppPreference.MaxDaysNextUp.setter.invoke(AppPreferences.getDefaultInstance(), 0).let {
Assert.assertEquals(7, it.homePagePreferences.maxDaysNextUp)
}
AppPreference.MaxDaysNextUp.setter
.invoke(
AppPreferences.getDefaultInstance(),
AppPreference.MaxDaysNextUpOptions.lastIndex.toLong(),
).let {
Assert.assertEquals(365, it.homePagePreferences.maxDaysNextUp)
}
AppPreference.MaxDaysNextUp.setter
.invoke(AppPreferences.getDefaultInstance(), 3)
.let {
Assert.assertEquals(60, it.homePagePreferences.maxDaysNextUp)
}
AppPreference.MaxDaysNextUp.setter
.invoke(
AppPreferences.getDefaultInstance(),
AppPreference.MaxDaysNextUpOptions.lastIndex + 1L,
).let {
Assert.assertEquals(-1, it.homePagePreferences.maxDaysNextUp)
}
}
@Test
fun `Test getting preference`() {
AppPreferences
.getDefaultInstance()
.updateHomePagePreferences { maxDaysNextUp = 7 }
.let {
val result = AppPreference.MaxDaysNextUp.getter.invoke(it)
Assert.assertEquals(0, result)
}
AppPreferences
.getDefaultInstance()
.updateHomePagePreferences { maxDaysNextUp = 60 }
.let {
val result = AppPreference.MaxDaysNextUp.getter.invoke(it)
Assert.assertEquals(3, result)
}
AppPreferences
.getDefaultInstance()
.updateHomePagePreferences { maxDaysNextUp = -1 }
.let {
val result = AppPreference.MaxDaysNextUp.getter.invoke(it)
Assert.assertEquals(AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, result)
}
}
}

View file

@ -8,9 +8,10 @@ desugar_jdk_libs = "2.1.5"
hiltCompiler = "1.3.0"
hiltNavigationCompose = "1.3.0"
hiltWork = "1.3.0"
kache = "2.1.1"
kotlin = "2.3.0"
kotlinxCoroutinesCore = "1.10.2"
ksp = "2.3.0"
ksp = "2.3.5"
coreKtx = "1.17.0"
appcompat = "1.7.1"
composeBom = "2026.01.01"
@ -25,7 +26,7 @@ tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.1"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.3"
androidx-media3 = "1.9.1"
androidx-media3 = "1.9.2"
coil = "3.3.0"
jellyfin-sdk = "1.7.1"
nav3Core = "1.0.0"
@ -80,6 +81,8 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
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" }
kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" }
kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" }
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" }

41
renovate.json Normal file
View file

@ -0,0 +1,41 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"groupName": "Dependencies",
"matchDepTypes": ["dependencies"],
"separateMultipleMajor": true
},
{
"groupName": "Github Actions",
"matchManagers": ["github-actions"]
},
{
"groupName": "Androidx Media3",
"matchPackageNames": [
"androidx.media3:**"
]
},
{
"groupName": "Jellyfin SDK",
"matchPackageNames": [
"org.jellyfin.sdk:**"
]
},
{
"groupName": "AGP",
"matchPackageNames": [
"com.android.application:**"
]
},
{
"groupName": "Kotlin",
"matchPackageNames": [
"org.jetbrains.kotlin**"
]
}
]
}