From 6e676b9474f0f97e3e070251a033db536f417f07 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:05:11 -0500 Subject: [PATCH 01/80] Fixes overscrolling on recommended tabs (#881) ## Description If the continue watching row is empty on recommended pages, it would scroll to the first successfully loaded row even if previous ones are still pending. This PR fixes that so the page waits until the right first row is ready. ### Related issues Fixes #839 ### Testing Emulator & shield 2019 ## Screenshots N/A ## AI or LLM usage None --- .../ui/components/RecommendedContent.kt | 10 ++++---- .../ui/components/RecommendedMovie.kt | 23 +++++++++++++++---- .../ui/components/RecommendedTvShow.kt | 21 +++++++++++++---- .../damontecres/wholphin/util/LoadingState.kt | 3 +++ 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 24b3d502..6c577c9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -37,9 +37,10 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaType import java.util.UUID @@ -99,13 +100,13 @@ abstract class RecommendedViewModel( abstract fun update( @StringRes title: Int, row: HomeRowLoadingState, - ) + ): HomeRowLoadingState fun update( @StringRes title: Int, block: suspend () -> List, - ) { - viewModelScope.launch(Dispatchers.IO) { + ): Deferred = + viewModelScope.async(Dispatchers.IO) { val titleStr = context.getString(title) val row = try { @@ -115,7 +116,6 @@ abstract class RecommendedViewModel( } update(title, row) } - } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index e44c72f7..a1a5be80 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -32,6 +32,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull @@ -123,6 +124,8 @@ class RecommendedMovieViewModel } } + val jobs = mutableListOf>() + update(R.string.recently_released) { val request = GetItemsRequest( @@ -138,7 +141,7 @@ class RecommendedMovieViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - } + }.also(jobs::add) update(R.string.recently_added) { val request = @@ -155,7 +158,7 @@ class RecommendedMovieViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - } + }.also(jobs::add) update(R.string.top_unwatched) { val request = @@ -173,7 +176,7 @@ class RecommendedMovieViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - } + }.also(jobs::add) viewModelScope.launch(Dispatchers.IO) { try { @@ -216,8 +219,17 @@ class RecommendedMovieViewModel } } + // If the continue watching row is empty, then wait until the first successful row + // is loaded before telling the UI that the page is loaded if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - loading.setValueOnMain(LoadingState.Success) + for (i in 0.. current.toMutableList().apply { set(rowTitles[title]!!, row) } } + return row } companion object { 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 4e952033..00430a2c 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 @@ -33,6 +33,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -170,6 +171,8 @@ class RecommendedTvShowViewModel } } + val jobs = mutableListOf>() + update(R.string.recently_released) { val request = GetItemsRequest( @@ -185,7 +188,7 @@ class RecommendedTvShowViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - } + }.also(jobs::add) update(R.string.recently_added) { val request = @@ -202,7 +205,7 @@ class RecommendedTvShowViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - } + }.also(jobs::add) update(R.string.top_unwatched) { val request = @@ -220,7 +223,7 @@ class RecommendedTvShowViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - } + }.also(jobs::add) viewModelScope.launch(Dispatchers.IO) { try { @@ -264,7 +267,14 @@ class RecommendedTvShowViewModel } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - loading.setValueOnMain(LoadingState.Success) + for (i in 0.. current.toMutableList().apply { set(rowTitles[title]!!, row) } } + return row } companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index c35e9512..1a4ca1aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -48,6 +48,9 @@ sealed interface RowLoadingState { sealed interface HomeRowLoadingState { val title: String + val completed: Boolean + get() = this is Success || this is Error + data class Pending( override val title: String, ) : HomeRowLoadingState From 83e5a44f37ef8ff2e3767b108332b82b27b81936 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:07:22 -0500 Subject: [PATCH 02/80] Fixes glitches when scrolling on the home page (#882) ## Description Fixes the possible glitch on some devices when scrolling left-right on rows on the home page ### Related issues Fixes #367 Fixes #872 ### Testing Emulator & shield 2019 ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 61 ++-- .../damontecres/wholphin/ui/main/HomePage.kt | 283 ++++++++++-------- .../ui/util/ScrollToTopBringIntoViewSpec.kt | 44 +++ 3 files changed, 236 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 691b00cb..97b974ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin.ui.discover import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -23,6 +26,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -47,6 +51,7 @@ import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.DataLoadingState import com.google.common.cache.CacheBuilder import dagger.hilt.android.lifecycle.HiltViewModel @@ -186,6 +191,7 @@ data class DiscoverState( val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, ) +@OptIn(ExperimentalFoundationApi::class) @Composable fun SeerrDiscoverPage( preferences: UserPreferences, @@ -249,28 +255,41 @@ fun SeerrDiscoverPage( .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .fillMaxHeight(.25f), ) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), - modifier = - Modifier - .focusRestorer() - .fillMaxSize(), + val density = LocalDensity.current + val spaceAbovePx = + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + CompositionLocalProvider( + LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { - itemsIndexed(rows) { rowIndex, row -> - DiscoverRow( - row = row, - onClickItem = { index, item -> - position = RowColumn(rowIndex, index) - viewModel.navigationManager.navigateTo(item.destination) - }, - onLongClickItem = { index, item -> }, - onCardFocus = { index -> position = RowColumn(rowIndex, index) }, - focusRequester = focusRequesters[rowIndex], - modifier = - Modifier - .fillMaxWidth(), - ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), + modifier = + Modifier + .focusRestorer() + .fillMaxSize(), + ) { + itemsIndexed(rows) { rowIndex, row -> + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> position = RowColumn(rowIndex, index) }, + focusRequester = focusRequesters[rowIndex], + modifier = + Modifier + .fillMaxWidth(), + ) + } + } } } } 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 cfb13ff7..e41acd1b 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 @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.ui.main +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,6 +21,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -34,6 +37,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -69,6 +73,7 @@ import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.delay @@ -183,6 +188,7 @@ fun HomePage( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun HomePageContent( homeRows: List, @@ -223,14 +229,15 @@ fun HomePageContent( } } } - LaunchedEffect(position) { - if (position.row >= 0) { - listState.animateScrollToItem(position.row) - } - } LaunchedEffect(onUpdateBackdrop, focusedItem) { focusedItem?.let { onUpdateBackdrop.invoke(it) } } + val density = LocalDensity.current + val spaceAbovePx = + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { HomePageHeader( @@ -240,134 +247,148 @@ fun HomePageContent( .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .fillMaxHeight(.33f), ) - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = - PaddingValues( - bottom = Cards.height2x3, - ), - modifier = - Modifier - .focusRestorer(), + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + CompositionLocalProvider( + LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { - itemsIndexed(homeRows) { rowIndex, row -> - when (val r = row) { - is HomeRowLoadingState.Loading, - is HomeRowLoadingState.Pending, - -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringResource(R.string.loading), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } - } - - is HomeRowLoadingState.Error -> { - var focused by remember { mutableStateOf(false) } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .onFocusChanged { - focused = it.isFocused - }.focusable() - .background( - if (focused) { - // Just so the user can tell it has focus - MaterialTheme.colorScheme.border.copy(alpha = .25f) - } else { - Color.Unspecified - }, - ).animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = r.localizedMessage, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - - is HomeRowLoadingState.Success -> { - if (row.items.isNotEmpty()) { - ItemRow( - title = row.title, - items = row.items, - onClickItem = { index, item -> - onClickItem.invoke(RowColumn(rowIndex, index), item) - }, - onLongClickItem = { index, item -> - onLongClickItem.invoke(RowColumn(rowIndex, index), item) - }, - modifier = - Modifier - .fillMaxWidth() - .focusGroup() - .focusRequester(rowFocusRequesters[rowIndex]) - .animateItem(), - cardContent = { index, item, cardModifier, onClick, onLongClick -> - BannerCard( - name = item?.data?.seriesName ?: item?.name, - item = item, - aspectRatio = AspectRatios.TALL, - cornerText = item?.ui?.episodeUnplayedCornerText, - played = item?.data?.userData?.played ?: false, - favorite = item?.favorite ?: false, - playPercent = - item?.data?.userData?.playedPercentage - ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, - modifier = - cardModifier - .onFocusChanged { - if (it.isFocused) { - position = RowColumn(rowIndex, index) -// item?.let(onUpdateBackdrop) - } - if (it.isFocused && onFocusPosition != null) { - val nonEmptyRowBefore = - homeRows - .subList(0, rowIndex) - .count { - it is HomeRowLoadingState.Success && it.items.isEmpty() - } - onFocusPosition.invoke( - RowColumn( - rowIndex - nonEmptyRowBefore, - index, - ), - ) - } - }.onKeyEvent { - if (isPlayKeyUp(it) && item?.type?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke(position, item) - return@onKeyEvent true - } - return@onKeyEvent false - }, - interactionSource = null, - cardHeight = Cards.height2x3, + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = + PaddingValues( + bottom = Cards.height2x3, + ), + modifier = + Modifier + .focusRestorer(), + ) { + itemsIndexed(homeRows) { rowIndex, row -> + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + when (val r = row) { + is HomeRowLoadingState.Loading, + is HomeRowLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.animateItem(), + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, ) - }, - ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is HomeRowLoadingState.Error -> { + var focused by remember { mutableStateOf(false) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .onFocusChanged { + focused = it.isFocused + }.focusable() + .background( + if (focused) { + // Just so the user can tell it has focus + MaterialTheme.colorScheme.border.copy(alpha = .25f) + } else { + Color.Unspecified + }, + ).animateItem(), + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = r.localizedMessage, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + + is HomeRowLoadingState.Success -> { + if (row.items.isNotEmpty()) { + ItemRow( + title = row.title, + items = row.items, + onClickItem = { index, item -> + onClickItem.invoke(RowColumn(rowIndex, index), item) + }, + onLongClickItem = { index, item -> + onLongClickItem.invoke( + RowColumn(rowIndex, index), + item, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusGroup() + .focusRequester(rowFocusRequesters[rowIndex]) + .animateItem(), + cardContent = { index, item, cardModifier, onClick, onLongClick -> + BannerCard( + name = item?.data?.seriesName ?: item?.name, + item = item, + aspectRatio = AspectRatios.TALL, + cornerText = item?.ui?.episodeUnplayedCornerText, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = + cardModifier + .onFocusChanged { + if (it.isFocused) { + position = + RowColumn(rowIndex, index) +// item?.let(onUpdateBackdrop) + } + if (it.isFocused && onFocusPosition != null) { + val nonEmptyRowBefore = + homeRows + .subList(0, rowIndex) + .count { + it is HomeRowLoadingState.Success && it.items.isEmpty() + } + onFocusPosition.invoke( + RowColumn( + rowIndex - nonEmptyRowBefore, + index, + ), + ) + } + }.onKeyEvent { + if (isPlayKeyUp(it) && item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + onClickPlay.invoke( + position, + item, + ) + return@onKeyEvent true + } + return@onKeyEvent false + }, + interactionSource = null, + cardHeight = Cards.height2x3, + ) + }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt new file mode 100644 index 00000000..73f22de6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.ui.util + +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.lazy.LazyColumn + +/** + * Overrides scrolling so that the item being scrolled to is at the top of the view offset by the provided pixels + * + * Note: the offset is necessary for anything that is focuseable, but has content before (eg a title) that needs to be displayed too + * + * Note: this applies to ALL scrollable composables within its scope, so a [LazyColumn] of [androidx.compose.foundation.lazy.LazyRow]s likely needs nested [LocalBringIntoViewSpec] overrides + * + * Example: + * ```kotlin + * val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + * CompositionLocalProvider(LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx)) { + * LazyColumn{ + * items(list){ + * CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + * // Content + * } + * } + * } + * } + * ``` + */ +class ScrollToTopBringIntoViewSpec( + val spaceAbovePx: Float = 100f, +) : BringIntoViewSpec { + override fun calculateScrollDistance( + offset: Float, + size: Float, + containerSize: Float, + ): Float { +// Timber.v( +// "calculateScrollDistance: offset=%s, size=%s, containerSize=%s", +// offset, +// size, +// containerSize, +// ) + return offset - spaceAbovePx + } +} From 028553dc911ed8d70b879154f9b429f8d17945f5 Mon Sep 17 00:00:00 2001 From: voc0der Date: Thu, 12 Feb 2026 01:06:03 +0000 Subject: [PATCH 03/80] Add Quick Connect authorization to Settings (#820) ## Description - Adding `Quick Connect` drawer to the Settings under the Seer integration, and before Additional options. - When interacted with, A inline-dialog appears (after internally resetting itself), with gutter options Cancel / OK, like existing dialogs, and when that is completed, where it completes the other side of `Quick Connect`, e.g. login to your phone Jellyfin app by using Wholphin. - Failure and success both are indicated to the user and if success, it closes the window. ### Related issues Related to #819 Closes #819 ### Testing Tested on development build from my repo on a Nvidia Shield 2019. Performed several logout/login, both success and fail to observe dialog state and function. ## AI or LLM usage Used Claude Sonnet 4.5 for most of the code, ChatGPT, reviewed it, then I reviewed it, made a few tweaks. --- .../wholphin/data/ServerRepository.kt | 12 ++ .../wholphin/preferences/AppPreference.kt | 9 ++ .../ui/preferences/PreferencesContent.kt | 48 +++++++ .../ui/preferences/PreferencesViewModel.kt | 27 ++++ .../ui/preferences/QuickConnectDialog.kt | 127 ++++++++++++++++++ app/src/main/res/values/strings.xml | 5 + 6 files changed, 228 insertions(+) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt 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 f66dcfca..f013525b 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 @@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.model.api.AuthenticationResult @@ -265,6 +266,17 @@ class ServerRepository } } + suspend fun authorizeQuickConnect(code: String): Boolean = + withContext(Dispatchers.IO) { + val userId = currentUser.value?.id + if (userId == null) { + Timber.e("No user logged in for Quick Connect authorization") + throw IllegalStateException("Must be logged in to authorize Quick Connect") + } + val response = apiClient.quickConnectApi.authorizeQuickConnect(code, userId) + response.content + } + companion object { fun getServerSharedPreferences(context: Context): SharedPreferences = context.getSharedPreferences( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 4d7dd76f..4c17ac07 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -943,6 +943,14 @@ sealed interface AppPreference { setter = { prefs, _ -> prefs }, ) + val QuickConnect = + AppClickablePreference( + title = R.string.quick_connect, + summary = R.string.quick_connect_summary, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + val SlideshowDuration = AppSliderPreference( title = R.string.slideshow_duration, @@ -1168,6 +1176,7 @@ val advancedPreferences = title = R.string.more, preferences = listOf( + AppPreference.QuickConnect, AppPreference.SendAppLogs, AppPreference.SendCrashReports, AppPreference.DebugLogging, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index dcbda2fc..29fa873e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -93,6 +93,7 @@ fun PreferencesContent( var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } + var showQuickConnectDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -413,6 +414,22 @@ fun PreferencesContent( ) } + AppPreference.QuickConnect -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + if (currentUser != null) { + viewModel.resetQuickConnectStatus() + showQuickConnectDialog = true + } + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -506,6 +523,37 @@ fun PreferencesContent( SeerrDialogMode.None -> {} } } + + if (showQuickConnectDialog) { + val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) + val successMessage = stringResource(R.string.quick_connect_success) + + LaunchedEffect(quickConnectStatus) { + when (val status = quickConnectStatus) { + LoadingState.Success -> { + Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() + showQuickConnectDialog = false + } + + is LoadingState.Error -> { + val errorMessage = status.message ?: "Authorization failed" + Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show() + } + + else -> {} + } + } + + QuickConnectDialog( + onSubmit = { code -> + viewModel.authorizeQuickConnect(code) + }, + onDismissRequest = { + viewModel.resetQuickConnectStatus() + showQuickConnectDialog = false + }, + ) + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 9973bbec..dd7a8e79 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -24,9 +24,12 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo @@ -61,6 +64,9 @@ class PreferencesViewModel seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId } + private val _quickConnectStatus = MutableStateFlow(LoadingState.Pending) + val quickConnectStatus: StateFlow = _quickConnectStatus + init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> @@ -123,6 +129,27 @@ class PreferencesViewModel } } + fun resetQuickConnectStatus() { + _quickConnectStatus.value = LoadingState.Pending + } + + fun authorizeQuickConnect(code: String) { + viewModelScope.launchIO { + _quickConnectStatus.value = LoadingState.Loading + try { + val success = serverRepository.authorizeQuickConnect(code) + _quickConnectStatus.value = + if (success) { + LoadingState.Success + } else { + LoadingState.Error("Authorization failed") + } + } catch (e: Exception) { + _quickConnectStatus.value = LoadingState.Error(e) + } + } + } + companion object { suspend fun resetSubtitleSettings(appPreferences: DataStore) { appPreferences.updateData { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt new file mode 100644 index 00000000..dfad49b5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt @@ -0,0 +1,127 @@ +package com.github.damontecres.wholphin.ui.preferences + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.EditTextBox +import com.github.damontecres.wholphin.ui.components.TextButton + +@Composable +fun QuickConnectDialog( + onSubmit: (String) -> Unit, + onDismissRequest: () -> Unit, + elevation: Dp = 3.dp, +) { + var code by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } + + val isValidCode: (String) -> Boolean = { value -> + val trimmed = value.trim() + trimmed.length == 6 && trimmed.all { it.isDigit() } + } + + val onSubmitCode = { + if (isValidCode(code)) { + showError = false + onSubmit(code.trim()) + } else { + showError = true + } + } + + Dialog( + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + onDismissRequest = onDismissRequest, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .padding(16.dp) + .width(360.dp) + .background( + color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), + shape = RoundedCornerShape(8.dp), + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.quick_connect_code), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + EditTextBox( + value = code, + onValueChange = { + code = it + showError = false + }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + onSubmitCode() + }, + ), + isInputValid = { value -> + !showError || isValidCode(value) + }, + modifier = Modifier.fillMaxWidth(), + ) + + if (showError) { + Text( + text = stringResource(R.string.quick_connect_code_error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp), + ) + } + + TextButton( + stringRes = R.string.submit, + onClick = onSubmitCode, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2dbad4f1..b8fdc1fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -458,6 +458,11 @@ Seerr integration Remove Seerr Server Seerr server added + Quick Connect + Authorize another device to log in to your account + Enter Quick Connect code + Code must be 6 digits + Device authorized successfully Password Username URL From 4161ea418c3e6156d46db97f9526303d484abe4d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:06:15 -0500 Subject: [PATCH 04/80] Fixes the skip segment button appearance behavior (#884) ## Description Fixes the segment (intro, credits, etc) skip button appearance behavior For the button shown on the video: if the button for a given segment is clicked, dismissed by the back button, or times out after 10 seconds, it will never appear again even if you rewind the video. However, the current non-ignored segment will always show a button on the playback controls though so it's possible to still use it. ### Related issues Fixes #875 ### Testing Tested a bunch of scenarios on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/Extensions.kt | 9 +++ .../wholphin/ui/playback/PlaybackPage.kt | 17 ++--- .../wholphin/ui/playback/PlaybackViewModel.kt | 73 +++++++++++++++---- 3 files changed, 73 insertions(+), 26 deletions(-) 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 12854cd7..c997efd1 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 @@ -391,6 +391,15 @@ fun CoroutineScope.launchIO( block: suspend CoroutineScope.() -> Unit, ): Job = launch(context = Dispatchers.IO + context, start = start, block = block) +/** + * Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] + */ +fun CoroutineScope.launchDefault( + context: CoroutineContext = ExceptionHandler(), + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit, +): Job = launch(context = Dispatchers.Default + context, start = start, block = block) + /** * Converts a UUID to the format used server-side (ie without hyphens). * 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 f8b80020..0313a8c5 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 @@ -86,7 +86,6 @@ import com.github.damontecres.wholphin.util.mpv.MpvPlayer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -163,8 +162,7 @@ fun PlaybackPageContent( itemId = UUID.randomUUID(), ), ) - val currentSegment by viewModel.currentSegment.observeAsState(null) - var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } + val currentSegment by viewModel.currentSegment.collectAsState() val cues by viewModel.subtitleCues.observeAsState(listOf()) var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } @@ -302,10 +300,10 @@ fun PlaybackPageContent( } val showSegment = - !segmentCancelled && currentSegment != null && + currentSegment?.interacted == false && nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L BackHandler(showSegment) { - segmentCancelled = true + viewModel.updateSegment(currentSegment?.segment?.id, true) } Box( @@ -400,7 +398,7 @@ fun PlaybackPageContent( onClickPlaylist = { viewModel.playItemInPlaylist(it) }, - currentSegment = currentSegment, + currentSegment = currentSegment?.segment, showClock = preferences.appPreferences.interfacePreferences.showClock, ) } @@ -462,13 +460,12 @@ fun PlaybackPageContent( LaunchedEffect(Unit) { focusRequester.tryRequestFocus() delay(10.seconds) - segmentCancelled = true + viewModel.updateSegment(segment.segment.id, true) } TextButton( - stringRes = segment.type.skipStringRes, + stringRes = segment.segment.type.skipStringRes, onClick = { - segmentCancelled = true - player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + viewModel.updateSegment(segment.segment.id, false) }, modifier = Modifier.focusRequester(focusRequester), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 931ac551..7ed25311 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -49,6 +49,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain @@ -57,7 +58,6 @@ import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener @@ -164,7 +164,7 @@ class PlaybackViewModel val currentMediaInfo = MutableLiveData(CurrentMediaInfo.EMPTY) val currentPlayback = MutableStateFlow(null) val currentItemPlayback = MutableLiveData() - val currentSegment = EqualityMutableLiveData(null) + val currentSegment = MutableStateFlow(null) private val autoSkippedSegments = mutableSetOf() val subtitleCues = MutableLiveData>(listOf()) @@ -962,10 +962,10 @@ class PlaybackViewModel /** * Cancels listening for segments and clears current segment state */ - private suspend fun resetSegmentState() { + private fun resetSegmentState() { segmentJob?.cancel() autoSkippedSegments.clear() - currentSegment.setValueOnMain(null) + currentSegment.value = null } /** @@ -988,15 +988,21 @@ class PlaybackViewModel it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks } if (currentSegment != null && - currentSegment.itemId == this@PlaybackViewModel.itemId && - autoSkippedSegments.add(currentSegment.id) + currentSegment.itemId == this@PlaybackViewModel.itemId ) { - Timber.d( - "Found media segment for %s: %s, %s", - currentSegment.itemId, - currentSegment.id, - currentSegment.type, - ) + if (currentSegment.id != + this@PlaybackViewModel + .currentSegment.value + ?.segment + ?.id + ) { + Timber.d( + "Found media segment for %s: %s, %s", + currentSegment.itemId, + currentSegment.id, + currentSegment.type, + ) + } val playlist = this@PlaybackViewModel.playlist.value if (currentSegment.type == MediaSegmentType.OUTRO && @@ -1021,13 +1027,21 @@ class PlaybackViewModel withContext(Dispatchers.Main) { when (behavior) { SkipSegmentBehavior.AUTO_SKIP -> { - this@PlaybackViewModel.currentSegment.value = null - player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) + if (autoSkippedSegments.add(currentSegment.id)) { + onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) } + } + this@PlaybackViewModel.currentSegment.update { + MediaSegmentState(currentSegment, true) + } } SkipSegmentBehavior.ASK_TO_SKIP -> { - this@PlaybackViewModel.currentSegment.value = - currentSegment + this@PlaybackViewModel.currentSegment.update { + MediaSegmentState( + currentSegment, + autoSkippedSegments.contains(currentSegment.id), + ) + } } else -> { @@ -1046,6 +1060,28 @@ class PlaybackViewModel } } + fun updateSegment( + segmentId: UUID?, + dismissed: Boolean, + ) { + viewModelScope.launchDefault { + val segment = currentSegment.value?.segment + if (segment != null && segment.id == segmentId) { + autoSkippedSegments.add(segment.id) + if (dismissed) { + currentSegment.update { + it?.copy(interacted = true) + } + } else { + currentSegment.update { + null + } + onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) } + } + } + } + } + private fun listenForTranscodeReason(): Job = viewModelScope.launchIO { currentPlayback.collectLatest { @@ -1379,3 +1415,8 @@ data class PlayerState( val player: Player, val backend: PlayerBackend, ) + +data class MediaSegmentState( + val segment: MediaSegmentDto, + val interacted: Boolean, +) From 11fd780b311a2b32a4036a82ca9c91bb7c02bad8 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Thu, 12 Feb 2026 17:27:59 -0600 Subject: [PATCH 05/80] Fix suggestions cache miss handling for missing files (#880) ## Description Fix a bug where movie suggestions would get stuck loading when the on-disk cache file doesn't exist. ### Related issues Fixes #876 ### Testing Ran unit tests locally. ## Screenshots N/A ## AI or LLM usage Used AI to add testing for this scenario to unit test --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../wholphin/services/SuggestionService.kt | 35 ++++++++++------- .../wholphin/services/SuggestionsCache.kt | 17 +++++--- .../ui/components/RecommendedMovie.kt | 3 ++ .../ui/components/RecommendedTvShow.kt | 3 ++ .../services/SuggestionServiceTest.kt | 39 +++++++++++++++++++ 5 files changed, 76 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt index db6cef2c..0fe32179 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -6,6 +6,7 @@ import androidx.work.WorkManager import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -49,21 +50,8 @@ class SuggestionService .asFlow() .flatMapLatest { user -> val userId = user?.id ?: return@flatMapLatest flowOf(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 { + val cachedSuggestions = cache.get(userId, parentId, itemKind) + if (cachedSuggestions == null) { workManager .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) .map { workInfos -> @@ -73,6 +61,23 @@ class SuggestionService } if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty } + } else if (cachedSuggestions.ids.isEmpty()) { + flowOf(SuggestionsResource.Empty) + } else { + flow { + try { + emit( + SuggestionsResource.Success( + fetchItemsByIds(cachedSuggestions.ids, itemKind), + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to fetch items") + emit(SuggestionsResource.Empty) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt index 5542ea51..0921a1be 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -60,15 +60,20 @@ class SuggestionsCache ): CachedSuggestions? { val key = cacheKey(userId, libraryId, itemKind) return memoryCache.getOrPut(key) { - try { - mutex.withLock { - File(cacheDir, "$key.json").inputStream().use { + mutex.withLock { + try { + val cacheFile = File(cacheDir, "$key.json") + if (!cacheFile.exists()) { + return@withLock null + } + + cacheFile.inputStream().use { json.decodeFromStream(it) } + } catch (ex: Exception) { + Timber.e(ex, "Exception reading from disk cache") + null } - } catch (ex: Exception) { - Timber.e(ex, "Exception reading from disk cache") - null } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index a1a5be80..403ff5fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -32,6 +32,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -207,6 +208,8 @@ class RecommendedMovieViewModel } update(R.string.suggestions, state) } + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e(ex, "Failed to fetch suggestions") update( 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 00430a2c..3258930c 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 @@ -33,6 +33,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -254,6 +255,8 @@ class RecommendedTvShowViewModel } update(R.string.suggestions, state) } + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e(ex, "Failed to fetch suggestions") update( diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt index e1663543..f8fc8445 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -150,6 +151,44 @@ class SuggestionServiceTest { assertEquals(SuggestionsResource.Empty, result) } + @Test + fun getSuggestionsFlow_returnsEmpty_whenCachedIdsEmpty_evenIfWorkIsEnqueued() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList()) + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + } + + @Test + fun getSuggestionsFlow_returnsLoading_whenCacheMissing_andWorkIsEnqueued() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Loading, result) + verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + } + @Test fun passes_correct_arguments_to_cache() = runTest { From 3f0dedd71efe933604a57a7f1847eca8a9e3e525 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:21:16 -0500 Subject: [PATCH 06/80] Fix error pages overlapping with nav drawer (#887) ## Description As of #842, if an error occurred on some pages that display a generic error message, it would be shown under the nav drawer making it difficult to read. This PR fixes that by passing the `modifier` into the `ErrorMessage` composables ensuring it has proper offset & padding. Also, for consistency, passes it the `LoadingPage`s. ### Related issues Caused by #842 ### Testing Threw an exception during home page loading to observe the issue and fix on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/components/CollectionFolderGrid.kt | 2 +- .../com/github/damontecres/wholphin/ui/components/ItemGrid.kt | 4 ++-- .../damontecres/wholphin/ui/components/RecommendedContent.kt | 4 ++-- .../wholphin/ui/detail/discover/DiscoverMovieDetails.kt | 4 ++-- .../wholphin/ui/detail/discover/DiscoverSeriesDetails.kt | 4 ++-- .../damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt | 4 ++-- .../damontecres/wholphin/ui/detail/movie/MovieDetails.kt | 4 ++-- .../damontecres/wholphin/ui/detail/series/SeriesDetails.kt | 4 ++-- .../damontecres/wholphin/ui/detail/series/SeriesOverview.kt | 4 ++-- .../java/com/github/damontecres/wholphin/ui/main/HomePage.kt | 4 ++-- .../github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt | 4 ++-- 11 files changed, 21 insertions(+), 21 deletions(-) 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 6d20211f..c44f3b3c 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 @@ -531,7 +531,7 @@ fun CollectionFolderGrid( DataLoadingState.Loading, DataLoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } is DataLoadingState.Error, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index 1e274de6..38591a67 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -95,13 +95,13 @@ fun ItemGrid( val items by viewModel.items.observeAsState(listOf()) when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 6c577c9b..174f4665 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -139,13 +139,13 @@ fun RecommendedContent( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 7df86615..0c7d08b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -113,13 +113,13 @@ fun DiscoverMovieDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 64748bd0..3c036534 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -105,13 +105,13 @@ fun DiscoverSeriesDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { 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 bac9f51a..63d5ee5d 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 @@ -102,13 +102,13 @@ fun EpisodeDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { 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 23ed08c3..2a40a399 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 @@ -130,13 +130,13 @@ fun MovieDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { 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 55335057..f0c74be3 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 @@ -120,13 +120,13 @@ fun SeriesDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { 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 a8a9d83f..8fba8ed6 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 @@ -148,13 +148,13 @@ fun SeriesOverview( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { 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 e41acd1b..28937e55 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 @@ -103,13 +103,13 @@ fun HomePage( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index a513715d..1c121ef6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -314,11 +314,11 @@ fun SlideshowPage( ) { when (loadingState) { ImageLoadingState.Error -> { - ErrorMessage("Error loading image", null) + ErrorMessage("Error loading image", null, modifier) } ImageLoadingState.Loading -> { - LoadingPage() + LoadingPage(modifier) } is ImageLoadingState.Success -> { From b7679b3aa1f8a81e0f1c560ce4e2a9b66c1e3f37 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 12 Feb 2026 19:26:19 -0500 Subject: [PATCH 07/80] Release v0.4.3 From fcba1c744405cc3686afa8c2ad73174ec0c210c7 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:54:51 -0500 Subject: [PATCH 08/80] Customize home page (#803) ## Description This PR adds the ability to customize the home page in-app ### Features - Add, remove, & reorder rows - Persist the configuration locally - Save the configuration to the server, allowing to pull down on other devices - Adjust view options for rows such as card height, image type, preferring series images, or aspect ratio (similar to libraries) - Pull down the web client's home rows (no plugins are supported yet!) - Preview of the home page which is usable & updated as changes are made ### Row options These are row types that can be added in-app via the UI: - Continue watching - Next up - Combined continue waiting & next up - Recently added in a library - Recently released in a library - Genres in a library - Suggestions for a library (movie & TV show libraries only) - Favorite movies, tv shows, episodes, etc Additionally, there are more row types that don't have a UI to add them (yet): - Simple query to get items from a parent ID such as a collection or playlist - Complex query to get arbitrary items via the `/Items` API endpoint ### Dev notes Settings are loaded in order: 1. Locally saved 2. Remote saved 3. Fallback to default similar to Wholphin's original home rows The remote saved settings are stored via the display preferences API. I know some server admins would prefer to push a default setup to their clients. This PR does not have that ability, but it does define a straightforward API for defining the settings. Something like the potential server plugin work started in #625 could be slimmed down to expose a URL to be added in the load order. I'm also investigating integration with popular home page plugins to allow for further customization, but will take more time. ### Related issues Closes #399 Closes #361 Closes #282 Related to #340 --- .../wholphin/data/model/BaseItem.kt | 1 + .../wholphin/data/model/HomeRowConfig.kt | 221 ++++ .../wholphin/preferences/AppPreference.kt | 12 +- .../wholphin/services/BackdropService.kt | 8 +- .../wholphin/services/HomeSettingsService.kt | 951 ++++++++++++++++++ .../wholphin/services/LatestNextUpService.kt | 23 +- .../services/SeerrServerRepository.kt | 82 +- .../wholphin/services/SuggestionsWorker.kt | 14 +- .../services/tvprovider/TvProviderWorker.kt | 8 +- .../damontecres/wholphin/ui/UiConstants.kt | 6 +- .../wholphin/ui/cards/BannerCard.kt | 104 +- .../wholphin/ui/cards/GenreCard.kt | 31 +- .../wholphin/ui/cards/SeasonCard.kt | 36 +- .../ui/components/FocusableItemRow.kt | 78 ++ .../wholphin/ui/components/GenreCardGrid.kt | 107 +- .../damontecres/wholphin/ui/main/HomePage.kt | 246 +++-- .../wholphin/ui/main/HomeViewModel.kt | 227 +++-- .../main/settings/HomeLibraryRowTypeList.kt | 81 ++ .../ui/main/settings/HomeRowPresets.kt | 206 ++++ .../ui/main/settings/HomeRowSettings.kt | 307 ++++++ .../ui/main/settings/HomeSettingsAddRow.kt | 102 ++ .../main/settings/HomeSettingsDestination.kt | 38 + .../main/settings/HomeSettingsFavoriteList.kt | 64 ++ .../ui/main/settings/HomeSettingsGlobal.kt | 184 ++++ .../ui/main/settings/HomeSettingsListItem.kt | 59 ++ .../ui/main/settings/HomeSettingsPage.kt | 290 ++++++ .../ui/main/settings/HomeSettingsRowList.kt | 269 +++++ .../ui/main/settings/HomeSettingsViewModel.kt | 670 ++++++++++++ .../wholphin/ui/nav/Destination.kt | 3 + .../wholphin/ui/nav/DestinationContent.kt | 5 + .../damontecres/wholphin/util/Constants.kt | 8 + .../damontecres/wholphin/util/LoadingState.kt | 2 + app/src/main/res/values/fa_strings.xml | 2 + app/src/main/res/values/strings.xml | 23 + .../wholphin/test/TestHomeRowSamples.kt | 150 +++ 35 files changed, 4297 insertions(+), 321 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 2d8c7baa..3030b7d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -32,6 +32,7 @@ import kotlin.time.Duration data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, + val imageUrlOverride: String? = null, ) : CardGridItem { val id get() = data.id diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt new file mode 100644 index 00000000..7d521ce4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -0,0 +1,221 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID + +@Serializable +sealed interface HomeRowConfig { + val viewOptions: HomeRowViewOptions + + fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig + + /** + * Continue watching media that the user has started but not finished + */ + @Serializable + @SerialName("ContinueWatching") + data class ContinueWatching( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions) + } + + /** + * Next up row for next episodes in a series the user has started + */ + @Serializable + @SerialName("NextUp") + data class NextUp( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions) + } + + /** + * Combined [ContinueWatching] and [NextUp] + */ + @Serializable + @SerialName("ContinueWatchingCombined") + data class ContinueWatchingCombined( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions) + } + + /** + * Media recently added to a library + */ + @Serializable + @SerialName("RecentlyAdded") + data class RecentlyAdded( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions) + } + + /** + * Media recently released (premiere date) in a library + */ + @Serializable + @SerialName("RecentlyReleased") + data class RecentlyReleased( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions) + } + + /** + * Row of a genres in a library + */ + @Serializable + @SerialName("Genres") + data class Genres( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions) + } + + /** + * Favorites for a specific type + */ + @Serializable + @SerialName("Favorite") + data class Favorite( + val kind: BaseItemKind, + override val viewOptions: HomeRowViewOptions = + if (kind == BaseItemKind.EPISODE) { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + ) + } else { + HomeRowViewOptions() + }, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions) + } + + /** + * + */ + @Serializable + @SerialName("Recordings") + data class Recordings( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions) + } + + /** + * + */ + @Serializable + @SerialName("TvPrograms") + data class TvPrograms( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions) + } + + /** + * Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID + */ + @Serializable + @SerialName("Suggestions") + data class Suggestions( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions) + } + + /** + * Fetch by parent ID such as a library, collection, or playlist with optional simple sorting + */ + @Serializable + @SerialName("ByParent") + data class ByParent( + val parentId: UUID, + val recursive: Boolean, + val sort: SortAndDirection? = null, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions) + } + + /** + * An arbitrary [GetItemsRequest] allowing to query for anything + */ + @Serializable + @SerialName("GetItems") + data class GetItems( + val name: String, + val getItems: GetItemsRequest, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions) + } +} + +/** + * Root class for home page settings + * + * Contains the list of rows and a version + */ +@Serializable +@SerialName("HomePageSettings") +data class HomePageSettings( + val rows: List, + val version: Int, +) { + companion object { + val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + } +} + +/** + * This is the max version supported by this version of the app + */ +const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1 + +/** + * View options for displaying a row + * + * Allows for changing things like height or aspect ratio + */ +@Serializable +data class HomeRowViewOptions( + val heightDp: Int = Cards.HEIGHT_2X3_DP, + val spacing: Int = 16, + val contentScale: PrefContentScale = PrefContentScale.FIT, + val aspectRatio: AspectRatio = AspectRatio.TALL, + val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, + val showTitles: Boolean = false, + val useSeries: Boolean = true, + val episodeContentScale: PrefContentScale = PrefContentScale.FIT, + val episodeAspectRatio: AspectRatio = AspectRatio.TALL, + val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, +) { + companion object { + val genreDefault = + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 4c17ac07..7eaa2ffe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -656,6 +656,12 @@ sealed interface AppPreference { setter = { prefs, _ -> prefs }, ) + val CustomizeHome = + AppDestinationPreference( + title = R.string.customize_home, + destination = Destination.HomeSettings, + ) + val SendCrashReports = AppSwitchPreference( title = R.string.send_crash_reports, @@ -1000,10 +1006,6 @@ val basicPreferences = preferences = listOf( AppPreference.SignInAuto, - AppPreference.HomePageItems, - AppPreference.CombineContinueNext, - AppPreference.RewatchNextUp, - AppPreference.MaxDaysNextUp, AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, @@ -1034,6 +1036,7 @@ val basicPreferences = preferences = listOf( AppPreference.RequireProfilePin, + AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, ), ), @@ -1099,6 +1102,7 @@ val advancedPreferences = preferences = listOf( AppPreference.ShowClock, + AppPreference.CombineContinueNext, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, AppPreference.ControllerTimeout, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index a64fc9d9..e082c5fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject @@ -47,7 +48,12 @@ class BackdropService suspend fun submit(item: BaseItem) = withContext(Dispatchers.IO) { - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + val imageUrl = + if (item.type == BaseItemKind.GENRE) { + item.imageUrlOverride + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + } submit(item.id.toString(), imageUrl) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt new file mode 100644 index 00000000..a868692a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -0,0 +1,951 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.NavDrawerItemRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.HomePagePreferences +import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.GetGenresRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success +import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.request.GetGenresRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest +import org.jellyfin.sdk.model.api.request.GetRecordingsRequest +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HomeSettingsService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val userPreferencesService: UserPreferencesService, + private val navDrawerItemRepository: NavDrawerItemRepository, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + private val suggestionService: SuggestionService, + ) { + @OptIn(ExperimentalSerializationApi::class) + val jsonParser = + Json { + isLenient = true + ignoreUnknownKeys = true + allowTrailingComma = true + } + + val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY) + + /** + * Saves a [HomePageSettings] to the server for the user under the display preference ID + * + * @see loadFromServer + */ + suspend fun saveToServer( + userId: UUID, + settings: HomePageSettings, + displayPreferencesId: String = DISPLAY_PREF_ID, + ) { + val current = getDisplayPreferences(userId, DISPLAY_PREF_ID) + val customPrefs = + current.customPrefs.toMutableMap().apply { + put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) + } + api.displayPreferencesApi.updateDisplayPreferences( + displayPreferencesId = displayPreferencesId, + userId = userId, + client = context.getString(R.string.app_name), + data = current.copy(customPrefs = customPrefs), + ) + } + + /** + * Reads a [HomePageSettings] from the server for the user and display preference ID + * + * Returns null if there is none saved + * + * @see saveToServer + */ + suspend fun loadFromServer( + userId: UUID, + displayPreferencesId: String = DISPLAY_PREF_ID, + ): HomePageSettings? { + val current = getDisplayPreferences(userId, displayPreferencesId) + return current.customPrefs[CUSTOM_PREF_ID]?.let { + val jsonElement = jsonParser.parseToJsonElement(it) + decode(jsonElement) + } + } + + private suspend fun getDisplayPreferences( + userId: UUID, + displayPreferencesId: String, + ) = api.displayPreferencesApi + .getDisplayPreferences( + userId = userId, + displayPreferencesId = displayPreferencesId, + client = context.getString(R.string.app_name), + ).content + + /** + * Computes the filename for locally saved [HomePageSettings] + */ + private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json" + + /** + * Save the [HomePageSettings] for the user locally on the device + * + * @see loadFromLocal + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun saveToLocal( + userId: UUID, + settings: HomePageSettings, + ) { + val dir = File(context.filesDir, CUSTOM_PREF_ID) + dir.mkdirs() + File(dir, filename(userId)).outputStream().use { + jsonParser.encodeToStream(settings, it) + } + } + + /** + * Reads [HomePageSettings] for the user if it exists + * + * @see saveToLocal + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun loadFromLocal(userId: UUID): HomePageSettings? { + val dir = File(context.filesDir, CUSTOM_PREF_ID) + val file = File(dir, filename(userId)) + return if (file.exists()) { + val fileContents = file.readText() + val jsonElement = jsonParser.parseToJsonElement(fileContents) + decode(jsonElement) + } else { + null + } + } + + /** + * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows + * + * This is public only for testing + */ + fun decode(element: JsonElement): HomePageSettings { + val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull + if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) { + throw UnsupportedHomeSettingsVersionException(version) + } + val rowsElement = element.jsonObject["rows"]?.jsonArray + val rows = + rowsElement + ?.mapNotNull { row -> + try { + jsonParser.decodeFromJsonElement(row) + } catch (ex: Exception) { + Timber.w(ex, "Unknown row %s", row) + // TODO maybe use placeholder instead of null? + null + } + }.orEmpty() + return HomePageSettings(rows, version) + } + + /** + * Loads [HomePageSettings] into [currentSettings] + * + * First checks locally, then on the server, and finally creates a default if needed + * + * Does not persist either the server nor default + */ + suspend fun loadCurrentSettings(userId: UUID) { + Timber.v("Getting setting for %s", userId) + // User local then server/remote otherwise create a default + val settings = + try { + val local = loadFromLocal(userId) + Timber.v("Found local? %s", local != null) + local + } catch (ex: Exception) { + Timber.w(ex, "Error loading local settings") + // TODO show toast? + null + } ?: try { + val remote = loadFromServer(userId) + Timber.v("Found remote? %s", remote != null) + remote + } catch (ex: Exception) { + Timber.w(ex, "Error loading remote settings") + null + } + val resolvedSettings = + if (settings != null) { + Timber.v("Found settings") + // Resolve + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + HomePageResolvedSettings(resolvedRows) + } else { + createDefault() + } + + currentSettings.update { resolvedSettings } + } + + suspend fun updateCurrent(settings: HomePageSettings) { + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + val resolvedSettings = HomePageResolvedSettings(resolvedRows) + currentSettings.update { resolvedSettings } + } + + /** + * Create a default [HomePageResolvedSettings] using the available libraries + */ + suspend fun createDefault(): HomePageResolvedSettings { + Timber.v("Creating default settings") + val navDrawerItems = navDrawerItemRepository.getNavDrawerItems() + val libraries = + navDrawerItems + .filter { it is ServerNavDrawerItem } + .map { + it as ServerNavDrawerItem + Library(it.itemId, it.name, it.type) + } + val prefs = + userPreferencesService.getCurrent().appPreferences.homePagePreferences + val includedIds = + navDrawerItemRepository + .getFilteredNavDrawerItems(navDrawerItems) + .filter { it is ServerNavDrawerItem } + .mapIndexed { index, it -> + val parentId = (it as ServerNavDrawerItem).itemId + val name = libraries.firstOrNull { it.itemId == parentId }?.name + val title = + name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + HomeRowConfigDisplay( + id = index, + title = title, + config = HomeRowConfig.RecentlyAdded(parentId), + ) + } + val continueWatchingRows = + if (prefs.combineContinueNext) { // TODO + listOf( + HomeRowConfigDisplay( + id = includedIds.size + 1, + title = context.getString(R.string.combine_continue_next), + config = HomeRowConfig.ContinueWatchingCombined(), + ), + ) + } else { + listOf( + HomeRowConfigDisplay( + id = includedIds.size + 1, + title = context.getString(R.string.continue_watching), + config = HomeRowConfig.ContinueWatching(), + ), + HomeRowConfigDisplay( + id = includedIds.size + 2, + title = context.getString(R.string.next_up), + config = HomeRowConfig.NextUp(), + ), + ) + } + val rowConfig = continueWatchingRows + includedIds + return HomePageResolvedSettings(rowConfig) + } + + suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { + val customPrefs = + api.displayPreferencesApi + .getDisplayPreferences( + displayPreferencesId = "usersettings", + userId = userId, + client = "emby", + ).content.customPrefs + val userDto by api.userApi.getUserById(userId) + val config = userDto.configuration ?: DefaultUserConfiguration + val libraries = + api.userViewsApi + .getUserViews(userId = userId) + .content.items + .filter { + it.collectionType in supportedHomeCollectionTypes && + it.id !in config.latestItemsExcludes + } + + return if (customPrefs.isNotEmpty()) { + var id = 0 + val rowConfigs = + (0..9) + .mapNotNull { idx -> + val sectionType = + HomeSectionType.fromString(customPrefs["homesection$idx"]?.lowercase()) + Timber.v( + "sectionType=$sectionType, %s", + customPrefs["homesection$idx"]?.lowercase(), + ) + val config = + when (sectionType) { + HomeSectionType.ACTIVE_RECORDINGS -> { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.active_recordings), + config = HomeRowConfig.Recordings(), + ) + } + + HomeSectionType.RESUME -> { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.continue_watching), + config = HomeRowConfig.ContinueWatching(), + ) + } + + HomeSectionType.NEXT_UP -> { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.next_up), + config = HomeRowConfig.NextUp(), + ) + } + + HomeSectionType.LIVE_TV -> { + if (userDto.policy?.enableLiveTvAccess == true) { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.live_tv), + config = HomeRowConfig.TvPrograms(), + ) + } else { + null + } + } + + HomeSectionType.LATEST_MEDIA -> { + // Handled below + null + } + + // Unsupported + HomeSectionType.RESUME_AUDIO, + HomeSectionType.RESUME_BOOK, + -> { + null + } + + HomeSectionType.SMALL_LIBRARY_TILES, + HomeSectionType.LIBRARY_BUTTONS, + HomeSectionType.NONE, + null, + -> { + null + } + } + if (sectionType == HomeSectionType.LATEST_MEDIA) { + libraries.map { + HomeRowConfigDisplay( + id = id++, + title = + context.getString( + R.string.recently_added_in, + it.name ?: "", + ), + config = HomeRowConfig.RecentlyAdded(it.id), + ) + } + } else if (config != null) { + listOf(config) + } else { + null + } + }.flatten() + HomePageResolvedSettings(rowConfigs) + } else { + null + } + } + + /** + * Converts a [HomeRowConfig] into [HomeRowConfigDisplay] for UI purposes + */ + suspend fun resolve( + id: Int, + config: HomeRowConfig, + ): HomeRowConfigDisplay = + when (config) { + is HomeRowConfig.ByParent -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + name, + config, + ) + } + + is HomeRowConfig.ContinueWatching -> { + HomeRowConfigDisplay( + id, + context.getString(R.string.continue_watching), + config, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + HomeRowConfigDisplay( + id, + context.getString(R.string.combine_continue_next), + config, + ) + } + + is HomeRowConfig.Genres -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.genres_in, name), + config, + ) + } + + is HomeRowConfig.GetItems -> { + HomeRowConfigDisplay(id, config.name, config) + } + + is HomeRowConfig.NextUp -> { + HomeRowConfigDisplay( + id, + context.getString(R.string.next_up), + config, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.recently_added_in, name), + config, + ) + } + + is HomeRowConfig.RecentlyReleased -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.recently_released_in, name), + config, + ) + } + + is HomeRowConfig.Favorite -> { + val name = context.getString(R.string.favorites) // TODO "Favorite " + HomeRowConfigDisplay(id, name, config) + } + + is HomeRowConfig.Recordings -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.active_recordings), + config, + ) + } + + is HomeRowConfig.TvPrograms -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.live_tv), + config, + ) + } + + is HomeRowConfig.Suggestions -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.suggestions_for, name), + config, + ) + } + } + + /** + * Fetch the data from the server for a given [HomeRowConfig] + */ + suspend fun fetchDataForRow( + row: HomeRowConfig, + scope: CoroutineScope, + prefs: HomePagePreferences, + userDto: UserDto, + libraries: List, + limit: Int = prefs.maxItemsPerRow, + ): HomeRowLoadingState = + when (row) { + is HomeRowConfig.ContinueWatching -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + + Success( + title = context.getString(R.string.continue_watching), + items = resume, + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.NextUp -> { + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = context.getString(R.string.next_up), + items = nextUp, + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = context.getString(R.string.continue_watching), + items = + latestNextUpService.buildCombined( + resume, + nextUp, + ), + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.Genres -> { + val request = + GetGenresRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + ) + val items = + GetGenresRequestHandler + .execute(api, request) + .content.items + val genreIds = items.map { it.id } + val genreImages = + getGenreImageMap( + api = api, + scope = scope, + imageUrlService = imageUrlService, + genres = genreIds, + parentId = row.parentId, + includeItemTypes = null, + cardWidthPx = null, + ) + val genres = + items.map { + BaseItem(it, false, genreImages[it.id]) + } + + val name = + libraries + .firstOrNull { it.itemId == row.parentId } + ?.name + val title = + name?.let { context.getString(R.string.genres_in, it) } + ?: context.getString(R.string.genres) + + Success( + title, + genres, + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val name = + libraries + .firstOrNull { it.itemId == row.parentId } + ?.name + val title = + name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = row.parentId, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + ) + } + latest + } + + is HomeRowConfig.RecentlyReleased -> { + val name = + libraries + .firstOrNull { it.itemId == row.parentId } + ?.name + val title = + name?.let { + context.getString(R.string.recently_released_in, it) + } ?: context.getString(R.string.recently_released) + val request = + GetItemsRequest( + parentId = row.parentId, + limit = limit, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + fields = DefaultItemFields, + recursive = true, + ) + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.ByParent -> { + val request = + GetItemsRequest( + userId = userDto.id, + parentId = row.parentId, + recursive = row.recursive, + sortBy = row.sort?.let { listOf(it.sort) }, + sortOrder = row.sort?.let { listOf(it.direction) }, + limit = limit, + fields = DefaultItemFields, + ) + val name = + api.userLibraryApi + .getItem(itemId = row.parentId) + .content.name + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + name ?: context.getString(R.string.collection), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.GetItems -> { + val request = + row.getItems.let { + if (it.limit == null) { + it.copy( + userId = userDto.id, + limit = limit, + ) + } else { + it.copy( + userId = userDto.id, + ) + } + } + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + row.name, + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.Favorite -> { + if (row.kind == BaseItemKind.PERSON) { + val request = + GetPersonsRequest( + userId = userDto.id, + limit = limit, + fields = DefaultItemFields, + isFavorite = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + ) + GetPersonsHandler + .execute(api, request) + .content.items + .map { BaseItem(it, true) } + .let { + Success( + context.getString(R.string.favorites), // TODO + it, + row.viewOptions, + ) + } + } else { + val request = + GetItemsRequest( + userId = userDto.id, + recursive = true, + limit = limit, + fields = DefaultItemFields, + includeItemTypes = listOf(row.kind), + isFavorite = true, + ) + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + context.getString(R.string.favorites), // TODO + it, + row.viewOptions, + ) + } + } + } + + is HomeRowConfig.Recordings -> { + val request = + GetRecordingsRequest( + userId = userDto.id, + isInProgress = true, + fields = DefaultItemFields, + limit = limit, + enableImages = true, + enableUserData = true, + ) + api.liveTvApi + .getRecordings(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + context.getString(R.string.active_recordings), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.TvPrograms -> { + val request = + GetRecommendedProgramsRequest( + userId = userDto.id, + fields = DefaultItemFields, + limit = limit, + enableImages = true, + enableUserData = true, + ) + api.liveTvApi + .getRecommendedPrograms(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + context.getString(R.string.live_tv), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.Suggestions -> { + val library = + api.userLibraryApi + .getItem(itemId = row.parentId) + .content + val title = context.getString(R.string.suggestions_for, library.name ?: "") + val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) + val suggestions = + itemKind?.let { + suggestionService + .getSuggestionsFlow(row.parentId, itemKind) + .firstOrNull() + } + if (suggestions != null && suggestions is SuggestionsResource.Success) { + Success( + title, + suggestions.items, + row.viewOptions, + ) + } else if (suggestions is SuggestionsResource.Empty) { + Success( + title, + listOf(), + row.viewOptions, + ) + } else { + HomeRowLoadingState.Error( + title, + message = "Unsupported type ${library.collectionType}", + ) + } + } + } + + companion object { + const val DISPLAY_PREF_ID = "default" + const val CUSTOM_PREF_ID = "home_settings" + } + } + +/** + * A [HomeRowConfig] with a resolved ID and title so it is usable in the UI + */ +data class HomeRowConfigDisplay( + val id: Int, + val title: String, + val config: HomeRowConfig, +) + +/** + * List of resolved [HomeRowConfig]s as [HomeRowConfigDisplay]s + * + * @see HomePageSettings + */ +data class HomePageResolvedSettings( + val rows: List, +) { + companion object { + val EMPTY = HomePageResolvedSettings(listOf()) + } +} + +// https://github.com/jellyfin/jellyfin/blob/v10.11.6/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs +enum class HomeSectionType( + val serialName: String, +) { + NONE("none"), + SMALL_LIBRARY_TILES("smalllibrarytitles"), + LIBRARY_BUTTONS("librarybuttons"), + ACTIVE_RECORDINGS("activerecordings"), + RESUME("resume"), + RESUME_AUDIO("resumeaudio"), + LATEST_MEDIA("latestmedia"), + NEXT_UP("nextup"), + LIVE_TV("livetv"), + RESUME_BOOK("resumebook"), + ; + + companion object { + fun fromString(homeKey: String?) = homeKey?.let { entries.firstOrNull { it.serialName == homeKey } } + } +} + +class UnsupportedHomeSettingsVersionException( + val unsupportedVersion: Int?, + val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, +) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") 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 index 6ac016a3..9342f63a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -4,8 +4,6 @@ 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 @@ -21,6 +19,7 @@ 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 @@ -44,6 +43,7 @@ class LatestNextUpService userId: UUID, limit: Int, includeEpisodes: Boolean, + useSeriesForPrimary: Boolean = true, ): List { val request = GetResumeItemsRequest( @@ -66,7 +66,7 @@ class LatestNextUpService .getResumeItems(request) .content .items - .map { BaseItem.from(it, api, true) } + .map { BaseItem.from(it, api, useSeriesForPrimary) } return items } @@ -76,6 +76,7 @@ class LatestNextUpService enableRewatching: Boolean, enableResumable: Boolean, maxDays: Int, + useSeriesForPrimary: Boolean = true, ): List { val nextUpDateCutoff = maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } @@ -96,7 +97,7 @@ class LatestNextUpService .getNextUp(request) .content .items - .map { BaseItem.from(it, api, true) } + .map { BaseItem.from(it, api, useSeriesForPrimary) } return nextUp } @@ -192,3 +193,17 @@ class LatestNextUpService return@withContext result } } + +val supportedLatestCollectionTypes = + setOf( + CollectionType.MOVIES, + CollectionType.TVSHOWS, + CollectionType.HOMEVIDEOS, + // Exclude Live TV because a recording folder view will be used instead + null, // Recordings & mixed collection types + ) + +data class LatestData( + val title: String, + val request: GetLatestMediaRequest, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index f06a7529..c6da831f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -226,6 +226,7 @@ class UserSwitchListener private val seerrServerRepository: SeerrServerRepository, private val seerrServerDao: SeerrServerDao, private val seerrApi: SeerrApi, + private val homeSettingsService: HomeSettingsService, ) { init { context as AppCompatActivity @@ -233,41 +234,58 @@ class UserSwitchListener serverRepository.currentUser.asFlow().collect { user -> Timber.d("New user") seerrServerRepository.clear() + homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } if (user != null) { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .firstOrNull() - ?.let { seerrUser -> - val server = seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - try { - login( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } catch (ex: Exception) { - Timber.w(ex, "Error logging into %s", server.url) - seerrServerRepository.clear() - return@let + // Check for home settings + launchIO { + homeSettingsService.loadCurrentSettings(user.id) + } + // Check for seerr server + launchIO { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .firstOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + try { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.clear() + return@let + } + } else { + try { + seerrApi.api.usersApi.authMeGet() + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.clear() + return@let + } } - } else { - try { - seerrApi.api.usersApi.authMeGet() - } catch (ex: Exception) { - Timber.w(ex, "Error logging into %s", server.url) - seerrServerRepository.clear() - return@let - } - } - seerrServerRepository.set(server, seerrUser, userConfig) + seerrServerRepository.set(server, seerrUser, userConfig) + } } - } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index 1ee5870b..eab87f20 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -85,11 +85,8 @@ class SuggestionsWorker views .mapNotNull { view -> val itemKind = - when (view.collectionType) { - CollectionType.MOVIES -> BaseItemKind.MOVIE - CollectionType.TVSHOWS -> BaseItemKind.SERIES - else -> return@mapNotNull null - } + getTypeForCollection(view.collectionType) + ?: return@mapNotNull null async(Dispatchers.IO) { runCatching { Timber.v("Fetching suggestions for view %s", view.id) @@ -267,5 +264,12 @@ class SuggestionsWorker const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" const val PARAM_USER_ID = "userId" const val PARAM_SERVER_ID = "serverId" + + fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? = + when (collectionType) { + CollectionType.MOVIES -> BaseItemKind.MOVIE + CollectionType.TVSHOWS -> BaseItemKind.SERIES + else -> null + } } } 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 index d8816e71..03ea0478 100644 --- 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 @@ -80,7 +80,6 @@ class TvProviderWorker getPotentialItems( userId, prefs.homePagePreferences.enableRewatchingNextUp, - prefs.homePagePreferences.combineContinueNext, prefs.homePagePreferences.maxDaysNextUp, ) val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } @@ -145,7 +144,6 @@ class TvProviderWorker private suspend fun getPotentialItems( userId: UUID, enableRewatching: Boolean, - combineContinueNext: Boolean, maxDaysNextUp: Int, ): List { val resumeItems = latestNextUpService.getResume(userId, 10, true) @@ -154,11 +152,7 @@ class TvProviderWorker latestNextUpService .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp) .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } - return if (combineContinueNext) { - latestNextUpService.buildCombined(resumeItems, nextUpItems) - } else { - resumeItems + nextUpItems - } + return latestNextUpService.buildCombined(resumeItems, nextUpItems) } private suspend fun getCurrentTvChannelNextUp(): List = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 958f9dfd..0db41202 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -82,8 +82,10 @@ val PhotoItemFields = ) object Cards { - val height2x3 = 172.dp - val heightEpisode = height2x3 * .75f + const val HEIGHT_2X3_DP = 172 + val height2x3 = HEIGHT_2X3_DP.dp + val HEIGHT_EPISODE = (HEIGHT_2X3_DP * .75f).toInt().let { it - it % 4 } + val heightEpisode = HEIGHT_EPISODE.dp val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 70de419b..5a0985c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -1,15 +1,19 @@ package com.github.damontecres.wholphin.ui.cards +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.enableMarquee import org.jellyfin.sdk.model.api.ImageType /** @@ -60,6 +66,8 @@ fun BannerCard( cardHeight: Dp = 120.dp, aspectRatio: Float = AspectRatios.WIDE, interactionSource: MutableInteractionSource? = null, + imageType: ImageType = ImageType.PRIMARY, + imageContentScale: ContentScale = ContentScale.FillBounds, ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current @@ -74,14 +82,15 @@ fun BannerCard( } } val imageUrl = - remember(item, fillHeight) { + remember(item, fillHeight, imageType) { if (item != null) { - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - fillWidth = null, - fillHeight = fillHeight, - ) + item.imageUrlOverride + ?: imageUrlService.getItemImageUrl( + item, + imageType, + fillWidth = null, + fillHeight = fillHeight, + ) } else { null } @@ -107,7 +116,7 @@ fun BannerCard( AsyncImage( model = imageUrl, contentDescription = null, - contentScale = ContentScale.FillBounds, + contentScale = imageContentScale, onError = { imageError = true }, modifier = Modifier.fillMaxSize(), ) @@ -181,3 +190,82 @@ fun BannerCard( } } } + +@Composable +fun BannerCardWithTitle( + title: String?, + subtitle: String?, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + cornerText: String? = null, + played: Boolean = false, + favorite: Boolean = false, + playPercent: Double = 0.0, + cardHeight: Dp = 120.dp, + aspectRatio: Float = AspectRatios.WIDE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + imageType: ImageType = ImageType.PRIMARY, + imageContentScale: ContentScale = ContentScale.FillBounds, +) { + val focused by interactionSource.collectIsFocusedAsState() + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) + val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) + val width = cardHeight * aspectRationToUse + Column( + verticalArrangement = Arrangement.spacedBy(spaceBetween), + modifier = modifier.width(width), + ) { + BannerCard( + name = null, + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier, + cornerText = cornerText, + played = played, + favorite = favorite, + playPercent = playPercent, + cardHeight = cardHeight, + aspectRatio = aspectRatio, + interactionSource = interactionSource, + imageType = imageType, + imageContentScale = imageContentScale, + ) + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { + Text( + text = title ?: "", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = subtitle ?: "", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } +} 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 73cf5ba3..a32fece9 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 @@ -42,11 +42,29 @@ fun GenreCard( onLongClick: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = GenreCard( + genreId = genre?.id, + name = genre?.name, + imageUrl = genre?.imageUrl, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = interactionSource, +) + +@Composable +fun GenreCard( + genreId: UUID?, + name: String?, + imageUrl: String?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - val background = rememberIdColor(genre?.id).copy(alpha = .6f) + val background = rememberIdColor(genreId).copy(alpha = .6f) Card( - modifier = - modifier, + modifier = modifier, onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, @@ -63,12 +81,12 @@ fun GenreCard( .fillMaxSize() .clip(RoundedCornerShape(8.dp)), ) { - if (genre?.imageUrl.isNotNullOrBlank()) { + if (imageUrl != null) { AsyncImage( model = ImageRequest .Builder(LocalContext.current) - .data(genre.imageUrl) + .data(imageUrl) .crossfade(true) .build(), contentScale = ContentScale.FillBounds, @@ -88,7 +106,7 @@ fun GenreCard( .background(background), ) { Text( - text = genre?.name ?: "", + text = name ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, @@ -112,7 +130,6 @@ private fun GenreCardPreview() { UUID.randomUUID(), "Adventure", null, - Color.Black, ) GenreCard( genre = genre, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index e3e6059e..c2cbd435 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity @@ -126,21 +125,7 @@ fun SeasonCard( val focused by interactionSource.collectIsFocusedAsState() val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) - var focusedAfterDelay by remember { mutableStateOf(false) } - - val hideOverlayDelay = 500L - if (focused) { - LaunchedEffect(Unit) { - delay(hideOverlayDelay) - if (focused) { - focusedAfterDelay = true - } else { - focusedAfterDelay = false - } - } - } else { - focusedAfterDelay = false - } + val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val width = imageHeight * aspectRationToUse val height = imageWidth * (1f / aspectRationToUse) @@ -212,3 +197,22 @@ fun SeasonCard( } } } + +/** + * Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while + */ +@Composable +fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State { + val focused by interactionSource.collectIsFocusedAsState() + val state = remember { mutableStateOf(false) } + + LaunchedEffect(focused) { + if (!focused) { + state.value = false + return@LaunchedEffect + } + delay(500L) + state.value = true + } + return state +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt new file mode 100644 index 00000000..b04bf451 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt @@ -0,0 +1,78 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text + +/** + * Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled. + */ +@Composable +@NonRestartableComposable +fun FocusableItemRow( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + isError: Boolean = false, +) = FocusableItemRow( + titleContent = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + }, + subtitleContent = { + Text( + text = subtitle, + style = MaterialTheme.typography.titleMedium, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), + ) + }, + modifier = modifier, +) + +@Composable +fun FocusableItemRow( + titleContent: @Composable () -> Unit, + subtitleContent: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + val background by animateColorAsState( + if (focused) { + MaterialTheme.colorScheme.border.copy(alpha = .25f) + } else { + Color.Unspecified + }, + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .padding(start = 8.dp) + .focusable(interactionSource = interactionSource) + .background(background, shape = RoundedCornerShape(8.dp)) + .padding(8.dp), + ) { + titleContent.invoke() + subtitleContent.invoke() + } +} 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 cdf18969..84f36f02 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 @@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState 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 @@ -41,6 +41,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -102,51 +103,22 @@ class GenreViewModel .execute(api, request) .content.items .map { - Genre(it.id, it.name ?: "", null, Color.Black) + Genre(it.id, it.name ?: "", null) } withContext(Dispatchers.Main) { this@GenreViewModel.genres.value = genres loading.value = LoadingState.Success } - val genreToUrl = ConcurrentHashMap() - val semaphore = Semaphore(4) - genres - .map { genre -> - viewModelScope.async(Dispatchers.IO) { - semaphore.withPermit { - val item = - GetItemsRequestHandler - .execute( - api, - GetItemsRequest( - parentId = itemId, - recursive = true, - limit = 1, - sortBy = listOf(ItemSortBy.RANDOM), - fields = listOf(ItemFields.GENRES), - imageTypes = listOf(ImageType.BACKDROP), - imageTypeLimit = 1, - includeItemTypes = includeItemTypes, - genreIds = listOf(genre.id), - enableTotalRecordCount = false, - ), - ).content.items - .firstOrNull() - if (item != null) { - genreToUrl[genre.id] = - imageUrlService.getItemImageUrl( - itemId = item.id, - itemType = item.type, - seriesId = null, - useSeriesForPrimary = true, - imageType = ImageType.BACKDROP, - imageTags = item.imageTags.orEmpty(), - fillWidth = cardWidthPx, - ) - } - } - } - }.awaitAll() + val genreToUrl = + getGenreImageMap( + api = api, + scope = viewModelScope, + imageUrlService = imageUrlService, + genres = genres.map { it.id }, + parentId = itemId, + includeItemTypes = includeItemTypes, + cardWidthPx = cardWidthPx, + ) val genresWithImages = genres.map { it.copy( @@ -171,11 +143,62 @@ class GenreViewModel } } +suspend fun getGenreImageMap( + api: ApiClient, + scope: CoroutineScope, + imageUrlService: ImageUrlService, + genres: List, + parentId: UUID, + includeItemTypes: List?, + cardWidthPx: Int?, +): Map { + val genreToUrl = ConcurrentHashMap() + val semaphore = Semaphore(4) + genres + .map { genreId -> + scope.async(Dispatchers.IO) { + semaphore.withPermit { + val item = + GetItemsRequestHandler + .execute( + api, + GetItemsRequest( + parentId = parentId, + recursive = true, + limit = 1, + sortBy = listOf(ItemSortBy.RANDOM), + fields = listOf(ItemFields.GENRES), + imageTypes = listOf(ImageType.BACKDROP), + imageTypeLimit = 1, + includeItemTypes = includeItemTypes, + genreIds = listOf(genreId), + enableTotalRecordCount = false, + ), + ).content.items + .firstOrNull() + if (item != null) { + genreToUrl[genreId] = + imageUrlService.getItemImageUrl( + itemId = item.id, + itemType = item.type, + seriesId = null, + useSeriesForPrimary = true, + imageType = ImageType.BACKDROP, + imageTags = item.imageTags.orEmpty(), + fillWidth = cardWidthPx, + ) + } + } + } + }.awaitAll() + return genreToUrl +} + +@Stable data class Genre( val id: UUID, val name: String, val imageUrl: String?, - val color: Color, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false 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 28937e55..50acc56f 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 @@ -1,9 +1,7 @@ package com.github.damontecres.wholphin.ui.main import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,11 +16,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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 @@ -34,7 +34,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -48,16 +47,19 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions 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.cards.BannerCard +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeName import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.FocusableItemRow import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -71,6 +73,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.playable +import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec @@ -94,12 +97,10 @@ fun HomePage( LaunchedEffect(Unit) { viewModel.init() } - val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) - val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) - val watchingRows by viewModel.watchingRows.observeAsState(listOf()) - val latestRows by viewModel.latestRows.observeAsState(listOf()) - - val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows } + val state by viewModel.state.collectAsState() + val loading = state.loadingState + val refreshing = state.refreshState + val homeRows = state.homeRows when (val state = loading) { is LoadingState.Error -> { @@ -200,6 +201,9 @@ fun HomePageContent( modifier: Modifier = Modifier, onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, + listState: LazyListState = rememberLazyListState(), + takeFocus: Boolean = true, + showEmptyRows: Boolean = false, ) { var position by rememberPosition() val focusedItem = @@ -207,37 +211,37 @@ fun HomePageContent( (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) } - val listState = rememberLazyListState() val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } var firstFocused by remember { mutableStateOf(false) } - LaunchedEffect(homeRows) { - if (!firstFocused && homeRows.isNotEmpty()) { - if (position.row >= 0) { - val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) - rowFocusRequesters.getOrNull(index)?.tryRequestFocus() - firstFocused = true - } else { - // Waiting for the first home row to load, then focus on it - homeRows - .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - ?.let { - rowFocusRequesters[it].tryRequestFocus() - firstFocused = true - delay(50) - listState.scrollToItem(it) - } + if (takeFocus) { + LaunchedEffect(homeRows) { + if (!firstFocused && homeRows.isNotEmpty()) { + if (position.row >= 0) { + val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) + rowFocusRequesters.getOrNull(index)?.tryRequestFocus() + firstFocused = true + } else { + // Waiting for the first home row to load, then focus on it + homeRows + .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + ?.let { + rowFocusRequesters[it].tryRequestFocus() + firstFocused = true + delay(50) + listState.scrollToItem(it) + } + } } } } + LaunchedEffect(position) { + if (position.row >= 0) { + listState.animateScrollToItem(position.row) + } + } LaunchedEffect(onUpdateBackdrop, focusedItem) { focusedItem?.let { onUpdateBackdrop.invoke(it) } } - val density = LocalDensity.current - val spaceAbovePx = - with(density) { - // The size of the row titles & spacing - 50.dp.toPx() - } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { HomePageHeader( @@ -247,6 +251,12 @@ fun HomePageContent( .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .fillMaxHeight(.33f), ) + val density = LocalDensity.current + val spaceAbovePx = + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current CompositionLocalProvider( LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), @@ -263,61 +273,32 @@ fun HomePageContent( .focusRestorer(), ) { itemsIndexed(homeRows) { rowIndex, row -> - CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + CompositionLocalProvider( + LocalBringIntoViewSpec provides defaultBringIntoViewSpec, + ) { when (val r = row) { is HomeRowLoadingState.Loading, is HomeRowLoadingState.Pending, -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + FocusableItemRow( + title = r.title, + subtitle = stringResource(R.string.loading), modifier = Modifier.animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringResource(R.string.loading), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } + ) } is HomeRowLoadingState.Error -> { - var focused by remember { mutableStateOf(false) } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .onFocusChanged { - focused = it.isFocused - }.focusable() - .background( - if (focused) { - // Just so the user can tell it has focus - MaterialTheme.colorScheme.border.copy(alpha = .25f) - } else { - Color.Unspecified - }, - ).animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = r.localizedMessage, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - } + FocusableItemRow( + title = r.title, + subtitle = r.localizedMessage, + isError = true, + modifier = Modifier.animateItem(), + ) } is HomeRowLoadingState.Success -> { if (row.items.isNotEmpty()) { + val viewOptions = row.viewOptions ItemRow( title = row.title, items = row.items, @@ -336,26 +317,20 @@ fun HomePageContent( .focusGroup() .focusRequester(rowFocusRequesters[rowIndex]) .animateItem(), + horizontalPadding = viewOptions.spacing.dp, cardContent = { index, item, cardModifier, onClick, onLongClick -> - BannerCard( - name = item?.data?.seriesName ?: item?.name, + HomePageCardContent( + index = index, item = item, - aspectRatio = AspectRatios.TALL, - cornerText = item?.ui?.episodeUnplayedCornerText, - played = item?.data?.userData?.played ?: false, - favorite = item?.favorite ?: false, - playPercent = - item?.data?.userData?.playedPercentage - ?: 0.0, onClick = onClick, onLongClick = onLongClick, + viewOptions = viewOptions, modifier = cardModifier .onFocusChanged { if (it.isFocused) { position = RowColumn(rowIndex, index) -// item?.let(onUpdateBackdrop) } if (it.isFocused && onFocusPosition != null) { val nonEmptyRowBefore = @@ -382,11 +357,15 @@ fun HomePageContent( } return@onKeyEvent false }, - interactionSource = null, - cardHeight = Cards.height2x3, ) }, ) + } else if (showEmptyRows) { + FocusableItemRow( + title = r.title, + subtitle = stringResource(R.string.no_results), + modifier = Modifier.animateItem(), + ) } } } @@ -427,7 +406,7 @@ fun HomePageHeader( subtitle = if (isEpisode) dto?.name else null, overview = dto?.overview, overviewTwoLines = isEpisode, - quickDetails = item?.ui?.quickDetails, + quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""), timeRemaining = item?.timeRemainingOrRuntime, modifier = modifier, ) @@ -488,3 +467,92 @@ fun HomePageHeader( } } } + +@Composable +fun HomePageCardContent( + index: Int, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + viewOptions: HomeRowViewOptions, + modifier: Modifier, +) { + when (item?.type) { + BaseItemKind.GENRE -> { + GenreCard( + genreId = item.id, + name = item.name, + imageUrl = item.imageUrlOverride, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.height(viewOptions.heightDp.dp), + ) + } + + else -> { + val imageType = + remember(item, viewOptions) { + if (item?.type == BaseItemKind.EPISODE) { + viewOptions.episodeImageType.imageType + } else { + viewOptions.imageType.imageType + } + } + val ratio = + remember(item, viewOptions) { + if (item?.type == BaseItemKind.EPISODE) { + viewOptions.episodeAspectRatio.ratio + } else { + viewOptions.aspectRatio.ratio + } + } + val scale = + remember(item, viewOptions) { + if (item?.type == BaseItemKind.EPISODE) { + viewOptions.episodeContentScale.scale + } else { + viewOptions.contentScale.scale + } + } + if (viewOptions.showTitles) { + BannerCardWithTitle( + title = item?.title, + subtitle = item?.subtitle, + item = item, + aspectRatio = ratio, + imageType = imageType, + imageContentScale = scale, + cornerText = item?.ui?.episodeUnplayedCornerText, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + cardHeight = viewOptions.heightDp.dp, + ) + } else { + BannerCard( + name = item?.data?.seriesName ?: item?.name, + item = item, + aspectRatio = ratio, + imageType = imageType, + imageContentScale = scale, + cornerText = item?.ui?.episodeUnplayedCornerText, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = null, + cardHeight = viewOptions.heightDp.dp, + ) + } + } + } +} 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 0e571544..7ee22902 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 @@ -1,37 +1,40 @@ package com.github.damontecres.wholphin.ui.main import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.data.model.HomeRowConfig 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.HomePageResolvedSettings +import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast 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 dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update 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.model.api.CollectionType -import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -41,115 +44,120 @@ class HomeViewModel @Inject constructor( @param:ApplicationContext private val context: Context, - val api: ApiClient, val navigationManager: NavigationManager, val serverRepository: ServerRepository, val navDrawerItemRepository: NavDrawerItemRepository, val mediaReportService: MediaReportService, + private val homeSettingsService: HomeSettingsService, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, - private val latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, private val userPreferencesService: UserPreferencesService, ) : ViewModel() { - val loadingState = MutableLiveData(LoadingState.Pending) - val refreshState = MutableLiveData(LoadingState.Pending) - val watchingRows = MutableLiveData>(listOf()) - val latestRows = MutableLiveData>(listOf()) - - private lateinit var preferences: UserPreferences + private val _state = MutableStateFlow(HomeState.EMPTY) + val state: StateFlow = _state init { datePlayedService.invalidateAll() - init() +// init() } fun init() { - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loadingState, - "Error loading home page", - ), - ) { + viewModelScope.launchIO { Timber.d("init HomeViewModel") - val reload = loadingState.value != LoadingState.Success - if (reload) { - loadingState.setValueOnMain(LoadingState.Loading) - } - refreshState.setValueOnMain(LoadingState.Loading) - this@HomeViewModel.preferences = userPreferencesService.getCurrent() - val prefs = preferences.appPreferences.homePagePreferences - val limit = prefs.maxItemsPerRow - if (reload) { - backdropService.clearBackdrop() - } try { + val preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + + val navDrawerItems = + navDrawerItemRepository + .getNavDrawerItems() + val libraries = + navDrawerItems + .filter { it is ServerNavDrawerItem } + .map { + it as ServerNavDrawerItem + Library(it.itemId, it.name, it.type) + } serverRepository.currentUserDto.value?.let { userDto -> - val includedIds = - navDrawerItemRepository - .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) - .filter { it is ServerNavDrawerItem } - .map { (it as ServerNavDrawerItem).itemId } - val resume = latestNextUpService.getResume(userDto.id, limit, true) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - ) - val watching = - buildList { - if (prefs.combineContinueNext) { - val items = latestNextUpService.buildCombined(resume, nextUp) - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = items, - ), - ) - } else { - if (resume.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = resume, - ), - ) - } - if (nextUp.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.next_up), - items = nextUp, - ), - ) + val settings = + homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } + val state = state.value + + // Refreshing if a load has already occurred and the rows haven't significantly changed + val refresh = + state.loadingState == LoadingState.Success && state.settings == settings + + val semaphore = Semaphore(4) + + val watchingRowIndexes = + settings.rows + .mapIndexedNotNull { index, row -> + if (isWatchingRow(row.config)) index else null + } + val deferred = + settings.rows + // Load the watching rows first + .sortedByDescending { isWatchingRow(it.config) } + .map { row -> + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + Timber.v("Fetching row: %s", row) + try { + homeSettingsService.fetchDataForRow( + row = row.config, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = libraries, + limit = prefs.maxItemsPerRow, + ) + } catch (ex: Exception) { + Timber.e(ex, "Error on row %s", row) + HomeRowLoadingState.Error(row.title, exception = ex) + } + } } } - } - val latest = latestNextUpService.getLatest(userDto, limit, includedIds) - val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } - - withContext(Dispatchers.Main) { - this@HomeViewModel.watchingRows.value = watching - if (reload) { - this@HomeViewModel.latestRows.value = pendingLatest + if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) { + // Replace watching rows first + Timber.v("Refreshing rows: %s", watchingRowIndexes) + val rows = + deferred + .filterIndexed { index, _ -> index in watchingRowIndexes } + .awaitAll() + _state.update { + val newRows = + it.homeRows.toMutableList().apply { + rows.forEachIndexed { index, row -> + set(watchingRowIndexes[index], row) + } + } + it.copy( + loadingState = LoadingState.Success, + homeRows = newRows, + ) } - loadingState.value = LoadingState.Success } - refreshState.setValueOnMain(LoadingState.Success) - val loadedLatest = latestNextUpService.loadLatest(latest) - this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) + val rows = deferred.awaitAll() + Timber.v("Got all rows") + _state.update { + it.copy( + loadingState = LoadingState.Success, + refreshState = LoadingState.Success, + homeRows = rows, + ) + } } } catch (ex: Exception) { - Timber.e(ex) - if (!reload) { - loadingState.setValueOnMain(LoadingState.Error(ex)) - } else { + Timber.e(ex, "Exception during home page loading") + if (state.value.loadingState == LoadingState.Success) { showToast(context, "Error refreshing home: ${ex.localizedMessage}") + } else { + _state.update { + it.copy(loadingState = LoadingState.Error(ex)) + } } } } @@ -182,16 +190,27 @@ class HomeViewModel } } -val supportedLatestCollectionTypes = - setOf( - CollectionType.MOVIES, - CollectionType.TVSHOWS, - CollectionType.HOMEVIDEOS, - // Exclude Live TV because a recording folder view will be used instead - null, // Recordings & mixed collection types - ) +data class HomeState( + val loadingState: LoadingState, + val refreshState: LoadingState, + val homeRows: List, + val settings: HomePageResolvedSettings, +) { + companion object { + val EMPTY = + HomeState( + LoadingState.Pending, + LoadingState.Pending, + listOf(), + HomePageResolvedSettings.EMPTY, + ) + } +} -data class LatestData( - val title: String, - val request: GetLatestMediaRequest, -) +/** + * Whether a row is a "is watching" type + */ +private fun isWatchingRow(row: HomeRowConfig) = + row is HomeRowConfig.ContinueWatching || + row is HomeRowConfig.NextUp || + row is HomeRowConfig.ContinueWatchingCombined diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt new file mode 100644 index 00000000..c680e077 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -0,0 +1,81 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.SuggestionsWorker +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeLibraryRowTypeList( + library: Library, + onClick: (LibraryRowType) -> Unit, + modifier: Modifier, + firstFocus: FocusRequester = remember { FocusRequester() }, +) { + val items = remember(library) { getSupportedRowTypes(library) } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.add_row_for, library.name)) + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed(items) { index, rowType -> + ListItem( + selected = false, + headlineContent = { + Text( + text = stringResource(rowType.stringId), + ) + }, + onClick = { onClick.invoke(rowType) }, + modifier = + Modifier + .fillMaxWidth() + .ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + } + } +} + +fun getSupportedRowTypes(library: Library): List { + val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) + return if (itemKind != null) { + LibraryRowType.entries + } else { + LibraryRowType.entries.toMutableList().apply { remove(LibraryRowType.SUGGESTIONS) } + } +} + +enum class LibraryRowType( + @param:StringRes val stringId: Int, +) { + RECENTLY_ADDED(R.string.recently_added), + RECENTLY_RELEASED(R.string.recently_released), + SUGGESTIONS(R.string.suggestions), + GENRES(R.string.genres), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt new file mode 100644 index 00000000..e0a64221 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -0,0 +1,206 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.CollectionType + +data class HomeRowPresets( + val continueWatching: HomeRowViewOptions, + val movieLibrary: HomeRowViewOptions, + val tvLibrary: HomeRowViewOptions, + val videoLibrary: HomeRowViewOptions, + val photoLibrary: HomeRowViewOptions, + val playlist: HomeRowViewOptions, + val genreSize: Int, +) { + fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions = + when (collectionType) { + CollectionType.MOVIES -> movieLibrary + + CollectionType.TVSHOWS -> tvLibrary + + CollectionType.MUSICVIDEOS -> videoLibrary + + CollectionType.TRAILERS -> videoLibrary + + CollectionType.HOMEVIDEOS -> videoLibrary + + CollectionType.BOXSETS -> movieLibrary + + CollectionType.PHOTOS -> photoLibrary + + CollectionType.UNKNOWN, + CollectionType.MUSIC, + CollectionType.BOOKS, + CollectionType.LIVETV, + CollectionType.PLAYLISTS, + CollectionType.FOLDERS, + -> HomeRowViewOptions() + } + + companion object { + val WholphinDefault by lazy { + HomeRowPresets( + continueWatching = HomeRowViewOptions(), + movieLibrary = HomeRowViewOptions(), + tvLibrary = HomeRowViewOptions(), + videoLibrary = + HomeRowViewOptions( + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + genreSize = Cards.HEIGHT_2X3_DP, + ) + } + + val WholphinCompact by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = height, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + genreSize = epHeight, + ) + } + + val Thumbnails by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = epHeight, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + episodeImageType = ViewOptionImageType.THUMB, + episodeAspectRatio = AspectRatio.WIDE, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + genreSize = epHeight, + ) + } + } +} + +@Composable +fun HomeRowPresetsContent( + onApply: (HomeRowPresets) -> Unit, + modifier: Modifier = Modifier, +) { + val presets = + remember { + listOf( + "Wholphin Default", + "Wholphin Compact", + "Thumbnails", + ) + } + val focusRequesters = remember { List(presets.size) { FocusRequester() } } + LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.display_presets)) + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(focusRequesters[0]), + ) { + itemsIndexed(presets) { index, title -> + HomeSettingsListItem( + selected = false, + headlineText = title, + onClick = { + when (index) { + 0 -> onApply.invoke(HomeRowPresets.WholphinDefault) + 1 -> onApply.invoke(HomeRowPresets.WholphinCompact) + 2 -> onApply.invoke(HomeRowPresets.Thumbnails) + } + }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt new file mode 100644 index 00000000..9e861c61 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -0,0 +1,307 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.AppChoicePreference +import com.github.damontecres.wholphin.preferences.AppClickablePreference +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppSliderPreference +import com.github.damontecres.wholphin.preferences.AppSwitchPreference +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeRowSettings( + title: String, + preferenceOptions: List>, + viewOptions: HomeRowViewOptions, + onViewOptionsChange: (HomeRowViewOptions) -> Unit, + onApplyApplyAll: () -> Unit, + modifier: Modifier = Modifier, + defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), +) { + val firstFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(title) + LazyColumn { + preferenceOptions.forEachIndexed { groupIndex, prefGroup -> + if (preferenceOptions.size > 1) { + item { + TitleText(stringResource(prefGroup.title)) + } + } + itemsIndexed(prefGroup.preferences) { index, pref -> + pref as AppPreference + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + onClickPreference = { pref -> + when (pref) { + Options.ViewOptionsReset -> { + onViewOptionsChange.invoke(defaultViewOptions) + } + + Options.ViewOptionsApplyAll -> { + onApplyApplyAll.invoke() + } + + Options.ViewOptionsUseThumb -> { + onViewOptionsChange.invoke( + viewOptions.copy( + heightDp = Cards.HEIGHT_EPISODE, + spacing = 20, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.FIT, + episodeImageType = ViewOptionImageType.THUMB, + episodeAspectRatio = AspectRatio.WIDE, + episodeContentScale = PrefContentScale.FIT, + ), + ) + } + } + }, + modifier = + Modifier + .ifElse( + groupIndex == 0 && index == 0, + Modifier.focusRequester(firstFocus), + ), + ) + } + } + } + } +} + +internal object Options { + val ViewOptionsCardHeight = + AppSliderPreference( + title = R.string.height, + defaultValue = Cards.HEIGHT_2X3_DP.toLong(), + min = 64L, + max = Cards.HEIGHT_2X3_DP + 64L, + interval = 4, + getter = { it.heightDp.toLong() }, + setter = { prefs, value -> prefs.copy(heightDp = value.toInt()) }, + ) + val ViewOptionsSpacing = + AppSliderPreference( + title = R.string.spacing, + defaultValue = 16, + min = 0, + max = 32, + interval = 2, + getter = { it.spacing.toLong() }, + setter = { prefs, value -> prefs.copy(spacing = value.toInt()) }, + ) + + val ViewOptionsContentScale = + AppChoicePreference( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.contentScale }, + setter = { viewOptions, value -> viewOptions.copy(contentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsAspectRatio = + AppChoicePreference( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.aspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsShowTitles = + AppSwitchPreference( + title = R.string.show_titles, + defaultValue = true, + getter = { it.showTitles }, + setter = { vo, value -> vo.copy(showTitles = value) }, + ) + + val ViewOptionsUseSeries = + AppSwitchPreference( + title = R.string.use_series, + defaultValue = true, + getter = { it.useSeries }, + setter = { vo, value -> vo.copy(useSeries = value) }, + ) + + val ViewOptionsImageType = + AppChoicePreference( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.imageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(imageType = value, aspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsApplyAll = + AppClickablePreference( + title = R.string.apply_all_rows, + ) + + val ViewOptionsReset = + AppClickablePreference( + title = R.string.reset, + ) + + val ViewOptionsUseThumb = + AppClickablePreference( + title = R.string.use_thumb_images, + ) + + val ViewOptionsEpisodeContentScale = + AppChoicePreference( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.contentScale }, + setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsEpisodeAspectRatio = + AppChoicePreference( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.episodeAspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(episodeAspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsEpisodeImageType = + AppChoicePreference( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.imageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(episodeImageType = value, episodeAspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val OPTIONS = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsShowTitles, + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsContentScale, + ViewOptionsUseSeries, + ), + ), + PreferenceGroup( + title = R.string.more, + preferences = + listOf( +// ViewOptionsApplyAll, + ViewOptionsUseThumb, + ViewOptionsReset, + ), + ), + ) + + val OPTIONS_EPISODES = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsShowTitles, + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsContentScale, + ), + ), + PreferenceGroup( + title = R.string.for_episodes, + preferences = + listOf( + ViewOptionsUseSeries, + ViewOptionsEpisodeImageType, + ViewOptionsEpisodeAspectRatio, + ViewOptionsEpisodeContentScale, + ), + ), + PreferenceGroup( + title = R.string.more, + preferences = + listOf( + ViewOptionsUseThumb, + ViewOptionsReset, + ), + ), + ) + + val GENRE_OPTIONS = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsReset, + ), + ), + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt new file mode 100644 index 00000000..ab48de30 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -0,0 +1,102 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.LazyColumn +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.ifElse + +@Composable +fun HomeSettingsAddRow( + libraries: List, + showDiscover: Boolean, + onClick: (Library) -> Unit, + onClickMeta: (MetaRowType) -> Unit, + modifier: Modifier, + firstFocus: FocusRequester = remember { FocusRequester() }, +) { +// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.add_row)) + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed( + listOf( + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, + MetaRowType.COMBINED_CONTINUE_WATCHING, + ), + ) { index, type -> + HomeSettingsListItem( + selected = false, + headlineText = stringResource(type.stringId), + onClick = { onClickMeta.invoke(type) }, + modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + item { + TitleText(stringResource(R.string.library)) + HorizontalDivider() + } + itemsIndexed(libraries) { index, library -> + HomeSettingsListItem( + selected = false, + headlineText = library.name, + onClick = { onClick.invoke(library) }, + modifier = Modifier, // .ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + item { + TitleText(stringResource(R.string.more)) + HorizontalDivider() + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(MetaRowType.FAVORITES.stringId), + onClick = { onClickMeta.invoke(MetaRowType.FAVORITES) }, + modifier = Modifier, + ) + } + if (showDiscover) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(MetaRowType.DISCOVER.stringId), + onClick = { onClickMeta.invoke(MetaRowType.DISCOVER) }, + modifier = Modifier, + ) + } + } + } + } +} + +enum class MetaRowType( + @param:StringRes val stringId: Int, +) { + CONTINUE_WATCHING(R.string.continue_watching), + NEXT_UP(R.string.next_up), + COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next), + FAVORITES(R.string.favorites), + DISCOVER(R.string.discover), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt new file mode 100644 index 00000000..ad433c23 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt @@ -0,0 +1,38 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +/** + * Tracking the pages for selecting and configuring rows + */ +@Serializable +sealed interface HomeSettingsDestination : NavKey { + @Serializable + data object RowList : HomeSettingsDestination + + @Serializable + data object AddRow : HomeSettingsDestination + + @Serializable + data class ChooseRowType( + val library: Library, + ) : HomeSettingsDestination + + @Serializable + data class RowSettings( + val rowId: Int, + ) : HomeSettingsDestination + + @Serializable + data object ChooseFavorite : HomeSettingsDestination + + @Serializable + data object ChooseDiscover : HomeSettingsDestination + + @Serializable + data object GlobalSettings : HomeSettingsDestination + + @Serializable + data object Presets : HomeSettingsDestination +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt new file mode 100644 index 00000000..3bac590a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -0,0 +1,64 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun HomeSettingsFavoriteList( + onClick: (BaseItemKind) -> Unit, + modifier: Modifier = Modifier, + firstFocus: FocusRequester = remember { FocusRequester() }, +) { + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText( + stringResource(R.string.add_row_for, stringResource(R.string.favorites)), + ) + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed(favoriteOptionsList) { index, type -> + HomeSettingsListItem( + selected = false, + headlineText = stringResource(favoriteOptions[type]!!), + onClick = { onClick.invoke(type) }, + modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + } + } +} + +val favoriteOptions by lazy { + mapOf( + BaseItemKind.MOVIE to R.string.movies, + BaseItemKind.SERIES to R.string.tv_shows, + BaseItemKind.EPISODE to R.string.episodes, + BaseItemKind.VIDEO to R.string.videos, + BaseItemKind.PLAYLIST to R.string.playlists, + BaseItemKind.PERSON to R.string.people, + ) +} +val favoriteOptionsList by lazy { favoriteOptions.keys.toList() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt new file mode 100644 index 00000000..e4b61881 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -0,0 +1,184 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeSettingsGlobal( + preferences: AppPreferences, + onPreferenceChange: (AppPreferences) -> Unit, + onClickResize: (Int) -> Unit, + onClickSave: () -> Unit, + onClickLoad: () -> Unit, + onClickLoadWeb: () -> Unit, + onClickReset: () -> Unit, + modifier: Modifier = Modifier, +) { + val firstFocus: FocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + Text( + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider() + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + item { + ComposablePreference( + preference = AppPreference.HomePageItems, + value = AppPreference.HomePageItems.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.HomePageItems.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + modifier = Modifier.focusRequester(firstFocus), + ) + } + item { + ComposablePreference( + preference = AppPreference.RewatchNextUp, + value = AppPreference.RewatchNextUp.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.RewatchNextUp.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + 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( + selected = false, + headlineText = stringResource(R.string.increase_all_cards_size), + leadingContent = { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = null, + ) + }, + onClick = { onClickResize.invoke(1) }, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.decrease_all_cards_size), + leadingContent = { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + ) + }, + onClick = { onClickResize.invoke(-1) }, + modifier = Modifier, + ) + } + item { HorizontalDivider() } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.save_to_server), + leadingContent = { + Text( + text = stringResource(R.string.fa_cloud_arrow_up), + fontFamily = FontAwesome, + ) + }, + onClick = onClickSave, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_server), + leadingContent = { + Text( + text = stringResource(R.string.fa_cloud_arrow_down), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoad, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_web_client), + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoadWeb, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.reset), + leadingContent = { + Text( + text = stringResource(R.string.fa_arrows_rotate), + fontFamily = FontAwesome, + ) + }, + onClick = onClickReset, + modifier = Modifier, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt new file mode 100644 index 00000000..4f265dbb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt @@ -0,0 +1,59 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemBorder +import androidx.tv.material3.ListItemColors +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.ListItemGlow +import androidx.tv.material3.ListItemScale +import androidx.tv.material3.ListItemShape +import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle + +@Composable +@NonRestartableComposable +fun HomeSettingsListItem( + selected: Boolean, + onClick: () -> Unit, + headlineText: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, + overlineContent: (@Composable () -> Unit)? = null, + supportingContent: (@Composable () -> Unit)? = null, + leadingContent: (@Composable BoxScope.() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + tonalElevation: Dp = 3.dp, + shape: ListItemShape = ListItemDefaults.shape(), + colors: ListItemColors = ListItemDefaults.colors(), + scale: ListItemScale = ListItemDefaults.scale(), + border: ListItemBorder = ListItemDefaults.border(), + glow: ListItemGlow = ListItemDefaults.glow(), + interactionSource: MutableInteractionSource? = null, +) = ListItem( + selected = selected, + onClick = onClick, + headlineContent = { + PreferenceTitle(headlineText) + }, + modifier = modifier, + enabled = enabled, + onLongClick = onLongClick, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + tonalElevation = tonalElevation, + shape = shape, + colors = colors, + scale = scale, + border = border, + glow = glow, + interactionSource = interactionSource, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt new file mode 100644 index 00000000..616eff13 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -0,0 +1,290 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import timber.log.Timber + +val settingsWidth = 360.dp + +@Composable +fun HomeSettingsPage( + modifier: Modifier, + viewModel: HomeSettingsViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) + var showConfirmDialog by remember { mutableStateOf(null) } + + val state by viewModel.state.collectAsState() + // TODO discover rows + val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) + + // Adds a row, waits until its done loading, then scrolls to the new row + fun addRow(func: () -> Job) { + scope.launch(ExceptionHandler(autoToast = true)) { + backStack.add(HomeSettingsDestination.RowList) + func.invoke().join() + listState.animateScrollToItem(state.rows.lastIndex) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Box( + modifier = + Modifier + .width(settingsWidth) + .fillMaxHeight() + .background(color = MaterialTheme.colorScheme.surface), + ) { + NavDisplay( + backStack = backStack, +// onBack = { navigationManager.goBack() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + entryProvider = { key -> + val dest = key as HomeSettingsDestination + NavEntry(dest, contentKey = key.toString()) { + val destModifier = + Modifier + .fillMaxSize() + .padding(8.dp) + when (dest) { + HomeSettingsDestination.RowList -> { + HomeSettingsRowList( + state = state, + onClickAdd = { backStack.add(HomeSettingsDestination.AddRow) }, + onClickSettings = { backStack.add(HomeSettingsDestination.GlobalSettings) }, + onClickPresets = { backStack.add(HomeSettingsDestination.Presets) }, + onClickMove = viewModel::moveRow, + onClickDelete = viewModel::deleteRow, + onClick = { index, row -> + backStack.add(RowSettings(row.id)) + scope.launch(ExceptionHandler()) { + Timber.v("Scroll to $index") + listState.scrollToItem(index) + } + }, + modifier = destModifier, + ) + } + + is HomeSettingsDestination.AddRow -> { + HomeSettingsAddRow( + libraries = state.libraries, + showDiscover = discoverEnabled, + onClick = { backStack.add(ChooseRowType(it)) }, + onClickMeta = { + when (it) { + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, + MetaRowType.COMBINED_CONTINUE_WATCHING, + -> { + addRow { viewModel.addRow(it) } + } + + MetaRowType.FAVORITES -> { + backStack.add(HomeSettingsDestination.ChooseFavorite) + } + + MetaRowType.DISCOVER -> { + backStack.add(HomeSettingsDestination.ChooseDiscover) + } + } + }, + modifier = destModifier, + ) + } + + is ChooseRowType -> { + HomeLibraryRowTypeList( + library = dest.library, + onClick = { type -> + addRow { viewModel.addRow(dest.library, type) } + }, + modifier = destModifier, + ) + } + + is RowSettings -> { + val row = + state.rows + .first { it.id == dest.rowId } + val preferenceOptions = + remember(row.config) { + when (row.config) { + is HomeRowConfig.ContinueWatching, + is HomeRowConfig.ContinueWatchingCombined, + -> Options.OPTIONS_EPISODES + + is HomeRowConfig.Genres -> Options.GENRE_OPTIONS + + else -> Options.OPTIONS + } + } + val defaultViewOptions = + remember(row.config) { + when (row.config) { + is HomeRowConfig.Genres -> HomeRowViewOptions.genreDefault + else -> HomeRowViewOptions() + } + } + HomeRowSettings( + title = row.title, + preferenceOptions = preferenceOptions, + viewOptions = row.config.viewOptions, + defaultViewOptions = defaultViewOptions, + onViewOptionsChange = { + viewModel.updateViewOptions(dest.rowId, it) + }, + onApplyApplyAll = { + viewModel.updateViewOptionsForAll(row.config.viewOptions) + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.ChooseDiscover -> { + TODO() + } + + HomeSettingsDestination.ChooseFavorite -> { + HomeSettingsFavoriteList( + onClick = { type -> + addRow { viewModel.addFavoriteRow(type) } + }, + ) + } + + HomeSettingsDestination.GlobalSettings -> { + val preferences by + viewModel.preferencesDataStore.data.collectAsState( + AppPreferences.getDefaultInstance(), + ) + + HomeSettingsGlobal( + preferences = preferences, + onPreferenceChange = { newPrefs -> + scope.launchIO { + viewModel.preferencesDataStore.updateData { newPrefs } + } + }, + onClickResize = { viewModel.resizeCards(it) }, + onClickSave = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_server_settings) { + viewModel.saveToRemote() + } + }, + onClickLoad = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemote() + } + }, + onClickLoadWeb = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemoteWeb() + } + }, + onClickReset = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.resetToDefault() + } + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.Presets -> { + HomeRowPresetsContent( + onApply = viewModel::applyPreset, + modifier = destModifier, + ) + } + } + } + }, + ) + } + HomePageContent( + loadingState = state.loading, + homeRows = state.rowData, + onClickItem = { _, _ -> }, + onLongClickItem = { _, _ -> }, + onClickPlay = { _, _ -> }, + showClock = false, + onUpdateBackdrop = viewModel::updateBackdrop, + listState = listState, + takeFocus = false, + showEmptyRows = true, + modifier = + Modifier + .fillMaxHeight() + .weight(1f), + ) + } + showConfirmDialog?.let { (body, onConfirm) -> + ConfirmDialog( + title = stringResource(R.string.confirm), + body = stringResource(body), + onCancel = { showConfirmDialog = null }, + onConfirm = { + onConfirm.invoke() + showConfirmDialog = null + }, + ) + } +} + +data class ShowConfirm( + @param:StringRes val body: Int, + val onConfirm: () -> Unit, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt new file mode 100644 index 00000000..231243ce --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -0,0 +1,269 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.HomeRowConfigDisplay +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch + +enum class MoveDirection { + UP, + DOWN, +} + +@Composable +fun HomeSettingsRowList( + state: HomePageSettingsState, + onClick: (Int, HomeRowConfigDisplay) -> Unit, + onClickAdd: () -> Unit, + onClickSettings: () -> Unit, + onClickPresets: () -> Unit, + onClickMove: (MoveDirection, Int) -> Unit, + onClickDelete: (Int) -> Unit, + modifier: Modifier, +) { + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + val itemsBeforeRows = 4 + val focusRequesters = + remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } } + + var position by rememberInt(0) + + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + Column(modifier = modifier) { + TitleText(stringResource(R.string.customize_home)) + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(focusRequesters[0]), + ) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.add_row), + leadingContent = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + }, + onClick = { + position = 0 + onClickAdd.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[0]), + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.settings), + leadingContent = { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + ) + }, + onClick = { + position = 1 + onClickSettings.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[1]), + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.display_presets), + supportingContent = { + Text( + text = stringResource(R.string.display_presets_description), + ) + }, + leadingContent = { + Text( + text = stringResource(R.string.fa_sliders), + fontFamily = FontAwesome, + ) + }, + onClick = { + position = 2 + onClickPresets.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[1]), + ) + } + item { + TitleText(stringResource(R.string.home_rows)) + HorizontalDivider() + } + itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row -> + HomeRowConfigContent( + config = row, + moveUpAllowed = index > 0, + moveDownAllowed = index != state.rows.lastIndex, + deleteAllowed = state.rows.size > 1, + onClickMove = { + onClickMove.invoke(it, index) + scope.launch { + val scrollIndex = + itemsBeforeRows + if (it == MoveDirection.UP) index - 1 else index + 1 + if (scrollIndex < listState.firstVisibleItemIndex || + scrollIndex > listState.layoutInfo.visibleItemsInfo.lastIndex + ) { + listState.animateScrollToItem(scrollIndex) + } + } + }, + onClickDelete = { + if (index != state.rows.lastIndex) { + focusManager.moveFocus(FocusDirection.Down) + } else { + focusManager.moveFocus(FocusDirection.Up) + } + onClickDelete.invoke(index) + }, + onClick = { + position = itemsBeforeRows + index + onClick.invoke(index, row) + }, + modifier = + Modifier + .fillMaxWidth() + .animateItem() + .focusRequester(focusRequesters[itemsBeforeRows + index]), + ) + } + } + } +} + +@Composable +fun HomeRowConfigContent( + config: HomeRowConfigDisplay, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + deleteAllowed: Boolean, + onClick: () -> Unit, + onClickMove: (MoveDirection) -> Unit, + onClickDelete: () -> Unit, + modifier: Modifier, +) { + Box( + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + HomeSettingsListItem( + selected = false, + headlineText = config.title, + onClick = onClick, + modifier = Modifier.weight(1f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.wrapContentWidth(), + ) { + Button( + onClick = { onClickMove.invoke(MoveDirection.UP) }, + enabled = moveUpAllowed, + ) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, + ) + } + Button( + onClick = { onClickMove.invoke(MoveDirection.DOWN) }, + enabled = moveDownAllowed, + ) { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, + ) + } + Button( + onClick = onClickDelete, + enabled = deleteAllowed, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "delete", + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +@NonRestartableComposable +fun TitleText( + title: String, + modifier: Modifier = Modifier, +) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt new file mode 100644 index 00000000..3a5f088a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -0,0 +1,670 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Immutable +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.NavDrawerItemRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatching +import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatchingCombined +import com.github.damontecres.wholphin.data.model.HomeRowConfig.Genres +import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp +import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded +import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased +import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.HomePageResolvedSettings +import com.github.damontecres.wholphin.services.HomeRowConfigDisplay +import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.serialization.Serializable +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import kotlin.properties.Delegates + +@HiltViewModel +class HomeSettingsViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val homeSettingsService: HomeSettingsService, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + private val navDrawerItemRepository: NavDrawerItemRepository, + private val backdropService: BackdropService, + private val seerrServerRepository: SeerrServerRepository, + val preferencesDataStore: DataStore, + @param:IoCoroutineScope private val ioScope: CoroutineScope, + ) : ViewModel() { + private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) + val state: StateFlow = _state + + private var idCounter by Delegates.notNull() + + val discoverEnabled = seerrServerRepository.active + + init { + addCloseable { saveToLocal() } + viewModelScope.launchIO { + val navDrawerItems = + navDrawerItemRepository + .getNavDrawerItems() + val libraries = + navDrawerItems + .filter { it is ServerNavDrawerItem } + .map { + it as ServerNavDrawerItem + Library(it.itemId, it.name, it.type) + } + val currentSettings = + homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } + Timber.v("currentSettings=%s", currentSettings) + idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy( + libraries = libraries, + rows = currentSettings.rows, + ) + } + fetchRowData() + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + private suspend fun fetchRowData() { + val limit = 6 + val semaphore = Semaphore(4) + val rows = + serverRepository.currentUserDto.value?.let { userDto -> + val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences + state.value + .let { state -> + state.rows + .map { it.config } + .map { row -> + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + homeSettingsService.fetchDataForRow( + row = row, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = state.libraries, + limit = limit, + ) + } + } + } + }.awaitAll() + } + rows?.let { rows -> + rows + .firstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + ?.let { + it as HomeRowLoadingState.Success + it.items.firstOrNull()?.let { + Timber.v("Updating backdrop") + updateBackdrop(it) + } + } + updateState { + it.copy(loading = LoadingState.Success, rowData = rows) + } + } + } + + private fun List.move( + direction: MoveDirection, + index: Int, + ): List = + toMutableList().apply { + if (direction == MoveDirection.DOWN) { + val down = this[index] + val up = this[index + 1] + set(index, up) + set(index + 1, down) + } else { + val up = this[index] + val down = this[index - 1] + set(index - 1, up) + set(index, down) + } + } + + fun moveRow( + direction: MoveDirection, + index: Int, + ) { + viewModelScope.launchIO { + updateState { + val rows = it.rows.move(direction, index) + val rowData = it.rowData.move(direction, index) + it.copy( + rows = rows, + rowData = rowData, + ) + } + } +// viewModelScope.launchIO { fetchRowData() } + } + + fun deleteRow(index: Int) { + viewModelScope.launchIO { + updateState { + val rows = it.rows.toMutableList().apply { removeAt(index) } + val rowData = it.rowData.toMutableList().apply { removeAt(index) } + it.copy( + rows = rows, + rowData = rowData, + ) + } + } + } + + fun addRow(type: MetaRowType): Job = + viewModelScope.launchIO { + val id = idCounter++ + val newRow = + when (type) { + MetaRowType.CONTINUE_WATCHING -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.continue_watching), + config = ContinueWatching(), + ) + } + + MetaRowType.NEXT_UP -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.continue_watching), + config = NextUp(), + ) + } + + MetaRowType.COMBINED_CONTINUE_WATCHING -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.combine_continue_next), + config = ContinueWatchingCombined(), + ) + } + + MetaRowType.FAVORITES -> { + throw IllegalArgumentException("Should use addRow(BaseItemKind) instead") + } + + MetaRowType.DISCOVER -> { + TODO() + } + } + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addRow( + library: Library, + rowType: LibraryRowType, + ): Job = + viewModelScope.launchIO { + val id = idCounter++ + val newRow = + when (rowType) { + LibraryRowType.RECENTLY_ADDED -> { + val title = + library.name.let { context.getString(R.string.recently_added_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyAdded(library.itemId), + ) + } + + LibraryRowType.RECENTLY_RELEASED -> { + val title = + library.name.let { + context.getString( + R.string.recently_released_in, + it, + ) + } + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyReleased(library.itemId), + ) + } + + LibraryRowType.GENRES -> { + val title = library.name.let { context.getString(R.string.genres_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = Genres(library.itemId), + ) + } + + LibraryRowType.SUGGESTIONS -> { + val title = + library.name.let { context.getString(R.string.suggestions_for, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = Suggestions(library.itemId), + ) + } + } + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addFavoriteRow(type: BaseItemKind): Job = + viewModelScope.launchIO { + Timber.v("Adding favorite row for $type") + val id = idCounter++ + val newRow = + HomeRowConfigDisplay( + id = id, + title = context.getString(favoriteOptions[type]!!), + config = HomeRowConfig.Favorite(type), + ) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun updateViewOptions( + rowId: Int, + viewOptions: HomeRowViewOptions, + ) { + viewModelScope.launchIO { + var fetchData = false + updateState { + val index = it.rows.indexOfFirst { it.id == rowId } + val config = it.rows[index].config + val newRowConfig = config.updateViewOptions(viewOptions) + val newRow = it.rows[index].copy(config = newRowConfig) + if (config.viewOptions.useSeries != viewOptions.useSeries) { + fetchData = true + } + it.copy( + rows = + it.rows.toMutableList().apply { + set(index, newRow) + }, + rowData = + it.rowData.toMutableList().apply { + val row = it.rowData[index] + val newRow = + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = viewOptions) + } else { + row + } + set(index, newRow) + }, + ) + } + if (fetchData) { + fetchRowData() + } + } + } + + fun updateViewOptionsForAll(viewOptions: HomeRowViewOptions) { + viewModelScope.launchIO { + updateState { + it.copy( + rowData = + it.rowData.toMutableList().map { row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = viewOptions) + } else { + row + } + }, + ) + } + } + } + + fun saveToRemote() { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + Timber.d("Saving home settings to remote") + val rows = state.value.rows.map { it.config } + val settings = + HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + try { + Timber.d("saveToRemote") + homeSettingsService.saveToServer(user.id, settings) + showSaveToast() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error saving: ${ex.localizedMessage}") + } + } + } + } + + fun loadFromRemote() { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + Timber.d("Loading home settings from remote") + try { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.loadFromServer(user.id) + if (result != null) { + Timber.v("Got remote settings") + val newRows = + result.rows.mapIndexed { index, config -> + homeSettingsService.resolve(index, config) + } + _state.update { + it.copy(rows = newRows) + } + } else { + Timber.v("No remote settings") + showToast(context, "No server-side settings found") + } + fetchRowData() + } catch (ex: UnsupportedHomeSettingsVersionException) { + // TODO + Timber.w(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + } + + fun loadFromRemoteWeb() { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + Timber.d("Loading home settings from web") + try { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.parseFromWebConfig(user.id) + if (result != null) { + Timber.v("Got web settings") + _state.update { + it.copy(rows = result.rows) + } + } else { + Timber.v("No web settings") + showToast(context, "No server-side web settings found") + } + fetchRowData() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + } + + fun saveToLocal() { + // This uses injected ioScope so that it will still run when the page is closing + ioScope.launchIO { + serverRepository.currentUser.value?.let { user -> + val rows = state.value.rows.map { it.config } + val settings = + HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + try { + Timber.d("saveToLocal") + val local = homeSettingsService.loadFromLocal(user.id) + // Only save if there are changes + if (local != settings) { + homeSettingsService.saveToLocal(user.id, settings) + homeSettingsService.updateCurrent(settings) + showSaveToast() + } else { + Timber.d("No changes") + } + } catch (ex: UnsupportedHomeSettingsVersionException) { + Timber.w(ex, "Overwriting local settings") + homeSettingsService.saveToLocal(user.id, settings) + showSaveToast() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error saving: ${ex.localizedMessage}") + } + } + } + } + + private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) { + _state.update { + update.invoke(it) + } + homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) } + } + + fun resizeCards(relative: Int) { + viewModelScope.launchIO { + updateState { + val newRows = + it.rows.toMutableList().map { row -> + val vo = row.config.viewOptions + val newVo = vo.copy(heightDp = vo.heightDp + (4 * relative)) + row.copy(config = row.config.updateViewOptions(newVo)) + } + it.copy( + rows = newRows, + rowData = + it.rowData.toMutableList().mapIndexed { index, row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = newRows[index].config.viewOptions) + } else { + row + } + }, + ) + } + } + } + + fun resetToDefault() { + viewModelScope.launchIO { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.createDefault() + _state.update { + it.copy(rows = result.rows) + } + fetchRowData() + } + } + + private suspend fun showSaveToast() = + showToast( + context, + context.getString(R.string.settings_saved), + Toast.LENGTH_SHORT, + ) + + fun applyPreset(preset: HomeRowPresets) { + _state.update { it.copy(loading = LoadingState.Loading) } + viewModelScope.launchIO { + val state = state.value + + val typeCache = mutableMapOf() + + suspend fun getCollectionType(itemId: UUID): CollectionType = + typeCache.getOrPut(itemId) { + state.libraries + .firstOrNull { it.itemId == itemId } + ?.collectionType + ?: api.userLibraryApi + .getItem(itemId) + .content.collectionType ?: CollectionType.UNKNOWN + } ?: CollectionType.UNKNOWN + + val newRows = + state.rows.map { + val newConfig = + when (it.config) { + is ContinueWatching, + is NextUp, + is ContinueWatchingCombined, + -> { + it.config.updateViewOptions(preset.continueWatching) + } + + is HomeRowConfig.ByParent -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.Favorite -> { + val viewOptions = + when (it.config.kind) { + BaseItemKind.MOVIE -> preset.movieLibrary + BaseItemKind.SERIES -> preset.tvLibrary + BaseItemKind.EPISODE -> preset.continueWatching + BaseItemKind.VIDEO -> preset.videoLibrary + BaseItemKind.PLAYLIST -> preset.playlist + BaseItemKind.PERSON -> preset.movieLibrary + else -> preset.movieLibrary + } + it.config.updateViewOptions(viewOptions) + } + + is Genres -> { + it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) + } + + is HomeRowConfig.GetItems -> { + it.config + } + + is RecentlyAdded -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is RecentlyReleased -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.Recordings -> { + it.config.updateViewOptions(preset.tvLibrary) + } + + is Suggestions -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.TvPrograms -> { + it.config.updateViewOptions(preset.tvLibrary) + } + } + it.copy(config = newConfig) + } + + _state.update { + it.copy( + loading = LoadingState.Success, + rows = newRows, + rowData = + it.rowData.toMutableList().mapIndexed { index, row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = newRows[index].config.viewOptions) + } else { + row + } + }, + ) + } + } + } + } + +data class HomePageSettingsState( + val loading: LoadingState, + val rows: List, + val rowData: List, + val libraries: List, +) { + companion object { + val EMPTY = + HomePageSettingsState( + LoadingState.Pending, + listOf(), + listOf(), + listOf(), + ) + } +} + +@Immutable +@Serializable +data class Library( + @Serializable(UUIDSerializer::class) val itemId: UUID, + val name: String, + val collectionType: CollectionType, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 0d16f36a..59d102c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -33,6 +33,9 @@ sealed class Destination( val id: Long = 0L, ) : Destination() + @Serializable + data object HomeSettings : Destination(true) + @Serializable data class Settings( val screen: PreferenceScreenOption, 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 c1e70880..a16e909a 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 @@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage @@ -63,6 +64,10 @@ fun DestinationContent( ) } + is Destination.HomeSettings -> { + HomeSettingsPage(modifier) + } + is Destination.PlaybackList, is Destination.Playback, -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index c0e93556..da246f73 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -34,6 +34,14 @@ val supportedCollectionTypes = null, // Mixed ) +val supportedHomeCollectionTypes = + setOf( + CollectionType.MOVIES, + CollectionType.TVSHOWS, + CollectionType.HOMEVIDEOS, + null, // Mixed + ) + val supportedPlayableTypes = setOf( BaseItemKind.MOVIE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 1a4ca1aa..5da98fec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.util import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions /** * Generic state for loading something from the API @@ -62,6 +63,7 @@ sealed interface HomeRowLoadingState { data class Success( override val title: String, val items: List, + val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowLoadingState data class Error( diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 371560a1..38cf32c1 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -51,4 +51,6 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8fdc1fc..e8533441 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -498,6 +498,29 @@ No limit Max days in Next Up + Add row + Genres in %1$s + Recently released in %1$s + Height + Apply to all rows + Customize home page + Home rows + Load from server + Save to server + Load from web client + Use series image + Add row for %1$s + Overwrite settings on server? + Overwrite local settings? + For episodes + Suggestions for %1$s + Increase size for all cards + Decrease size for all cards + Use thumb images + Settings saved + Display presets + Built-in presets to quickly style all rows + Disabled Lowest diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt new file mode 100644 index 00000000..3aedb3ba --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -0,0 +1,150 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import io.mockk.mockk +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.junit.Assert +import org.junit.Test +import kotlin.reflect.KClass + +class TestHomeRowSamples { + companion object { + val SAMPLES = + listOf( + HomeRowConfig.RecentlyAdded( + parentId = UUID.randomUUID(), + viewOptions = + HomeRowViewOptions( + heightDp = 100, + spacing = 8, + contentScale = PrefContentScale.CROP, + aspectRatio = AspectRatio.FOUR_THREE, + imageType = ViewOptionImageType.THUMB, + showTitles = false, + useSeries = false, + ), + ), + HomeRowConfig.RecentlyReleased( + parentId = UUID.randomUUID(), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.Genres( + parentId = UUID.randomUUID(), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ContinueWatching( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.NextUp( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ContinueWatchingCombined( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ByParent( + parentId = UUID.randomUUID(), + recursive = true, + sort = SortAndDirection(ItemSortBy.CRITIC_RATING, SortOrder.ASCENDING), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.GetItems( + name = "Episodes by date created", + getItems = + GetItemsRequest( + parentId = UUID.randomUUID(), + recursive = true, + isFavorite = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + ), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.Favorite(kind = BaseItemKind.SERIES), + HomeRowConfig.Recordings(), + HomeRowConfig.TvPrograms(), + HomeRowConfig.Suggestions(parentId = UUID.randomUUID()), + ) + } + + @Test + fun `Check all types have a sample`() { + // This ensures there is a sample for each possible HomeRowConfig type + val foundTypes = mutableSetOf>() + SAMPLES.forEach { + when (it) { + is HomeRowConfig.ContinueWatching -> foundTypes.add(it::class) + is HomeRowConfig.ContinueWatchingCombined -> foundTypes.add(it::class) + is HomeRowConfig.Genres -> foundTypes.add(it::class) + is HomeRowConfig.NextUp -> foundTypes.add(it::class) + is HomeRowConfig.RecentlyAdded -> foundTypes.add(it::class) + is HomeRowConfig.RecentlyReleased -> foundTypes.add(it::class) + is HomeRowConfig.ByParent -> foundTypes.add(it::class) + is HomeRowConfig.GetItems -> foundTypes.add(it::class) + is HomeRowConfig.Favorite -> foundTypes.add(it::class) + is HomeRowConfig.Recordings -> foundTypes.add(it::class) + is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) + is HomeRowConfig.Suggestions -> foundTypes.add(it::class) + } + } + Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) + } + + @Test + fun `Print sample JSON`() { + // This just prints out the JSON of the samples so developers can review + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + } + val string = json.encodeToString(SAMPLES) + println(string) + json.decodeFromString>(string) + } + + @Test + fun `Parse list of rows with an unknown type`() { + val service = + HomeSettingsService( + context = mockk(), + api = mockk(), + userPreferencesService = mockk(), + navDrawerItemRepository = mockk(), + latestNextUpService = mockk(), + imageUrlService = mockk(), + suggestionService = mockk(), + ) + + val str = """{ + "type": "HomePageSettings", + "version": 1, + "rows": [ + { + "type": "RecentlyAdded", + "parentId": "1dd1c2fd-2e1b-48e4-ba94-17a2350fe9cf" + }, + { + "type": "Does not exist", + "viewOptions": {} + } + ] + }""" + + val jsonElement = service.jsonParser.parseToJsonElement(str) + val settings = service.decode(jsonElement) + Assert.assertEquals(1, settings.rows.size) + } +} From 17b0eef5c5f8cb95e91f17cccd10f02af6fbd519 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:55:12 -0500 Subject: [PATCH 09/80] Add ability to order navigation drawer items (#886) ## Description Allows for reordering the items in the navigation drawer This does not change the home page row order since #803 decouples the nav drawer and home page rows. The initial order will be the "Library Order" settings on the web under Profile->Home. Making any changes locally in Wholphin will set the order. If any new libraries are added or if Seerr integration is enabled, these will added to the end of the "pinned" list. ### Related issues Closes #822 Related to #399 & #803 ### Testing Tested on emulator - Re-ordering - Granting/Revoking user permission to a new library ## Screenshots N/A, nav drawer is the same, just sorted differently ## AI or LLM usage None --- .../31.json | 642 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 3 +- .../wholphin/data/model/ServerPreferences.kt | 2 + .../wholphin/services/NavDrawerService.kt | 147 ++++ .../wholphin/services/hilt/AppModule.kt | 9 + .../damontecres/wholphin/ui/nav/NavDrawer.kt | 191 ++---- .../ui/preferences/NavDrawerPreference.kt | 245 +++++++ .../ui/preferences/PreferencesContent.kt | 21 +- .../ui/preferences/PreferencesViewModel.kt | 117 +++- 9 files changed, 1214 insertions(+), 163 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json new file mode 100644 index 00000000..b719e5f4 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json @@ -0,0 +1,642 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "c6829d764ec85321ab3be9905d6c0e3a", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c6829d764ec85321ab3be9905d6c0e3a')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index a3629843..b3f33bed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -40,7 +40,7 @@ import java.util.UUID SeerrUser::class, ], - version = 30, + version = 31, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -54,6 +54,7 @@ import java.util.UUID AutoMigration(11, 12), AutoMigration(12, 20), AutoMigration(20, 30), + AutoMigration(30, 31), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt index f3f52585..2b503023 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.data.model +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey @@ -24,4 +25,5 @@ data class NavDrawerPinnedItem( val userId: Int, val itemId: String, val type: NavPinType, + @ColumnInfo(defaultValue = "-1") val order: Int, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt new file mode 100644 index 00000000..785eb676 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -0,0 +1,147 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.lifecycle.asFlow +import com.github.damontecres.wholphin.data.ServerPreferencesDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.util.supportedCollectionTypes +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.UserDto +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NavDrawerService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val coroutineScope: CoroutineScope, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, + ) { + private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) + val state: StateFlow = _state + + init { + serverRepository.currentUser + .asFlow() + .combine(serverRepository.currentUserDto.asFlow()) { user, userDto -> + Pair(user, userDto) + }.onEach { (user, userDto) -> + Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id) + _state.update { + it.copy( + items = emptyList(), + moreItems = emptyList(), + ) + } + if (user != null && userDto != null && user.id == userDto.id) { + updateNavDrawer(user, userDto) + } + }.launchIn(coroutineScope) + seerrServerRepository.active + .onEach { discoverActive -> + _state.update { it.copy(discoverEnabled = discoverActive) } + }.launchIn(coroutineScope) + } + + suspend fun updateNavDrawer( + user: JellyfinUser, + userDto: UserDto, + ) { + val tvAccess = userDto.policy?.enableLiveTvAccess ?: false + val userViews = + api.userViewsApi + .getUserViews(userId = user.id) + .content.items + val recordingFolders = + if (tvAccess) { + api.liveTvApi + .getRecordingFolders(userId = user.id) + .content.items + .map { it.id } + .toSet() + } else { + setOf() + } + + val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + + val libraries = + userViews + .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + .map { + val destination = + if (it.id in recordingFolders) { + Destination.Recordings(it.id) + } else { + BaseItem.from(it, api).destination() + } + ServerNavDrawerItem( + itemId = it.id, + name = it.name ?: it.id.toString(), + destination = destination, + type = it.collectionType ?: CollectionType.UNKNOWN, + ) + } + val allItems = builtins + libraries + + val navDrawerPins = + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + + val items = mutableListOf() + val moreItems = mutableListOf() + allItems + // Sort by order if non-default, existing items before customize will have -1 value + // New items from the server will get Int.MAX_VALUE + // Items the user doesn't have access to anymore will be skipped + .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } + .forEach { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + if (pinned == NavPinType.PINNED) { + items.add(it) + } else { + moreItems.add(it) + } + } + + _state.update { + it.copy( + items = items, + moreItems = moreItems, + ) + } + } + } + +data class NavDrawerItemState( + val items: List, + val moreItems: List, + val discoverEnabled: Boolean, +) { + companion object { + val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index f207562c..52c37413 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -44,6 +44,10 @@ annotation class StandardOkHttpClient @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultCoroutineScope + @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -177,6 +181,11 @@ object AppModule { @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides @Singleton fun workManager( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 6eac3206..f1c43618 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -27,8 +27,8 @@ import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember @@ -54,7 +54,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.findViewTreeViewModelStoreOwner -import androidx.lifecycle.viewModelScope import androidx.tv.material3.DrawerState import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon @@ -72,6 +71,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SetupDestination @@ -79,23 +79,16 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setup.UserIconCardImage 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 dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.CollectionType -import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -104,80 +97,17 @@ class NavDrawerViewModel @Inject constructor( private val api: ApiClient, + private val navDrawerService: NavDrawerService, private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { - val moreLibraries = MutableLiveData>(null) - val libraries = MutableLiveData>(listOf()) + val state = navDrawerService.state val selectedIndex = MutableLiveData(-1) - val showMore = MutableLiveData(false) - - init { - seerrServerRepository.active - .onEach { - init() - }.launchIn(viewModelScope) - } - - fun init() { - viewModelScope.launchIO { - val all = navDrawerItemRepository.getNavDrawerItems() - val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) - val moreLibraries = all.toMutableList().apply { removeAll(libraries) } - - withContext(Dispatchers.Main) { - this@NavDrawerViewModel.moreLibraries.value = moreLibraries - this@NavDrawerViewModel.libraries.value = libraries - } - val asDestinations = - ( - libraries + - listOf( - NavDrawerItem.More, - NavDrawerItem.Discover, - ) + moreLibraries - ).map { - if (it is ServerNavDrawerItem) { - it.destination - } else if (it is NavDrawerItem.Favorites) { - Destination.Favorites - } else if (it is NavDrawerItem.Discover) { - Destination.Discover - } else { - null - } - } - - val backstack = navigationManager.backStack.toList().reversed() - for (i in 0..= 0) { - idx - } else { - null - } - } - Timber.v("Found $index => $key") - if (index != null) { - selectedIndex.setValueOnMain(index) - break - } - } - } - } - } + val moreExpanded = MutableLiveData(false) fun onClickDrawerItem( index: Int, @@ -193,7 +123,7 @@ class NavDrawerViewModel } NavDrawerItem.More -> { - setShowMore(!showMore.value!!) + setShowMore(!moreExpanded.value!!) } NavDrawerItem.Discover -> { @@ -215,7 +145,7 @@ class NavDrawerViewModel } fun setShowMore(value: Boolean) { - showMore.value = value + moreExpanded.value = value } fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) @@ -289,15 +219,12 @@ fun NavDrawer( drawerState.setValue(DrawerValue.Open) focusRequester.requestFocus() } - val moreLibraries by viewModel.moreLibraries.observeAsState(listOf()) - val libraries by viewModel.libraries.observeAsState(listOf()) - LaunchedEffect(Unit) { viewModel.init() } - - val showMore by viewModel.showMore.observeAsState(false) - // A negative index is a built in page, >=0 is a library + val state by viewModel.state.collectAsState() + val moreExpanded by viewModel.moreExpanded.observeAsState(false) + // A negative index is a built-in page, >=0 is a library val selectedIndex by viewModel.selectedIndex.observeAsState(-1) - BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) { + BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) { viewModel.setShowMore(false) } @@ -412,51 +339,83 @@ fun NavDrawer( ), ) } - itemsIndexed(libraries) { index, it -> - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == index, - moreExpanded = showMore, - drawerOpen = isOpen, - interactionSource = interactionSource, - onClick = { - viewModel.onClickDrawerItem(index, it) - }, - modifier = - Modifier - .ifElse( - selectedIndex == index, - Modifier.focusRequester(focusRequester), - ), - ) - } - if (showMore) { - itemsIndexed(moreLibraries) { index, it -> - val adjustedIndex = (index + libraries.size + 1) + itemsIndexed(state.items) { index, it -> + if (it !is NavDrawerItem.Discover || state.discoverEnabled) { val interactionSource = remember { MutableInteractionSource() } NavItem( library = it, - selected = selectedIndex == adjustedIndex, - moreExpanded = showMore, + selected = selectedIndex == index, + moreExpanded = moreExpanded, drawerOpen = isOpen, - onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, - containerColor = - if (isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - } else { - Color.Unspecified - }, interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, it) + }, modifier = Modifier .ifElse( - selectedIndex == adjustedIndex, + selectedIndex == index, Modifier.focusRequester(focusRequester), ), ) } } + if (state.moreItems.isNotEmpty()) { + item { + val index = state.items.size + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = NavDrawerItem.More, + selected = selectedIndex == index, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, NavDrawerItem.More) + }, + modifier = + Modifier + .ifElse( + selectedIndex == index, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + if (moreExpanded) { + itemsIndexed(state.moreItems) { index, it -> + val adjustedIndex = + remember(state) { (index + state.items.size + 1) } + if (it !is NavDrawerItem.Discover || state.discoverEnabled) { + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = it, + selected = selectedIndex == adjustedIndex, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + onClick = { + viewModel.onClickDrawerItem( + adjustedIndex, + it, + ) + }, + containerColor = + if (isOpen) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + } else { + Color.Unspecified + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + selectedIndex == adjustedIndex, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + } item { val interactionSource = remember { MutableInteractionSource() } IconNavItem( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt new file mode 100644 index 00000000..0504d988 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -0,0 +1,245 @@ +package com.github.damontecres.wholphin.ui.preferences + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +data class NavDrawerPin( + val id: String, + val title: String, + val pinned: Boolean, + val item: NavDrawerItem, +) { + companion object { + fun create( + context: Context, + items: Map, + ) { + items.map { (item, pinned) -> + NavDrawerPin(item.id, item.name(context), pinned, item) + } + } + } +} + +enum class MoveDirection { + UP, + DOWN, +} + +private fun List.move( + direction: MoveDirection, + index: Int, +): List = + toMutableList().apply { + if (direction == MoveDirection.DOWN) { + val down = this[index] + val up = this[index + 1] + set(index, up) + set(index + 1, down) + } else { + val up = this[index] + val down = this[index - 1] + set(index - 1, up) + set(index, down) + } + } + +@Composable +fun NavDrawerPreference( + title: String, + summary: String?, + items: List, + onSave: (List) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + var showDialog by remember { mutableStateOf(false) } + ClickPreference( + title = title, + summary = summary, + onClick = { showDialog = true }, + interactionSource = interactionSource, + modifier = modifier, + ) + if (showDialog) { + NavDrawerPreferenceDialog( + items = items, + onDismissRequest = { showDialog = false }, + onClick = { index -> + val newItems = + items.toMutableList().apply { + set(index, items[index].let { it.copy(pinned = !it.pinned) }) + } + onSave.invoke(newItems) + }, + onMoveUp = { index -> + onSave(items.move(MoveDirection.UP, index)) + }, + onMoveDown = { index -> + onSave(items.move(MoveDirection.DOWN, index)) + }, + ) + } +} + +@Composable +fun NavDrawerPreferenceDialog( + items: List, + onDismissRequest: () -> Unit, + onClick: (Int) -> Unit, + onMoveUp: (Int) -> Unit, + onMoveDown: (Int) -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(R.string.nav_drawer_pins), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + NavDrawerPreferenceListItem( + title = item.title, + pinned = item.pinned, + moveUpAllowed = index > 0, + moveDownAllowed = index < items.lastIndex, + onClick = { onClick.invoke(index) }, + onMoveUp = { onMoveUp.invoke(index) }, + onMoveDown = { onMoveDown.invoke(index) }, + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +fun NavDrawerPreferenceListItem( + title: String, + pinned: Boolean, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + onClick: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + ListItem( + selected = false, + headlineContent = { + Text( + text = title, + ) + }, + trailingContent = { + Switch( + checked = pinned, + onCheckedChange = { + onClick.invoke() + }, + ) + }, + onClick = onClick, + modifier = Modifier.weight(1f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.wrapContentWidth(), + ) { + MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp) + MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown) + } + } + } +} + +@Composable +private fun MoveButton( + @StringRes icon: Int, + enabled: Boolean, + onClick: () -> Unit, +) = Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(32.dp), +) { + Text( + text = stringResource(icon), + fontSize = 16.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) +} + +@PreviewTvSpec +@Composable +fun NavDrawerPreferenceListItemPreview() { + WholphinTheme { + NavDrawerPreferenceListItem( + title = "Movies", + pinned = true, + moveUpAllowed = true, + moveDownAllowed = true, + onClick = {}, + onMoveUp = {}, + onMoveDown = { }, + modifier = Modifier.width(360.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 29fa873e..8db91b08 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -89,7 +89,7 @@ fun PreferencesContent( val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } - val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) + val navDrawerPins by viewModel.navDrawerPins.collectAsState(emptyList()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } @@ -335,21 +335,16 @@ fun PreferencesContent( } AppPreference.UserPinnedNavDrawerItems -> { - val selectedItems = - navDrawerPins.keys.mapNotNull { - if (navDrawerPins[it] ?: false) it else null - } - MultiChoicePreference( + NavDrawerPreference( title = stringResource(pref.title), summary = pref.summary(context, null), - possibleValues = navDrawerPins.keys, - selectedValues = selectedItems.toSet(), - onValueChange = { newSelectedItems -> - viewModel.updatePins(newSelectedItems) + items = navDrawerPins, + onSave = { + viewModel.updatePins(it) }, - ) { - Text(it.name(context)) - } + modifier = Modifier, + interactionSource = interactionSource, + ) } AppPreference.SendAppLogs -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index dd7a8e79..4113dbea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -2,14 +2,12 @@ package com.github.damontecres.wholphin.ui.preferences import android.content.Context import androidx.datastore.core.DataStore -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.isPinned import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType @@ -17,23 +15,28 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -48,6 +51,7 @@ class PreferencesViewModel private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val navDrawerItemRepository: NavDrawerItemRepository, + private val navDrawerService: NavDrawerService, private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, @@ -55,7 +59,46 @@ class PreferencesViewModel ) : ViewModel(), RememberTabManager by rememberTabManager { private lateinit var allNavDrawerItems: List - val navDrawerPins = MutableLiveData>(mapOf()) +// val navDrawerPins = MutableLiveData>(emptyList()) + + val navDrawerPins = + navDrawerService.state + .combine( + serverRepository.currentUser.asFlow(), + ) { state, user -> + Pair(state, user) + }.combine(seerrServerRepository.active) { (state, user), seerr -> + Triple(state, user, seerr) + }.map { (state, user, seerr) -> + withContext(Dispatchers.IO) { + val navDrawerPins = + serverPreferencesDao + .getNavDrawerPinnedItems(user!!) + .associateBy { it.itemId } + + val allItems = state.let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { + navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE + }.mapNotNull { + if (!seerr && it is NavDrawerItem.Discover) { + null + } else { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin( + it.id, + it.name(context), + pinned == NavPinType.PINNED, + it, + ) + } + } + + pins + } + } val currentUser get() = serverRepository.currentUser @@ -70,42 +113,50 @@ class PreferencesViewModel init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> - allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems() - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) +// fetchNavDrawerPins(user) } } } - fun updatePins(newSelectedItems: List) { + private suspend fun fetchNavDrawerPins(user: JellyfinUser) { + navDrawerService.state.map { + val navDrawerPins = + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + + val allItems = navDrawerService.state.first().let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } + .map { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin(it.id, it.name(context), pinned == NavPinType.PINNED, it) + } + pins + } + } + + fun updatePins(items: List) { viewModelScope.launchIO(ExceptionHandler(true)) { serverRepository.currentUser.value?.let { user -> - val disabledItems = - mutableListOf().apply { - addAll(allNavDrawerItems) - removeAll(newSelectedItems) + serverRepository.currentUserDto.value?.let { userDto -> + if (user.id == userDto.id) { + Timber.v("Updating pins") + val toSave = + items.mapIndexed { index, item -> + NavDrawerPinnedItem( + user.rowId, + item.id, + if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, + index, + ) + } + serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) + navDrawerService.updateNavDrawer(user, userDto) + } else { + throw IllegalStateException("User IDs do not match") } - val enabledItems = newSelectedItems.toSet() - val toSave = - disabledItems.map { - NavDrawerPinnedItem( - user.rowId, - it.id, - NavPinType.UNPINNED, - ) - } + - enabledItems.map { - NavDrawerPinnedItem( - user.rowId, - it.id, - NavPinType.PINNED, - ) - } - serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) + } } } } From 7fcd66f4073642f83f9f848ea50ac8c44630de97 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:19:33 -0500 Subject: [PATCH 10/80] Integrate nav drawer settings for default home page (#888) ## Description Customizing the home page (#803) and nav drawer (#886) were developed independently, so this PR just cleans up somethings so that those two changes work together. This PR restores the default home page when there is no customization to be the same as before: continue watching/next up row(s) followed by the order of the nav drawer libraries' recently added rows. Finally gets rid of `NavDrawerIteRepository` and related functions which all had weird, hard to follow code. ### Related issues Related to #803 & #886 ### Testing Emulator testing ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/NavDrawerItemRepository.kt | 97 ------------------- .../wholphin/services/HomeSettingsService.kt | 34 +++---- .../wholphin/services/NavDrawerService.kt | 25 +++++ .../wholphin/ui/main/HomeViewModel.kt | 17 +--- .../ui/main/settings/HomeSettingsViewModel.kt | 20 ++-- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 10 +- .../ui/preferences/PreferencesViewModel.kt | 5 - .../wholphin/test/TestHomeRowSamples.kt | 3 +- 8 files changed, 58 insertions(+), 153 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt deleted file mode 100644 index 2312c62c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.github.damontecres.wholphin.data - -import android.content.Context -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem -import com.github.damontecres.wholphin.data.model.NavPinType -import com.github.damontecres.wholphin.services.SeerrServerRepository -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem -import com.github.damontecres.wholphin.util.supportedCollectionTypes -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.first -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.liveTvApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.model.api.CollectionType -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NavDrawerItemRepository - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val serverPreferencesDao: ServerPreferencesDao, - private val seerrServerRepository: SeerrServerRepository, - ) { - suspend fun getNavDrawerItems(): List { - val user = serverRepository.currentUser.value - val tvAccess = - serverRepository.currentUserDto.value - ?.policy - ?.enableLiveTvAccess ?: false - val userViews = - api.userViewsApi - .getUserViews(userId = user?.id) - .content.items - val recordingFolders = - if (tvAccess) { - api.liveTvApi - .getRecordingFolders(userId = user?.id) - .content.items - .map { it.id } - .toSet() - } else { - setOf() - } - - val builtins = - if (seerrServerRepository.active.first()) { - listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) - } else { - listOf(NavDrawerItem.Favorites) - } - - val libraries = - userViews - .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } - .map { - val destination = - if (it.id in recordingFolders) { - Destination.Recordings(it.id) - } else { - BaseItem.from(it, api).destination() - } - ServerNavDrawerItem( - itemId = it.id, - name = it.name ?: it.id.toString(), - destination = destination, - type = it.collectionType ?: CollectionType.UNKNOWN, - ) - } - return builtins + libraries - } - - suspend fun getFilteredNavDrawerItems(items: List): List { - val user = serverRepository.currentUser.value - val navDrawerPins = - user - ?.let { - serverPreferencesDao.getNavDrawerPinnedItems(it) - }.orEmpty() - val filtered = items.filter { navDrawerPins.isPinned(it.id) } - if (items.size != filtered.size) { - // Some were filtered out, check if should include More - if (navDrawerPins.isPinned(NavDrawerItem.More.id)) { - return filtered + listOf(NavDrawerItem.More) - } - } - return filtered - } - } - -fun List.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index a868692a..083d461d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.services import android.content.Context import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.NavDrawerItemRepository +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig @@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.main.settings.Library -import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -64,8 +64,9 @@ class HomeSettingsService constructor( @param:ApplicationContext private val context: Context, private val api: ApiClient, + private val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, - private val navDrawerItemRepository: NavDrawerItemRepository, + private val navDrawerService: NavDrawerService, private val latestNextUpService: LatestNextUpService, private val imageUrlService: ImageUrlService, private val suggestionService: SuggestionService, @@ -233,7 +234,7 @@ class HomeSettingsService } HomePageResolvedSettings(resolvedRows) } else { - createDefault() + createDefault(userId) } currentSettings.update { resolvedSettings } @@ -251,25 +252,24 @@ class HomeSettingsService /** * Create a default [HomePageResolvedSettings] using the available libraries */ - suspend fun createDefault(): HomePageResolvedSettings { + suspend fun createDefault(userId: UUID): HomePageResolvedSettings { Timber.v("Creating default settings") - val navDrawerItems = navDrawerItemRepository.getNavDrawerItems() + val user = serverRepository.currentUser.value?.takeIf { it.id == userId } val libraries = - navDrawerItems - .filter { it is ServerNavDrawerItem } - .map { - it as ServerNavDrawerItem - Library(it.itemId, it.name, it.type) - } + if (user != null) { + navDrawerService.getFilteredUserLibraries(user) + } else { + navDrawerService.getAllUserLibraries(userId) + } + val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences + val includedIds = - navDrawerItemRepository - .getFilteredNavDrawerItems(navDrawerItems) - .filter { it is ServerNavDrawerItem } + libraries .mapIndexed { index, it -> - val parentId = (it as ServerNavDrawerItem).itemId - val name = libraries.firstOrNull { it.itemId == parentId }?.name + val parentId = it.itemId + val name = it.name.takeIf { it.isNotNullOrBlank() } val title = name?.let { context.getString(R.string.recently_added_in, it) } ?: context.getString(R.string.recently_added) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 785eb676..fbce042c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem @@ -26,6 +27,7 @@ import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.UserDto import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -66,6 +68,29 @@ class NavDrawerService }.launchIn(coroutineScope) } + suspend fun getAllUserLibraries(userId: UUID): List { + val userViews = + api.userViewsApi + .getUserViews(userId = userId) + .content.items + val libraries = + userViews + .filter { it.collectionType in supportedCollectionTypes } + .map { Library(it.id, it.name ?: "", it.collectionType ?: CollectionType.UNKNOWN) } + return libraries + } + + suspend fun getFilteredUserLibraries(user: JellyfinUser): List { + val pins = + serverPreferencesDao + .getNavDrawerPinnedItems(user) + .associateBy { it.itemId } + val libraries = + getAllUserLibraries(user.id) + .filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED } + return libraries + } + suspend fun updateNavDrawer( user: JellyfinUser, userDto: UserDto, 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 7ee22902..1e72830f 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 @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.main import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomeRowConfig @@ -13,11 +12,10 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.settings.Library -import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState @@ -46,8 +44,8 @@ class HomeViewModel @param:ApplicationContext private val context: Context, val navigationManager: NavigationManager, val serverRepository: ServerRepository, - val navDrawerItemRepository: NavDrawerItemRepository, val mediaReportService: MediaReportService, + private val navDrawerService: NavDrawerService, private val homeSettingsService: HomeSettingsService, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, @@ -69,17 +67,8 @@ class HomeViewModel val preferences = userPreferencesService.getCurrent() val prefs = preferences.appPreferences.homePagePreferences - val navDrawerItems = - navDrawerItemRepository - .getNavDrawerItems() - val libraries = - navDrawerItems - .filter { it is ServerNavDrawerItem } - .map { - it as ServerNavDrawerItem - Library(it.itemId, it.name, it.type) - } serverRepository.currentUserDto.value?.let { userDto -> + val libraries = navDrawerService.getAllUserLibraries(userDto.id) val settings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } val state = state.value diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 3a5f088a..53c457ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -7,7 +7,6 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings @@ -26,12 +25,12 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeRowConfigDisplay import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState @@ -68,7 +67,7 @@ class HomeSettingsViewModel private val homeSettingsService: HomeSettingsService, private val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, - private val navDrawerItemRepository: NavDrawerItemRepository, + private val navDrawerService: NavDrawerService, private val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, val preferencesDataStore: DataStore, @@ -84,16 +83,8 @@ class HomeSettingsViewModel init { addCloseable { saveToLocal() } viewModelScope.launchIO { - val navDrawerItems = - navDrawerItemRepository - .getNavDrawerItems() - val libraries = - navDrawerItems - .filter { it is ServerNavDrawerItem } - .map { - it as ServerNavDrawerItem - Library(it.itemId, it.name, it.type) - } + val userId = serverRepository.currentUser.value?.id ?: return@launchIO + val libraries = navDrawerService.getAllUserLibraries(userId) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } Timber.v("currentSettings=%s", currentSettings) @@ -525,8 +516,9 @@ class HomeSettingsViewModel fun resetToDefault() { viewModelScope.launchIO { + val userId = serverRepository.currentUser.value?.id ?: return@launchIO _state.update { it.copy(loading = LoadingState.Loading) } - val result = homeSettingsService.createDefault() + val result = homeSettingsService.createDefault(userId) _state.update { it.copy(rows = result.rows) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index f1c43618..3527a95e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -65,7 +65,6 @@ import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors @@ -73,7 +72,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome @@ -98,11 +96,9 @@ class NavDrawerViewModel constructor( private val api: ApiClient, private val navDrawerService: NavDrawerService, - private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, - private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { val state = navDrawerService.state @@ -184,9 +180,13 @@ data class ServerNavDrawerItem( val destination: Destination, val type: CollectionType, ) : NavDrawerItem { - override val id: String = "s_" + itemId.toServerString() + override val id: String = getId(itemId) override fun name(context: Context): String = name + + companion object { + fun getId(itemId: UUID) = "s_" + itemId.toServerString() + } } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 4113dbea..97223b89 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -5,7 +5,6 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -50,7 +49,6 @@ class PreferencesViewModel val backdropService: BackdropService, private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, - private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerService: NavDrawerService, private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, @@ -58,9 +56,6 @@ class PreferencesViewModel private val clientInfo: ClientInfo, ) : ViewModel(), RememberTabManager by rememberTabManager { - private lateinit var allNavDrawerItems: List -// val navDrawerPins = MutableLiveData>(emptyList()) - val navDrawerPins = navDrawerService.state .combine( diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index 3aedb3ba..675b8b26 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -121,8 +121,9 @@ class TestHomeRowSamples { HomeSettingsService( context = mockk(), api = mockk(), + serverRepository = mockk(), userPreferencesService = mockk(), - navDrawerItemRepository = mockk(), + navDrawerService = mockk(), latestNextUpService = mockk(), imageUrlService = mockk(), suggestionService = mockk(), From 91900af7a925f8bfe08173d1f1cb6c955530d833 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:41:47 -0500 Subject: [PATCH 11/80] Improved home page support for Live TV & Recordings (#890) ## Description Improves home page customization for Live TV & Recordings - Add row for live tv channels - Add row for "on now" programs - Clicking on a TV channel or TV program starts playback - Better name for "Recently recorded" instead of "Recently added in Recordings" Also fixes a back stack issue after adding rows ### Related issues Follow up from #803 ### Testing Emulator ## Screenshots image ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 19 +++++ .../wholphin/data/model/HomeRowConfig.kt | 21 ++++- .../wholphin/preferences/AppPreference.kt | 1 + .../wholphin/services/HomeSettingsService.kt | 81 ++++++++++++++----- .../wholphin/services/NavDrawerService.kt | 74 ++++++++++------- .../damontecres/wholphin/ui/UiConstants.kt | 2 +- .../wholphin/ui/main/HomeViewModel.kt | 4 +- .../main/settings/HomeLibraryRowTypeList.kt | 41 ++++++++-- .../ui/main/settings/HomeSettingsPage.kt | 4 +- .../ui/main/settings/HomeSettingsViewModel.kt | 41 +++++++++- .../wholphin/ui/nav/DestinationContent.kt | 2 +- app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestHomeRowSamples.kt | 2 + 13 files changed, 229 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 3030b7d0..172c6ef5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -187,6 +187,25 @@ data class BaseItem( ) } + BaseItemKind.TV_CHANNEL -> { + Destination.Playback( + itemId = id, + positionMs = 0L, + ) + } + + BaseItemKind.PROGRAM -> { + val channelId = data.channelId + if (channelId != null) { + Destination.Playback( + itemId = channelId, + positionMs = 0L, + ) + } else { + Destination.MediaItem(this) + } + } + else -> { Destination.MediaItem(this) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 7d521ce4..c11ad321 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -111,7 +111,7 @@ sealed interface HomeRowConfig { } /** - * + * Currently recording */ @Serializable @SerialName("Recordings") @@ -122,7 +122,7 @@ sealed interface HomeRowConfig { } /** - * + * Programs on now/recommended */ @Serializable @SerialName("TvPrograms") @@ -132,6 +132,17 @@ sealed interface HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions) } + /** + * Live TV channels + */ + @Serializable + @SerialName("TvChannels") + data class TvChannels( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions) + } + /** * Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID */ @@ -217,5 +228,11 @@ data class HomeRowViewOptions( heightDp = Cards.HEIGHT_EPISODE, aspectRatio = AspectRatio.WIDE, ) + + val channelsDefault = + HomeRowViewOptions( + heightDp = 96, + aspectRatio = AspectRatio.WIDE, + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 7eaa2ffe..709abd6c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -660,6 +660,7 @@ sealed interface AppPreference { AppDestinationPreference( title = R.string.customize_home, destination = Destination.HomeSettings, + summary = R.string.customize_home_summary, ) val SendCrashReports = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 083d461d..99ced240 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -12,13 +12,14 @@ import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext @@ -43,6 +44,7 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder @@ -255,11 +257,12 @@ class HomeSettingsService suspend fun createDefault(userId: UUID): HomePageResolvedSettings { Timber.v("Creating default settings") val user = serverRepository.currentUser.value?.takeIf { it.id == userId } + val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId } val libraries = if (user != null) { - navDrawerService.getFilteredUserLibraries(user) + navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false) } else { - navDrawerService.getAllUserLibraries(userId) + navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false) } val prefs = @@ -269,18 +272,23 @@ class HomeSettingsService libraries .mapIndexed { index, it -> val parentId = it.itemId - val name = it.name.takeIf { it.isNotNullOrBlank() } - val title = - name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) - HomeRowConfigDisplay( - id = index, - title = title, - config = HomeRowConfig.RecentlyAdded(parentId), - ) + val title = getRecentlyAddedTitle(context, it) + if (it.collectionType == CollectionType.LIVETV) { + HomeRowConfigDisplay( + id = index, + title = context.getString(R.string.live_tv), + config = HomeRowConfig.TvPrograms(), + ) + } else { + HomeRowConfigDisplay( + id = index, + title = title, + config = HomeRowConfig.RecentlyAdded(parentId), + ) + } } val continueWatchingRows = - if (prefs.combineContinueNext) { // TODO + if (prefs.combineContinueNext) { listOf( HomeRowConfigDisplay( id = includedIds.size + 1, @@ -363,7 +371,7 @@ class HomeSettingsService } HomeSectionType.LIVE_TV -> { - if (userDto.policy?.enableLiveTvAccess == true) { + if (userDto.tvAccess) { HomeRowConfigDisplay( id = id++, title = context.getString(R.string.live_tv), @@ -523,6 +531,14 @@ class HomeSettingsService ) } + is HomeRowConfig.TvChannels -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.channels), + config, + ) + } + is HomeRowConfig.Suggestions -> { val name = api.userLibraryApi @@ -654,13 +670,10 @@ class HomeSettingsService } is HomeRowConfig.RecentlyAdded -> { - val name = + val library = libraries .firstOrNull { it.itemId == row.parentId } - ?.name - val title = - name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) + val title = getRecentlyAddedTitle(context, library) val request = GetLatestMediaRequest( fields = SlimItemFields, @@ -862,6 +875,23 @@ class HomeSettingsService } } + is HomeRowConfig.TvChannels -> { + api.liveTvApi + .getLiveTvChannels( + userId = userDto.id, + fields = DefaultItemFields, + limit = limit, + enableImages = true, + ).toBaseItems(api, row.viewOptions.useSeries) + .let { + Success( + context.getString(R.string.channels), + it, + row.viewOptions, + ) + } + } + is HomeRowConfig.Suggestions -> { val library = api.userLibraryApi @@ -888,7 +918,7 @@ class HomeSettingsService row.viewOptions, ) } else { - HomeRowLoadingState.Error( + Error( title, message = "Unsupported type ${library.collectionType}", ) @@ -949,3 +979,14 @@ class UnsupportedHomeSettingsVersionException( val unsupportedVersion: Int?, val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, ) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") + +fun getRecentlyAddedTitle( + context: Context, + library: Library?, +): String = + if (library?.isRecordingFolder == true) { + context.getString(R.string.recently_recorded) + } else { + library?.name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index fbce042c..adb21ff7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.lifecycle.asFlow import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope @@ -68,25 +67,49 @@ class NavDrawerService }.launchIn(coroutineScope) } - suspend fun getAllUserLibraries(userId: UUID): List { + suspend fun getAllUserLibraries( + userId: UUID, + tvAccess: Boolean, + ): List { val userViews = api.userViewsApi .getUserViews(userId = userId) .content.items + val recordingFolders = + if (tvAccess) { + api.liveTvApi + .getRecordingFolders(userId = userId) + .content.items + .map { it.id } + .toSet() + } else { + setOf() + } val libraries = userViews - .filter { it.collectionType in supportedCollectionTypes } - .map { Library(it.id, it.name ?: "", it.collectionType ?: CollectionType.UNKNOWN) } + .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + .map { + Library( + itemId = it.id, + name = it.name ?: "", + type = it.type, + collectionType = it.collectionType ?: CollectionType.UNKNOWN, + isRecordingFolder = it.id in recordingFolders, + ) + } return libraries } - suspend fun getFilteredUserLibraries(user: JellyfinUser): List { + suspend fun getFilteredUserLibraries( + user: JellyfinUser, + tvAccess: Boolean, + ): List { val pins = serverPreferencesDao .getNavDrawerPinnedItems(user) .associateBy { it.itemId } val libraries = - getAllUserLibraries(user.id) + getAllUserLibraries(user.id, tvAccess) .filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED } return libraries } @@ -95,39 +118,26 @@ class NavDrawerService user: JellyfinUser, userDto: UserDto, ) { - val tvAccess = userDto.policy?.enableLiveTvAccess ?: false - val userViews = - api.userViewsApi - .getUserViews(userId = user.id) - .content.items - val recordingFolders = - if (tvAccess) { - api.liveTvApi - .getRecordingFolders(userId = user.id) - .content.items - .map { it.id } - .toSet() - } else { - setOf() - } - val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) - + val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess) val libraries = - userViews - .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + allLibraries .map { val destination = - if (it.id in recordingFolders) { - Destination.Recordings(it.id) + if (it.isRecordingFolder) { + Destination.Recordings(it.itemId) } else { - BaseItem.from(it, api).destination() + Destination.MediaItem( + it.itemId, + it.type, + it.collectionType, + ) } ServerNavDrawerItem( - itemId = it.id, - name = it.name ?: it.id.toString(), + itemId = it.itemId, + name = it.name, destination = destination, - type = it.collectionType ?: CollectionType.UNKNOWN, + type = it.collectionType, ) } val allItems = builtins + libraries @@ -170,3 +180,5 @@ data class NavDrawerItemState( val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) } } + +val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 0db41202..2d9b6d5e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -84,7 +84,7 @@ val PhotoItemFields = object Cards { const val HEIGHT_2X3_DP = 172 val height2x3 = HEIGHT_2X3_DP.dp - val HEIGHT_EPISODE = (HEIGHT_2X3_DP * .75f).toInt().let { it - it % 4 } + const val HEIGHT_EPISODE = 128 val heightEpisode = HEIGHT_EPISODE.dp val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f 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 1e72830f..58296848 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 @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler @@ -68,7 +69,8 @@ class HomeViewModel val prefs = preferences.appPreferences.homePagePreferences serverRepository.currentUserDto.value?.let { userDto -> - val libraries = navDrawerService.getAllUserLibraries(userDto.id) + val libraries = + navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val settings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } val state = state.value diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index c680e077..87ce4d39 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.services.SuggestionsWorker import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.CollectionType @Composable fun HomeLibraryRowTypeList( @@ -63,11 +64,38 @@ fun HomeLibraryRowTypeList( } fun getSupportedRowTypes(library: Library): List { - val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) - return if (itemKind != null) { - LibraryRowType.entries - } else { - LibraryRowType.entries.toMutableList().apply { remove(LibraryRowType.SUGGESTIONS) } + val supportsSuggestions = SuggestionsWorker.getTypeForCollection(library.collectionType) != null + return when { + library.isRecordingFolder -> { + listOf( + LibraryRowType.RECENTLY_RECORDED, + LibraryRowType.GENRES, + ) + } + + library.collectionType == CollectionType.LIVETV -> { + listOf( + LibraryRowType.TV_CHANNELS, + LibraryRowType.TV_PROGRAMS, + ) + } + + supportsSuggestions -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.SUGGESTIONS, + LibraryRowType.GENRES, + ) + } + + else -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + ) + } } } @@ -78,4 +106,7 @@ enum class LibraryRowType( RECENTLY_RELEASED(R.string.recently_released), SUGGESTIONS(R.string.suggestions), GENRES(R.string.genres), + TV_CHANNELS(R.string.channels), + TV_PROGRAMS(R.string.live_tv), + RECENTLY_RECORDED(R.string.recently_recorded), } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 616eff13..17a02b59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -61,7 +61,9 @@ fun HomeSettingsPage( // Adds a row, waits until its done loading, then scrolls to the new row fun addRow(func: () -> Job) { scope.launch(ExceptionHandler(autoToast = true)) { - backStack.add(HomeSettingsDestination.RowList) + while (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } func.invoke().join() listState.animateScrollToItem(state.rows.lastIndex) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 53c457ff..11fd058c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.HomeRowLoadingState @@ -83,8 +84,8 @@ class HomeSettingsViewModel init { addCloseable { saveToLocal() } viewModelScope.launchIO { - val userId = serverRepository.currentUser.value?.id ?: return@launchIO - val libraries = navDrawerService.getAllUserLibraries(userId) + val userDto = serverRepository.currentUserDto.value ?: return@launchIO + val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } Timber.v("currentSettings=%s", currentSettings) @@ -292,6 +293,36 @@ class HomeSettingsViewModel config = Suggestions(library.itemId), ) } + + LibraryRowType.TV_CHANNELS -> { + val title = context.getString(R.string.channels) + HomeRowConfigDisplay( + id = id, + title = title, + config = + HomeRowConfig.TvChannels( + viewOptions = HomeRowViewOptions.channelsDefault, + ), + ) + } + + LibraryRowType.TV_PROGRAMS -> { + val title = context.getString(R.string.watch_live) + HomeRowConfigDisplay( + id = id, + title = title, + config = HomeRowConfig.TvPrograms(), + ) + } + + LibraryRowType.RECENTLY_RECORDED -> { + val title = context.getString(R.string.recently_recorded) + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyAdded(library.itemId), + ) + } } updateState { it.copy( @@ -614,6 +645,10 @@ class HomeSettingsViewModel is HomeRowConfig.TvPrograms -> { it.config.updateViewOptions(preset.tvLibrary) } + + is HomeRowConfig.TvChannels -> { + it.config + } } it.copy(config = newConfig) } @@ -658,5 +693,7 @@ data class HomePageSettingsState( data class Library( @Serializable(UUIDSerializer::class) val itemId: UUID, val name: String, + val type: BaseItemKind, val collectionType: CollectionType, + val isRecordingFolder: Boolean, ) 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 a16e909a..eccfd6de 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 @@ -214,7 +214,7 @@ fun DestinationContent( else -> { Timber.w("Unsupported item type: ${destination.type}") - Text("Unsupported item type: ${destination.type}") + Text("Unsupported item type: ${destination.type}", modifier) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8533441..3d7b1b5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -520,6 +520,7 @@ Settings saved Display presets Built-in presets to quickly style all rows + Choose rows and images on the home page Disabled diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index 675b8b26..84aa72b6 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -74,6 +74,7 @@ class TestHomeRowSamples { HomeRowConfig.Favorite(kind = BaseItemKind.SERIES), HomeRowConfig.Recordings(), HomeRowConfig.TvPrograms(), + HomeRowConfig.TvChannels(), HomeRowConfig.Suggestions(parentId = UUID.randomUUID()), ) } @@ -96,6 +97,7 @@ class TestHomeRowSamples { is HomeRowConfig.Recordings -> foundTypes.add(it::class) is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) is HomeRowConfig.Suggestions -> foundTypes.add(it::class) + is HomeRowConfig.TvChannels -> foundTypes.add(it::class) } } Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) From aa69646b2db96f4dcad2c16e1a3bf28b5d2aab62 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:49:19 -0500 Subject: [PATCH 12/80] Upgrade AGP to 9.x (#892) ## Description This PR has no user facing changes. Upgrade AGP to 9.x and Gradle to 9.x. Also updates some dependencies as well. Note: still using deprecated `android.newDsl=false` because the protobuf plugin is not yet compatible: https://github.com/google/protobuf-gradle-plugin/issues/793. ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- app/build.gradle.kts | 4 ++-- build.gradle.kts | 1 - gradle.properties | 10 ++++++++++ gradle/libs.versions.toml | 16 ++++++---------- gradle/wrapper/gradle-wrapper.properties | 2 +- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29c36ac2..5ab4a9cc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,7 +7,6 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.compose) alias(libs.plugins.hilt) @@ -70,6 +69,7 @@ android { } kotlin { compilerOptions { + languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 jvmTarget = JvmTarget.JVM_11 javaParameters = true } @@ -150,7 +150,7 @@ android { sourceSets { getByName("main") { - kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin") + kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" } } } diff --git a/build.gradle.kts b/build.gradle.kts index f2004532..7e765b97 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.protobuf) apply false alias(libs.plugins.ksp) apply false diff --git a/gradle.properties b/gradle.properties index 132244e5..23480cc7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,3 +21,13 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=false +android.sdk.defaultTargetSdkToCompileSdkIfUnset=true +android.enableAppCompileTimeRClass=true +android.usesSdkInManifest.disallowed=true +android.uniquePackageNames=true +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=true +android.r8.optimizedResourceShrinking=true +android.builtInKotlin=true +android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92c7835c..69f0d876 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,18 @@ [versions] aboutLibraries = "13.2.1" acra = "5.13.1" -agp = "8.13.2" +agp = "9.0.1" 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" kache = "2.1.1" -kotlin = "2.3.0" -kotlinxCoroutinesCore = "1.10.2" +kotlin = "2.3.10" ksp = "2.3.5" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2026.01.01" +composeBom = "2026.02.00" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.39.2" @@ -25,18 +23,18 @@ timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.3" +activityCompose = "1.12.4" androidx-media3 = "1.9.2" coil = "3.3.0" jellyfin-sdk = "1.7.1" -nav3Core = "1.0.0" +nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.10.0" protobuf-javalite = "4.33.5" -hilt = "2.58" +hilt = "2.59.1" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" @@ -83,7 +81,6 @@ 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" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } @@ -135,7 +132,6 @@ androidx-core-testing = { module = "androidx.arch.core:core-testing", version.re [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aaaabb3c..23449a2b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 1e810c11574c61b0cd3c9ddef0ad53da5ac4c587 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:30:03 -0500 Subject: [PATCH 13/80] Some fixes to customize homes (#893) ## Description - Update header when editing a row - Exclude non-empty rows to prevent scroll lock - Only save settings locally if they are different from the source ### Related issues Related to #803 ### Testing Emulator, tested remote & local settings changes ## Screenshots N/A ## AI or LLM usage None --- .../ui/components/RecommendedContent.kt | 19 +++++++++++- .../damontecres/wholphin/ui/main/HomePage.kt | 30 +++++++------------ .../wholphin/ui/main/HomeViewModel.kt | 9 +++++- .../ui/main/settings/HomeSettingsPage.kt | 16 +++++++++- .../ui/main/settings/HomeSettingsViewModel.kt | 18 +++++++++-- 5 files changed, 67 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 174f4665..908574e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState @@ -149,8 +150,10 @@ fun RecommendedContent( } LoadingState.Success -> { + var position by rememberPosition() HomePageContent( homeRows = rows, + position = position, onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) }, @@ -160,7 +163,21 @@ fun RecommendedContent( onClickPlay = { _, item -> viewModel.navigationManager.navigateTo(Destination.Playback(item)) }, - onFocusPosition = onFocusPosition, + onFocusPosition = { + position = it + val nonEmptyRowBefore = + rows + .subList(0, it.row) + .count { + it is HomeRowLoadingState.Success && it.items.isEmpty() + } + onFocusPosition?.invoke( + RowColumn( + it.row - nonEmptyRowBefore, + it.column, + ), + ) + }, showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, modifier = modifier, 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 50acc56f..b9a7cbd7 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 @@ -117,12 +117,17 @@ fun HomePage( var dialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var position by rememberPosition() HomePageContent( homeRows = homeRows, - onClickItem = { position, item -> + position = position, + onFocusPosition = { position = it }, + onClickItem = { clickedPosition, item -> + position = clickedPosition viewModel.navigationManager.navigateTo(item.destination()) }, - onLongClickItem = { position, item -> + onLongClickItem = { clickedPosition, item -> + position = clickedPosition val dialogItems = buildMoreDialogItemsForHome( context = context, @@ -193,19 +198,19 @@ fun HomePage( @Composable fun HomePageContent( homeRows: List, + position: RowColumn, + onFocusPosition: (RowColumn) -> Unit, onClickItem: (RowColumn, BaseItem) -> Unit, onLongClickItem: (RowColumn, BaseItem) -> Unit, onClickPlay: (RowColumn, BaseItem) -> Unit, showClock: Boolean, onUpdateBackdrop: (BaseItem) -> Unit, modifier: Modifier = Modifier, - onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, listState: LazyListState = rememberLazyListState(), takeFocus: Boolean = true, showEmptyRows: Boolean = false, ) { - var position by rememberPosition() val focusedItem = position.let { (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) @@ -329,21 +334,8 @@ fun HomePageContent( cardModifier .onFocusChanged { if (it.isFocused) { - position = - RowColumn(rowIndex, index) - } - if (it.isFocused && onFocusPosition != null) { - val nonEmptyRowBefore = - homeRows - .subList(0, rowIndex) - .count { - it is HomeRowLoadingState.Success && it.items.isEmpty() - } - onFocusPosition.invoke( - RowColumn( - rowIndex - nonEmptyRowBefore, - index, - ), + onFocusPosition?.invoke( + RowColumn(rowIndex, index), ) } }.onKeyEvent { 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 58296848..4471194e 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 @@ -131,7 +131,14 @@ class HomeViewModel ) } } - val rows = deferred.awaitAll() + val rows = + deferred + .awaitAll() + .filter { + // Include only errors & non-empty successes + it is HomeRowLoadingState.Error || + (it is HomeRowLoadingState.Success && it.items.isNotEmpty()) + } Timber.v("Got all rows") _state.update { it.copy( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 17a02b59..d729247a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -33,11 +33,14 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings +import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState import kotlinx.coroutines.Job import kotlinx.coroutines.launch import timber.log.Timber @@ -55,6 +58,7 @@ fun HomeSettingsPage( var showConfirmDialog by remember { mutableStateOf(null) } val state by viewModel.state.collectAsState() + var position by rememberPosition(0, 0) // TODO discover rows val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) @@ -109,7 +113,15 @@ fun HomeSettingsPage( backStack.add(RowSettings(row.id)) scope.launch(ExceptionHandler()) { Timber.v("Scroll to $index") - listState.scrollToItem(index) + listState.animateScrollToItem(index) + // Update backdrop to first item in the row + (state.rowData.getOrNull(index) as? HomeRowLoadingState.Success) + ?.items + ?.firstOrNull() + ?.let { + viewModel.updateBackdrop(it) + } + position = RowColumn(index, 0) } }, modifier = destModifier, @@ -259,6 +271,8 @@ fun HomeSettingsPage( HomePageContent( loadingState = state.loading, homeRows = state.rowData, + position = position, + onFocusPosition = { position = it }, onClickItem = { _, _ -> }, onLongClickItem = { _, _ -> }, onClickPlay = { _, _ -> }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 11fd058c..7400a872 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -81,6 +81,9 @@ class HomeSettingsViewModel val discoverEnabled = seerrServerRepository.active + private var originalLocalSettings: HomePageSettings? = null + private var originalRemoteSettings: HomePageSettings? = null + init { addCloseable { saveToLocal() } viewModelScope.launchIO { @@ -88,6 +91,8 @@ class HomeSettingsViewModel val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } + originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id) + originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id) Timber.v("currentSettings=%s", currentSettings) idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { @@ -493,9 +498,16 @@ class HomeSettingsViewModel HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) try { Timber.d("saveToLocal") - val local = homeSettingsService.loadFromLocal(user.id) - // Only save if there are changes - if (local != settings) { + // Only save if there are changes based on original source + val shouldSave = + if (originalLocalSettings != null) { + originalLocalSettings != settings + } else if (originalRemoteSettings != null) { + originalRemoteSettings != settings + } else { + true + } + if (shouldSave) { homeSettingsService.saveToLocal(user.id, settings) homeSettingsService.updateCurrent(settings) showSaveToast() From 44eb50910d4e38666ef3474dd22c9c2ef52c2fa3 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:13:38 -0500 Subject: [PATCH 14/80] Fixes unable to dismiss next up during credits (#899) ## Description Fixes an issue where the "Next Up" popup continuously pops up when dismissed if you have "Show next up during credits/outro" enabled. ### Related issues Fixes #895 Bug introduced by #884 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/playback/PlaybackViewModel.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 7ed25311..b3a446d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -165,7 +165,6 @@ class PlaybackViewModel val currentPlayback = MutableStateFlow(null) val currentItemPlayback = MutableLiveData() val currentSegment = MutableStateFlow(null) - private val autoSkippedSegments = mutableSetOf() val subtitleCues = MutableLiveData>(listOf()) @@ -957,7 +956,10 @@ class PlaybackViewModel } } + // Variables for tracking segment state private var segmentJob: Job? = null + private val autoSkippedSegments = mutableSetOf() + private val outroShownSegments = mutableSetOf() /** * Cancels listening for segments and clears current segment state @@ -965,6 +967,7 @@ class PlaybackViewModel private fun resetSegmentState() { segmentJob?.cancel() autoSkippedSegments.clear() + outroShownSegments.clear() currentSegment.value = null } @@ -1007,7 +1010,8 @@ class PlaybackViewModel if (currentSegment.type == MediaSegmentType.OUTRO && prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS && - playlist != null && playlist.hasNext() + playlist != null && playlist.hasNext() && + outroShownSegments.add(currentSegment.id) ) { val nextItem = playlist.peek() Timber.v("Setting next up during outro to ${nextItem?.id}") From 0276c5f49b2d33e257ebf2569ae9b64bc0e47ecc Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:13:45 -0500 Subject: [PATCH 15/80] Fix reordering the nav drawer items not always saving the changes (#898) ## Description Fixes the nav drawer preference reordering & save logic. Also improves the UI a bit with animations. It's also now stored in memory and saved when the dialog is dismissed. ### Related issues Related to #886 ### Testing Emulator mostly ## Screenshots N/A ## AI or LLM usage None --- .../ui/preferences/NavDrawerPreference.kt | 136 ++++++++++++++++-- .../ui/preferences/PreferencesContent.kt | 5 - .../ui/preferences/PreferencesViewModel.kt | 94 ------------ 3 files changed, 128 insertions(+), 107 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt index 0504d988..afdc93a1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -15,7 +15,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -26,17 +28,44 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.datastore.core.DataStore +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Switch import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerPreferencesDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavDrawerItemState +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.RememberTabManager +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import javax.inject.Inject data class NavDrawerPin( val id: String, @@ -83,11 +112,11 @@ private fun List.move( fun NavDrawerPreference( title: String, summary: String?, - items: List, - onSave: (List) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + viewModel: NavDrawerPreferencesViewModel = hiltViewModel(), ) { + val items by viewModel.state.collectAsState() var showDialog by remember { mutableStateOf(false) } ClickPreference( title = title, @@ -99,19 +128,22 @@ fun NavDrawerPreference( if (showDialog) { NavDrawerPreferenceDialog( items = items, - onDismissRequest = { showDialog = false }, + onDismissRequest = { + viewModel.save() + showDialog = false + }, onClick = { index -> val newItems = items.toMutableList().apply { set(index, items[index].let { it.copy(pinned = !it.pinned) }) } - onSave.invoke(newItems) + viewModel.update(newItems) }, onMoveUp = { index -> - onSave(items.move(MoveDirection.UP, index)) + viewModel.update(items.move(MoveDirection.UP, index)) }, onMoveDown = { index -> - onSave(items.move(MoveDirection.DOWN, index)) + viewModel.update(items.move(MoveDirection.DOWN, index)) }, ) } @@ -127,9 +159,12 @@ fun NavDrawerPreferenceDialog( ) { BasicDialog( onDismissRequest = onDismissRequest, + elevation = 3.dp, ) { Column( - modifier = Modifier.padding(16.dp), + modifier = + Modifier + .padding(16.dp), ) { Text( text = stringResource(R.string.nav_drawer_pins), @@ -137,7 +172,9 @@ fun NavDrawerPreferenceDialog( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp), ) + val listState = rememberLazyListState() LazyColumn( + state = listState, verticalArrangement = Arrangement.spacedBy(0.dp), ) { itemsIndexed(items, key = { _, item -> item.id }) { index, item -> @@ -149,7 +186,7 @@ fun NavDrawerPreferenceDialog( onClick = { onClick.invoke(index) }, onMoveUp = { onMoveUp.invoke(index) }, onMoveDown = { onMoveDown.invoke(index) }, - modifier = Modifier, + modifier = Modifier.animateItem(), ) } } @@ -243,3 +280,86 @@ fun NavDrawerPreferenceListItemPreview() { ) } } + +@HiltViewModel +class NavDrawerPreferencesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val preferenceDataStore: DataStore, + val navigationManager: NavigationManager, + val backdropService: BackdropService, + private val rememberTabManager: RememberTabManager, + private val serverRepository: ServerRepository, + private val navDrawerService: NavDrawerService, + private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, + ) : ViewModel() { + val state = MutableStateFlow>(listOf()) + + init { + viewModelScope.launchDefault { + val state = navDrawerService.state.value + val user = serverRepository.currentUser.value + val seerr = seerrServerRepository.active.firstOrNull() + if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) { + return@launchDefault + } + val navDrawerPins = + withContext(Dispatchers.IO) { + serverPreferencesDao + .getNavDrawerPinnedItems(user) + .associateBy { it.itemId } + } + val allItems = state.let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { + navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE + }.mapNotNull { + if (!seerr && it is NavDrawerItem.Discover) { + null + } else { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin( + it.id, + it.name(context), + pinned == NavPinType.PINNED, + it, + ) + } + } + this@NavDrawerPreferencesViewModel.state.value = pins + } + } + + fun update(items: List) { + state.update { items } + } + + fun save() { + viewModelScope.launchIO(ExceptionHandler(true)) { + serverRepository.currentUser.value?.let { user -> + serverRepository.currentUserDto.value?.let { userDto -> + if (user.id == userDto.id) { + val toSave = + state.value.mapIndexed { index, item -> + NavDrawerPinnedItem( + user.rowId, + item.id, + if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, + index, + ) + } + serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) + navDrawerService.updateNavDrawer(user, userDto) + } else { + throw IllegalStateException("User IDs do not match") + } + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 8db91b08..a806c31a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -89,7 +89,6 @@ fun PreferencesContent( val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } - val navDrawerPins by viewModel.navDrawerPins.collectAsState(emptyList()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } @@ -338,10 +337,6 @@ fun PreferencesContent( NavDrawerPreference( title = stringResource(pref.title), summary = pref.summary(context, null), - items = navDrawerPins, - onSave = { - viewModel.updatePins(it) - }, modifier = Modifier, interactionSource = interactionSource, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 97223b89..709b6d9d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -5,37 +5,27 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem -import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo -import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -49,52 +39,11 @@ class PreferencesViewModel val backdropService: BackdropService, private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, - private val navDrawerService: NavDrawerService, - private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, ) : ViewModel(), RememberTabManager by rememberTabManager { - val navDrawerPins = - navDrawerService.state - .combine( - serverRepository.currentUser.asFlow(), - ) { state, user -> - Pair(state, user) - }.combine(seerrServerRepository.active) { (state, user), seerr -> - Triple(state, user, seerr) - }.map { (state, user, seerr) -> - withContext(Dispatchers.IO) { - val navDrawerPins = - serverPreferencesDao - .getNavDrawerPinnedItems(user!!) - .associateBy { it.itemId } - - val allItems = state.let { it.items + it.moreItems } - val pins = - allItems - .sortedBy { - navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE - }.mapNotNull { - if (!seerr && it is NavDrawerItem.Discover) { - null - } else { - // Assume pinned if unknown - val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED - NavDrawerPin( - it.id, - it.name(context), - pinned == NavPinType.PINNED, - it, - ) - } - } - - pins - } - } - val currentUser get() = serverRepository.currentUser val seerrEnabled = @@ -113,49 +62,6 @@ class PreferencesViewModel } } - private suspend fun fetchNavDrawerPins(user: JellyfinUser) { - navDrawerService.state.map { - val navDrawerPins = - serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } - - val allItems = navDrawerService.state.first().let { it.items + it.moreItems } - val pins = - allItems - .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } - .map { - // Assume pinned if unknown - val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED - NavDrawerPin(it.id, it.name(context), pinned == NavPinType.PINNED, it) - } - pins - } - } - - fun updatePins(items: List) { - viewModelScope.launchIO(ExceptionHandler(true)) { - serverRepository.currentUser.value?.let { user -> - serverRepository.currentUserDto.value?.let { userDto -> - if (user.id == userDto.id) { - Timber.v("Updating pins") - val toSave = - items.mapIndexed { index, item -> - NavDrawerPinnedItem( - user.rowId, - item.id, - if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, - index, - ) - } - serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - navDrawerService.updateNavDrawer(user, userDto) - } else { - throw IllegalStateException("User IDs do not match") - } - } - } - } - } - fun sendAppLogs() { sendAppLogs(context, api, clientInfo, deviceInfo) } From dc3c6dc7397f98d8ad27f52e62ebf3cbd50e59c3 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:04:28 -0500 Subject: [PATCH 16/80] Fix some cases when refresh rate switching didn't work/timed out (#901) ## Description An attempt to fix #769 1. Always fetch the `DisplayManager` when switching refresh rates 2. Use the Activity context instead of Application context ### Related issues Fixes #769 ### Testing I still haven't reproduced #769 myself, but this change works fine on my shield + LG C2 ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 2 +- .../wholphin/services/RefreshRateService.kt | 27 +++++++++---------- .../wholphin/ui/detail/DebugPage.kt | 14 +++++++--- 3 files changed, 24 insertions(+), 19 deletions(-) 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 dcc25af0..34bd871c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -409,7 +409,7 @@ class MainActivity : AppCompatActivity() { } fun changeDisplayMode(modeId: Int) { - lifecycleScope.launch(Dispatchers.Main + ExceptionHandler()) { + lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) { val attrs = window.attributes if (attrs.preferredDisplayModeId != modeId) { Timber.d("Switch preferredDisplayModeId to %s", modeId) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index bc0c8f94..2ca3d1b1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -28,21 +28,6 @@ class RefreshRateService constructor( @param:ApplicationContext private val context: Context, ) { - private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - private val display get() = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - - val supportedDisplayModes get() = display.supportedModes.orEmpty() - - private val displayModes: List by lazy { - display.supportedModes - .orEmpty() - .map { DisplayMode(it) } - .sortedWith( - compareByDescending({ it.physicalWidth * it.physicalHeight }) - .thenBy { it.refreshRateRounded }, - ) - } - /** * Find the best display mode for the given stream and signal to change to it */ @@ -55,6 +40,18 @@ class RefreshRateService Timber.v("Not switching either refresh rate nor resolution") return@withContext } + val displayManager = + MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + val displayModes = + display.supportedModes + .orEmpty() + .map { DisplayMode(it) } + .sortedWith( + compareByDescending({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } val width = stream.width 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 0abcdc07..cfae65ba 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 @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context +import android.hardware.display.DisplayManager import android.os.Build import android.util.Log +import android.view.Display import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy @@ -32,11 +34,11 @@ import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler @@ -60,13 +62,19 @@ class DebugViewModel constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - val refreshRateService: RefreshRateService, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, ) : ViewModel() { val itemPlaybacks = MutableLiveData>(listOf()) val logcat = MutableLiveData>(listOf()) + val supportedModes by lazy { + val displayManager = + MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + display.supportedModes.orEmpty() + } + init { viewModelScope.launchIO { serverRepository.currentUser.value?.rowId?.let { @@ -260,7 +268,7 @@ fun DebugPage( "Model: ${Build.MODEL}", "API Level: ${Build.VERSION.SDK_INT}", "Display Modes:", - *viewModel.refreshRateService.supportedDisplayModes, + *viewModel.supportedModes, ).forEach { Text( text = it.toString(), From 8c2227bf67b34a44541733ac3529724240be9e9f Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:40:18 -0500 Subject: [PATCH 17/80] Various fixes for home page customization (#902) ## Description Fixes several issues with home page customization such as - Displaying wrong episode-specific display options - Smaller text on cards without images - Prefer fill scaling for posters, like the old home page ### Related issues Related to #803 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/data/model/HomeRowConfig.kt | 11 ++++++----- .../wholphin/services/HomeSettingsService.kt | 4 +++- .../damontecres/wholphin/ui/cards/BannerCard.kt | 2 +- .../wholphin/ui/main/settings/HomeRowPresets.kt | 7 ++++++- .../wholphin/ui/main/settings/HomeRowSettings.kt | 4 ++-- .../ui/main/settings/HomeSettingsViewModel.kt | 6 +++--- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index c11ad321..09622733 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -127,7 +127,7 @@ sealed interface HomeRowConfig { @Serializable @SerialName("TvPrograms") data class TvPrograms( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault, ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions) } @@ -138,7 +138,7 @@ sealed interface HomeRowConfig { @Serializable @SerialName("TvChannels") data class TvChannels( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault, ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions) } @@ -213,12 +213,12 @@ const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1 data class HomeRowViewOptions( val heightDp: Int = Cards.HEIGHT_2X3_DP, val spacing: Int = 16, - val contentScale: PrefContentScale = PrefContentScale.FIT, + val contentScale: PrefContentScale = PrefContentScale.FILL, val aspectRatio: AspectRatio = AspectRatio.TALL, val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, val showTitles: Boolean = false, val useSeries: Boolean = true, - val episodeContentScale: PrefContentScale = PrefContentScale.FIT, + val episodeContentScale: PrefContentScale = PrefContentScale.FILL, val episodeAspectRatio: AspectRatio = AspectRatio.TALL, val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, ) { @@ -229,10 +229,11 @@ data class HomeRowViewOptions( aspectRatio = AspectRatio.WIDE, ) - val channelsDefault = + val liveTvDefault = HomeRowViewOptions( heightDp = 96, aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.FIT, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 99ced240..34631934 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -859,8 +859,10 @@ class HomeSettingsService userId = userDto.id, fields = DefaultItemFields, limit = limit, - enableImages = true, enableUserData = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + imageTypeLimit = 1, ) api.liveTvApi .getRecommendedPrograms(request) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 5a0985c6..e5b57b79 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -124,7 +124,7 @@ fun BannerCard( Text( text = name ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index e0a64221..ff4cfe0a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -31,6 +31,7 @@ data class HomeRowPresets( val videoLibrary: HomeRowViewOptions, val photoLibrary: HomeRowViewOptions, val playlist: HomeRowViewOptions, + val liveTv: HomeRowViewOptions, val genreSize: Int, ) { fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions = @@ -49,10 +50,11 @@ data class HomeRowPresets( CollectionType.PHOTOS -> photoLibrary + CollectionType.LIVETV -> liveTv + CollectionType.UNKNOWN, CollectionType.MUSIC, CollectionType.BOOKS, - CollectionType.LIVETV, CollectionType.PLAYLISTS, CollectionType.FOLDERS, -> HomeRowViewOptions() @@ -78,6 +80,7 @@ data class HomeRowPresets( aspectRatio = AspectRatio.SQUARE, contentScale = PrefContentScale.FIT, ), + liveTv = HomeRowViewOptions.liveTvDefault, genreSize = Cards.HEIGHT_2X3_DP, ) } @@ -115,6 +118,7 @@ data class HomeRowPresets( aspectRatio = AspectRatio.SQUARE, contentScale = PrefContentScale.FIT, ), + liveTv = HomeRowViewOptions.liveTvDefault, genreSize = epHeight, ) } @@ -156,6 +160,7 @@ data class HomeRowPresets( aspectRatio = AspectRatio.SQUARE, contentScale = PrefContentScale.FIT, ), + liveTv = HomeRowViewOptions.liveTvDefault, genreSize = epHeight, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt index 9e861c61..7ac43548 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -197,7 +197,7 @@ internal object Options { title = R.string.global_content_scale, defaultValue = PrefContentScale.FIT, displayValues = R.array.content_scale, - getter = { it.contentScale }, + getter = { it.episodeContentScale }, setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) }, indexToValue = { PrefContentScale.forNumber(it) }, valueToIndex = { it.number }, @@ -219,7 +219,7 @@ internal object Options { title = R.string.image_type, defaultValue = ViewOptionImageType.PRIMARY, displayValues = R.array.image_types, - getter = { it.imageType }, + getter = { it.episodeImageType }, setter = { viewOptions, value -> val aspectRatio = when (value) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 7400a872..41990294 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -306,7 +306,7 @@ class HomeSettingsViewModel title = title, config = HomeRowConfig.TvChannels( - viewOptions = HomeRowViewOptions.channelsDefault, + viewOptions = HomeRowViewOptions.liveTvDefault, ), ) } @@ -655,11 +655,11 @@ class HomeSettingsViewModel } is HomeRowConfig.TvPrograms -> { - it.config.updateViewOptions(preset.tvLibrary) + it.config.updateViewOptions(preset.liveTv) } is HomeRowConfig.TvChannels -> { - it.config + it.config.updateViewOptions(preset.liveTv) } } it.copy(config = newConfig) From 31ff1b20721272be2172ad4191d2c5240dcfe780 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:57:16 -0500 Subject: [PATCH 18/80] Update dependency org.openapi.generator to v7.20.0 (#904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | org.openapi.generator | `7.19.0` → `7.20.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/org.openapi.generator:org.openapi.generator.gradle.plugin/7.20.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.openapi.generator:org.openapi.generator.gradle.plugin/7.19.0/7.20.0?slim=true) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69f0d876..0ca67261 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" -openapi-generator = "7.19.0" +openapi-generator = "7.20.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } From c488c05c12d6a4c0e8774c26902717969663175c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:57:48 -0500 Subject: [PATCH 19/80] Update dependency com.google.devtools.ksp to v2.3.6 (#910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.devtools.ksp](https://goo.gle/ksp) ([source](https://redirect.github.com/google/ksp)) | `2.3.5` → `2.3.6` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin/2.3.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin/2.3.5/2.3.6?slim=true) | --- ### Release Notes
google/ksp (com.google.devtools.ksp) ### [`v2.3.6`](https://redirect.github.com/google/ksp/releases/tag/2.3.6) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.5...2.3.6) #### What's Changed - Fixed an issue where module recompilation would fail on Windows environments when KSP2 was enabled ([#​2774](https://redirect.github.com/google/ksp/issues/2774)) - Resolved an issue where generated Java sources were ignored when using Android Kotlin Multiplatform with IP-compatible paths ([#​2744](https://redirect.github.com/google/ksp/issues/2744)) - Fixed a KSP version 2.3.5 CI error exception that does not break build checks ([#​2763](https://redirect.github.com/google/ksp/issues/2763)) - Added symbol-processing-api and common-deps to compile dependencies ([#​2789](https://redirect.github.com/google/ksp/issues/2789)) - Improved the detection of built-in Kotlin by removing the reliance on KotlinBaseApiPlugin ([#​2772](https://redirect.github.com/google/ksp/issues/2772)) - A back-port of a performance optimization in the Intellij / Analysis API ([2785](https://redirect.github.com/google/ksp/pull/2785) ) #### Contributors - Thanks to [@​salmanmkc](https://redirect.github.com/salmanmkc), [@​jaschdoc](https://redirect.github.com/jaschdoc), [@​gurusai-voleti](https://redirect.github.com/gurusai-voleti) and everyone who reported bugs and participated in discussions! **Full Changelog**:
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0ca67261..05bf860b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kache = "2.1.1" kotlin = "2.3.10" -ksp = "2.3.5" +ksp = "2.3.6" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2026.02.00" From 8bfe77e6e49644daf697449c9cab2f0e71ed04eb Mon Sep 17 00:00:00 2001 From: Cristiano Chelotti Date: Thu, 19 Feb 2026 16:18:20 -0500 Subject: [PATCH 20/80] Fixing seek text label overlap and visibility issues. (#923) ## Description - Smoothly bumps up the title and subtitle text above the seek progress label when the seek bar is focused to prevent overlap. - Adds a semi-transparent black backer to the text label that appears above the seek progress indicator. Helps with visibility when the image in the playback matches the color of the text. Note: the backer is black with Alpha of 0.6 for now because I felt that looked good for contrast across all themes. However, if you prefer it respect a particular theme color, I'd be willing to make that change. ### Related issues In the playback screen, when focusing the seek bar on Android TVs (at least the ones I was able to test), the seek text above the seek bar will overlap with the media title. This is a purely cosmetic issue in the UI, but it seemed worth a fix. See before and after images in in the Screenshots section below This problem is briefly mentioned in point number 2 of [#528 ](https://github.com/damontecres/Wholphin/issues/528) ### Testing Tested manually on TCL model 50S434 running Android TV. Tested manually using AVD emulation on Television (1080p) and Television (4K) preset devices. Reproduced the problem on all 3 devices and confirmed that my fix works across all. ## Screenshots Before: before After: after ## AI or LLM usage None --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../wholphin/ui/playback/PlaybackOverlay.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index ac6aaeed..2b311e71 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -18,10 +19,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -443,6 +446,10 @@ fun PlaybackOverlay( text = (seekProgressMs / 1000L).seconds.toString(), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .background(Color.Black.copy(alpha = 0.6f), shape = RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), ) } } @@ -525,13 +532,22 @@ fun Controller( seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, onNextStateFocus: () -> Unit = {}, ) { + val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() + val verticalOffset by animateDpAsState( + targetValue = if (seekBarFocused) (-32).dp else 0.dp, + label = "TitleBumpOffset", + ) + Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(start = 16.dp), + modifier = + Modifier + .padding(start = 16.dp) + .offset(y = verticalOffset), ) { title?.let { Text( From f57cba85f29e55ad547ae92ad73e6c223a5bff4b Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:28:21 +1100 Subject: [PATCH 21/80] Title/subtitle style update (#775) ## Description This PR updates cards that have a title and subtitle, so that they are visually distinct instead of sharing the same font and style. Titles use bodyMedium typography with semiBold weight. Subtitles use bodySmall typography with normal weight. This affects: - PersonCard - EpisodeCard - DiscoverItemCard - GridCard - SeasonCard ### Related issues Related to [735](https://github.com/damontecres/Wholphin/issues/735) ### Screenshots Screenshot_20260125_190057 ### AI/LLM usage No LLM usage --- .../damontecres/wholphin/ui/cards/DiscoverItemCard.kt | 5 +++++ .../com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt | 6 ++++++ .../com/github/damontecres/wholphin/ui/cards/GridCard.kt | 5 +++++ .../com/github/damontecres/wholphin/ui/cards/PersonCard.kt | 5 +++++ .../com/github/damontecres/wholphin/ui/cards/SeasonCard.kt | 6 ++++++ 5 files changed, 27 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index 4e1d0e86..a7df0cb3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -183,6 +184,8 @@ fun DiscoverItemCard( text = item?.title ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -193,6 +196,8 @@ fun DiscoverItemCard( text = item?.releaseDate?.year?.toString() ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index e4ffa5ee..279ca7b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -22,11 +22,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AppColors @@ -130,6 +132,8 @@ fun EpisodeCard( text = dto?.seriesName ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -140,6 +144,8 @@ fun EpisodeCard( text = item?.name ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 7b653865..c191a70d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -113,6 +114,8 @@ fun GridCard( text = item?.title ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, overflow = TextOverflow.Ellipsis, modifier = Modifier @@ -124,6 +127,8 @@ fun GridCard( text = item?.subtitle ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 47fd4e94..2ce33e09 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -174,6 +175,8 @@ fun PersonCard( text = name ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -185,6 +188,8 @@ fun PersonCard( text = role, maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index c2cbd435..b066d372 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -19,11 +19,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AspectRatios @@ -178,6 +180,8 @@ fun SeasonCard( text = title ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -188,6 +192,8 @@ fun SeasonCard( text = subtitle ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() From 9daf679f7d455d099da068c14733bff054eefb8b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:18:14 -0500 Subject: [PATCH 22/80] Fix some image issues (#926) ## Description Fixes/improves thumb image handling by falling back to backdrop images if available. Episodes without a parent thumb/backdrop will fall back to their primary image. Also fixes the nav drawer selected item not updating (bug introduced by #886). ### Related issues Fixes #642 Fixes #914 ### Testing Emulator in various situations with missing thumbs ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/ImageUrlService.kt | 65 ++++++++++++++++++- .../wholphin/services/LatestNextUpService.kt | 7 ++ .../wholphin/ui/cards/BannerCard.kt | 2 +- .../ui/components/CollectionFolderGrid.kt | 7 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 54 +++++++++++++++ 5 files changed, 132 insertions(+), 3 deletions(-) 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 39967e24..3dfc24c1 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 @@ -30,6 +30,9 @@ class ImageUrlService useSeriesForPrimary: Boolean, imageTags: Map, imageType: ImageType, + parentThumbId: UUID? = null, + parentBackdropId: UUID? = null, + backdropTags: List = emptyList(), fillWidth: Int? = null, fillHeight: Int? = null, ): String? = @@ -54,8 +57,65 @@ class ImageUrlService } } + ImageType.THUMB -> { + if (useSeriesForPrimary && parentThumbId != null && + (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) + ) { + // Use parent's thumb + getItemImageUrl( + itemId = parentThumbId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (useSeriesForPrimary && parentBackdropId != null && + (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) + ) { + // No parent thumb, so use backdrop instead + getItemImageUrl( + itemId = parentBackdropId, + imageType = ImageType.BACKDROP, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { + getItemImageUrl( + itemId = parentThumbId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (useSeriesForPrimary && + parentThumbId == null && + itemType == BaseItemKind.EPISODE && + imageType !in imageTags + ) { + // Workaround to fall back to episode image if no parent thumb + getItemImageUrl( + itemId = itemId, + imageType = ImageType.PRIMARY, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (imageType !in imageTags && backdropTags.isNotEmpty()) { + // If no thumb, use backdrop if available + getItemImageUrl( + itemId = itemId, + imageType = ImageType.BACKDROP, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } + } + ImageType.PRIMARY, - ImageType.THUMB, ImageType.BANNER, -> { if (useSeriesForPrimary && seriesId != null && @@ -108,6 +168,9 @@ class ImageUrlService useSeriesForPrimary = item.useSeriesForPrimary, imageTags = item.data.imageTags.orEmpty(), imageType = imageType, + parentThumbId = item.data.parentThumbItemId, + parentBackdropId = item.data.parentBackdropItemId, + backdropTags = item.data.backdropImageTags.orEmpty(), fillWidth = fillWidth, fillHeight = fillHeight, ) 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 index 9342f63a..e30a39ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -20,6 +20,7 @@ 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.ImageType import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest @@ -60,6 +61,12 @@ class LatestNextUpService remove(BaseItemKind.EPISODE) } }, + enableImageTypes = + listOf( + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.BACKDROP, + ), ) val items = api.itemsApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index e5b57b79..294df8f6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -95,7 +95,7 @@ fun BannerCard( null } } - var imageError by remember { mutableStateOf(false) } + var imageError by remember(imageUrl) { mutableStateOf(false) } Card( modifier = modifier.size(cardHeight * aspectRatio, cardHeight), onClick = onClick, 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 c44f3b3c..4c672998 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 @@ -346,7 +346,12 @@ class CollectionFolderViewModel filter.applyTo( GetItemsRequest( parentId = item?.id, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + enableImageTypes = + listOf( + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.BACKDROP, + ), includeItemTypes = includeItemTypes, recursive = recursive, excludeItemIds = item?.let { listOf(item.id) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 3527a95e..f0276ffd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -54,6 +55,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.findViewTreeViewModelStoreOwner +import androidx.lifecycle.viewModelScope import androidx.tv.material3.DrawerState import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon @@ -77,7 +79,9 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setup.UserIconCardImage import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme @@ -87,6 +91,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.CollectionType +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -145,6 +150,54 @@ class NavDrawerViewModel } fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) + + fun updateSelectedIndex() { + viewModelScope.launchDefault { + val asDestinations = + ( + state.value.items + + listOf( + NavDrawerItem.More, + NavDrawerItem.Discover, + ) + state.value.moreItems + ).map { + if (it is ServerNavDrawerItem) { + it.destination + } else if (it is NavDrawerItem.Favorites) { + Destination.Favorites + } else if (it is NavDrawerItem.Discover) { + Destination.Discover + } else { + null + } + } + + val backstack = navigationManager.backStack.toList().reversed() + for (i in 0..= 0) { + idx + } else { + null + } + } + Timber.v("Found $index => $key") + if (index != null) { + selectedIndex.setValueOnMain(index) + break + } + } + } + } + } } sealed interface NavDrawerItem { @@ -208,6 +261,7 @@ fun NavDrawer( key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either ), ) { + LaunchedEffect(Unit) { viewModel.updateSelectedIndex() } val scope = rememberCoroutineScope() val context = LocalContext.current val density = LocalDensity.current From 219e4b01dba84d9182780c7fd4eac86915258ee4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:18:39 -0500 Subject: [PATCH 23/80] Add a collection or playlist to home page (#915) ## Description Adds a basic way to add a collection or playlist as a row on the home page. There's a simple search dialog to find the collection or playlist. ### Related issues Related to #399 & #803 ### Testing Emulator testing ## Screenshots N/A ## AI or LLM usage None --- .../ui/detail/search/SearchForDialog.kt | 248 ++++++++++++++++++ .../ui/detail/search/SearchForViewModel.kt | 71 +++++ .../main/settings/HomeLibraryRowTypeList.kt | 20 ++ .../ui/main/settings/HomeSettingsAddRow.kt | 14 +- .../ui/main/settings/HomeSettingsPage.kt | 35 ++- .../ui/main/settings/HomeSettingsViewModel.kt | 44 +++- 6 files changed, 424 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt new file mode 100644 index 00000000..6717527e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt @@ -0,0 +1,248 @@ +package com.github.damontecres.wholphin.ui.detail.search + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.main.SearchResult +import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun SearchForContent( + searchType: BaseItemKind, + onClick: (BaseItem) -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName), +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val state by viewModel.state.collectAsState() + + var query by rememberSaveable { mutableStateOf("") } + val searchFocusRequester = remember { FocusRequester() } + val focusRequester = remember { FocusRequester() } + + var immediateSearchQuery by rememberSaveable { mutableStateOf(null) } + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + viewModel.search(searchType, searchQuery) + } + + LaunchedEffect(query) { + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750L) + viewModel.search(searchType, query) + } + } + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + var isSearchActive by remember { mutableStateOf(false) } + var isTextFieldFocused by remember { mutableStateOf(false) } + val textFieldFocusRequester = remember { FocusRequester() } + + BackHandler(isTextFieldFocused) { + when { + isSearchActive -> { + isSearchActive = false + keyboardController?.hide() + } + + else -> { + focusManager.moveFocus(FocusDirection.Next) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .focusGroup() + .focusRestorer(textFieldFocusRequester) + .focusRequester(searchFocusRequester), + ) { + VoiceSearchButton( + onSpeechResult = { spokenText -> + query = spokenText + triggerImmediateSearch(spokenText) + }, + voiceInputManager = viewModel.voiceInputManager, + ) + + SearchEditTextBox( + value = query, + onValueChange = { + isSearchActive = true + query = it + }, + onSearchClick = { triggerImmediateSearch(query) }, + readOnly = !isSearchActive, + modifier = + Modifier + .focusRequester(textFieldFocusRequester) + .onFocusChanged { state -> + isTextFieldFocused = state.isFocused + if (!state.isFocused) isSearchActive = false + }.onPreviewKeyEvent { event -> + val isActivationKey = + event.key in listOf(Key.DirectionCenter, Key.Enter) + if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { + isSearchActive = true + keyboardController?.show() + true + } else { + false + } + }, + ) + } + } + + when (val st = state.results) { + is SearchResult.Error -> { + ErrorMessage("Error", st.ex) + } + + SearchResult.NoQuery -> { + // no-op + } + + SearchResult.Searching -> { + Text( + text = stringResource(R.string.searching), + ) + } + + is SearchResult.SuccessSeerr -> { + Text( + text = "Not supported", + color = MaterialTheme.colorScheme.error, + ) + } + + is SearchResult.Success -> { + if (st.items.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + ) + } else { + val titleRes = + remember { + when (searchType) { + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.PLAYLIST -> R.string.playlists + else -> null + } + } + ItemRow( + title = titleRes?.let { stringResource(it) } ?: "", + items = st.items, + onClickItem = { _, item -> onClick.invoke(item) }, + onLongClickItem = { _, _ -> }, + modifier = Modifier.focusRequester(focusRequester), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.height2x3, + modifier = mod, + ) + }, + ) + } + } + } + } +} + +@Composable +fun SearchForDialog( + onDismissRequest: () -> Unit, + searchType: BaseItemKind, + onClick: (BaseItem) -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + SearchForContent( + searchType = searchType, + onClick = onClick, + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(.8f) + .fillMaxHeight(.66f), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt new file mode 100644 index 00000000..baa98ef1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt @@ -0,0 +1,71 @@ +package com.github.damontecres.wholphin.ui.detail.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.SearchResult +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SearchForViewModel + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + val navigationManager: NavigationManager, + val voiceInputManager: VoiceInputManager, + ) : ViewModel() { + val state = MutableStateFlow(SearchForState()) + + init { + state.value = SearchForState() + } + + fun search( + searchType: BaseItemKind, + query: String, + ) { + viewModelScope.launchIO { + if (state.value.query != query) { + if (query.isBlank()) { + state.update { SearchForState(query, SearchResult.NoQuery) } + return@launchIO + } + state.update { SearchForState(query, SearchResult.Searching) } + try { + val request = + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + searchTerm = query, + includeItemTypes = listOf(searchType), + recursive = true, + fields = SlimItemFields, + ) + val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() + state.update { SearchForState(query, SearchResult.Success(pager)) } + } catch (ex: Exception) { + Timber.e(ex) + state.update { SearchForState(query, SearchResult.Error(ex)) } + } + } + } + } + } + +data class SearchForState( + val query: String = "", + val results: SearchResult = SearchResult.NoQuery, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index 87ce4d39..64cdd246 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -89,6 +89,24 @@ fun getSupportedRowTypes(library: Library): List { ) } + library.collectionType == CollectionType.BOXSETS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + LibraryRowType.COLLECTION, + ) + } + + library.collectionType == CollectionType.PLAYLISTS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + LibraryRowType.PLAYLIST, + ) + } + else -> { listOf( LibraryRowType.RECENTLY_ADDED, @@ -109,4 +127,6 @@ enum class LibraryRowType( TV_CHANNELS(R.string.channels), TV_PROGRAMS(R.string.live_tv), RECENTLY_RECORDED(R.string.recently_recorded), + COLLECTION(R.string.collections), + PLAYLIST(R.string.playlist), } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt index ab48de30..97249aa4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -69,11 +69,17 @@ fun HomeSettingsAddRow( TitleText(stringResource(R.string.more)) HorizontalDivider() } - item { + itemsIndexed( + listOf( + MetaRowType.FAVORITES, + MetaRowType.COLLECTION, + MetaRowType.PLAYLIST, + ), + ) { index, type -> HomeSettingsListItem( selected = false, - headlineText = stringResource(MetaRowType.FAVORITES.stringId), - onClick = { onClickMeta.invoke(MetaRowType.FAVORITES) }, + headlineText = stringResource(type.stringId), + onClick = { onClickMeta.invoke(type) }, modifier = Modifier, ) } @@ -99,4 +105,6 @@ enum class MetaRowType( COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next), FAVORITES(R.string.favorites), DISCOVER(R.string.discover), + COLLECTION(R.string.collection), + PLAYLIST(R.string.playlist), } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index d729247a..26126b82 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType @@ -43,6 +44,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber val settingsWidth = 360.dp @@ -56,6 +58,7 @@ fun HomeSettingsPage( val listState = rememberLazyListState() val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) var showConfirmDialog by remember { mutableStateOf(null) } + var searchForDialog by remember { mutableStateOf(null) } val state by viewModel.state.collectAsState() var position by rememberPosition(0, 0) @@ -149,6 +152,14 @@ fun HomeSettingsPage( MetaRowType.DISCOVER -> { backStack.add(HomeSettingsDestination.ChooseDiscover) } + + MetaRowType.COLLECTION -> { + searchForDialog = BaseItemKind.BOX_SET + } + + MetaRowType.PLAYLIST -> { + searchForDialog = BaseItemKind.PLAYLIST + } } }, modifier = destModifier, @@ -159,7 +170,19 @@ fun HomeSettingsPage( HomeLibraryRowTypeList( library = dest.library, onClick = { type -> - addRow { viewModel.addRow(dest.library, type) } + when (type) { + LibraryRowType.COLLECTION -> { + searchForDialog = BaseItemKind.BOX_SET + } + + LibraryRowType.PLAYLIST -> { + searchForDialog = BaseItemKind.PLAYLIST + } + + else -> { + addRow { viewModel.addRow(dest.library, type) } + } + } }, modifier = destModifier, ) @@ -298,6 +321,16 @@ fun HomeSettingsPage( }, ) } + searchForDialog?.let { searchType -> + SearchForDialog( + onDismissRequest = { searchForDialog = null }, + searchType = searchType, + onClick = { + searchForDialog = null + addRow { viewModel.addRow(searchType, it) } + }, + ) + } } data class ShowConfirm( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 41990294..73d659bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -18,6 +18,8 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions +import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels +import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.preferences.AppPreferences @@ -230,8 +232,11 @@ class HomeSettingsViewModel ) } - MetaRowType.FAVORITES -> { - throw IllegalArgumentException("Should use addRow(BaseItemKind) instead") + MetaRowType.FAVORITES, + MetaRowType.COLLECTION, + MetaRowType.PLAYLIST, + -> { + throw IllegalArgumentException("Should use a different addRow() instead") } MetaRowType.DISCOVER -> { @@ -305,7 +310,7 @@ class HomeSettingsViewModel id = id, title = title, config = - HomeRowConfig.TvChannels( + TvChannels( viewOptions = HomeRowViewOptions.liveTvDefault, ), ) @@ -316,7 +321,7 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = title, - config = HomeRowConfig.TvPrograms(), + config = TvPrograms(), ) } @@ -328,6 +333,12 @@ class HomeSettingsViewModel config = RecentlyAdded(library.itemId), ) } + + LibraryRowType.COLLECTION, + LibraryRowType.PLAYLIST, + -> { + throw IllegalArgumentException("Use different addRow") + } } updateState { it.copy( @@ -357,6 +368,31 @@ class HomeSettingsViewModel fetchRowData() } + fun addRow( + type: BaseItemKind, + parent: BaseItem, + ) = viewModelScope.launchIO { + Timber.v("Adding %s row for %s", type, parent.id) + val id = idCounter++ + val newRow = + HomeRowConfigDisplay( + id = id, + title = parent.name ?: "", + config = + HomeRowConfig.ByParent( + parentId = parent.id, + recursive = true, + ), + ) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + fun updateViewOptions( rowId: Int, viewOptions: HomeRowViewOptions, From 65f20e2daf97d471eec6c17bd612905f23181ebb Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:50:55 -0500 Subject: [PATCH 24/80] Fix a few customize home bugs (#929) ## Description Hopefully this is the last round of bug fixes for customizing the home page! Fixes a possible crash when scrolling after adding a row after resetting to default or loading remote settings that have more rows than the original settings Fixes some incorrect titles ### Related issues Related to #399 & #803 ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 16 ++++++++++--- .../main/settings/HomeLibraryRowTypeList.kt | 2 +- .../ui/main/settings/HomeSettingsPage.kt | 11 ++++++--- .../ui/main/settings/HomeSettingsRowList.kt | 2 +- .../ui/main/settings/HomeSettingsViewModel.kt | 24 ++++++++++++------- app/src/main/res/values/strings.xml | 5 ++-- 6 files changed, 41 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 34631934..93dc32d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler @@ -511,7 +512,11 @@ class HomeSettingsService } is HomeRowConfig.Favorite -> { - val name = context.getString(R.string.favorites) // TODO "Favorite " + val name = + context.getString( + R.string.favorite_items, + context.getString(favoriteOptions[config.kind]!!), + ) HomeRowConfigDisplay(id, name, config) } @@ -785,6 +790,11 @@ class HomeSettingsService } is HomeRowConfig.Favorite -> { + val title = + context.getString( + R.string.favorite_items, + context.getString(favoriteOptions[row.kind]!!), + ) if (row.kind == BaseItemKind.PERSON) { val request = GetPersonsRequest( @@ -801,7 +811,7 @@ class HomeSettingsService .map { BaseItem(it, true) } .let { Success( - context.getString(R.string.favorites), // TODO + title, it, row.viewOptions, ) @@ -822,7 +832,7 @@ class HomeSettingsService .map { BaseItem(it, row.viewOptions.useSeries) } .let { Success( - context.getString(R.string.favorites), // TODO + title, it, row.viewOptions, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index 64cdd246..4236c03a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -127,6 +127,6 @@ enum class LibraryRowType( TV_CHANNELS(R.string.channels), TV_PROGRAMS(R.string.live_tv), RECENTLY_RECORDED(R.string.recently_recorded), - COLLECTION(R.string.collections), + COLLECTION(R.string.collection), PLAYLIST(R.string.playlist), } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 26126b82..1185f719 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -66,13 +66,18 @@ fun HomeSettingsPage( val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) // Adds a row, waits until its done loading, then scrolls to the new row - fun addRow(func: () -> Job) { + fun addRow( + scrollToBottom: Boolean = true, + func: () -> Job, + ) { scope.launch(ExceptionHandler(autoToast = true)) { while (backStack.size > 1) { backStack.removeAt(backStack.lastIndex) } func.invoke().join() - listState.animateScrollToItem(state.rows.lastIndex) + if (scrollToBottom) { + listState.animateScrollToItem(state.rows.lastIndex) + } } } @@ -273,7 +278,7 @@ fun HomeSettingsPage( onClickReset = { showConfirmDialog = ShowConfirm(R.string.overwrite_local_settings) { - viewModel.resetToDefault() + addRow(false) { viewModel.resetToDefault() } } }, modifier = destModifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index 231243ce..5a0ac965 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -141,7 +141,7 @@ fun HomeSettingsRowList( ) } item { - TitleText(stringResource(R.string.home_rows)) + TitleText(stringResource(R.string.home_rows) + " (${state.rows.size})") HorizontalDivider() } itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 73d659bc..9110af12 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -114,7 +114,7 @@ class HomeSettingsViewModel } private suspend fun fetchRowData() { - val limit = 6 + val limit = 8 val semaphore = Semaphore(4) val rows = serverRepository.currentUserDto.value?.let { userDto -> @@ -219,7 +219,7 @@ class HomeSettingsViewModel MetaRowType.NEXT_UP -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.continue_watching), + title = context.getString(R.string.next_up), config = NextUp(), ) } @@ -356,7 +356,11 @@ class HomeSettingsViewModel val newRow = HomeRowConfigDisplay( id = id, - title = context.getString(favoriteOptions[type]!!), + title = + context.getString( + R.string.favorite_items, + context.getString(favoriteOptions[type]!!), + ), config = HomeRowConfig.Favorite(type), ) updateState { @@ -480,6 +484,7 @@ class HomeSettingsViewModel result.rows.mapIndexed { index, config -> homeSettingsService.resolve(index, config) } + idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { it.copy(rows = newRows) } @@ -509,6 +514,7 @@ class HomeSettingsViewModel val result = homeSettingsService.parseFromWebConfig(user.id) if (result != null) { Timber.v("Got web settings") + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { it.copy(rows = result.rows) } @@ -593,17 +599,17 @@ class HomeSettingsViewModel } } - fun resetToDefault() { + fun resetToDefault() = viewModelScope.launchIO { val userId = serverRepository.currentUser.value?.id ?: return@launchIO _state.update { it.copy(loading = LoadingState.Loading) } val result = homeSettingsService.createDefault(userId) + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = result.rows) + it.copy(rows = result.rows, rowData = emptyList()) } fetchRowData() } - } private suspend fun showSaveToast() = showToast( @@ -729,9 +735,9 @@ data class HomePageSettingsState( val EMPTY = HomePageSettingsState( LoadingState.Pending, - listOf(), - listOf(), - listOf(), + emptyList(), + emptyList(), + emptyList(), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d7b1b5c..c4f3db4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -188,6 +188,7 @@ Samples Featurettes Shorts + Favorite %s Trailers @@ -505,8 +506,8 @@ Apply to all rows Customize home page Home rows - Load from server - Save to server + Load from server user profile + Save to server user profile Load from web client Use series image Add row for %1$s From a11474061417314dbe58c0fa4605bb3059a96dfa Mon Sep 17 00:00:00 2001 From: idezentas Date: Tue, 10 Feb 2026 20:38:16 +0000 Subject: [PATCH 25/80] Translated using Weblate (Turkish) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 745da44f..12367b44 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -436,4 +436,11 @@ Uzaklaştır Slayt Gösterisi Süresi Slayt gösterisi sırasında videoları oynat + + %s gün + %s günler + + Medya bilgisini sunucuya gönder + Sınırsız + Sıradaki bölümlerde kalacağı gün sayısı From 9e1e065b958264cc5f450d623455c5095d149f0c Mon Sep 17 00:00:00 2001 From: SimonHung Date: Wed, 11 Feb 2026 12:30:41 +0000 Subject: [PATCH 26/80] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 330f5dd0..cc5700b1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -411,4 +411,10 @@ 幻燈片播放時播放影片 圖形字幕不透明度 亮度 + + %s 天 + + 將媒體訊息日誌傳送到伺服器 + 無限制 + 接下來播放的保留天數 From 8530ba334f9d8ff7f07ef83a27e2270aa2a8d07c Mon Sep 17 00:00:00 2001 From: arcker95 Date: Wed, 11 Feb 2026 02:19:17 +0000 Subject: [PATCH 27/80] Translated using Weblate (Italian) Currently translated at 99.7% (386 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e5aa51b4..76b7d093 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -444,4 +444,10 @@ Durata slideshow Riproduci video durante lo slideshow Invia log informazioni media al server + + %s giorno + %s giorni + %s giorni + + Nessun limite From fe9968227fd856ac362752043ebaf123f6be30fb Mon Sep 17 00:00:00 2001 From: Fjuro Date: Wed, 11 Feb 2026 13:08:44 +0000 Subject: [PATCH 28/80] Translated using Weblate (Czech) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 1a328c33..0f005aa8 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -475,4 +475,12 @@ Trvání prezentace Přehrávat videa během prezentace Odesílat protokol informací o médiích serveru + + %s den + %s dny + %s dní + %s dní + + Bez omezení + Max. počet dnů v Dalších v pořadí From 543e521972dae3426825eab7d10d955458ff664a Mon Sep 17 00:00:00 2001 From: idezentas Date: Wed, 11 Feb 2026 19:07:07 +0000 Subject: [PATCH 29/80] Translated using Weblate (Turkish) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 12367b44..dbd3cf13 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -66,7 +66,7 @@ Diğer Filmler - + Filmler Ad Sıradaki bölümler @@ -81,7 +81,7 @@ Yol Kişiler - + Kişiler Oynatma Sayısı Buradan oynat @@ -152,7 +152,7 @@ Fragman Fragmanlar - + Fragmanlar Kayıt Takvimi Rehber @@ -160,7 +160,7 @@ Sezonlar Diziler - + Diziler Arayüz Bilinmiyor From 99d2f37114db97036022df6d76639091470ef61f Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 12 Feb 2026 00:42:05 +0000 Subject: [PATCH 30/80] Translated using Weblate (Italian) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 76b7d093..595ceb91 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -450,4 +450,5 @@ %s giorni Nessun limite + Giorni massimi in Prossimo From a0cf71166c19de14f46474999f05bf86484f4a12 Mon Sep 17 00:00:00 2001 From: flipip Date: Thu, 12 Feb 2026 15:58:30 +0000 Subject: [PATCH 31/80] Translated using Weblate (German) Currently translated at 97.4% (382 of 392 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eddd471d..013f5ccd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,7 +19,7 @@ Bestätigen Weiterschauen Kritiken - %.1f Sekunden + %.2f Sekunden Standard Löschen Gestorben @@ -321,7 +321,7 @@ Nein Titel anzeigen Wiederholen - Generell + Allgemein Keine Trailer Trailer abspielen Lokal @@ -417,4 +417,19 @@ Diashow anhalten Helligkeit Kontrast + Verkleinern + Vergrößern + Gerät erfolgreich authorisiert + Sättigung + Farbton + + %s Tag + %s Tage + + Der Code muss aus 6 Ziffern bestehen + HDR-Untertitelstil + Dauer der Diashow + Videos während der Diashow abspielen + Maximale Tage in \"Als nächstes\" + Quick Connect From c1bd8010b3c5b7f31cca80ce795298ce63b976ad Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 12 Feb 2026 20:46:33 +0000 Subject: [PATCH 32/80] Translated using Weblate (Italian) Currently translated at 100.0% (392 of 392 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 595ceb91..1cbb364b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -451,4 +451,9 @@ Nessun limite Giorni massimi in Prossimo + Connessione rapida + Autorizza un altro dispositivo per accedere al tuo account + Inserisci il codice di Connessione rapida + Il codice deve essere di 6 cifre + Dispositivo autorizzato con successo From f14514f2794bac4e178f9b6acf50d052f7b1938c Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Thu, 12 Feb 2026 01:23:28 +0000 Subject: [PATCH 33/80] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (392 of 392 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b08bfeb5..3e8abf29 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -417,4 +417,9 @@ 无限制 即将播放中的最大天数 + 快速连接 + 授权其他设备登录您的账号 + 输入快速连接代码 + 代码必须为六位数字 + 设备授权成功 From 8253b62775f471a534fff7cc366a00717fd80585 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Fri, 13 Feb 2026 01:27:43 +0000 Subject: [PATCH 34/80] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 94.6% (392 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index cc5700b1..1406e232 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -417,4 +417,9 @@ 將媒體訊息日誌傳送到伺服器 無限制 接下來播放的保留天數 + 快速連線 + 授權其他裝置登入您的帳號 + 輸入快速連線驗證碼 + 驗證碼必須為6位數 + 裝置授權成功 From b9cb93f22e85c3052d31c088f2fca5b2f182abdf Mon Sep 17 00:00:00 2001 From: SimonHung Date: Fri, 13 Feb 2026 04:03:48 +0000 Subject: [PATCH 35/80] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 96.1% (398 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1406e232..4025fe30 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -422,4 +422,10 @@ 輸入快速連線驗證碼 驗證碼必須為6位數 裝置授權成功 + %1$s 類型 + 最近發行於 %1$s + 推薦的 %1$s + 放大海報尺寸 + 縮小海報尺寸 + 使用橫向縮圖 From 29063a20e74787da9aea8d50f8d25f0f749a89b2 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Fri, 13 Feb 2026 12:56:18 +0000 Subject: [PATCH 36/80] Translated using Weblate (Italian) Currently translated at 100.0% (414 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1cbb364b..a3614e19 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -456,4 +456,26 @@ Inserisci il codice di Connessione rapida Il codice deve essere di 6 cifre Dispositivo autorizzato con successo + Aggiungi riga + Generi in %1$s + Recentemente rilasciato in %1$s + Altezza + Applica a tutte le righe + Carica dal server + Aggiungi riga per %1$s + Sovrascrivere le impostazioni sul server? + Sovrascrivere le impostazioni locali? + Suggerimenti per %1$s + Aumenta dimensione di tutte le card + Riduci dimensione di tutte le card + Usa immagini thumbnail + Impostazioni salvate + Mostra preset + Preset integrati per stilizzare rapidamente tutte le righe + Righe home + Per gli episodi + Personalizza home page + Salva sul server + Carica dal client web + Usa immagine delle serie From 8c997518aa00cf27318712df74bedacebe5022aa Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Fri, 13 Feb 2026 02:47:52 +0000 Subject: [PATCH 37/80] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (414 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 26 ++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3e8abf29..b0dbec5b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -117,7 +117,7 @@ 下载时间过长,可能需要重新开始播放 字幕 字幕 - 建议 + 推荐 切换服务器 切换 高分未看 @@ -215,7 +215,7 @@ 默认内容比例 安装更新 安装版本 - 首页每行最大项目数 + 首页行最大项目数 选择默认显示的项目,其他项目将隐藏 自定义抽屉式导航栏项目 点击切换页面 @@ -422,4 +422,26 @@ 输入快速连接代码 代码必须为六位数字 设备授权成功 + 添加行 + %1$s 中的类型 + %1$s 最近发行 + 高度 + 应用到所有行 + 自定义首页 + 首页行 + 从服务器加载 + 保存到服务器 + 从网络客户端加载 + 使用剧集图片 + 为 %1$s 添加行 + 覆盖服务器上的设置? + 覆盖本地设置? + 针对剧集 + %1$s 推荐内容 + 增大所有卡片的尺寸 + 减小所有卡片的尺寸 + 使用缩略图 + 设置已保存 + 显示预设 + 可快速设置所有行样式的内置预设 From ce79afcd68512bf849c1fdef48c34538ee5d94ac Mon Sep 17 00:00:00 2001 From: Fjuro Date: Fri, 13 Feb 2026 14:05:04 +0000 Subject: [PATCH 38/80] Translated using Weblate (Czech) Currently translated at 100.0% (414 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 0f005aa8..be3d9f6b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -483,4 +483,31 @@ Bez omezení Max. počet dnů v Dalších v pořadí + Rychlé připojení + Autorizovat jiné zařízení k přihlášení k vašemu účtu + Zadejte kód Rychlého připojení + Kód musí mít 6 číslic + Zařízení úspěšně autorizováno + Přidat řádek + Žánry v %1$s + Nedávno vydáno v %1$s + Výška + Použít na všechny řádky + Přizpůsobit domovskou stránku + Řádky na domovské stránce + Načíst ze serveru + Uložit na server + Načíst z webového klienta + Použít obrázek seriálu + Přidat řádek pro %1$s + Přepsat nastavení na serveru? + Přepsat místní nastavení? + Pro epizody + Návrhy na %1$s + Zvětšit velikost všech karet + Zmenšit velikost všech karet + Použít obrázky miniatur + Nastavení uložena + Zobrazit předvolby + Vestavěné předvolby pro rychlou úpravu všech řádků From 7113b438099655117b300f0faaf69425b4bc5df4 Mon Sep 17 00:00:00 2001 From: idezentas Date: Fri, 13 Feb 2026 14:18:01 +0000 Subject: [PATCH 39/80] Translated using Weblate (Turkish) Currently translated at 100.0% (414 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index dbd3cf13..117d6e2f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -443,4 +443,31 @@ Medya bilgisini sunucuya gönder Sınırsız Sıradaki bölümlerde kalacağı gün sayısı + Hızlı Bağlan + Başka bir cihazın giriş yapmasına izin ver + Hızlı Bağlan kodunu girin + Kod 6 haneli olmalıdır + Cihaz başarıyla yetkilendirildi + Satır ekle + %1$s içindeki türler + %1$s kütüphanesinde son çıkanlar + Yükseklik + Tüm satırlara uygula + Ana ekranı özelleştir + Ana ekran satırları + Sunucudan yükle + Sunucuya kaydet + Web istemcisinden yükle + Dizi görselini kullan + %1$s için satır ekle + Sunucudaki ayarların üzerine yazılsın mı? + Yerel ayarların üzerine yazılsın mı? + Bölümler için + %1$s için tavsiyeler + Tüm kartların boyutunu artır + Tüm kartların boyutunu azalt + Küçük resimleri kullan + Ayarlar kaydedildi + Görüntü ön ayarları + Tüm satırları hızlıca stilize etmek için hazır ayarlar From 419cb68af417e527d45c2751fcbf810870c31de1 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sat, 14 Feb 2026 07:58:13 +0000 Subject: [PATCH 40/80] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4025fe30..1cae12e0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -425,7 +425,24 @@ %1$s 類型 最近發行於 %1$s 推薦的 %1$s - 放大海報尺寸 - 縮小海報尺寸 + 放大所有海報尺寸 + 縮小所有海報尺寸 使用橫向縮圖 + 新增一列 + 套用到所有列 + 首頁各列 + 為 %1$s 新增列項目 + 快速設定所有項目為內建預設樣式 + 選擇首頁要顯示的列項目及圖片樣式 + 高度 + 自訂首頁 + 從伺服器載入 + 保存到伺服器 + 從網頁版載入 + 使用劇集圖片 + 覆蓋伺服器上的設定? + 覆蓋本地設定? + 設定已儲存 + 顯示預設樣式 + 針對單集 From 5df9791a3c85f6f9fd972a5d7a4c9a5617efa53a Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sat, 14 Feb 2026 01:27:15 +0000 Subject: [PATCH 41/80] Translated using Weblate (Italian) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a3614e19..0aadb5bd 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -478,4 +478,5 @@ Salva sul server Carica dal client web Usa immagine delle serie + Scegli righe e immagini nella home page From 12499d1f38e961222ed8c36ad6a34e6f21d56dea Mon Sep 17 00:00:00 2001 From: Fjuro Date: Sat, 14 Feb 2026 11:32:59 +0000 Subject: [PATCH 42/80] Translated using Weblate (Czech) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index be3d9f6b..f0e3d0be 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -510,4 +510,5 @@ Nastavení uložena Zobrazit předvolby Vestavěné předvolby pro rychlou úpravu všech řádků + Vyberte řádky a obrázky na domovské stránce From 124b6a741e8aec41e2f4564ab8f99126a2ec9915 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Sun, 15 Feb 2026 11:46:24 +0000 Subject: [PATCH 43/80] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b0dbec5b..49bab214 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -444,4 +444,5 @@ 设置已保存 显示预设 可快速设置所有行样式的内置预设 + 选择首页上的行和图片 From 0d6b82bce0fa74368aab028e4dc73f63c16a8d42 Mon Sep 17 00:00:00 2001 From: DoTheBarrelRoll Date: Sun, 15 Feb 2026 13:11:18 +0000 Subject: [PATCH 44/80] Added translation using Weblate (Finnish) --- app/src/main/res/values-fi/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-fi/strings.xml diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 5ce702b4756e076977dd0ecd512ff112d4c50a37 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 16 Feb 2026 14:00:39 +0000 Subject: [PATCH 45/80] Translated using Weblate (Portuguese) Currently translated at 98.5% (409 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c2ee4aec..3e5db7b7 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -451,4 +451,26 @@ Enviar registo de informações de mídia para o servidor Sem limite Dias máximos em \'A Seguir\' + Ligação Rápida + Autorize outro dispositivo a iniciar sessão na sua conta + Insira o código de Ligação Rápida + Código deve ter 6 dígitos + Dispositivo autorizado com sucesso + Adicionar linha + Géneros em %1$s + Lançado recentemente em %1$s + Altura + Aplicar a todas as linhas + Personalizar página inicial + Linhas da página inicial + Carregar do servidor + Gravar para o servidor + Carregar a partir do cliente web + Usar imagem da série + Adicionar linha para %1$s + Substituir as definições no servidor? + Substituir as definições locais? + Por episódios + Sugestões para %1$s + Aumentar o tamanho de todos os cartões From d0c44d4342f916122b241ba59744dbb930db1381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 16 Feb 2026 10:01:24 +0000 Subject: [PATCH 46/80] Translated using Weblate (Estonian) Currently translated at 92.5% (384 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 482554a0..1fb7f6c3 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -427,4 +427,8 @@ Suumi välja Slaidiesitluse kestus Slaidiesitluse ajal esita videoid + + %s päev + %s päeva + From 5965fde6b1b7755bf133903a2b4394fcf3ad45ea Mon Sep 17 00:00:00 2001 From: opakholis Date: Mon, 16 Feb 2026 07:41:10 +0000 Subject: [PATCH 47/80] Translated using Weblate (Indonesian) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 03fd5a93..91708cf7 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -412,4 +412,37 @@ Durasi tayangan slide Putar video selama tayangan slide Kirim log info media ke server + + %s hari + + Koneksi Cepat + Izinkan perangkat lain untuk masuk ke akun kamu + Masukkan kode Koneksi Cepat + Kode harus terdiri dari 6 digit + Perangkat berhasil diizinkan + Tanpa batasan + Batas hari untuk Berikutnya + Tambah baris + Genre di %1$s + Baru saja dirilis di %1$s + Tinggi + Terapkan ke semua baris + Kustomisasi beranda + Baris beranda + Muat dari server + Simpan ke server + Muat dari klien web + Gunakan gambar seri + Tambah baris untuk %1$s + Timpa pengaturan di server? + Timpa pengaturan lokal? + Untuk episode + Rekomendasi untuk %1$s + Perbesar ukuran semua kartu + Perkecil ukuran semua kartu + Gunakan gambar thumbnail + Pengaturan disimpan + Preset tampilan + Preset bawaan untuk kustomisasi cepat semua baris + Pilih baris dan gambar di halaman beranda From 2a3defdcd0f8310cf205e31b634db294b744dd4b Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 16 Feb 2026 21:22:45 +0000 Subject: [PATCH 48/80] Translated using Weblate (Spanish) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 61 +++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 31445b5a..831c6036 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -15,7 +15,7 @@ Comercial Confirmar Continuar viendo - %.1f segundos + %.2f segundos Eliminar Fallecido Activado @@ -181,7 +181,7 @@ Clip Clips - + Clips Escena eliminada @@ -422,4 +422,61 @@ Solicitar en 4K Decodificación por software AV1 MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes. + + %s día + %s días + %s días + + Conexión Rápida + Autoriza a otro dispositivo para acceder a tu cuenta + Introduce el código de Conexión Rápida + El código debe tener 6 dígitos + Dispositivo autorizado con éxito + Estilo de subtítulos HDR + Opacidad de los subtítulos + Brillo + Contraste + Saturación + Tono de color + Rojo + Verde + Azul + Desenfoque + Guardar en el álbum + Reproducir presentación + Detener presentación + Presentación inicial + No hay más imágenes + Girar a la izquierda + Girar a la derecha + Acercar + Alejar + Duración de la presentación + Reproducir vídeos durante la presentación + Enviar registro de medios al servidor + Sin límite + Límite de días en \"Siguiente\" + Añadir fila + Géneros de %1$s + Altura + Aplicar a todas las filas + Personalizar página de inicio + Filas de inicio + Cargar desde el servidor + Guardar en el servidor + Cargar del cliente web + Usar imágenes de la serie + Agregar fila para %1$s + ¿Sobrescribir ajustes en el servidor? + ¿Sobrescribir ajustes locales? + Para episodios + Sugerencias para %1$s + Aumentar tamaño de todas las tarjetas + Reducir tamaño de todas las tarjetas + Usar miniaturas + Ajustes guardados + Preajustes de visualización + Preajustes integrados para estilizar todas las filas + Elegir filas e imágenes en la página de inicio + Lanzado recientemente en %1$s From 9f66c68e92ed2204740b41c28393159b42ba5e82 Mon Sep 17 00:00:00 2001 From: RafaelSoaresP Date: Mon, 16 Feb 2026 23:43:29 +0000 Subject: [PATCH 49/80] Translated using Weblate (Portuguese) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3e5db7b7..1fb5fbb5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,6 +1,6 @@ - O Wholphin + Sobre Favorito Adicionar Servidor Adicionar Utilizador @@ -10,8 +10,8 @@ Capítulos Escolher %1$s Eliminar cache de imagens - Colecção - Colecções + Coleção + Coleções Confirmar %.2f segundos Padrão @@ -27,7 +27,7 @@ Tamanho Forçado Ir para - Gravações Activas + Gravações Ativas Local de Nascimento Bitrate Nascido @@ -473,4 +473,10 @@ Por episódios Sugestões para %1$s Aumentar o tamanho de todos os cartões + Usar imagens de prévia + Configurações salvas + Mostrar pré definições + Predefinições embarcadas para definir todas as linhas rapidamente + Escolha as linhas e imagens na página inicial + Diminuir o tamanho de todos os cartões From 42b686f4299ee56c292b370e025b80a62d63e08b Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 16 Feb 2026 17:25:15 +0000 Subject: [PATCH 50/80] Translated using Weblate (French) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 38 +++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c131a379..ae405a53 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -24,7 +24,7 @@ Note de la communauté Une erreur est survenue ! Appuyez sur le bouton pour envoyer les logs à votre serveur. Note des critiques - %.1f secondes + %.2f secondes Décédé(e) Désactivé Téléchargement en cours… @@ -443,4 +443,40 @@ Dézoomer Durée du diaporama Diffuser des vidéos pendant le diaporama + + %s jour + %s jours + %s jours + + Connexion rapide + Autoriser un autre appareil à se connecter à votre compte + Entrez le code de connexion rapide + Le code doit comporter 6 chiffres + Appareil autorisé avec succès + Envoyer le journal des informations multimédias au serveur + Aucune limite + Nombre maximal de jours dans Prochainement + Ajouter une ligne + Genres en %1$s + Récemment publié en %1$s + Hauteur + S\'applique à toutes les lignes + Personnaliser la page d\'accueil + Lignes d\'accueil + Charger depuis le serveur + Enregistrer sur le serveur + Charger à partir du client Web + Utiliser l\'image de la série + Ajouter une ligne pour %1$s + Écraser les paramètres sur le serveur ? + Écraser les paramètres locaux ? + Pour les épisodes + Suggestions pour %1$s + Augmenter la taille pour toutes les cartes + Diminuer la taille pour toutes les cartes + Utilisez des images de miniatures + Paramètres enregistrés + Afficher les préréglages + Préréglages intégrés pour styliser rapidement toutes les lignes + Choisissez des lignes et des images sur la page d\'accueil From 7595fad3a19c5a4182f402d364905eb587b4f572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 17 Feb 2026 21:57:31 +0000 Subject: [PATCH 51/80] Translated using Weblate (Estonian) Currently translated at 94.4% (392 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 1fb7f6c3..8e049eb7 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -431,4 +431,12 @@ %s päev %s päeva + Kiirühendus + Luba muul seadmel logida sinu kasutajakontoga sisse + Sisesta kiirühenduse kood + Kood peab olema kuuenumbriline + Seadme autentimine õnnestus + Salvesta meediumi infologi serverisse + Piirangut pole + Järgmisena esitamisel sisu päevade ülempiir From fb79eff3c7d47cefa9d44366e16648e675656d36 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Tue, 17 Feb 2026 22:02:41 +0000 Subject: [PATCH 52/80] Added translation using Weblate (Portuguese (Brazil)) --- app/src/main/res/values-pt-rBR/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-pt-rBR/strings.xml diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 46637dca503645fa131a0469a1e6fca514f027c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 17 Feb 2026 22:16:33 +0000 Subject: [PATCH 53/80] Translated using Weblate (Estonian) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 8e049eb7..d4d5d17f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -439,4 +439,27 @@ Salvesta meediumi infologi serverisse Piirangut pole Järgmisena esitamisel sisu päevade ülempiir + Lisa rida + Žanrid: %1$s + Hiljuti avaldatud: %1$s + Kõrgus + Rakenda kõikidele ridadele + Kohanda kodulehte + Kodulehe/avalehe read + Laadi serverist + Salvesta serverisse + Laadi veebikliendist + Kasuta sarja pilti + Lisa rida: %1$s + Kas kirjutad serveris leiduvad seadistused üle? + Kas kirjutad kohalikud seadistused üle? + Episoodide jaoks + Soovitused: %1$s + Suurenda suurust kõikide kaartide jaoks + Vähenda suurust kõikide kaartide jaoks + Kasuta pisipilte + Seadistused on salvesatatud + Kuvamise eelseadistused + Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks + Vali kodulehe/avalehe read ja pildid From b1196d2df3f54568417c6c360cf541d0d05c0582 Mon Sep 17 00:00:00 2001 From: RafaelSoaresP Date: Thu, 19 Feb 2026 00:20:37 +0000 Subject: [PATCH 54/80] Translated using Weblate (Portuguese (Brazil)) Currently translated at 7.7% (32 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 33 +++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 55344e51..c7889af3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,3 +1,34 @@ - \ No newline at end of file + Sobre + Gravações ativas + Favoritar + Adicionar Servidor + Adicionar Usuário + Áudio + Local de nascimento + Taxa de bits + Nascido + Cancelar gravação + Cancelar gravação da série + Cancelar + Capítulos + Escolha %1$s + Limpar cache de imagens + Coleção + Coleções + Comerciais + Avaliação da comunidade + Ocorreu um erro! Aperte o botão para enviar os logs para seu servidor. + Confirmar + Continue assistindo + Avaliação dos críticos + %.2f segundos + Padrão + Remover + Falecido + Dirigido por %1$s + Diretor + Desabilitado + Servidores encontrados + From 3dbf080e4cad061405fcf98526e73a2410d040ef Mon Sep 17 00:00:00 2001 From: alexkahler Date: Thu, 19 Feb 2026 22:47:04 +0000 Subject: [PATCH 55/80] Added translation using Weblate (Danish) --- app/src/main/res/values-da/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-da/strings.xml diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 9cffc2575642a067f3d5f79969c99105d87117f8 Mon Sep 17 00:00:00 2001 From: alexkahler Date: Fri, 20 Feb 2026 00:16:18 +0000 Subject: [PATCH 56/80] Translated using Weblate (Danish) Currently translated at 21.4% (89 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 93 +++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 55344e51..c54cb27c 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1,3 +1,94 @@ - \ No newline at end of file + Om + Aktive optagelser + Favorit + Tilføj server + Tilføj bruger + Lyd + Fødested + Bitrate + Født + Annullér optagelse + Annullér serieoptagelse + Annullér + Kapitler + Vælg %1$s + Ryd billedcache + Samling + Samlinger + Brugerbedømmelse + Der opstod en fejl! Tryk på knappen for at sende logfiler til din server. + Bekræft + Fortsæt med at se + Kritikerbedømmelse + %.2f sekunder + Standard + Slet + Død + Instrueret af %1$s + Instruktør + Deaktiveret + Opdagede servere + + Downloader… + Aktiveret + Slutter %1$s + Indtast server-IP eller URL + Indtast serveradresse + Episoder + Fejl ved indlæsning af samling %1$s + Ekstern + Favoritter + Størrelse + Tvunget + Genrer + Gå til serien + Gå til + Skjul afspilningsknapper + Skjul fejlfindingsoplysninger + Skjul + Hjem + Umiddelbar + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ + Bibliotek + Licensoplysninger + Live-tv + Indlæser… + Markér hele serien som afspillet? + Markér hele serien som ikke afspillet? + Markér som uset + Markér som set + Maks. bitrate + Mere som dette + Mere + + Film + + + Navn + Næste + Ingen data + Ingen resultater + Ingen servere fundet + Ingen planlagte optagelser + Ingen opdatering tilgængelig + Ingen + Kun tvungne undertekster + Outro + Filsti + + Personer + + + Antal afspilninger + Afspil herfra + Afspil + Vis debugoplysninger for afspilning + Afspilningshastighed + Afspilning + Playlist + Playlists + Forhåndsvisning + From 17b0a09b7a4c518af2e70525960d8491a43f8a9d Mon Sep 17 00:00:00 2001 From: alexkahler Date: Fri, 20 Feb 2026 00:18:39 +0000 Subject: [PATCH 57/80] Translated using Weblate (Danish) Currently translated at 21.9% (91 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index c54cb27c..e5f26aae 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -65,7 +65,7 @@ Mere Film - + Navn Næste @@ -80,7 +80,7 @@ Filsti Personer - + Antal afspilninger Afspil herfra @@ -91,4 +91,6 @@ Playlist Playlists Forhåndsvisning + Profilindstillinger + From 9b10fcd6596d53c9fa93e73e0a82db326c8f63de Mon Sep 17 00:00:00 2001 From: alexkahler Date: Fri, 20 Feb 2026 02:11:44 +0000 Subject: [PATCH 58/80] Translated using Weblate (Danish) Currently translated at 57.8% (240 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 211 +++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index e5f26aae..43da96b0 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -93,4 +93,215 @@ Forhåndsvisning Profilindstillinger + Opsummering + Nyligt tilføjet i %1$s + Nyligt tilføjet + Nyligt optaget + Nyligt udkommet + Anbefalet + Optag program + Optag serie + Fjern fra favoritter + Genstart + Fortsæt + Gem + + Søg + Søger… + Stemmesøgning + Starter + Tal for at søge + Arbejder + Tryk tilbage for at afbryde + Fejl ved lydoptagelse + Fejl ved stemmegenkendelse + Mikrofontilladelse kræves + Netværksfejl + Netværkstimeout + Ingen tale genkendt + Stemmegenkendelse optaget + Serverfejl + Ingen tale registreret + Ukendt fejl + Kunne ikke starte stemmegenkendelse + Tidsgrænsen for stemmegenkendelse er overskredet + Prøv igen + Vælg server + Vælg bruger + Vis fejlfindingsoplysninger + Vis næste + Vis + Bland + Spring over + Dato tilføjet + Dato episode tilføjet + Dato afspillet + Udgivelsesdato + Navn + Tilfældig + Studier + Indsend + Downloaden tager lang tid, du skal muligvis genstarte afspilningen + Undertekst + Undertekster + Forslag + Skift servere + Skift + Bedst bedømte usete + Trailer + + Trailere + + + DVR-plan + TV-guide + Sæson + Sæsoner + + TV-serier + + + Grænseflade + Ukendt + Opdateringer + Version + Videoskalering + Video + Videoer + Se direkte + %1$d år gammel + Afspil med transkodning + Medieinformation + Vis ur + Udsendelsesrækkefølge + Opret ny playliste + Føj til playliste + Pause med ét klik + Tryk på midten af D-Pad\'en for at pause/afspille + Kursiv skrift + Skrifttype + Baggrund + Succes + Aldersgrænse + Spilletid + Ekstra + + Andet + + + + Bag kulisserne + + + + Temasange + + + + Temavideoer + + + + Klip + + + + Slettede scener + + + + Interview + + + + Scener + + + + Prøver + + + + Specialindslag + + + + Kortfilm + + + + %s download + %s downloads + + + %d time + %d timer + + + %s dag + %s dage + + + %d element + %d elementer + + + %d sekund + %d sekunder + + Enheden understøtter AC3/Dolby Digital + Avancerede indstillinger + Avanceret brugergrænseflade + Tema + Søg automatisk efter opdateringer + Forsinkelse før næste afspilning + Afspil næste automatisk + Søg efter opdateringer + Gælder kun for tv-serier + + Direkte afspilning af ASS-undertekster + Direkte afspilning af PGS-undertekster + Mix altid ned til stereo + Brug FFmpeg-dekodermodulet + Standard skalering + Installer opdatering + Installeret version + Maks antal elementer per række på startsiden + Vælg de standardelementer, der skal vises, andre vil blive skjult + Tilpas elementer i navigationsmenuen + Klik for at skifte side + Skift sider i navigationsmenuen ved fokus + Inaktivitetsbeskyttelse + Afspil temamusik + Tilsidesættelser af afspilning + Husk valgte faner + Tillad gensening i \"Næste\" + Trinlængde for søgelinje + Nyttig til fejlfinding + Send app-logfiler til den aktuelle server + Vil forsøge at sende til den sidst forbundne server + Send nedbrudsrapporter + Indstillinger + Spring tilbage, når afspilningen genoptages + Spring tilbage + Adfærd ved at springe reklamer over + Spring fremad + Adfærd ved at springe intro over + Adfærd ved at springe outro over + Adfærd ved at springe forhåndsvisninger over + Adfærd ved at springe opsummeringer over + Opdatering tilgængelig + URL brugt til at søge efter appopdateringer + URL til opdatering + Tekststørrelse + Tekstfarve + Tekstgennemsigtighed + Fed skrift + Kantstil + Kantfarve + Baggrundsopacitet + Baggrundsstil + Undertekststil + Baggrundsfarve + Nulstil From 2b7a1ecd4838cba1ba153c44f5c65b65fd15b466 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 20 Feb 2026 10:50:07 -0500 Subject: [PATCH 59/80] Fix linting --- app/src/main/res/values-fi/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 55344e51..045e125f 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1,3 +1,3 @@ - \ No newline at end of file + From 81133695469d811e6bfd371e50888c2352382230 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:07:36 -0500 Subject: [PATCH 60/80] Update readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index b4121cb9..1b3a0708 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin ### User interface +- Customize the home page to see the content you are interested in + - Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows! - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app - Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - Option to combine Continue Watching & Next Up rows @@ -112,6 +114,10 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin ## Additional screenshots +### Customized home page +![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) + + ### Movie library browsing 0 3 0_movies From a8ab002dd1baf10ef02aad0e0816be7d361bbf35 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 20 Feb 2026 12:08:08 -0500 Subject: [PATCH 61/80] Release v0.5.0 From dfd9acf561b4f6779f22e5f49214657ec6e0fa3a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:28:57 -0500 Subject: [PATCH 62/80] Add metadata to media session (#934) ## Description Adds some metadata about playing media for consumption in remote control apps such as Google Home or others. ### Related issues Partially address #889, doesn't add explicit controls yet, but Google Home works ### Testing Nvidia shield + Google Home app ## Screenshots iOS Google Home app: ![ios_google_home](https://github.com/user-attachments/assets/680ccae5-b66e-404a-a3a1-7c89991d841b) ## AI or LLM usage None --- .../wholphin/services/ImageUrlService.kt | 3 ++- .../wholphin/ui/playback/CurrentMediaInfo.kt | 24 +++++++++++++++++++ .../wholphin/ui/playback/PlaybackViewModel.kt | 13 +++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) 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 3dfc24c1..f0de9d24 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 @@ -159,13 +159,14 @@ class ImageUrlService imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, + useSeriesForPrimary: Boolean? = null, ): String? = if (item != null) { getItemImageUrl( itemId = item.id, itemType = item.type, seriesId = item.data.seriesId, - useSeriesForPrimary = item.useSeriesForPrimary, + useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary, imageTags = item.data.imageTags.orEmpty(), imageType = imageType, parentThumbId = item.data.parentThumbItemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 2b7d61bc..e8dc7d1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -1,7 +1,12 @@ package com.github.damontecres.wholphin.ui.playback +import androidx.core.net.toUri +import androidx.media3.common.MediaMetadata +import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.TrickplayInfo +import org.jellyfin.sdk.model.extensions.ticks data class CurrentMediaInfo( val sourceId: String?, @@ -15,3 +20,22 @@ data class CurrentMediaInfo( val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null) } } + +fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata = + MediaMetadata + .Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setReleaseYear(data.productionYear) + .setDescription(data.overview) + .setArtworkUri(imageUrl?.toUri()) + .setDurationMs(data.runTimeTicks?.ticks?.inWholeMilliseconds) + .setMediaType( + when (type) { + BaseItemKind.MOVIE -> MediaMetadata.MEDIA_TYPE_MOVIE + BaseItemKind.EPISODE -> MediaMetadata.MEDIA_TYPE_TV_SHOW + BaseItemKind.VIDEO -> MediaMetadata.MEDIA_TYPE_VIDEO + BaseItemKind.TV_CHANNEL, BaseItemKind.CHANNEL, BaseItemKind.LIVE_TV_CHANNEL -> MediaMetadata.MEDIA_TYPE_TV_CHANNEL + else -> MediaMetadata.MEDIA_TYPE_VIDEO + }, + ).build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index b3a446d7..4d44ff80 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreationResult @@ -95,6 +96,7 @@ import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaStreamType @@ -137,6 +139,7 @@ class PlaybackViewModel private val refreshRateService: RefreshRateService, val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, + private val imageUrlService: ImageUrlService, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -671,7 +674,15 @@ class PlaybackViewModel MediaItem .Builder() .setMediaId(itemId.toString()) - .setUri(mediaUrl.toUri()) + .setMediaMetadata( + item.toMediaMetadata( + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + useSeriesForPrimary = true, + ), + ), + ).setUri(mediaUrl.toUri()) .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) .apply { when (source.container) { From 4f8f69c438e9493aa608022e9e1f29d3daefc58e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:29:58 -0500 Subject: [PATCH 63/80] Better Jellyseerr connection error handling (#933) ## Description Show Jellyseerr connection errors in settings. If an error occurred trying to login, in setting it will show "Server error" and clicking gives more error info and option to remove the server. ### Related issues Fixes #907 ### Testing Emulator with simulated errors ## Screenshots N/A ## AI or LLM usage None --- .../services/SeerrServerRepository.kt | 89 +++++++++++-------- .../wholphin/ui/components/Dialogs.kt | 6 +- .../detail/discover/DiscoverMovieViewModel.kt | 3 +- .../wholphin/ui/discover/SeerrRequestsPage.kt | 2 +- .../ui/preferences/PreferencesContent.kt | 76 ++++++++++++---- .../ui/preferences/PreferencesViewModel.kt | 7 +- 6 files changed, 120 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index c6da831f..78ff7d3b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -44,18 +44,33 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _current = MutableStateFlow(null) - val current: StateFlow = _current - val currentServer: Flow = current.map { it?.server } - val currentUser: Flow = current.map { it?.user } + private val _connection = + MutableStateFlow(SeerrConnectionStatus.NotConfigured) + val connection: StateFlow = _connection + + val current: Flow = + _connection.map { (it as? SeerrConnectionStatus.Success)?.current } + val currentServer: Flow = + connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } + val currentUser: Flow = + connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } /** * Whether Seerr integration is currently active of not */ - val active: Flow = current.map { it != null && seerrApi.active } + val active: Flow = + connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _current.update { null } + _connection.update { SeerrConnectionStatus.NotConfigured } + seerrApi.update("", null) + } + + fun error( + serverUrl: String, + exception: Exception, + ) { + _connection.update { SeerrConnectionStatus.Error(serverUrl, exception) } seerrApi.update("", null) } @@ -65,8 +80,10 @@ class SeerrServerRepository userConfig: SeerrUserConfig, ) { val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() - _current.update { - CurrentSeerr(server, user, userConfig, publicSettings) + _connection.update { + SeerrConnectionStatus.Success( + CurrentSeerr(server, user, userConfig, publicSettings), + ) } } @@ -154,7 +171,7 @@ class SeerrServerRepository } suspend fun removeServer() { - val current = _current.value ?: return + val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) clear() } @@ -165,6 +182,19 @@ class SeerrServerRepository */ typealias SeerrUserConfig = User +sealed interface SeerrConnectionStatus { + data object NotConfigured : SeerrConnectionStatus + + data class Error( + val serverUrl: String, + val ex: Exception, + ) : SeerrConnectionStatus + + data class Success( + val current: CurrentSeerr, + ) : SeerrConnectionStatus +} + data class CurrentSeerr( val server: SeerrServer, val user: SeerrUser, @@ -244,45 +274,34 @@ class UserSwitchListener launchIO { seerrServerDao .getUsersByJellyfinUser(user.rowId) - .firstOrNull() + .lastOrNull() ?.let { seerrUser -> val server = seerrServerDao.getServer(seerrUser.serverId)?.server if (server != null) { Timber.i("Found a seerr user & server") - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - try { + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { login( seerrApi.api, seerrUser.authMethod, seerrUser.username, seerrUser.password, ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.clear() - return@let - } - } else { - try { + } else { seerrApi.api.usersApi.authMeGet() - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.clear() - return@let } - } - seerrServerRepository.set(server, seerrUser, userConfig) + seerrServerRepository.set(server, seerrUser, userConfig) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server.url, ex) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index d5897a6b..579d9a97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -408,12 +408,13 @@ fun ConfirmDialog( onConfirm: () -> Unit, properties: DialogProperties = DialogProperties(), elevation: Dp = 8.dp, + bodyColor: Color = MaterialTheme.colorScheme.onSurface, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, elevation = elevation, content = { - ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor) }, ) @@ -427,6 +428,7 @@ fun ConfirmDialogContent( onCancel: () -> Unit, onConfirm: () -> Unit, modifier: Modifier = Modifier, + bodyColor: Color = MaterialTheme.colorScheme.onSurface, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -446,7 +448,7 @@ fun ConfirmDialogContent( item { Text( text = body, - color = MaterialTheme.colorScheme.onSurface, + color = bodyColor, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index cc80d404..a50e848d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -73,7 +73,8 @@ class DiscoverMovieViewModel val canCancelRequest = MutableStateFlow(false) val userConfig = seerrServerRepository.current.map { it?.config } - val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } + val request4kEnabled = + seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } init { init() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt index 3a838815..85863650 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -64,7 +64,7 @@ class SeerrRequestsViewModel viewModelScope.launchIO { backdropService.clearBackdrop() } - seerrServerRepository.current + seerrServerRepository.connection .onEach { user -> state.update { it.copy(requests = DataLoadingState.Loading) } if (user != null) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index a806c31a..cb3856f6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.ifElse @@ -90,7 +91,7 @@ fun PreferencesContent( var showPinFlow by remember { mutableStateOf(false) } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } - val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) + val seerrConnection by viewModel.seerrConnection.collectAsState() var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } var showQuickConnectDialog by remember { mutableStateOf(false) } @@ -385,19 +386,29 @@ fun PreferencesContent( ClickPreference( title = stringResource(pref.title), onClick = { - if (seerrIntegrationEnabled) { - seerrDialogMode = SeerrDialogMode.Remove - } else { - seerrVm.resetStatus() - seerrDialogMode = SeerrDialogMode.Add - } + seerrDialogMode = + when (val conn = seerrConnection) { + is SeerrConnectionStatus.Error -> { + SeerrDialogMode.Error(conn.serverUrl, conn.ex) + } + + SeerrConnectionStatus.NotConfigured -> { + SeerrDialogMode.Add + } + + is SeerrConnectionStatus.Success -> { + SeerrDialogMode.Remove( + conn.current.server.url, + ) + } + } }, modifier = Modifier, summary = - if (seerrIntegrationEnabled) { - stringResource(R.string.enabled) - } else { - null + when (seerrConnection) { + is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) + SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) + is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) }, onLongClick = {}, interactionSource = interactionSource, @@ -479,11 +490,11 @@ fun PreferencesContent( ) } } - when (seerrDialogMode) { - SeerrDialogMode.Remove -> { + when (val mode = seerrDialogMode) { + is SeerrDialogMode.Remove -> { ConfirmDialog( title = stringResource(R.string.remove_seerr_server), - body = currentServer?.url ?: "", + body = mode.serverUrl, onCancel = { seerrDialogMode = SeerrDialogMode.None }, onConfirm = { seerrVm.removeServer() @@ -510,6 +521,28 @@ fun PreferencesContent( ) } + is SeerrDialogMode.Error -> { + val errorStr = stringResource(R.string.voice_error_server) + val body = + remember(mode) { + """ + ${mode.serverUrl} + + $errorStr: ${mode.ex.localizedMessage} + """.trimIndent() + } + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = { seerrDialogMode = SeerrDialogMode.None }, + onConfirm = { + seerrVm.removeServer() + seerrDialogMode = SeerrDialogMode.None + }, + bodyColor = MaterialTheme.colorScheme.error, + ) + } + SeerrDialogMode.None -> {} } } @@ -580,10 +613,17 @@ data class CacheUsage( val imageDiskUsed: Long, ) -private sealed class SeerrDialogMode { - data object None : SeerrDialogMode() +private sealed interface SeerrDialogMode { + data object None : SeerrDialogMode - data object Add : SeerrDialogMode() + data object Add : SeerrDialogMode - data object Remove : SeerrDialogMode() + data class Remove( + val serverUrl: String, + ) : SeerrDialogMode + + data class Error( + val serverUrl: String, + val ex: Exception, + ) : SeerrDialogMode } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 709b6d9d..bec82e58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.preferences import android.content.Context import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -22,7 +21,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo @@ -46,10 +44,7 @@ class PreferencesViewModel RememberTabManager by rememberTabManager { val currentUser get() = serverRepository.currentUser - val seerrEnabled = - seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser -> - seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId - } + val seerrConnection = seerrServerRepository.connection private val _quickConnectStatus = MutableStateFlow(LoadingState.Pending) val quickConnectStatus: StateFlow = _quickConnectStatus From 7939fffd486246c0ba025076c6da2e82448dc8c1 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:07:22 -0500 Subject: [PATCH 64/80] Override H264/AVC High 10 Profile Level 5.2 support for some devices (#935) ## Description Several Fire TV devices natively support H264/AVC High 10 Profile Level 5.2 according to [their specs](https://developer.amazon.com/docs/device-specs/device-specifications-fire-tv-streaming-media-player.html), but the `MediaCodecInfo` doesn't return this support. So this PR adds an override for the known devices that support this which prevents unnecessary transcoding. ### Related issues Closes #786 ### Testing Verified on a Fire TV 4k Max 2nd Gen ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/util/profile/DeviceProfileUtils.kt | 11 +++++++++-- .../wholphin/util/profile/KnownDefects.kt | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index b98ececd..6272af5a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.util.profile // Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt +import android.media.MediaCodecInfo import androidx.media3.common.MimeTypes +import com.github.damontecres.wholphin.util.profile.KnownDefects.supportsHi10P52 import org.jellyfin.sdk.model.api.CodecType import org.jellyfin.sdk.model.api.DlnaProfileType import org.jellyfin.sdk.model.api.EncodingContext @@ -92,9 +94,14 @@ fun createDeviceProfile( val hevcMainLevel = mediaTest.getHevcMainLevel() val hevcMain10Level = mediaTest.getHevcMain10Level() val supportsAVC = mediaTest.supportsAVC() - val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() + val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() || supportsHi10P52 val avcMainLevel = mediaTest.getAVCMainLevel() - val avcHigh10Level = mediaTest.getAVCHigh10Level() + val avcHigh10Level = + if (supportsHi10P52) { + MediaCodecInfo.CodecProfileLevel.AVCLevel52 + } else { + mediaTest.getAVCHigh10Level() + } val supportsAV1 = mediaTest.supportsAV1() val supportsAV1Main10 = mediaTest.supportsAV1Main10() val supportsVC1 = mediaTest.supportsVc1() diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt index 0a936de9..a4cd60c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt @@ -5,7 +5,7 @@ package com.github.damontecres.wholphin.util.profile import android.os.Build /** - * List of devie models with known HEVC DoVi/HDR10+ playback issues. + * List of device models with known HEVC DoVi/HDR10+ playback issues. */ private val modelsWithDoViHdr10PlusBug = listOf( @@ -14,6 +14,21 @@ private val modelsWithDoViHdr10PlusBug = "AFTKM", // Amazon Fire TV 4K (2nd Gen) ) +/** + * List of device models that support H264 Hi10P 5.2, but don't advertise it + * + * Amazon devices from https://developer.amazon.com/docs/device-specs/device-specifications-fire-tv-streaming-media-player.html + */ +private val modelsWithHi10P52Support = + listOf( + "AFTMA08C15", // Fire TV Stick 4K Plus (2025) + "AFTKRT", // Amazon Fire TV 4K Max (2nd Gen) + "AFTKA", // Amazon Fire TV 4K Max (1st Gen) + "AFTKM", // Amazon Fire TV 4K (2nd Gen) + "AFTMM", // Fire TV Stick 4K - 1st Gen (2018) + ) + object KnownDefects { val hevcDoviHdr10PlusBug = Build.MODEL in modelsWithDoViHdr10PlusBug + val supportsHi10P52 = Build.MODEL in modelsWithHi10P52Support } From 55b148971ed004b9a281487b6a2f501684676d05 Mon Sep 17 00:00:00 2001 From: Cristiano Chelotti Date: Fri, 20 Feb 2026 19:24:35 -0500 Subject: [PATCH 65/80] Fixing Settings sticky header text overlapping with menu items (#928) ## Description - Adding a background for the Settings stickyHeader. This helps with visibility and prevents overlap with the menu items when scrolling. ### Related issues In the Settings screen, the header can overlap the text of the menu items when scrolling down. This is a minor UI bug only and does not affect functionality at all, but the fix seemed easy and harmless so I figured I'd give it a go. ### Testing Tested manually on TCL model 50S434 running Android TV. Tested manually using AVD emulation on Television (1080p) and Television (4K) preset devices. Reproduced the problem on all 3 devices and confirmed that my fix works across all. ## Screenshots Before: before-settings-fix After: after-settings-fix ## AI or LLM usage None --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../ui/preferences/PreferencesContent.kt | 576 +++++++++--------- 1 file changed, 290 insertions(+), 286 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index cb3856f6..b04293f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -166,308 +168,310 @@ fun PreferencesContent( LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - LazyColumn( - state = state, - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(0.dp), - contentPadding = PaddingValues(16.dp), + Column( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), ) { - stickyHeader { - Text( - text = stringResource(screenTitle), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) - } - if (UpdateChecker.ACTIVE && - preferenceScreenOption == PreferenceScreenOption.BASIC && - preferences.autoCheckForUpdates && - updateAvailable + Text( + text = stringResource(screenTitle), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.fillMaxSize(), ) { - item { - val updateFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (focusedIndex.first == 0 && focusedIndex.second == 0) { - // Only re-focus if the user hasn't moved - updateFocusRequester.tryRequestFocus() - } - } - ClickPreference( - title = stringResource(R.string.install_update), - onClick = { - if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = release?.version?.toString(), - modifier = - Modifier - .focusRequester(updateFocusRequester) - .playSoundOnFocus(movementSounds), - ) - } - } - prefList.forEachIndexed { groupIndex, group -> - item { - Text( - text = stringResource(group.title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Start, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 4.dp), - ) - } - val groupPreferences = - group.preferences + - group.conditionalPreferences - .filter { it.condition.invoke(preferences) } - .map { it.preferences } - .flatten() - groupPreferences.forEachIndexed { prefIndex, pref -> - pref as AppPreference + if (UpdateChecker.ACTIVE && + preferenceScreenOption == PreferenceScreenOption.BASIC && + preferences.autoCheckForUpdates && + updateAvailable + ) { item { - val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(focused) { - if (focused) { - focusedIndex = Pair(groupIndex, prefIndex) - if (movementSounds) playOnClickSound(context) - onFocus.invoke(groupIndex, prefIndex) + val updateFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (focusedIndex.first == 0 && focusedIndex.second == 0) { + // Only re-focus if the user hasn't moved + updateFocusRequester.tryRequestFocus() } } - when (pref) { - AppPreference.InstalledVersion -> { - var clickCount by remember { mutableIntStateOf(0) } - ClickPreference( - title = stringResource(R.string.installed_version), - onClick = { - if (movementSounds) playOnClickSound(context) - if (clickCount++ >= 2) { - clickCount = 0 - viewModel.navigationManager.navigateTo(Destination.Debug) - } - }, - summary = installedVersion.toString(), - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) + ClickPreference( + title = stringResource(R.string.install_update), + onClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + }, + summary = release?.version?.toString(), + modifier = + Modifier + .focusRequester(updateFocusRequester) + .playSoundOnFocus(movementSounds), + ) + } + } + prefList.forEachIndexed { groupIndex, group -> + item { + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) + } + val groupPreferences = + group.preferences + + group.conditionalPreferences + .filter { it.condition.invoke(preferences) } + .map { it.preferences } + .flatten() + groupPreferences.forEachIndexed { prefIndex, pref -> + pref as AppPreference + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + if (movementSounds) playOnClickSound(context) + onFocus.invoke(groupIndex, prefIndex) + } } - - AppPreference.Update -> { - ClickPreference( - title = - if (release != null && updateAvailable) { - stringResource(R.string.install_update) - } else if (!preferences.autoCheckForUpdates && release == null) { - stringResource(R.string.check_for_updates) - } else { - stringResource(R.string.no_update_available) + when (pref) { + AppPreference.InstalledVersion -> { + var clickCount by remember { mutableIntStateOf(0) } + ClickPreference( + title = stringResource(R.string.installed_version), + onClick = { + if (movementSounds) playOnClickSound(context) + if (clickCount++ >= 2) { + clickCount = 0 + viewModel.navigationManager.navigateTo(Destination.Debug) + } }, - onClick = { - if (movementSounds) playOnClickSound(context) - if (release != null && updateAvailable) { - release?.let { - viewModel.navigationManager.navigateTo(Destination.UpdateApp) + summary = installedVersion.toString(), + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } + + AppPreference.Update -> { + ClickPreference( + title = + if (release != null && updateAvailable) { + stringResource(R.string.install_update) + } else if (!preferences.autoCheckForUpdates && release == null) { + stringResource(R.string.check_for_updates) + } else { + stringResource(R.string.no_update_available) + }, + onClick = { + if (movementSounds) playOnClickSound(context) + if (release != null && updateAvailable) { + release?.let { + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + } + } else { + updateVM.init(preferences.updateUrl) } - } else { - updateVM.init(preferences.updateUrl) - } - }, - onLongClick = { - if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = - if (updateAvailable) { - release?.version?.toString() - } else { - null }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) - } - - AppPreference.ClearImageCache -> { - val summary = - remember(cacheUsage) { - cacheUsage.let { - val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT - val memoryUsedMB = - it.imageMemoryUsed / AppPreference.MEGA_BIT - val memoryMaxMB = - it.imageMemoryMax / AppPreference.MEGA_BIT - "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb" - } - } - ClickPreference( - title = stringResource(pref.title), - onClick = { - SingletonImageLoader.get(context).let { - it.memoryCache?.clear() - it.diskCache?.clear() - updateCache = true - } - }, - modifier = Modifier, - summary = summary, - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.UserPinnedNavDrawerItems -> { - NavDrawerPreference( - title = stringResource(pref.title), - summary = pref.summary(context, null), - modifier = Modifier, - interactionSource = interactionSource, - ) - } - - AppPreference.SendAppLogs -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.sendAppLogs() - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - SubtitleSettings.Reset -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.resetSubtitleSettings() - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.RequireProfilePin -> { - SwitchPreference( - title = stringResource(pref.title), - value = currentUser?.pin.isNotNullOrBlank(), - onClick = { - showPinFlow = true - }, - summaryOn = stringResource(R.string.enabled), - summaryOff = null, - modifier = Modifier, - ) - } - - AppPreference.SeerrIntegration -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - seerrDialogMode = - when (val conn = seerrConnection) { - is SeerrConnectionStatus.Error -> { - SeerrDialogMode.Error(conn.serverUrl, conn.ex) - } - - SeerrConnectionStatus.NotConfigured -> { - SeerrDialogMode.Add - } - - is SeerrConnectionStatus.Success -> { - SeerrDialogMode.Remove( - conn.current.server.url, - ) - } - } - }, - modifier = Modifier, - summary = - when (seerrConnection) { - is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) - SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) - is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) + onLongClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) }, - onLongClick = {}, - interactionSource = interactionSource, - ) - } + summary = + if (updateAvailable) { + release?.version?.toString() + } else { + null + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } - AppPreference.QuickConnect -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - if (currentUser != null) { - viewModel.resetQuickConnectStatus() - showQuickConnectDialog = true - } - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - else -> { - val value = pref.getter.invoke(preferences) - ComposablePreference( - preference = pref, - value = value, - onNavigate = viewModel.navigationManager::navigateTo, - onValueChange = { newValue -> - val validation = pref.validate(newValue) - when (validation) { - is PreferenceValidation.Invalid -> { - // TODO? - Toast - .makeText( - context, - validation.message, - Toast.LENGTH_SHORT, - ).show() + AppPreference.ClearImageCache -> { + val summary = + remember(cacheUsage) { + cacheUsage.let { + val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT + val memoryUsedMB = + it.imageMemoryUsed / AppPreference.MEGA_BIT + val memoryMaxMB = + it.imageMemoryMax / AppPreference.MEGA_BIT + "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb" } + } + ClickPreference( + title = stringResource(pref.title), + onClick = { + SingletonImageLoader.get(context).let { + it.memoryCache?.clear() + it.diskCache?.clear() + updateCache = true + } + }, + modifier = Modifier, + summary = summary, + onLongClick = {}, + interactionSource = interactionSource, + ) + } - PreferenceValidation.Valid -> { - scope.launch(ExceptionHandler()) { - preferences = - viewModel.preferenceDataStore.updateData { prefs -> - pref.setter(prefs, newValue) - } + AppPreference.UserPinnedNavDrawerItems -> { + NavDrawerPreference( + title = stringResource(pref.title), + summary = pref.summary(context, null), + modifier = Modifier, + interactionSource = interactionSource, + ) + } + + AppPreference.SendAppLogs -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.sendAppLogs() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.resetSubtitleSettings() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.RequireProfilePin -> { + SwitchPreference( + title = stringResource(pref.title), + value = currentUser?.pin.isNotNullOrBlank(), + onClick = { + showPinFlow = true + }, + summaryOn = stringResource(R.string.enabled), + summaryOff = null, + modifier = Modifier, + ) + } + + AppPreference.SeerrIntegration -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + seerrDialogMode = + when (val conn = seerrConnection) { + is SeerrConnectionStatus.Error -> { + SeerrDialogMode.Error(conn.serverUrl, conn.ex) + } + + SeerrConnectionStatus.NotConfigured -> { + SeerrDialogMode.Add + } + + is SeerrConnectionStatus.Success -> { + SeerrDialogMode.Remove( + conn.current.server.url, + ) + } + } + }, + modifier = Modifier, + summary = + when (seerrConnection) { + is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) + SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) + is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) + }, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.QuickConnect -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + if (currentUser != null) { + viewModel.resetQuickConnectStatus() + showQuickConnectDialog = true + } + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + scope.launch(ExceptionHandler()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter(prefs, newValue) + } + } } } - } - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } } } } From cc052453a4ab1a1b2ae91570df831ef61e486862 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:30:16 -0500 Subject: [PATCH 66/80] Add basic music video support (#948) ## Description Just enables showing and playing music videos as if they were regular videos. Basic metadata is shown as well. Navigation isn't great because a music video library goes by folders which don't necessary have the best UI choices. ### Related issues Related to #267 ### Testing Checked playback on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../github/damontecres/wholphin/ui/nav/DestinationContent.kt | 4 +++- .../java/com/github/damontecres/wholphin/util/Constants.kt | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 eccfd6de..0888e721 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 @@ -121,7 +121,9 @@ fun DestinationContent( ) } - BaseItemKind.VIDEO -> { + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { // TODO Use VideoDetails MovieDetails( preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index da246f73..fa0476cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -9,6 +9,7 @@ val supportItemKinds = BaseItemKind.EPISODE, BaseItemKind.SERIES, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, BaseItemKind.SEASON, BaseItemKind.COLLECTION_FOLDER, BaseItemKind.FOLDER, @@ -47,6 +48,7 @@ val supportedPlayableTypes = BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, BaseItemKind.TV_CHANNEL, BaseItemKind.TV_PROGRAM, BaseItemKind.RECORDING, From e8c9b4e208feae611672acfee0d906996078d70c Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:27:05 +1100 Subject: [PATCH 67/80] SeriesOverview Alignment (#930) ## Description This PR changes the padding on the SeriesOverview to align it with the updated layout on the Home, MovieDetails and SeriesDetails pages. There is also a small change to fix an incorrect spacing on the SeriesDetails, and start padding added to the ItemRow to bring it into alignment with other text elements on the page and ensure consistent spacing from the navdrawer ### Related issues I believe this is the final PR to close out [704](https://github.com/damontecres/Wholphin/discussions/704) ### Testing Tested changes on both the emulator and physical tv ## Screenshots Home screen showing the ItemRow alignment home SeriesOverview Screenshot_20260221_200450 ## AI or LLM usage AI was used to identify the current padding and spacing values --- .../com/github/damontecres/wholphin/ui/cards/ItemRow.kt | 3 ++- .../wholphin/ui/detail/series/FocusedEpisodeHeader.kt | 7 ++++--- .../wholphin/ui/detail/series/SeriesDetails.kt | 2 +- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 9 +++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 7ebf0faf..314a7e1a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -57,7 +58,7 @@ fun ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index ff0ddade..aab6e056 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusState @@ -31,17 +32,17 @@ fun FocusedEpisodeHeader( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - EpisodeName(dto, modifier = Modifier) + EpisodeName(dto, modifier = Modifier.padding(start = 8.dp)) ep?.ui?.quickDetails?.let { - QuickDetails(it, ep.timeRemainingOrRuntime) + QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp)) } if (dto != null) { VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = dto.mediaSourceCount ?: 0, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) } OverviewText( 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 f0c74be3..579a3866 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 @@ -621,7 +621,7 @@ fun SeriesDetailsHeader( ) { QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp)) + GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp)) } dto.overview?.let { overview -> OverviewText( 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 86e2b398..114454c1 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 @@ -134,7 +134,7 @@ fun SeriesOverviewContent( .onFocusChanged { pageHasFocus = it.hasFocus }, ) { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .focusGroup() @@ -159,9 +159,10 @@ fun SeriesOverviewContent( Modifier .focusRequester(tabRowFocusRequester) .padding(paddingValues) + .padding(bottom = 4.dp) .fillMaxWidth(), ) - SeriesName(series.name, Modifier) + SeriesName(series.name, Modifier.padding(start = 8.dp)) FocusedEpisodeHeader( preferences = preferences, ep = focusedEpisode, @@ -294,8 +295,8 @@ fun SeriesOverviewContent( }, modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), + .padding(top = 4.dp) + .fillMaxWidth(), ) } } From a51d9e9c65ec1f93674c3e0b085135e5d4018b91 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:18:18 -0500 Subject: [PATCH 68/80] Fix clicking genre cards on home page (#953) ## Description Fixes clicking on a genre card added to the home page ### Related issues Fixes #952 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 28 ++++++++++++++++ .../wholphin/services/HomeSettingsService.kt | 33 ++++++++++++++----- .../wholphin/ui/components/GenreCardGrid.kt | 27 ++++----------- .../wholphin/ui/playback/PlaybackConstants.kt | 18 ++++++++++ 4 files changed, 78 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 172c6ef5..950e9b40 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.extensions.ticks import java.util.Locale +import java.util.UUID import kotlin.time.Duration @Serializable @@ -33,6 +34,7 @@ data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, val imageUrlOverride: String? = null, + val destinationOverride: Destination? = null, ) : CardGridItem { val id get() = data.id @@ -166,6 +168,7 @@ data class BaseItem( }?.toIntOrNull() fun destination(index: Int? = null): Destination { + if (destinationOverride != null) return destinationOverride val result = // Redirect episodes & seasons to their series if possible when (type) { @@ -234,3 +237,28 @@ data class BaseItemUi( val episodeUnplayedCornerText: String?, val quickDetails: AnnotatedString, ) + +fun createGenreDestination( + genreId: UUID, + genreName: String, + parentId: UUID, + parentName: String?, + includeItemTypes: List?, +) = Destination.FilteredCollection( + itemId = parentId, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + genreName, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + genres = listOf(genreId), + includeItemTypes = includeItemTypes, + ), + useSavedLibraryDisplayInfo = false, + ), + recursive = true, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 93dc32d2..2fe5a6cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions +import com.github.damontecres.wholphin.ui.playback.getTypeFor import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler @@ -654,18 +656,33 @@ class HomeSettingsService includeItemTypes = null, cardWidthPx = null, ) - val genres = - items.map { - BaseItem(it, false, genreImages[it.id]) - } - - val name = + val library = libraries .firstOrNull { it.itemId == row.parentId } - ?.name + val title = - name?.let { context.getString(R.string.genres_in, it) } + library?.name?.let { context.getString(R.string.genres_in, it) } ?: context.getString(R.string.genres) + val genres = + items.map { + BaseItem( + it, + false, + genreImages[it.id], + createGenreDestination( + genreId = it.id, + genreName = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = + library?.collectionType?.let { + getTypeFor(it)?.let { + listOf(it) + } + }, + ), + ) + } Success( title, 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 84f36f02..e64f1824 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 @@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -30,7 +29,6 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.GetGenresRequestHandler @@ -256,23 +254,12 @@ fun GenreCardGrid( pager = genres, onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( - Destination.FilteredCollection( - itemId = itemId, - filter = - CollectionFolderFilter( - nameOverride = - listOfNotNull( - genre.name, - item?.title, - ).joinToString(" "), - filter = - GetItemsFilter( - genres = listOf(genre.id), - includeItemTypes = includeItemTypes, - ), - useSavedLibraryDisplayInfo = false, - ), - recursive = true, + createGenreDestination( + genreId = genre.id, + genreName = genre.name, + parentId = itemId, + parentName = item?.title, + includeItemTypes = includeItemTypes, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index 55fd5424..fa56bb84 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale import com.github.damontecres.wholphin.preferences.PrefContentScale import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0") @@ -81,3 +82,20 @@ val BaseItemKind.playable: Boolean BaseItemKind.YEAR, -> false } + +fun getTypeFor(collectionType: CollectionType): BaseItemKind? = + when (collectionType) { + CollectionType.UNKNOWN -> null + CollectionType.MOVIES -> BaseItemKind.MOVIE + CollectionType.TVSHOWS -> BaseItemKind.SERIES + CollectionType.MUSIC -> BaseItemKind.AUDIO + CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO + CollectionType.TRAILERS -> BaseItemKind.TRAILER + CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO + CollectionType.BOXSETS -> BaseItemKind.BOX_SET + CollectionType.BOOKS -> BaseItemKind.BOOK + CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM + CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL + CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST + CollectionType.FOLDERS -> BaseItemKind.FOLDER + } From a44fad890f13285a4618397da5523162166f91fd Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:56:22 -0500 Subject: [PATCH 69/80] Add preset for home page episode thumbnails (#954) ## Description Adds a preset for "Episode thumbnails" which shows episodes using their Primary image (ie a thumbnail or screenshot of the episode). The previous "Thumbnails" preset is renamed "Series Thumbs" because it uses the episode's Series Thumb image. Also fixes a bug where toggling "Use series" might not update the home page preview automatically. ### Related issues Fixes #940 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/cards/BannerCard.kt | 6 +- .../damontecres/wholphin/ui/main/HomePage.kt | 2 + .../ui/main/settings/HomeRowPresets.kt | 69 +++++++++++++++---- app/src/main/res/values/strings.xml | 4 ++ 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 294df8f6..51d003fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -68,6 +68,7 @@ fun BannerCard( interactionSource: MutableInteractionSource? = null, imageType: ImageType = ImageType.PRIMARY, imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = true, ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current @@ -82,7 +83,7 @@ fun BannerCard( } } val imageUrl = - remember(item, fillHeight, imageType) { + remember(item, fillHeight, imageType, useSeriesForPrimary) { if (item != null) { item.imageUrlOverride ?: imageUrlService.getItemImageUrl( @@ -90,6 +91,7 @@ fun BannerCard( imageType, fillWidth = null, fillHeight = fillHeight, + useSeriesForPrimary = useSeriesForPrimary, ) } else { null @@ -208,6 +210,7 @@ fun BannerCardWithTitle( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, imageType: ImageType = ImageType.PRIMARY, imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true, ) { val focused by interactionSource.collectIsFocusedAsState() val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) @@ -234,6 +237,7 @@ fun BannerCardWithTitle( interactionSource = interactionSource, imageType = imageType, imageContentScale = imageContentScale, + useSeriesForPrimary = useSeriesForPrimary, ) Column( verticalArrangement = Arrangement.spacedBy(0.dp), 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 b9a7cbd7..cf8738c5 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 @@ -524,6 +524,7 @@ fun HomePageCardContent( onLongClick = onLongClick, modifier = modifier, cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, ) } else { BannerCard( @@ -543,6 +544,7 @@ fun HomePageCardContent( modifier = modifier, interactionSource = null, cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index ff4cfe0a..815c343a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -123,7 +123,7 @@ data class HomeRowPresets( ) } - val Thumbnails by lazy { + val SeriesThumbs by lazy { val height = 148 val epHeight = 100 HomeRowPresets( @@ -132,6 +132,7 @@ data class HomeRowPresets( heightDp = epHeight, imageType = ViewOptionImageType.THUMB, aspectRatio = AspectRatio.WIDE, + useSeries = true, episodeImageType = ViewOptionImageType.THUMB, episodeAspectRatio = AspectRatio.WIDE, ), @@ -164,6 +165,50 @@ data class HomeRowPresets( genreSize = epHeight, ) } + + val EpisodeThumbnails by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = epHeight, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + showTitles = true, + useSeries = false, + episodeImageType = ViewOptionImageType.PRIMARY, + episodeAspectRatio = AspectRatio.WIDE, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + liveTv = HomeRowViewOptions.liveTvDefault, + genreSize = epHeight, + ) + } } } @@ -173,13 +218,13 @@ fun HomeRowPresetsContent( modifier: Modifier = Modifier, ) { val presets = - remember { - listOf( - "Wholphin Default", - "Wholphin Compact", - "Thumbnails", - ) - } + listOf( + stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault, + stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact, + stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs, + stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails, + ) + val focusRequesters = remember { List(presets.size) { FocusRequester() } } LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } Column(modifier = modifier) { @@ -192,16 +237,12 @@ fun HomeRowPresetsContent( .fillMaxHeight() .focusRestorer(focusRequesters[0]), ) { - itemsIndexed(presets) { index, title -> + itemsIndexed(presets) { index, (title, preset) -> HomeSettingsListItem( selected = false, headlineText = title, onClick = { - when (index) { - 0 -> onApply.invoke(HomeRowPresets.WholphinDefault) - 1 -> onApply.invoke(HomeRowPresets.WholphinCompact) - 2 -> onApply.invoke(HomeRowPresets.Thumbnails) - } + onApply.invoke(preset) }, modifier = Modifier.focusRequester(focusRequesters[index]), ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4f3db4f..3baee433 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,6 +522,10 @@ Display presets Built-in presets to quickly style all rows Choose rows and images on the home page + Wholphin Default + Wholphin Compact + Series Thumb images + Episode Thumbnail images Disabled From 2a371e7d6bb3d3021459c1585bdb00915b368d5e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:17:57 -0500 Subject: [PATCH 70/80] Fix strings missing from translations (#959) ## Description Weblate's Android string parser (aka https://translate.codeberg.org/projects/wholphin/) doesn't support direct translations of string arrays. Many of Wholphin's preference options are defined in string arrays and therefore could not be translated. So this PR implements the [suggested workaround](https://docs.weblate.org/en/latest/formats/android.html) to move the string into individual entries and reference them in arrays. Also moves some hardcoded strings into `strings.xml` so they can be translated. Note: virtually all error messages are still hardcoded, but they are only for exceptional cases, so it's probably not a huge problem. A minor bug where the backdrop would show briefly after switching users is fixed. ### Related issues Fixes #957 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 6 + .../damontecres/wholphin/ui/Formatting.kt | 3 +- .../ui/components/CollectionFolderGrid.kt | 2 +- .../ui/playback/DownloadSubtitlesDialog.kt | 6 +- .../wholphin/ui/playback/PlaybackConstants.kt | 13 +- .../wholphin/ui/playback/PlaybackDialog.kt | 6 +- .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 6 +- .../ui/setup/seerr/AddSeerrServerDialog.kt | 41 ++--- app/src/main/res/values/strings.xml | 166 ++++++++++++------ 9 files changed, 156 insertions(+), 93 deletions(-) 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 34bd871c..b7f4b1d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -123,6 +123,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + @Inject + lateinit var backdropService: BackdropService + // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener @@ -247,6 +250,9 @@ class MainActivity : AppCompatActivity() { } is SetupDestination.AppContent -> { + LaunchedEffect(Unit) { + backdropService.clearBackdrop() + } val current = key.current ProvideLocalClock { if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 1842620a..8f4066bb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber @@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String? append(productionYear.toString()) if (status == "Continuing") { append(" - ") - append("Present") + append(WholphinApplication.instance.getString(R.string.series_continueing)) } else if (status == "Ended") { endDate?.let { if (it.year != productionYear) { 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 4c672998..9110802d 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 @@ -857,7 +857,7 @@ fun CollectionFolderGridContent( DataLoadingState.Loading, -> { // This shouldn't happen, so just show placeholder - Text("Loading") + Text(stringResource(R.string.loading)) } is DataLoadingState.Error -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index b3813ed5..e1a291e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -102,7 +102,7 @@ fun DownloadSubtitlesContent( .padding(PaddingValues(24.dp)), ) { Text( - text = "Search & download subtitles", + text = stringResource(R.string.search_and_download_subtitles), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -113,7 +113,7 @@ fun DownloadSubtitlesContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Language", + text = stringResource(R.string.language), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -137,7 +137,7 @@ fun DownloadSubtitlesContent( } if (dialogItems.isEmpty()) { Text( - text = "No remote subtitles were found", + text = stringResource(R.string.no_subtitles_found), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index fa56bb84..474c7932 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.PrefContentScale import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType @@ -9,13 +10,13 @@ val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.7 val playbackScaleOptions = mapOf( - ContentScale.Fit to "Fit", - ContentScale.None to "None", - ContentScale.Crop to "Crop", + ContentScale.Fit to R.string.content_scale_fit, + ContentScale.None to R.string.none, + ContentScale.Crop to R.string.content_scale_crop, // ContentScale.Inside to "Inside", - ContentScale.FillBounds to "Fill", - ContentScale.FillWidth to "Fill Width", - ContentScale.FillHeight to "Fill Height", + ContentScale.FillBounds to R.string.content_scale_fill, + ContentScale.FillWidth to R.string.content_scale_fill_width, + ContentScale.FillHeight to R.string.content_scale_fill_height, ) val PrefContentScale.scale: ContentScale diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 04ff620a..c6d7b622 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -144,7 +144,9 @@ fun PlaybackDialog( BottomDialogItem( data = PlaybackDialogType.VIDEO_SCALE, headline = stringResource(R.string.video_scale), - supporting = playbackScaleOptions[settings.contentScale], + supporting = + playbackScaleOptions[settings.contentScale] + ?.let { stringResource(it) }, ), ) } @@ -220,7 +222,7 @@ fun PlaybackDialog( playbackScaleOptions.map { (scale, name) -> BottomDialogItem( data = scale, - headline = name, + headline = stringResource(name), supporting = null, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index d2b48c35..81e06fb8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -62,7 +62,7 @@ fun AddSeerrServerApiKey( val passwordFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Text( - text = "Enter URL & API Key", + text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, @@ -73,7 +73,7 @@ fun AddSeerrServerApiKey( modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "URL", + text = stringResource(R.string.url), modifier = Modifier.padding(end = 8.dp), ) EditTextBox( @@ -104,7 +104,7 @@ fun AddSeerrServerApiKey( modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "API Key", + text = stringResource(R.string.api_key), modifier = Modifier.padding(end = 8.dp), ) EditTextBox( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index 3601fdd7..9566b88b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.DialogItem @@ -71,27 +73,26 @@ fun ChooseSeerrLoginType( onChoose: (SeerrAuthMethod) -> Unit, ) { val params = - remember { - DialogParams( - fromLongClick = false, - title = "Login to Seerr server", - items = - listOf( - DialogItem( - text = "API Key", - onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, - ), - DialogItem( - text = "Jellyfin user", - onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, - ), - DialogItem( - text = "Local user", - onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, - ), + DialogParams( + fromLongClick = false, + title = stringResource(R.string.seerr_login), + items = + listOf( + DialogItem( + text = stringResource(R.string.api_key), + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, ), - ) - } + DialogItem( + text = stringResource(R.string.seerr_jellyfin_user), + onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, + ), + DialogItem( + text = stringResource(R.string.seerr_local_user), + onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, + ), + ), + ) + DialogPopup( params = params, onDismissRequest = onDismissRequest, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3baee433..2967ada1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,109 +522,161 @@ Display presets Built-in presets to quickly style all rows Choose rows and images on the home page + Fit + Crop + Fill + Fill width + Fill height Wholphin Default Wholphin Compact Series Thumb images Episode Thumbnail images + Lowest + Low + Medium + High + Full - Disabled - Lowest - Low - Medium - High - Full + @string/disabled + @string/volume_lowest + @string/volume_low + @string/volume_medium + @string/volume_high + @string/volume_full + Purple + Orange + Bold Blue + Black - Purple - Blue - Green - Orange - Bold Blue - Black + @string/purple + @string/blue + @string/green + @string/orange + @string/bold_blue + @string/black + Ignore + Skip automatically + Ask to skip - Ignore - Skip automatically - Ask to skip + @string/skip_ignore + @string/skip_automatically + @string/skip_ask - Fit - None - Crop - Fill - Fill Width - Fill Height + @string/content_scale_fit + @string/none + @string/content_scale_crop + @string/content_scale_fill + @string/content_scale_fill_width + @string/content_scale_fill_height + Only use FFmpeg if no built-in decoder exists + Prefer to use FFmpeg over built-in decoders + Never use FFmpeg decoders - Only use FFmpeg if no built-in decoder exists - Prefer to use FFmpeg over built-in decoders - Never use FFmpeg decoders + @string/ffmpeg_fallback + @string/ffmpeg_prefer + @string/ffmpeg_never + At the end of playback + During end credits/outro - At the end of playback - During end credits/outro + @string/next_up_playback_end + @string/next_up_outro + White + Light gray + Dark gray + Yellow + Cyan + Magenta - White - Black - Light Gray - Dark Gray - Red - Yellow - Green - Cyan - Blue - Magenta + @string/white + @string/black + @string/light_gray + @string/dark_gray + @string/red + @string/yellow + @string/green + @string/cyan + @string/blue + @string/magenta + Outline + Shadow - None - Outline - Shadow + @string/none + @string/subtitle_edge_outline + @string/subtitle_edge_shadow + Wrap + Boxed - None - Wrap - Boxed + @string/none + @string/background_style_wrap + @string/background_style_boxed + ExoPlayer + MPV + Prefer MPV - ExoPlayer - MPV - Prefer MPV + @string/exoplayer + @string/mpv + @string/prefer_mpv + Use ExoPlayer for HDR playback - - - Use ExoPlayer for HDR playback + + + @string/player_backend_options_subtitles_prefer_mpv + Poster (2:3) + 16:9 + 4:3 + Square (1:1) - Poster (2:3) - 16:9 - 4:3 - Square (1:1) + @string/aspect_ratios_poster + @string/aspect_ratios_16_9 + @string/aspect_ratios_4_3 + @string/aspect_ratios_square + Primary + Thumbary - Primary - Thumb + @string/image_type_primary + @string/image_type_thumb + Image with dynamic color + Image only + + API Key + Login to Seerr server + Jellyfin user + Local user + + No remote subtitles were found + Present - Image with dynamic color - Image only - None + @string/backdrop_style_dynamic + @string/backdrop_style_image + @string/none From e1c66dfb1d8dc7644afa091b9881eba697460fa7 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 23 Feb 2026 12:34:46 -0500 Subject: [PATCH 71/80] Fix series overview episode images --- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 1 + 1 file changed, 1 insertion(+) 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 114454c1..a0d120e1 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 @@ -267,6 +267,7 @@ fun SeriesOverviewContent( }, interactionSource = interactionSource, cardHeight = 120.dp, + useSeriesForPrimary = false, ) } } From 3b162a2f98f6b53023fdfad84bdacc63504db217 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:38:02 -0500 Subject: [PATCH 72/80] Play theme songs for collections (#962) ## Description Play theme songs for collections if available ### Related issues Closes #711 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 105 ++++++++++++------ .../ui/detail/CollectionFolderPhotoAlbum.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 73 insertions(+), 36 deletions(-) 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 9110802d..c5dc912d 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 @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -60,6 +61,8 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields @@ -120,7 +123,9 @@ class CollectionFolderViewModel private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, - val navigationManager: NavigationManager, + private val navigationManager: NavigationManager, + private val themeSongPlayer: ThemeSongPlayer, + private val userPreferencesService: UserPreferencesService, val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -157,9 +162,10 @@ class CollectionFolderViewModel viewModelScope.launchIO { super.itemId = itemId try { - itemId.toUUIDOrNull()?.let { - fetchItem(it) - } + val item = + itemId.toUUIDOrNull()?.let { + fetchItem(it) + } val libraryDisplayInfo = serverRepository.currentUser.value?.let { user -> @@ -184,6 +190,8 @@ class CollectionFolderViewModel } loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + .join() +// onResumePage() } catch (ex: Exception) { Timber.e(ex, "Error during init") loading.setValueOnMain(DataLoadingState.Error(ex)) @@ -254,34 +262,32 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, - ) { - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - if (resetState) { - loading.value = DataLoadingState.Loading - } - backgroundLoading.value = LoadingState.Loading - this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection - this@CollectionFolderViewModel.filter.value = filter + ) = viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + if (resetState) { + loading.value = DataLoadingState.Loading } - try { - val newPager = - createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Success(newPager) - backgroundLoading.value = LoadingState.Success - } - } catch (ex: Exception) { - Timber.e( - ex, - "Exception while loading data: sort=%s, filter=%s", - sortAndDirection, - filter, - ) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Error(ex) - } + backgroundLoading.value = LoadingState.Loading + this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection + this@CollectionFolderViewModel.filter.value = filter + } + try { + val newPager = + createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Success(newPager) + backgroundLoading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e( + ex, + "Exception while loading data: sort=%s, filter=%s", + sortAndDirection, + filter, + ) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Error(ex) } } } @@ -443,6 +449,30 @@ class CollectionFolderViewModel backdropService.submit(item) } } + + fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + + fun release() { + themeSongPlayer.stop() + } + + fun onResumePage() { + viewModelScope.launchIO { + item.value?.let { + Timber.v("onResumePage: %s", loading.value!!::class) + if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { + val volume = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.playThemeSongs + themeSongPlayer.playThemeFor(it.id, volume) + } + } + } + } } /** @@ -548,6 +578,13 @@ fun CollectionFolderGrid( ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) Box(modifier = modifier) { + LifecycleResumeEffect(itemId) { + viewModel.onResumePage() + + onPauseOrDispose { + viewModel.release() + } + } CollectionFolderGridContent( preferences = preferences, initialPosition = viewModel.position, @@ -598,7 +635,7 @@ fun CollectionFolderGrid( } else { Destination.Playback(item) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) }, onClickPlayAll = { shuffle -> itemId.toUUIDOrNull()?.let { @@ -622,7 +659,7 @@ fun CollectionFolderGrid( filter = filter, ) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) } }, ) @@ -660,7 +697,7 @@ fun CollectionFolderGrid( favorite = item.favorite, actions = MoreDialogActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, + navigateTo = { viewModel.navigateTo(it) }, onClickWatch = { itemId, watched -> viewModel.setWatched(position, itemId, watched) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index fd4d46d3..4ddc41c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -65,7 +65,7 @@ fun CollectionFolderPhotoAlbum( } else { item.destination(index) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) }, itemId = itemId.toServerString(), initialFilter = filter, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2967ada1..93476b74 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -656,7 +656,7 @@ Primary - Thumbary + Thumb @string/image_type_primary @string/image_type_thumb From 348a3022d6420a8ed4b821d9bdd07a17c4298607 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:20:25 -0500 Subject: [PATCH 73/80] Fix cut off cards on grids with show details enabled (#963) ## Description Fixes the previous row of cards showing the bottom cut off when the "Show details" view option is enabled. ### Related issues Fixes #404 Fixes #877 Related to #882 ### Testing Emulator ## Screenshots ![grid_show_details Large](https://github.com/user-attachments/assets/3376275e-261e-4e5a-b874-18be7606a9bf) ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 20 ++- .../wholphin/ui/detail/CardGrid.kt | 166 +++++++++--------- 2 files changed, 104 insertions(+), 82 deletions(-) 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 c5dc912d..4a71404a 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 @@ -6,7 +6,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,6 +35,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -84,6 +87,7 @@ 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.ui.util.FilterUtils +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -735,6 +739,7 @@ fun CollectionFolderGrid( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun CollectionFolderGridContent( preferences: UserPreferences, @@ -879,14 +884,16 @@ fun CollectionFolderGridContent( } } } + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + val density = LocalDensity.current AnimatedVisibility(viewOptions.showDetails) { HomePageHeader( item = focusedItem, modifier = Modifier .fillMaxWidth() - .height(140.dp) - .padding(16.dp), + .height(200.dp) + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp), ) } when (val state = loadingState) { @@ -932,6 +939,15 @@ fun CollectionFolderGridContent( }, columns = viewOptions.columns, spacing = viewOptions.spacing.dp, + bringIntoViewSpec = + remember(viewOptions) { + val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } + if (viewOptions.showDetails) { + ScrollToTopBringIntoViewSpec(spacingPx) + } else { + defaultBringIntoViewSpec + } + }, ) AnimatedVisibility(showViewOptions) { ViewOptionsDialog( 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 8893246c..ac72a144 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 @@ -4,6 +4,8 @@ import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -112,6 +115,7 @@ fun CardGrid( }, columns: Int = 6, spacing: Dp = 16.dp, + bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current, ) { val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) @@ -269,100 +273,102 @@ fun CardGrid( Box( modifier = Modifier.weight(1f), ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(vertical = 16.dp), - modifier = - Modifier - .fillMaxSize() - .focusGroup() - .focusRestorer(firstFocus) - .focusProperties { - onExit = { - // Leaving the grid, so "forget" the position + CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + state = gridState, + contentPadding = PaddingValues(vertical = 16.dp), + modifier = + Modifier + .fillMaxSize() + .focusGroup() + .focusRestorer(firstFocus) + .focusProperties { + onExit = { + // Leaving the grid, so "forget" the position // focusedIndex = -1 - } - onEnter = { - if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { - focusedIndex = startPosition } - } - }, - ) { - items(pager.size) { index -> - val mod = - if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { - if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") - Modifier - .focusRequester(firstFocus) - .focusRequester(gridFocusRequester) - .focusRequester(alphabetFocusRequester) - } else { - Modifier - } - val item = pager[index] - cardContent( - item, - { - if (item != null) { - focusedIndex = index - onClickItem.invoke(index, item) - } - }, - { if (item != null) onLongClickItem.invoke(index, item) }, - mod - .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) - .onFocusChanged { focusState -> - if (DEBUG) { - Timber.v( - "$index isFocused=${focusState.isFocused}", - ) + onEnter = { + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition + } } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } else if (focusedIndex == index) { + }, + ) { + items(pager.size) { index -> + val mod = + if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") + Modifier + .focusRequester(firstFocus) + .focusRequester(gridFocusRequester) + .focusRequester(alphabetFocusRequester) + } else { + Modifier + } + val item = pager[index] + cardContent( + item, + { + if (item != null) { + focusedIndex = index + onClickItem.invoke(index, item) + } + }, + { if (item != null) onLongClickItem.invoke(index, item) }, + mod + .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) + .onFocusChanged { focusState -> + if (DEBUG) { + Timber.v( + "$index isFocused=${focusState.isFocused}", + ) + } + if (focusState.isFocused) { + // Focused, so set that up + focusOn(index) + positionCallback?.invoke(columns, index) + } else if (focusedIndex == index) { // savedFocusedIndex = index // // Was focused on this, so mark unfocused // focusedIndex = -1 - } - }, - ) + } + }, + ) + } } - } - if (pager.isEmpty()) { + if (pager.isEmpty()) { // focusedIndex = -1 - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.no_results), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.align(Alignment.Center), - ) + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.no_results), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + ) + } } - } - if (showFooter) { - // Footer - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .background(AppColors.TransparentBlack50), - ) { - val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" + if (showFooter) { + // Footer + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .background(AppColors.TransparentBlack50), + ) { + val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" // if (focusedIndex >= 0) { // focusedIndex + 1 // } else { // max(savedFocusedIndex, focusedIndexOnExit) + 1 // } - Text( - modifier = Modifier.padding(4.dp), - color = MaterialTheme.colorScheme.onBackground, - text = "$index / ${pager.size}", - ) + Text( + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onBackground, + text = "$index / ${pager.size}", + ) + } } } } From d483ebf7354f800da2a053417ad7acf62f3f9918 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:00:31 -0500 Subject: [PATCH 74/80] Update Gradle to v9.3.1 (#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | [gradle](https://gradle.org) ([source](https://redirect.github.com/gradle/gradle)) | minor | `9.2.1` → `9.3.1` | --- ### Release Notes
gradle/gradle (gradle) ### [`v9.3.1`](https://redirect.github.com/gradle/gradle/releases/tag/v9.3.1): 9.3.1 [Compare Source](https://redirect.github.com/gradle/gradle/compare/v9.3.0...v9.3.1) This is a patch release for 9.3.0. We recommend using 9.3.1 instead of 9.3.0. The following issues were resolved: - [Cannot find testcases from Android Screenshot Test plugin since Gradle 9.3.0](https://redirect.github.com/gradle/gradle/issues/36320) - [Excluding dependencies from included builds doesn't work in Gradle 9.3.0](https://redirect.github.com/gradle/gradle/issues/36331) - [ExternalDependency and DependencyConstraint cannot be passed to DependencyResolveDetails#useTarget](https://redirect.github.com/gradle/gradle/issues/36359) - [Gradle 9.3.0 generate JUnit test result files with wrong name](https://redirect.github.com/gradle/gradle/issues/36379) - [Build cache cannot handle outputs with non-BMP characters in the filename](https://redirect.github.com/gradle/gradle/issues/36387) - [Emojis in test names should not break build caching](https://redirect.github.com/gradle/gradle/issues/36395) - [Non utf-8 c code is no longer buildable](https://redirect.github.com/gradle/gradle/issues/36399) - [Breaking change in 9.3.0 regarding cross-project dependency manipulation](https://redirect.github.com/gradle/gradle/issues/36428) - [JUnit3 tests cannot be run with Gradle 9.3.0](https://redirect.github.com/gradle/gradle/issues/36451) - [Test.setScanForTestClasses(false) causes all junit4 tests to be skipped](https://redirect.github.com/gradle/gradle/issues/36508) [Read the Release Notes](https://docs.gradle.org/9.3.1/release-notes.html) #### Upgrade instructions Switch your build to use Gradle 9.3.1 by updating your wrapper: ``` ./gradlew wrapper --gradle-version=9.3.1 && ./gradlew wrapper ``` See the Gradle [9.x upgrade guide](https://docs.gradle.org/9.3.1/userguide/upgrading_version_9.html) to learn about deprecations, breaking changes and other considerations when upgrading. For Java, Groovy, Kotlin and Android compatibility, see the [full compatibility notes](https://docs.gradle.org/9.3.1/userguide/compatibility.html). #### Reporting problems If you find a problem with this release, please file a bug on [GitHub Issues](https://redirect.github.com/gradle/gradle/issues) adhering to our issue guidelines. If you're not sure you're encountering a bug, please use the [forum](https://discuss.gradle.org/c/help-discuss). We hope you will build happiness with Gradle, and we look forward to your feedback via [Twitter](https://twitter.com/gradle) or on [GitHub](https://redirect.github.com/gradle). ### [`v9.3.0`](https://redirect.github.com/gradle/gradle/compare/v9.2.1...v9.3.0) [Compare Source](https://redirect.github.com/gradle/gradle/compare/v9.2.1...v9.3.0)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 +---- gradlew.bat | 3 +-- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..61285a659d17295f1de7c53e24fdf13ad755c379 100644 GIT binary patch delta 37988 zcmX6@V|bli*Gyxa*tTsajcwbu)95rhv2ELp-Pl&+oY+>=r1|>1-;aI&zOTJz_N+B) z9#P&Gv}#H23%q7=tU;GV-|;-tohf=I~hj-MKNe%d3eh&|2-wPA>Ee zMNrvEdyVSI`lvDy^z`gwVkn}LK|GSq#|4JnxoIRO@sx!|eY?;bc>3*K_2AXEE_$R{ zzCVzv3UKiDb5r_h5D*ZZ5Gi+pL@8*f(!iG1x>gdb9^Yv>r}mh(L3OFb;);CzTapwz zf*i|?OK0?9kw_P?-0dFJtLi=zod6Wn?%aD|P%jYTC%iX)k0Vd>Vbm^Cr+C9_<`m40 zM^@Lgu3F}@42za*z}FZG8H@~yghPxY23DilF(fmO%LhdnWy_>0YdUU%{5;eNLSXXl zUnx6g^xx`|70?R~2k4=9*}u3!x!&jn08l8EddKmclPN&pp#^~95+?=Q%XO*?SHv{B znC+V^K--hOSb9;Y5YgB1Ej3fir@e4s?^di<$}xQHuJ$q9?N!Bv!`7I6)5sbU#Lw1u^S7sb(g%zNh3L5MJQsdwRb zgNo>59Bh1jM9C5avMI{R90Kw05r4looJRr#4qh*PUNQS31%o#@dU69Nb{wvnpC=mn zcY`1@hbV^re0-c7@lN8bd570AB1N~=VPVbKaVf?8DYvLWmcdQ+38(I$J+%Pl`B!V> zZq%>Y`%Vt>v8{!@1PO9E2 z9VBjQeL_arEHz!fGJkWBR?n$iC=29KvaHy#Um6^Nh-;kO0n9to04%I0VDXCW&Btps3x;+T81snc7-CrYOO1 zBwrOxlYveR3vF?GBNGFK(sl#BMS^d}9ZAd|AuI~qjv&_lj;$rIO{nGxgiPL|9DKXC z#tSNLxrea`Z|G>h1HZmhBsPlM5D1TDN+yJ5N^b0-)-S?aZIOvmD<@f5T70wrgb#~L za_t)z{ST{ujY^K=AR!<&q5jdEF{PgoCnXw_80e&eDTWr54k^=6hJ}7>BqX-6Sfa*O zwVX*76(t8X%1|D_zS(`{=Gwk?X;d>jj(W%YDu$UVi3$8JI>{GEPR3=|qu_0AlW$|~ z?fv{xK-v#cOEGqPc6)1e7piu!+LzeYWUcE(>7pCF>ncpr8O-(Z6US0#5K{>2aOl)rz?K{}OE1UoTv+&+5Y0HkKiPPx_ zSUMqKtF!#T&Cp4YDQDIn9b;#M?Zx0qqt5TlH)Vr5!Xg@RQo&-HV|Ik@n=3Oamu2lh z49_-ckP&y{-UyE%0O8+5(Hp5n`BHJk0y(H^HQ+mE^&zM2ap4%A~gIc#u*(J)5 zFWKk7)#JdESw8EXxkA^MIWExbt1a0@>sH2q;J4sfvuv@)8Yj~R*bKk6ac2b^px5*s z;S0EVw7#6!03@ahulYnKSqr8O6fq3?}t12hy0(&)WAI^$5R$gvv z@QTmbJl?Dt^=s$=+Cf{OGen!c|6sH&L~`a>NPKsP3{U~V7nSYIyfY5F10YH9u)4{jG`)}f1N+J)`LBYoYP2OG7uKJx2PLrk zT)QlSITdY?d3m;=xZj!6HJ(ntr@OTsF?H7IVL#|XSiL@jJFN!e0Ny9P&*E-|UWWDt z{>&}87BdLGssBIReYp{#Gx&$QHt7G!3L!aliV6-bP-9aYO&He@IvTzqQ6`5*8V1l7 zj;RBnl;-^y~v2}NI+ zyRv85wprnBpSkOu717VWKvYx2T@E4O^Q9TsrgiWM*-U3ePs>E7x%yfcdFZeY{44uN z69!xlWP^EuW?t>AIP)rU@l~4AuvzOoi>lqIw8L?+mEJ4S&zn>+p2A#XzT9ZwRZ4+q zm~E|jq`S;ELjn_c$IUB&{aSFr;Zg6BVl~l9PZ`Q=u$_loMn+rQiUVv{9j%5lM_L+( zo=fA*eCZ=s=NRAK;=A)*BTh!T4lp*K-qo9di~1RfrX3^>-HY&#vrca9;i&=FNC?CL z;-KwYPy<_~f}3;naG%*P5HJP2qbu}5Mj2M)?>lm*1(SDrK4_zgHO@yZF=z*J9xI18 z5{=F=b8U}5tr*<3XBnh6?shxr2>l?LFYL&wztoJ~>N zHvJ8pWBAHvq`zfaPQcFUiDGRr0L@xX3=JqX(^Tpz1GR0mdn@^%aIw`r^AmT2itv2^LM4_W^UDgrqS>F zMf918L|84oF#mQq*L! z&>!lVl~2Q=vbfpt<_`DU-D;n>J`H!XRsHknkuab)&H#9ADq_iOw+^_w?g5l^%e+u+ zh?oj&sTU`Oqw>XsVaem8eA%jSNPV2Yh-qm7b5)(vhLTg1(QU{TD=(cBxan3lB42%e z`HyiWi<_#NP!JF)aQ|c66uu(x!h~oAKL~{hz?336j+|^ulS9bVZ3@W%sRR>CpqI{t zb~>s_?2R%Nww^UJ%>-R1cZ1u?>p!xwp}^Hz?$k1sJl;@O@K&_D2`v2hCHixfA#iS* z#nDtlynj0PA^+vRXU*g9{hdD$dOnI8q{C_=vh);SE3TyMp@HNTk(>f7lBKgNavnvN*|4K_6V&(sTiVPiK40Sb>EmM&r^LVwdWJUm@F-}i0yg8QMUWddZmW)J&?gyYq#0bS3AO_c+c%_lCemS1~~WLa!>+C>l}I5AS5ra>86N z`IK^Ng`*ayum9rwCR{DhQ=hvP&rYCj1En1mIeWF1KNtOH;ACV?m!bG~@GEH0>aZH$ za*8sc1Cmj~|B~hD6;c?m(gz8e|3rJTitJ|TlxL#c&zhHqmMfh^H^txJ0cr2&e&s14 z(7R^4j5SiVS$?jqA-oD~tD7D19K#0mc2#xL;??uGEMCPKZT|u`&vY(vjH20!tZ>kj zd=U&uY)mop$M2`Qw61h~#?J|{9VWqlDeS}1`bAp;+iLDr5KDGGET2Sf5u+P!={UmE zBp{h)mlf#EkaJua@h)3a(;SPFfI3+3V< zDekSd)8r^4K~*u_lWVUy%8iX!U>4svfQb|u$1_|${+P)DM8vA>NhXSa$bnV`6>v0M z@70rW&6j<0WJI_Spa8vr<%3K3KFahU)hsPyY6}C-u2CSj)#8sd@fRuN#k$wH8Y1CK zBBz=GxoCu@Qmx6CA*=fj&8+XWC&_nw`DjT(tu&yK>bpsIw#cAiJOSA2?=lNa*L3Aa z4D|vt*eiy2QHi7Ucg2`mn{f{cEF+O(i+L%7KCA_)6)ND^@g?}7P{Ly?Ss$XoV_b4O zM!;AzR}%2Xk7aA8dT{KHZ1mPHBzylFv$}ahv8m$;XepbuvP~~_puD&$Y~L;tfaJibfB*SWC z*(ZJ7);`c5sj-&vSbyvMZdWu+_K=f3fuYF0G!8^S%6eL|-hNLr<#c?YynLL)R&!$Q zWTbP_kgx}R?Hpen8~`AfdW~VQsI91_G0G>z=gWwMTV`Y+g@DXZDHkJ`@lQ+JI<2p(#48RjPr zbnYy^9C6!&o=z}=(MF; zE5nC_*BY;~gOU3+u^wjCWz`Sg7s9ero~sds8{StZw1j;hEdQK}I()6MM(R(*VR8Ede!NZxC)Y&G=G$whxxiXV?JTemD6@ysi~^qCQG`UFaY%z zB!ab{_Otdr&XWF}sDDQe?4}Ogl-I{==aXAna|)!&IH4_sCqpLc@O< ziJGO-OiuVR=tV$_*fAP8dJ62oT4UMR${9NqI;4)J9*mLQf=9Atq>+`qd%U6O4}*z9 zX~XzNdWGVvCKAC3L)hy{uL<}C9I*UIF4=w zLLt?jz)0+mW#&Gn1D~iSbulJxaCLw+)x5;Cg0H{=vFK24)HLFgkF*(w=j$-Hpv$SH$qlmCws_|Dgk$AEG_31BM?)Q4dq|=EHV-k@ zAFppMXXxdu{bl{9Rtq-pE6@9_lBUVfUyjZk*5g?pFm2PS!7I#=P&P%2UkW(Tb5zbs zb9uD&2>42{J9+SiXv1W9=ty1)cYwa%D`sm8o;g$Wm5t4-Fd`~CNB+8*Y^dwg<85M}zjlEOeif~q zos6Yr%nrx#+DnSN7XmB^L?#6JE(rHA z;aOD@PYAQoTjZK;sQ@+c(pTdTNuY&F8^z!K<2{T20DPWO;5FOuq@OC%n6bZAexswS z9;e6{!lS{X5&ql83{N0QXqRslc!j)sfSjSw-Go2VYE=8eWycEqo$>yR_M7~RigxYDK{J+N>F--=v2I{}q^*ea`6v2^t08s< zd(IfOp_&^B?mmSB0LG;LkNfRr0!Zu0*DRUKj=(=ZF)8{YGr?k~(3)x;H8Y`XByk~S zv|+K%EHiTZLb!oBB%|jFQBmyyyQ@3an+vIhuq2QKM!&{0TN;+q5(%k+bJw-{4LhX( zT~y;SU45QO%v>{3ojLE!;@F~Cr^G9lNui|*kKPxA#c3IXWWu&SwqjqG;837G!#`co z=w6)_W40pl_@y)?Tr^QvFg$UwCUjbtLNsLB<-aCx`H1XL@!wdL@&9AhCZz&U<3brz z4C^llQb7TIQt7Lvp_su&nPHh>m}UqFS^-Kj1UT;Lql@F+Zs{F^Mv1!5`6_{&A&E)) zGlC=ENvxB4Tglp|?;-ET@Ob)0R5a)d-k8wP$-%+Ov`p*ICt)zbc}ulR4ZRktz@PM) zz?xHgw+kxJgqaT~4rd0!QOfJrkX@(>;=VICf3{j}km zONL_(giC}2Weaw_U8lJ06gPq}+G2@rmRNdX-Q3`nx*ba<@c zV%x8LY_04qeD9THm8xet1f%olCOb!PLQWoQiVeR9I;W|2IO*=bu@)gy6S@|>JF8=P zVvc}CW%UN{$vXZw%q3xXA zT2Ucb;d&DdtaXQN?(6L_2EQ-g-uCU|i~h*_sb}sdp5F(O8_5IR;Lq5&045Fr)KD-b z0OaypT*qs*G^e}aG^QysgjfrT5cVF^`Dz})ZY4EFxMTWpHo#W)L^mx<^cj5lS6a>P zuR3`}{A@?^%3|YQ#*HxBy#m=^3t^i~;e*q4*($=qevbD?t!A`bNHLRJl5A}$<`?sO z#;-2ZZ}gM(w_BOlk!$nL1A}qjtTIbl^ZNz}hbSqu#=l`8kAH;b(AtU) ze7y%u#9?v)$HqtTX?TOo?L86|PclE=RH`Cc&QC=Z`;Oa8C!~Acmp{11pDya~%qY`XEq@3rN91-8K?!hC%*VR{nvRzZFtf&dgxU1D zlBl(;ZEU-!)jRhuqk7Osu~A+F%h0r#u!?aQXuM-L=1qp%+YL6IL@C=IoOGm!)R(+K*l_8GAFvONs^N&5_*jIZ*%?}Noj$@^hb33^e}BjbST`=&KJPdl zXh9KYlWS>a(^fKt9!R^aD(!Z`c0Hdky>19Y zF6ry-H1XmU$}X~^jS|n(L|;k4eFB= zcA;nZQS%f7XGAG67&%23U?md>>O`npF|NrR6GvHd3oW|8`A7-A$`ohlcq({2glYHC z9VX@ohWj!DqbEx587lz9-PLRgJJQ{+kJg(Wu?-Ji=(Y!(F=xYr0%(hi{6|AGdI*8e z=%i4?H?OHU|{{Sa1EGYfY5W$oLhQG$Rkijy6WS+NNK(dXthd zBEe{cTPLn|S4amhH4+Wyvc7%BldUB0ZGg4_cgHM*KoS5!DxbUj1_Oi3^Ke)2PLtKs z+usBElZJ`iH^7&#VV7S7)mb%swhgl-w;J=bgOW-mT-&);qO?aWN=OW2Q^+lp2bNck zS2_0zCj$Yfou_;_+H-(71wS;iF{&MBuk_&ntYM_4PUi7hqnE@+2)7N3rrVTAQDvQ6 zTeElY;vLTS5QU8uE2`?I`AJEhB&L-!9s@w7_6x?^368g@AB0Vs?U0+V4al~h)R-Qk z3ti;CaZ_=}{#Nmq8`h4*9pK(A9_5)JR&Lmt8^#XAWBp2k2#}r{OPhkUtTT!JU6&BX zab`EB95Nu@c{k)x{(LK##t17__jlO{xr*>d^WBY?NN_((>yW8<4Q5@R{uQd=;^$uf zc(QiCJc^cV3SXg(1)CEl?e;GjkAc7_u0^J}$sm4HZ}%1!RW6D2q?vk=q2ZJj1?_yI z@hMAg8Cds)NdOKU;66$KW@#SCMxiH&fG*SLhAO(={8u#`-WC9K$?xE_zwQJO&ncU zZxKrdi3)nyT=RQaTi-P7iUvXI{$v_D2@S0`+*Ma z+Vud1INZ~aKFqyLVj-|dyjtqc-L6lQ$FZpac>cv=ycW*ODr&5r7VjEvlAZY9ZXq-M zB%3k##=}l0@{C`nNOh^w3fn0BU>x7U30UA@&fC7A)5+ctV{jEU z@xs+#R78r)DN9*H9@=MI%2p~i6#k2FVLo){7oo+ekK|)m#METfA93mBAeMe9@K=no zXzh_dw*2{Hjx4|(F>6RU9sB&qFp!+0W##^gHSpD>RmMi~Xe+#2AdVe5Prq0jOO&3N zBbF$mUZ&oLg>ghwbBj%Xku5H#w;|H=N0Jx~^_C)6Ig!98jgGZ}ZK|f+0b?y}yePA%_4BOHau;X2?92kYeFPzTm zm(C{oPyI{zq!X}tcnR?WSIMN1w}Skk54@lb*uM`r%FC(>F7r1bbBjgkqG164hr5q> zdasmtHeH|&rVL)tC^YY|E_ERnj#Z95LU1C3F8X^kIwK4QRb`y*S)(8oW6pL*H@HNb z)z5F+qbGH0$9Gd3sV#rQ_@!L5xWAKL0rPa<=DTn)62JX6*0X9IFe&qgj&K z?}?PCzcP3D_0tFvaj3&->%KmQ?2KAUC-K${ueIpP%yiHx+bifEJ&QN^29Q8Q;#il)}{(0pv_GbV**ux;%MO z&wulG^8cnX93Lb|m;%5ddd*mFMoTcEj{0Su6Z_RHi_!IE&DLdu$X=!vV{Mq|7R1qJ>Gbh){7;VHu=b53kqphD1CqF+|l0{^k-(GgfnVmxFu(@BZ zV0>e&*oIs3=I~jxsmYR~NC{F}@U|77rMB?JN?FLk&iBSmUuV~Dq0t%^s5LJSa5}q9 zCX)2d?oi`rh_&r3HRx;cgEw@9D<1$s2>?{;=A+_@SO_VHr{Mb)2}@)JZ&X&E#~=IysPiAE0z<)0sNf>1GO%|YB{$5Np5$Z5xFV8*+%SmI!{$!~mVk2i zulpRi3W_Thn}Tow;c_of(RO@>oR{cL!>*Ry8bQ*4F8R{=gXo;{%yMzQkAmSlm6Uk} zd|SaS_e;TSjh%s3Fos*+$e0;Jr3aOCxS%F6j>L_(W7~9Hh_5ath~iw9uSrmDCoiKFuNKvZo3k`{s2>Fky5yX|JPXiWy;9D?i%Bk(a1-}9wS|x_Xi*kj2};Jr)l*{9LVp^ zw9*TCGW*cl-_=jM_F|TwuS50`2Rphte_<49c@3p+ur?nZh(SQ52;pEW{WHfFgPL|` zQ%={{f@xk<(Q!AKOGOwNio}1<7MS9vCX-~P%#XpD0T`M*6HzfbL@C$ArY2yzju ze+y4&22eN95H-#{m4vW8)p{+;) zwDPiZJ9y;Uw0o`#n9l|1FP*3blpD6j?O;#Rtq*0p$Cz{6lSD*#_;C`d16W_v;9|v; zv!ob@TY7Vs}))4t(V+I)8aZomfN1K!*q$ zA_Q7IH1%zQ2(z(!_6k|glw9sz#f-iX|*rR*)| ztG=r|A7u#;HHcN{^jw^FwpCY+4dKMf5KWt-D}2Sk*tceJQ&We~7Xy-m1tEdt5wUv; zKr#bP-_K0{DzpLd+gnNWVg#)|T5$;Qcl8EC8lTF1hQLUztSiDe4E;I$`%+NKFR=n39PLSJHv&u(EPdM)f>ha@TGaM~RAbw~9$yz<`R zJv3Sv$cle;0?%<`vg_T?hyQS-ORfCn#Yy9FI^z-~^|X+BY^21v-9us5qQ|nOXOooC zZ*Bq7)KeGPW5`AeIRG4gg?0~9_*LqvMFjA0x&eQ($8YRYRHI95 zHkLBeibPQGHI1M-p5l0kRGdEf>jve5xy6AR1#dM~kV6vBu0PrGzE3toeE#xm zu=MtlH7E=8qeLv8%~0mD6tU*<4X)q%oU(#L5?Pal=E+dgUFF8bk{gK3snCPM*+D=T zc9lWsAqT}YwmfAL@V3Ns@_>Gez3N|PtUA5vxenBM;0`hW@Zjjf{S7NTRH*WtSjS|h z1ROZowrWoWno2Q6x4eut$S5R``*l~9pG6?02qS%|MyBH^-#>g4Fd>V)^g1FdY_*Sn zm4KdR7U=;D8(s&Ub(q2U8Rd(%I z?0v1zIJP}U=?1>Q8k<`@LFk;y5}78(A4ZXqmGQ0_7NrX2p2wEHpPecM{4k-Pj!?PN zW1dHZIWP3!l3bG{<7_yS)&WY}*!Dx`d&jWV)Vod=ASHdGdj|=?ZbNB<3h;UmET!mK zDxvE8|F@nFiMd4!`}aJlrxbA$r|1X>0;lAMzG4YmI4rHcRj$;Ypu?c@B-Cw^$UE8X63ZAf0mt&Q zy~4n552vLQdP{GDqSLL`1M#IkU7~Y)?y!l4!Vrr&W#>%A)Rv5o~2ZUj=LX*GR zQFtk9n>Gu+JSf_I4&wd(rNtw7WbiXfUelXJ_1AiDO;L0i1JN=x!RH}yt!7iSKC+c2 zgRZ1e;VMOD)*xNk? zkT)HcC8r?ulef=RMd$JtLw-t#aI``~m)nW@9Jpb=5p@#%h;86qg`Dpj320#`AKgGq%ChgU;Ny-eAy zAz&1Y5s_mDlvCzI+`#W*eQ?X?=+u(WBkE=HR2fhgpE+bfu>r;B5zVqUJ^;uwaKoYT z_puRMn6m@-Gajg>pak==g<*D6>EYSwK>j`IPc-F5H=}d z)uw2%6{o2K&=H5DE*_WBv9hvZ6@mgT9Mvd6Z5kf%Vw|&VpWiR^!fnhW0=Z$ju?ySm z{(RtJ(Sh}QD2r>g{3sMBwm-H+7Zf&qs%DPkS9E=sRLY_DWUKPx>m|XY6|y) zXO|R;c%*qmEV_lR-+UwB=Js=*A6J|xiVRFMvD|FcMSaOsDOGyxAlJxhUeU5W(zv*` zCNu<8Bz7Yb$>KI-IB*8tHpe4AFH$NDri3eQa!){xc%WN%7Obt-y6bJ77c45YxpG27k`w;2g1afr1&yaeL6VJ zO_;=HA|<`iD-96p&MF^id(fLTR~;oSKDh;oo2fYN&PdlW-ssIf zQ^GyOG(K6@pxsyo*j!Ol{kjf_65Az7YT2N!w^$!wc<%i*h!3@ewm;B_H^PqR#UJGMguO%A$!i8a6S6s67|z}yS3l})Sb;CiiPgTL-uUd%h>%In z&2C^0$}Q0Ve;ncHw+Bqgyz2HHVVeI-Aj0?$YTeOu`($eiGNaie2RbI|%-yPWmSx!3 zHKVRDlnmZcw}fYWEfOUHJ-L5b)FI3h^GrVUz}OvChn9Wwsg1|hcs&r&-Vp=R;TRi@ zz0tqpAh}dpEMWyjhx7g+O|WjH_9xwZi0G2JHrJ~aT+xuZ*%PikJyYHusndOlWIdf3 zt4OLYVJ*lT$bUDzk(i&%9|cG7O(vFIFamrUMU+h%INu`!QQeY@;~l++7#CsCc3+VzA)c6_`+jHlaud(lFpWkhi zrBR54UrUy=5k^A;E3f@-R%+y*=1hEEAW&{K_oilM{@hiVQmo|u;NwGF=K6)KnUAwE zu70AIdCbo4Jdx-*e5lx^IwLz{l-9Lp3uK5Z*)EhF)Wj*O7v6t0eZ2e~+3Nk?9;A;y z^gU<7wLoJMBM)6Kk2;oRT;O`-)z_tk7sWy!x6?X3`2s+*)M=~m{sYup_ zdR;Nwc2d*Qo!h47bR~@=Z7HUFuT1@LD{IpQL>U}k zy(F@_@Gl(#3rYA1+HqbvgtrL?u0K1*5(WG2i$y-C|!Qv;*4o4lPnl447`49&@0`ghufkx$E@* z-|{(o9hb@fXEoCN&ua7$Qv|9T{mV9E2~&oT{2)UdxoLPRv(3>Yr? z&Ii3~N#HNkN$ucpqlV*fJ5ddN{WO{&(YQ!AiEK;dfL|${q|bCh<3PBNt<6*UJdK$t z^L|!N6Kwus+upyga(SKQZq+v^E!Jc=a-ZlCslyE991KzT#K{w#I0td9Z~8+Cwx@xa z;b?U2i@^wIWs6kvvPwk5($d)>!CC^UQPe52#66rGQ{z3Vo!r%&bOgJcew<2toEk)_ z&^Rwgs<8SrZns^{D!?KyHp)f}Wm%h2NYokwi(vWCVn2_I_8zO4zML9NI xt3G?g z4&W%tBqXPbR`Dfgu>2G0TRV&4bOw6_Oz;EyI?i0{oODJ_4RFbbY2RF|&r|=*#sMY0 z^C#;!#u8O8tq#gKuO^NKL!6Empu_>FK3#2qJJ>Gg2Xel_lYOijF0X4d*)|59)BO|T zbURajp;K0G60wrdr5zug$aA=!F{BTlUpz*+?~5|qnzM^2-)J~`dY|bTblUUB2E0zZ zTVU9xOlwKgGs`C~SzmLe70MsFd5R%^#gG5Fe3Ew+Y@7c|KK8HV)<8aT1VdaD@53hs zK3VM$cX*5nGAbswuZ9B6S5JK1%{5s~{ABm!0TL{o--m-y_e#*5;Gks<%U4#kR=7EL zfdL<|duXWGB1Xo2BtemLawd&4`z5*w2gbEEKX_nZG}cRNIdHbz1`hQ=nDsa0xI&=n84=S@gGA2DHTxQF0jjouQnFsD*`p#;*Xg)EAP?cklFQb>(sA} zqNk`Su_t$9u&Lr6nlrh_xaFqd&PVrLKB?HbeLk9Nmy1i)h$6BN$+5&R?!ns!q3|_` zIU3m-`iV13Iwu;dUqV*uj0CvF&z5e_ryG5NqxHN$QUepAY&@Sf0-)FUqLKGPE?Tuybs0 zflp2+u!wi|uhII!bB}KtElo6FEaxecon!{PqX04^gX-k2woe$JhG1$>RKotzO>vVX zf~U5Pf+n937U@14ClFNVgE3AL>-48h;3<)wwUITF_%(1WH zt`@dMMY1U6RpwZhRTz1f@oPD?J~KglvhXauZl_U5!YyO@N!e(v>YEx#ue9$_$}Klc zs*8wd-HSapLJ!kDq;u0txcz@oOi2^~qFdeV`n?vl9v&LL=}oqoP8OqVAI@`b-wuJV z#+?@iA-7*ULLx$N1cjJ#h|QcqZoFJLn_I{uu?x*pMmvmx_kgMF0znV&_ztnBr@!8p zUC?2~#v)1@;PrS~$vt15qCoU8teD&L%Pq%N$EZFxAGBC8hc`FVXvTO(Jo_M1oy+eA z^_7k=J!_a^M?dmq@x)*>Kzt@6Y;3xou%6JGW&y8B> z%%tcqpf79fPUvikJUbYLNldGFu*~+sGnDd&gPW10*$Bj5mAH|82V>xVA~7jHbf_8) zkrU#%C=m-jZC@4f5pIxXQAfE2o*puTv=@LPB{+ngnBZA~wL)Sn@ezgnuokGZkd2Xp+j2&bAixl>UdhtWT=nGFR8N_Zz(q8aI!vtcZ;N^| z_PMlgO?9N@79w`#!HdJ_Bwwt`%JX-w>Wr?i5{#LOhtl6{jm^1C9Oaw(Ts4^QQJGmt z?>sWdDdptyT>&Adp*tjGx)@ko6sv(%+W1Jaia(KUfv12MZ&K9|@NE-I_&1qmt`**Am7|)DZk7<}0lEb(bp|OCIpv*7ETM(?Lz-@t>PU0~)D1BEIs_YYB&Z7Oh6Mb&2M1vC4_z5qw(S9VpM z(nw04WrEHJBmi=6!AM5Kz2u>-^?$<^=|x*Z=SbD^Quu;#a7yW-gd-TEj7IIvbGYQY zQ8Vby5K2EmJ!6U^gGiQ|bZ3v_9_J&t&?p*X@#E{>6R_(qR}F=yq_Ior>zBGeUfW8H~Sw?Z6`>8E+J9iP_v1=!S{< z7cL7;5UE+f;AR)G+fPuRb7yyAU!d8}<3ABR2{@VN{c~_q!3x1*kqkaMK6X6r?3z1<@E1h5xo+)q%j zQeUw}SQ(M@cv>Ykax~-it9ui1yu#%7$Qpo;opj*sy1)IXM>x}9{$ZmYTBW#%;qVqi zhZibvl2%4P>LkNfDwL&iLfuZ3WSr5XiNO嵍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 delta 35498 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 Date: Mon, 23 Feb 2026 18:00:42 -0500 Subject: [PATCH 75/80] Update Dependencies to v2.59.2 (#936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.dagger:hilt-android-compiler](https://redirect.github.com/google/dagger) | `2.59.1` → `2.59.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:hilt-android-compiler/2.59.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:hilt-android-compiler/2.59.1/2.59.2?slim=true) | | [com.google.dagger:hilt-android](https://redirect.github.com/google/dagger) | `2.59.1` → `2.59.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:hilt-android/2.59.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:hilt-android/2.59.1/2.59.2?slim=true) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05bf860b..2c2234f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,7 +34,7 @@ protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.10.0" protobuf-javalite = "4.33.5" -hilt = "2.59.1" +hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" From 790bb4b535fa2216d459d8dc75d9013bc6611957 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:00:53 -0500 Subject: [PATCH 76/80] Update dependency com.google.dagger.hilt.android to v2.59.2 (#937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.dagger.hilt.android](https://redirect.github.com/google/dagger) | `2.59.1` → `2.59.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger.hilt.android:com.google.dagger.hilt.android.gradle.plugin/2.59.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger.hilt.android:com.google.dagger.hilt.android.gradle.plugin/2.59.1/2.59.2?slim=true) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> From 3e2a1869ab3d84f6d1e6d31e5210fbba0bf9a5c0 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:52:38 -0500 Subject: [PATCH 77/80] Scroll to current chapter on playback overlay (#964) ## Description This PR makes it so when you open the playback overlay and move down to the "Chapters" row, the first focused chapter will be the current one instead of always the first one. ### Related issues Closes #695 Closes #951 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/Chapter.kt | 3 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 56 ++++++++++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt index 1bcacb3f..3444ac54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt @@ -31,6 +31,7 @@ data class Chapter( ) }, ) - }.orEmpty() + }?.sortedBy { it.position } + .orEmpty() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 2b311e71..b1500b60 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,6 +38,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged @@ -254,8 +258,34 @@ fun PlaybackOverlay( exit = slideOutVertically { it / 2 } + fadeOut(), ) { if (chapters.isNotEmpty()) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val chapterIndex = + remember { + val position = playerControls.currentPosition.milliseconds + val index = + chapters + .indexOfFirst { it.position > position } + .minus(1) + .let { + if (it < 0) { + // Didn't find a chapter, so it's either the first or last + if (position < chapters.first().position) { + 0 + } else { + chapters.lastIndex + } + } else { + it + } + }.coerceIn(0, chapters.lastIndex) + index + } + val listState = rememberLazyListState(chapterIndex) val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + bringIntoViewRequester.bringIntoView() + focusRequester.tryRequestFocus() + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = @@ -276,6 +306,7 @@ fun PlaybackOverlay( style = MaterialTheme.typography.titleLarge, ) LazyRow( + state = listState, contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = @@ -305,10 +336,19 @@ fun PlaybackOverlay( }, interactionSource = interactionSource, modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), + Modifier + .ifElse( + index == chapterIndex, + Modifier + .focusRequester(focusRequester) + .bringIntoViewRequester(bringIntoViewRequester), + ).ifElse( + index == 0, + Modifier.focusProperties { + // Prevent scrolling left on first card to prevent moving down + left = FocusRequester.Cancel + }, + ), ) } } @@ -448,8 +488,10 @@ fun PlaybackOverlay( style = MaterialTheme.typography.labelLarge, modifier = Modifier - .background(Color.Black.copy(alpha = 0.6f), shape = RoundedCornerShape(4.dp)) - .padding(horizontal = 8.dp, vertical = 4.dp), + .background( + Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), ) } } From 7424f812d8510add906b4f3a86727f687b4a733c Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:04:08 -0500 Subject: [PATCH 78/80] Dev: add CI to build app store releases (#468) Adding GHA workflows to build the app store releases Also cleans up gradle build script a bit --- .github/workflows/pr.yml | 27 ++++---- .github/workflows/release.yml | 30 +++++++-- app/build.gradle.kts | 103 ++++++++++++++----------------- app/src/patches/play_store.patch | 40 ++++++++++++ 4 files changed, 123 insertions(+), 77 deletions(-) create mode 100644 app/src/patches/play_store.patch diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index db31b32e..b4489867 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -44,19 +44,18 @@ jobs: id: buildapp run: | ./gradlew clean assembleDebug testDebugUnitTest --no-daemon - apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') + apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - - name: Tar build dirs + + test-patch: + runs-on: ubuntu-latest + needs: pre-commit + steps: + - name: Checkout the code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Test applying patch run: | - tar -czf build.tgz ./app/. - - uses: actions/upload-artifact@v6 - id: upload-build-dirs - with: - name: "${{ env.BUILD_DIRS_ARTIFACT }}" - path: build.tgz - if-no-files-found: error - - uses: actions/upload-artifact@v6 - with: - name: APKs - path: "${{ steps.buildapp.outputs.apks }}" - compression-level: 0 + git apply app/src/patches/play_store.patch + git diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b63017b5..86c17c4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,11 +39,24 @@ jobs: SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" run: | ./gradlew clean assembleRelease --no-daemon + + - name: Build app + id: buildaab + env: + KEY_ALIAS: "${{ secrets.KEY_ALIAS }}" + KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" + KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" + SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" + run: | + git apply app/src/patches/play_store.patch + ./gradlew bundleRelease --no-daemon + aab=$(find app/build/outputs -name '*.aab') + echo "aab=$aab" >> "$GITHUB_OUTPUT" - name: Verify signatures run: | - echo "Verify APK signatures" - find app/build/outputs/apk -name '*.apk' - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs + echo "Verify APK/AAB signatures" + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs - name: Copy APK to shorter names id: apks run: | @@ -59,11 +72,18 @@ jobs: - name: Checksums run: | echo "SHA256 checksums:" - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum + - name: Upload AAB + uses: actions/upload-artifact@v6 + with: + name: AAB + path: | + app/build/outputs/bundle/**/*.aab + compression-level: 0 - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ env.TAG_NAME }}" \ - --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \ + --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \ "app/build/outputs/apk/**/*.apk" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ab4a9cc..366dfcd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,20 +47,64 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + signingConfigs { + if (shouldSign) { + create("ci") { + file("ci.keystore").writeBytes( + Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), + ) + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + storePassword = System.getenv("KEY_STORE_PASSWORD") + storeFile = file("ci.keystore") + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + enableV4Signing = true + } + } + } + buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) isDebuggable = false + if (shouldSign) { + signingConfig = signingConfigs.getByName("ci") + } else { + val localPropertiesFile = project.rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + val properties = Properties() + properties.load(localPropertiesFile.inputStream()) + val signingConfigName = properties["release.signing.config"]?.toString() + if (signingConfigName != null) { + signingConfig = signingConfigs.getByName(signingConfigName) + } + } + } } + debug { isMinifyEnabled = false isDebuggable = true applicationIdSuffix = ".debug" } + + applicationVariants.all { + val variant = this + variant.outputs + .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } + .forEach { output -> + val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } + val outputFileName = + "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" + output.outputFileName = outputFileName + } + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -81,63 +125,6 @@ android { room { schemaDirectory("$projectDir/schemas") } - signingConfigs { - if (shouldSign) { - create("ci") { - file("ci.keystore").writeBytes( - Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), - ) - keyAlias = System.getenv("KEY_ALIAS") - keyPassword = System.getenv("KEY_PASSWORD") - storePassword = System.getenv("KEY_STORE_PASSWORD") - storeFile = file("ci.keystore") - enableV1Signing = true - enableV2Signing = true - enableV3Signing = true - enableV4Signing = true - } - } - } - buildTypes { - release { - isMinifyEnabled = false - - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - if (shouldSign) { - signingConfig = signingConfigs.getByName("ci") - } else { - val localPropertiesFile = project.rootProject.file("local.properties") - if (localPropertiesFile.exists()) { - val properties = Properties() - properties.load(localPropertiesFile.inputStream()) - val signingConfigName = properties["release.signing.config"]?.toString() - if (signingConfigName != null) { - signingConfig = signingConfigs.getByName(signingConfigName) - } - } - } - } - debug { - if (shouldSign) { - signingConfig = signingConfigs.getByName("ci") - } - } - - applicationVariants.all { - val variant = this - variant.outputs - .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } - .forEach { output -> - val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } - val outputFileName = - "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" - output.outputFileName = outputFileName - } - } - } splits { abi { diff --git a/app/src/patches/play_store.patch b/app/src/patches/play_store.patch new file mode 100644 index 00000000..ef5fb96d --- /dev/null +++ b/app/src/patches/play_store.patch @@ -0,0 +1,40 @@ +commit f82fb1a2d8ff9917a7bdb0bc7101a0474359ccdc +Author: Damontecres +Date: Sat Nov 22 13:00:55 2025 -0500 + + Setup for play store + +diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml +index 6d84299..12576af 100644 +--- a/app/src/main/AndroidManifest.xml ++++ b/app/src/main/AndroidManifest.xml +@@ -4,7 +4,6 @@ + + + +- + +@@ -17,7 +16,7 @@ + android:required="false" /> + ++ android:required="true" /> + +diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +index c7ac435..fa42fe1 100644 +--- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt ++++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +@@ -62,7 +62,7 @@ class UpdateChecker + + private val NOTE_REGEX = Regex("") + +- val ACTIVE = true ++ val ACTIVE = false + } + + suspend fun maybeShowUpdateToast( From c4cd1fbfd3ee9f53feb6b4811c61e35b78580eeb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:24:26 -0500 Subject: [PATCH 79/80] Update actions/checkout action to v6 (#965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/checkout](https://redirect.github.com/actions/checkout) | action | major | `v5` → `v6` | --- ### Release Notes
actions/checkout (actions/checkout) ### [`v6`](https://redirect.github.com/actions/checkout/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/checkout/compare/v5...v6)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b4489867..0b0f2204 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -52,7 +52,7 @@ jobs: needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 1 - name: Test applying patch From a14fdac85232b3f4a5f2e8b319461f02031fdf65 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:37:21 -0500 Subject: [PATCH 80/80] A few more home page fixes (#967) ## Description A few more home page fixes * Focus issue between settings & preset buttons * Fix default genre card size for "Wholphin Default" preset * Cache genre image urls for 2 hours so that they don't change on every refresh ### Related issues Related to #399 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 3 +++ .../wholphin/ui/components/GenreCardGrid.kt | 26 +++++++++++++++++++ .../wholphin/ui/main/HomeViewModel.kt | 3 +++ .../ui/main/settings/HomeRowPresets.kt | 3 +-- .../ui/main/settings/HomeSettingsRowList.kt | 2 +- .../ui/main/settings/HomeSettingsViewModel.kt | 1 + 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 2fe5a6cc..b8a850ef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -569,6 +569,7 @@ class HomeSettingsService userDto: UserDto, libraries: List, limit: Int = prefs.maxItemsPerRow, + isRefresh: Boolean, ): HomeRowLoadingState = when (row) { is HomeRowConfig.ContinueWatching -> { @@ -649,12 +650,14 @@ class HomeSettingsService val genreImages = getGenreImageMap( api = api, + userId = serverRepository.currentUser.value?.id, scope = scope, imageUrlService = imageUrlService, genres = genreIds, parentId = row.parentId, includeItemTypes = null, cardWidthPx = null, + useCache = isRefresh, ) val library = libraries 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 e64f1824..e1e28f0c 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 @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.mayakapps.kache.InMemoryKache import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -55,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.hours @HiltViewModel(assistedFactory = GenreViewModel.Factory::class) class GenreViewModel @@ -110,6 +113,7 @@ class GenreViewModel val genreToUrl = getGenreImageMap( api = api, + userId = serverRepository.currentUser.value?.id, scope = viewModelScope, imageUrlService = imageUrlService, genres = genres.map { it.id }, @@ -141,15 +145,35 @@ class GenreViewModel } } +data class GenreCacheKey( + val userId: UUID?, + val parentId: UUID, +) + +private val genreCache by lazy { + InMemoryKache>(8) { + expireAfterWriteDuration = 2.hours + } +} + suspend fun getGenreImageMap( api: ApiClient, + userId: UUID?, scope: CoroutineScope, imageUrlService: ImageUrlService, genres: List, parentId: UUID, includeItemTypes: List?, cardWidthPx: Int?, + useCache: Boolean = true, ): Map { + val key = GenreCacheKey(userId, parentId) + if (useCache) { + genreCache.getIfAvailable(key)?.let { + Timber.v("Got cached entry") + return it + } + } val genreToUrl = ConcurrentHashMap() val semaphore = Semaphore(4) genres @@ -161,6 +185,7 @@ suspend fun getGenreImageMap( .execute( api, GetItemsRequest( + userId = userId, parentId = parentId, recursive = true, limit = 1, @@ -189,6 +214,7 @@ suspend fun getGenreImageMap( } } }.awaitAll() + genreCache.put(key, genreToUrl) return genreToUrl } 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 4471194e..a8ce7fd9 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 @@ -78,6 +78,8 @@ class HomeViewModel // Refreshing if a load has already occurred and the rows haven't significantly changed val refresh = state.loadingState == LoadingState.Success && state.settings == settings + Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}") + _state.update { it.copy(settings = settings) } val semaphore = Semaphore(4) @@ -102,6 +104,7 @@ class HomeViewModel userDto = userDto, libraries = libraries, limit = prefs.maxItemsPerRow, + isRefresh = refresh, ) } catch (ex: Exception) { Timber.e(ex, "Error on row %s", row) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index 815c343a..eaf44d81 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -19,7 +19,6 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.CollectionType @@ -81,7 +80,7 @@ data class HomeRowPresets( contentScale = PrefContentScale.FIT, ), liveTv = HomeRowViewOptions.liveTvDefault, - genreSize = Cards.HEIGHT_2X3_DP, + genreSize = HomeRowViewOptions.genreDefault.heightDp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index 5a0ac965..9eadc615 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -137,7 +137,7 @@ fun HomeSettingsRowList( position = 2 onClickPresets.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[1]), + modifier = Modifier.focusRequester(focusRequesters[2]), ) } item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 9110af12..67b57e28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -133,6 +133,7 @@ class HomeSettingsViewModel userDto = userDto, libraries = state.libraries, limit = limit, + isRefresh = false, ) } }