From 56bf3cb7a000bbfd2372347680411661a0b85b76 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:00:21 -0500 Subject: [PATCH] Optimizes recommended content & image caching (#359) ## Details This PR contains a number of optimizations ### Images Updates to request images with maximum sizes The max sizes are estimates on the maximum number of pixels that will be actually displayed. This means the server is downscaling large images which means 1) better quality downscaling and 2) smaller images sent over the network and cached This should mean slightly better quality images and more images that can be cached locally ### Paging Reduces network calls when fetching paginated data, ie grid pages and some rows. This PR combines the total record count & first page queries which slightly reduces total time to load paginated data. Additionally, on movies/tv show recommended pages, the rows now only fetch a limited number of items (same as number on home page). While they previously fetched using pagination, there is an overhead required to paginate requests, so this is slightly faster. ### Async recommended fetching On movies/tv show recommended pages, the rows are now fetched completely separately from each other. Previously, the rows were requested separtely, but each was only added to the UI in order as each completed. Now they will add as the data arrived. Note: Top unwatched TV shows is still very slow on `10.11.x`, see #204 ### Image cache The local, on-disk image cache size is increased from 100mb to 200mb. Combined with the smaller images, this covers about 1500-2000 poster images, but will vary by server. The cache size is now also configurable in advanced settings from 25mb to 1000mb. Additionally, you can see how much of both the disk and memory caches are in use. ## Issues Closes #330 Related to #120 --- .../damontecres/wholphin/MainActivity.kt | 27 +- .../wholphin/data/model/BaseItem.kt | 35 ++- .../wholphin/preferences/AppPreference.kt | 27 +- .../preferences/AppPreferencesSerializer.kt | 13 + .../wholphin/services/AppUpgradeHandler.kt | 11 + .../damontecres/wholphin/ui/CoilConfig.kt | 47 +++- .../damontecres/wholphin/ui/Extensions.kt | 9 + .../wholphin/ui/cards/BannerCard.kt | 2 +- .../wholphin/ui/cards/ChapterCard.kt | 2 +- .../ui/components/RecommendedContent.kt | 26 ++ .../ui/components/RecommendedMovie.kt | 198 +++++++------- .../ui/components/RecommendedTvShow.kt | 253 +++++++++--------- .../ui/preferences/PreferencesContent.kt | 30 ++- .../wholphin/util/ApiRequestPager.kt | 20 +- app/src/main/proto/WholphinDataStore.proto | 5 + app/src/main/res/values/strings.xml | 1 + 16 files changed, 453 insertions(+), 253 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 22cc29be..b7c6a696 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore import androidx.lifecycle.lifecycleScope @@ -27,6 +28,8 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.UserPreferences @@ -85,9 +88,31 @@ class MainActivity : AppCompatActivity() { appUpgradeHandler.copySubfont(false) } setContent { - CoilConfig(okHttpClient, false) + val density = LocalDensity.current + LaunchedEffect(density) { + with(density) { + // Cards are never taller than 200 (most are around 120) + BaseItem.primaryMaxHeight = 200.dp.roundToPx() + // This width covers up to 2.35:1 aspect ratio images + BaseItem.primaryMaxWidth = 480.dp.roundToPx() + } + } + val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> + CoilConfig( + diskCacheSizeBytes = + appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let { + if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) { + AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + } else { + it + } + }, + okHttpClient = okHttpClient, + debugLogging = false, + enableCache = true, + ) LaunchedEffect(appPreferences.debugLogging) { DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging } 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 c6f83be6..5c422313 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 @@ -13,6 +13,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber import kotlin.time.Duration @Serializable @@ -94,6 +95,17 @@ data class BaseItem( } companion object { + var primaryMaxWidth: Int? = null + set(value) { + Timber.v("primaryMaxWidth=$value") + field = value + } + var primaryMaxHeight: Int? = null + set(value) { + Timber.v("primaryMaxHeight=$value") + field = value + } + fun from( dto: BaseItemDto, api: ApiClient, @@ -111,18 +123,25 @@ data class BaseItem( api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP) } val primaryImageUrl = - if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE) { - val seriesId = dto.seriesId - if (seriesId != null) { - api.imageApi.getItemImageUrl(seriesId, ImageType.PRIMARY) - } else { - api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY) - } + if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE && dto.seriesId != null) { + api.imageApi.getItemImageUrl( + dto.seriesId!!, + ImageType.PRIMARY, + maxHeight = primaryMaxHeight, + maxWidth = primaryMaxWidth, + quality = 96, + ) } else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) { // TODO is this a bad assumption? null } else { - api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY) + api.imageApi.getItemImageUrl( + dto.id, + ImageType.PRIMARY, + maxHeight = primaryMaxHeight, + maxWidth = primaryMaxWidth, + quality = 96, + ) } val logoImageUrl = if (dto.type == BaseItemKind.EPISODE || dto.type == BaseItemKind.SEASON) { 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 a82a3eb9..093ddbf2 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 @@ -323,7 +323,7 @@ sealed interface AppPreference { }, ) - private const val MEGA_BIT = 1024 * 1024L + const val MEGA_BIT = 1024 * 1024L const val DEFAULT_BITRATE = 100 * MEGA_BIT private val bitrateValues = listOf( @@ -713,6 +713,30 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val ImageDiskCacheSize = + AppSliderPreference( + title = R.string.image_cache_size, + defaultValue = 200, + min = 25, + max = 1_000, + interval = 25, + getter = { + it.advancedPreferences.imageDiskCacheSizeBytes / MEGA_BIT + }, + setter = { prefs, value -> + prefs.updateAdvancedPreferences { + imageDiskCacheSizeBytes = value * MEGA_BIT + } + }, + summarizer = { value -> + if (value != null) { + "$value MB" + } else { + null + } + }, + ) } } @@ -852,6 +876,7 @@ val advancedPreferences = AppPreference.SendAppLogs, AppPreference.SendCrashReports, AppPreference.DebugLogging, + AppPreference.ImageDiskCacheSize, AppPreference.ClearImageCache, AppPreference.OssLicenseInfo, ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 8cc88a21..2a582049 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -95,6 +95,14 @@ class AppPreferencesSerializer resetSubtitles() }.build() }.build() + + advancedPreferences = + AdvancedPreferences + .newBuilder() + .apply { + imageDiskCacheSizeBytes = + AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + }.build() }.build() override suspend fun readFrom(input: InputStream): AppPreferences { @@ -143,6 +151,11 @@ inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.B subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences = + update { + advancedPreferences = advancedPreferences.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 87356945..492ac67b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences @@ -133,4 +134,14 @@ suspend fun upgradeApp( } } } + if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) { + appPreferences.updateData { + it.updateAdvancedPreferences { + if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) { + imageDiskCacheSizeBytes = + AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + } + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt index 45205902..3efff666 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.compose.setSingletonImageLoaderFactory @@ -12,6 +13,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.crossfade import coil3.util.DebugLogger import okhttp3.OkHttpClient +import timber.log.Timber import kotlin.time.ExperimentalTime /** @@ -20,26 +22,53 @@ import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class, ExperimentalCoilApi::class) @Composable fun CoilConfig( + diskCacheSizeBytes: Long, okHttpClient: OkHttpClient, debugLogging: Boolean, + enableCache: Boolean = true, ) { + val client = + remember(okHttpClient, debugLogging) { + if (debugLogging) { + okHttpClient + .newBuilder() + .addInterceptor { + val start = System.currentTimeMillis() + val req = it.request() + val res = it.proceed(req) + val time = System.currentTimeMillis() - start + Timber.v("${time}ms - ${req.url}") + res + }.build() + } else { + okHttpClient + } + } setSingletonImageLoaderFactory { ctx -> + Timber.i("Image diskCacheSizeBytes=$diskCacheSizeBytes") ImageLoader .Builder(ctx) - .memoryCache(MemoryCache.Builder().maxSizePercent(ctx).build()) - .diskCache( - DiskCache - .Builder() - .directory(ctx.cacheDir.resolve("coil3_image_cache")) - .maxSizeBytes(100L * 1024 * 1024) - .build(), - ).crossfade(false) + .apply { + if (enableCache) { + memoryCache(MemoryCache.Builder().maxSizePercent(ctx).build()) + diskCache( + DiskCache + .Builder() + .directory(ctx.cacheDir.resolve("coil3_image_cache")) + .maxSizeBytes(diskCacheSizeBytes) + .build(), + ) + } else { + memoryCache(null) + diskCache(null) + } + }.crossfade(false) .logger(if (debugLogging) DebugLogger() else null) .components { add( OkHttpNetworkFetcherFactory( cacheStrategy = { CacheControlCacheStrategy() }, - callFactory = { okHttpClient }, + callFactory = { client }, ), ) }.build() 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 767d48ff..a8ceb0b2 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 @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.MutableLiveData import androidx.media3.common.Player import coil3.request.ErrorResult +import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumnSaver import com.github.damontecres.wholphin.util.ExceptionHandler @@ -39,7 +40,10 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.acra.ACRA +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID @@ -392,3 +396,8 @@ fun logTab( } suspend fun onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) + +fun Response.toBaseItems( + api: ApiClient, + useSeriesForPrimary: Boolean, +) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } 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 28de1f7b..4f22310e 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 @@ -53,7 +53,7 @@ fun BannerCard( played: Boolean = false, favorite: Boolean = false, playPercent: Double = 0.0, - cardHeight: Dp = 140.dp * .85f, + cardHeight: Dp = 120.dp, aspectRatio: Float = AspectRatios.WIDE, interactionSource: MutableInteractionSource? = null, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt index 4739b80c..54d1d2fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt @@ -32,7 +32,7 @@ fun ChapterCard( onClick: () -> Unit, aspectRatio: Float, modifier: Modifier = Modifier, - cardHeight: Dp = 140.dp * .85f, + cardHeight: Dp = 120.dp, onLongClick: (() -> Unit)? = null, interactionSource: MutableInteractionSource? = null, ) { 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 3a561785..abb4588e 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 @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -32,11 +34,14 @@ import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaType import java.util.UUID abstract class RecommendedViewModel( + val context: Context, val navigationManager: NavigationManager, val favoriteWatchManager: FavoriteWatchManager, ) : ViewModel() { @@ -79,6 +84,27 @@ abstract class RecommendedViewModel( refreshItem(position, itemId) } } + + abstract fun update( + @StringRes title: Int, + row: HomeRowLoadingState, + ) + + fun update( + @StringRes title: Int, + block: suspend () -> List, + ) { + viewModelScope.launch(Dispatchers.IO) { + val titleStr = context.getString(title) + val row = + try { + HomeRowLoadingState.Success(titleStr, block.invoke()) + } catch (ex: Exception) { + HomeRowLoadingState.Error(titleStr, null, ex) + } + 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 06159073..82eafee9 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 @@ -1,19 +1,23 @@ package com.github.damontecres.wholphin.ui.components import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler @@ -26,9 +30,8 @@ import dagger.assisted.AssistedInject 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.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -46,13 +49,14 @@ import java.util.UUID class RecommendedMovieViewModel @AssistedInject constructor( - @param:ApplicationContext private val context: Context, + @ApplicationContext context: Context, private val api: ApiClient, private val serverRepository: ServerRepository, + private val preferencesDataStore: DataStore, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, - ) : RecommendedViewModel(navigationManager, favoriteWatchManager) { + ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedMovieViewModel @@ -60,16 +64,21 @@ class RecommendedMovieViewModel override val rows = MutableStateFlow>( - rowTitles - .map { - HomeRowLoadingState.Pending( - context.getString(it), - ) - }, + rowTitles.keys.map { + HomeRowLoadingState.Pending( + context.getString(it), + ) + }, ) override fun init() { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + val itemsPerRow = + preferencesDataStore.data + .firstOrNull() + ?.homePagePreferences + ?.maxItemsPerRow + ?: AppPreference.HomePageItems.defaultValue.toInt() try { val resumeItemsRequest = GetResumeItemsRequest( @@ -77,20 +86,16 @@ class RecommendedMovieViewModel fields = SlimItemFields, includeItemTypes = listOf(BaseItemKind.MOVIE), enableUserData = true, + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, ) val resumeItems = - ApiRequestPager( - api, - resumeItemsRequest, - GetResumeItemsRequestHandler, - viewModelScope, - ).init() - if (resumeItems.isNotEmpty()) { - resumeItems.getBlocking(0) - } - + GetResumeItemsRequestHandler + .execute(api, resumeItemsRequest) + .toBaseItems(api, false) update( - 0, + R.string.continue_watching, HomeRowLoadingState.Success( context.getString(R.string.continue_watching), resumeItems, @@ -107,95 +112,90 @@ class RecommendedMovieViewModel } } - val recentlyReleasedRequest = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.PREMIERE_DATE), - sortOrder = listOf(SortOrder.DESCENDING), - ) - val recentlyReleasedItems = - ApiRequestPager(api, recentlyReleasedRequest, GetItemsRequestHandler, viewModelScope) + update(R.string.recently_released) { + val recentlyReleasedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetItemsRequestHandler + .execute(api, recentlyReleasedRequest) + .toBaseItems(api, false) + } - val recentlyAddedRequest = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - ) - val recentlyAddedItems = - ApiRequestPager(api, recentlyAddedRequest, GetItemsRequestHandler, viewModelScope) + update(R.string.recently_added) { + val recentlyAddedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetItemsRequestHandler + .execute(api, recentlyAddedRequest) + .toBaseItems(api, false) + } - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.MOVIE), - ) - val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope) + update(R.string.suggestions) { + val suggestionsRequest = + GetSuggestionsRequest( + userId = serverRepository.currentUser.value?.id, + type = listOf(BaseItemKind.MOVIE), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetSuggestionsRequestHandler + .execute(api, suggestionsRequest) + .toBaseItems(api, false) + } - val unwatchedTopRatedRequest = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - ) - val unwatchedTopRatedItems = - ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) + update(R.string.top_unwatched) { + val unwatchedTopRatedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetItemsRequestHandler + .execute(api, unwatchedTopRatedRequest) + .toBaseItems(api, false) + } - val rows = - listOf( - R.string.recently_released to recentlyReleasedItems, - R.string.recently_added to recentlyAddedItems, - R.string.suggestions to suggestedItems, - R.string.top_unwatched to unwatchedTopRatedItems, - ) - rows - .mapIndexed { index, (title, pager) -> - viewModelScope.async(Dispatchers.IO + ExceptionHandler()) { - val title = context.getString(title) - val result = - try { - pager.init() - if (pager.isNotEmpty()) { - pager.getBlocking(0) - } - HomeRowLoadingState.Success(title, pager) - } catch (ex: Exception) { - Timber.e(ex, "Error fetching %s", title) - HomeRowLoadingState.Error(title, null, ex) - } - update(index + 1, result) - if (result is HomeRowLoadingState.Success && result.items.isNotEmpty() && - (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) - ) { - loading.setValueOnMain(LoadingState.Success) - } - } - }.awaitAll() if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { loading.setValueOnMain(LoadingState.Success) } } } - private fun update( - position: Int, + override fun update( + @StringRes title: Int, row: HomeRowLoadingState, ) { rows.update { current -> - current.toMutableList().apply { set(position, row) } + current.toMutableList().apply { set(rowTitles[title]!!, row) } } } @@ -207,7 +207,7 @@ class RecommendedMovieViewModel R.string.recently_added, R.string.suggestions, R.string.top_unwatched, - ) + ).mapIndexed { index, i -> i to index }.toMap() } } 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 e1a18517..a97ff0b9 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 @@ -1,19 +1,23 @@ package com.github.damontecres.wholphin.ui.components import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler @@ -28,8 +32,8 @@ 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.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -48,13 +52,14 @@ import java.util.UUID class RecommendedTvShowViewModel @AssistedInject constructor( - @param:ApplicationContext private val context: Context, + @ApplicationContext context: Context, private val api: ApiClient, private val serverRepository: ServerRepository, + private val preferencesDataStore: DataStore, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, - ) : RecommendedViewModel(navigationManager, favoriteWatchManager) { + ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedTvShowViewModel @@ -62,63 +67,66 @@ class RecommendedTvShowViewModel override val rows = MutableStateFlow>( - rowTitles - .map { - HomeRowLoadingState.Pending( - context.getString(it), - ) - }, + rowTitles.keys.map { + HomeRowLoadingState.Pending( + context.getString(it), + ) + }, ) override fun init() { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + val itemsPerRow = + preferencesDataStore.data + .firstOrNull() + ?.homePagePreferences + ?.maxItemsPerRow + ?: AppPreference.HomePageItems.defaultValue.toInt() try { - val resumeItemsRequest = - GetResumeItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - enableUserData = true, - ) - val resumeItems = - ApiRequestPager( - api, - resumeItemsRequest, - GetResumeItemsRequestHandler, - viewModelScope, - useSeriesForPrimary = true, - ).init() - if (resumeItems.isNotEmpty()) { - resumeItems.getBlocking(0) - } + val resumeItemsDeferred = + viewModelScope.async { + val resumeItemsRequest = + GetResumeItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + enableUserData = true, + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetResumeItemsRequestHandler + .execute(api, resumeItemsRequest) + .toBaseItems(api, true) + } - val nextUpRequest = - GetNextUpRequest( - parentId = parentId, - fields = SlimItemFields, - enableUserData = true, - ) - val nextUpItems = - ApiRequestPager( - api, - nextUpRequest, - GetNextUpRequestHandler, - viewModelScope, - useSeriesForPrimary = true, - ).init() - if (nextUpItems.isNotEmpty()) { - nextUpItems.getBlocking(0) - } + val nextUpItemsDeferred = + viewModelScope.async { + val nextUpRequest = + GetNextUpRequest( + parentId = parentId, + fields = SlimItemFields, + enableUserData = true, + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetNextUpRequestHandler + .execute(api, nextUpRequest) + .toBaseItems(api, true) + } + val resumeItems = resumeItemsDeferred.await() update( - 0, + R.string.continue_watching, HomeRowLoadingState.Success( context.getString(R.string.continue_watching), resumeItems, ), ) + val nextUpItems = nextUpItemsDeferred.await() update( - 1, + R.string.next_up, HomeRowLoadingState.Success( context.getString(R.string.next_up), nextUpItems, @@ -135,96 +143,93 @@ class RecommendedTvShowViewModel } } - val recentlyReleasedRequest = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.PREMIERE_DATE), - sortOrder = listOf(SortOrder.DESCENDING), - ) - val recentlyReleasedItems = - ApiRequestPager(api, recentlyReleasedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) + update(R.string.recently_released) { + val recentlyReleasedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) - val recentlyAddedRequest = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - ) - val recentlyAddedItems = - ApiRequestPager(api, recentlyAddedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) + GetItemsRequestHandler + .execute(api, recentlyReleasedRequest) + .toBaseItems(api, true) + } - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.SERIES), - ) - val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope) + update(R.string.recently_added) { + val recentlyAddedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) - val unwatchedTopRatedRequest = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.SERIES), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - ) - val unwatchedTopRatedItems = - ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) + GetItemsRequestHandler + .execute(api, recentlyAddedRequest) + .toBaseItems(api, true) + } - val rows = - listOf( - R.string.recently_released to recentlyReleasedItems, - R.string.recently_added to recentlyAddedItems, - R.string.suggestions to suggestedItems, - R.string.top_unwatched to unwatchedTopRatedItems, - ) + update(R.string.suggestions) { + val suggestionsRequest = + GetSuggestionsRequest( + userId = serverRepository.currentUser.value?.id, + type = listOf(BaseItemKind.SERIES), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + + GetSuggestionsRequestHandler + .execute(api, suggestionsRequest) + .toBaseItems(api, true) + } + + update(R.string.top_unwatched) { + val unwatchedTopRatedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.SERIES), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetItemsRequestHandler + .execute(api, unwatchedTopRatedRequest) + .toBaseItems(api, true) + } - rows - .mapIndexed { index, (title, pager) -> - viewModelScope.async(Dispatchers.IO + ExceptionHandler()) { - val title = context.getString(title) - val result = - try { - pager.init() - if (pager.isNotEmpty()) { - pager.getBlocking(0) - } - HomeRowLoadingState.Success(title, pager) - } catch (ex: Exception) { - Timber.e(ex, "Error fetching %s", title) - HomeRowLoadingState.Error(title, null, ex) - } - update(index + 2, result) - if (result is HomeRowLoadingState.Success && result.items.isNotEmpty() && - (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) - ) { - loading.setValueOnMain(LoadingState.Success) - } - } - }.awaitAll() if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { loading.setValueOnMain(LoadingState.Success) } } } - private fun update( - position: Int, + override fun update( + @StringRes title: Int, row: HomeRowLoadingState, ) { rows.update { current -> - current.toMutableList().apply { set(position, row) } + current.toMutableList().apply { set(rowTitles[title]!!, row) } } } @@ -237,7 +242,7 @@ class RecommendedTvShowViewModel R.string.recently_added, R.string.suggestions, R.string.top_unwatched, - ) + ).mapIndexed { index, i -> i to index }.toMap() } } 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 1095703f..d4a988cc 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 @@ -40,6 +40,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader +import coil3.imageLoader import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences @@ -79,12 +80,21 @@ fun PreferencesContent( var preferences by remember { mutableStateOf(initialPreferences) } val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) + var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { preferences = it } } + var updateCache by remember { mutableStateOf(false) } + LaunchedEffect(updateCache) { + val imageUsedMemory = context.imageLoader.memoryCache?.size ?: 0L + val imageMaxMemory = context.imageLoader.memoryCache?.maxSize ?: 0L + val imageDisk = context.imageLoader.diskCache?.size ?: 0L + cacheUsage = CacheUsage(imageUsedMemory, imageMaxMemory, imageDisk) + updateCache = false + } val release by updateVM.release.observeAsState(null) LaunchedEffect(Unit) { @@ -284,16 +294,28 @@ fun PreferencesContent( } 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 = null, + summary = summary, onLongClick = {}, interactionSource = interactionSource, ) @@ -419,3 +441,9 @@ fun PreferencesPage( } } } + +data class CacheUsage( + val imageMemoryUsed: Long, + val imageMemoryMax: Long, + val imageDiskUsed: Long, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 3156bb3d..0ed74fe0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -64,11 +64,9 @@ class ApiRequestPager( .maximumSize(cacheSize) .build>() - suspend fun init(): ApiRequestPager { + suspend fun init(initialPosition: Int = 0): ApiRequestPager { if (totalCount < 0) { - val newRequest = requestHandler.prepare(request, 0, 1, true) - val result = requestHandler.execute(api, newRequest).content - totalCount = result.totalRecordCount + fetchPage(initialPosition, true).join() } return this } @@ -77,7 +75,7 @@ class ApiRequestPager( if (index in 0..( if (index in 0..( override val size: Int get() = totalCount - private fun fetchPage(position: Int): Job = + private fun fetchPage( + position: Int, + setTotalCount: Boolean, + ): Job = scope.launch(ExceptionHandler() + Dispatchers.IO) { mutex.withLock { val pageNumber = position / pageSize @@ -123,9 +124,12 @@ class ApiRequestPager( request, pageNumber * pageSize, pageSize, - false, + setTotalCount, ) val result = requestHandler.execute(api, newRequest).content + if (setTotalCount) { + totalCount = result.totalRecordCount + } val data = mutableListOf() result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } cachedPages.put(pageNumber, data) diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index fa112850..ad99df9f 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -132,6 +132,10 @@ message InterfacePreferences { SubtitlePreferences subtitles_preferences = 7; } +message AdvancedPreferences { + int64 image_disk_cache_size_bytes = 1; +} + message AppPreferences { // The currently signed in server and user IDs, mostly for restoring a session string current_server_id = 1; @@ -145,4 +149,5 @@ message AppPreferences { string update_url = 7; bool send_crash_reports = 8; bool debug_logging = 9; + AdvancedPreferences advanced_preferences = 10; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 890b5412..275e256e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -328,6 +328,7 @@ Dolby Atmos Margin Verbose logging + Image disk cache size (MB) Disabled