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
This commit is contained in:
damontecres 2025-12-02 16:00:21 -05:00 committed by GitHub
parent 10233eb459
commit 56bf3cb7a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 453 additions and 253 deletions

View file

@ -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
}

View file

@ -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) {

View file

@ -323,7 +323,7 @@ sealed interface AppPreference<T> {
},
)
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<T> {
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,
),

View file

@ -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()

View file

@ -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
}
}
}
}
}

View file

@ -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(
.apply {
if (enableCache) {
memoryCache(MemoryCache.Builder().maxSizePercent(ctx).build())
diskCache(
DiskCache
.Builder()
.directory(ctx.cacheDir.resolve("coil3_image_cache"))
.maxSizeBytes(100L * 1024 * 1024)
.maxSizeBytes(diskCacheSizeBytes)
.build(),
).crossfade(false)
)
} else {
memoryCache(null)
diskCache(null)
}
}.crossfade(false)
.logger(if (debugLogging) DebugLogger() else null)
.components {
add(
OkHttpNetworkFetcherFactory(
cacheStrategy = { CacheControlCacheStrategy() },
callFactory = { okHttpClient },
callFactory = { client },
),
)
}.build()

View file

@ -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 <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
fun Response<BaseItemDtoQueryResult>.toBaseItems(
api: ApiClient,
useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }

View file

@ -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,
) {

View file

@ -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,
) {

View file

@ -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<BaseItem>,
) {
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

View file

@ -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<AppPreferences>,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(navigationManager, favoriteWatchManager) {
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel
@ -60,8 +64,7 @@ class RecommendedMovieViewModel
override val rows =
MutableStateFlow<List<HomeRowLoadingState>>(
rowTitles
.map {
rowTitles.keys.map {
HomeRowLoadingState.Pending(
context.getString(it),
)
@ -70,6 +73,12 @@ class RecommendedMovieViewModel
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,6 +112,7 @@ class RecommendedMovieViewModel
}
}
update(R.string.recently_released) {
val recentlyReleasedRequest =
GetItemsRequest(
parentId = parentId,
@ -116,10 +122,16 @@ class RecommendedMovieViewModel
enableUserData = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val recentlyReleasedItems =
ApiRequestPager(api, recentlyReleasedRequest, GetItemsRequestHandler, viewModelScope)
GetItemsRequestHandler
.execute(api, recentlyReleasedRequest)
.toBaseItems(api, false)
}
update(R.string.recently_added) {
val recentlyAddedRequest =
GetItemsRequest(
parentId = parentId,
@ -129,17 +141,30 @@ class RecommendedMovieViewModel
enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val recentlyAddedItems =
ApiRequestPager(api, recentlyAddedRequest, GetItemsRequestHandler, viewModelScope)
GetItemsRequestHandler
.execute(api, recentlyAddedRequest)
.toBaseItems(api, false)
}
update(R.string.suggestions) {
val suggestionsRequest =
GetSuggestionsRequest(
userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.MOVIE),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope)
GetSuggestionsRequestHandler
.execute(api, suggestionsRequest)
.toBaseItems(api, false)
}
update(R.string.top_unwatched) {
val unwatchedTopRatedRequest =
GetItemsRequest(
parentId = parentId,
@ -150,52 +175,27 @@ class RecommendedMovieViewModel
isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val unwatchedTopRatedItems =
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
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()
}
}

View file

@ -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<AppPreferences>,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(navigationManager, favoriteWatchManager) {
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel
@ -62,8 +67,7 @@ class RecommendedTvShowViewModel
override val rows =
MutableStateFlow<List<HomeRowLoadingState>>(
rowTitles
.map {
rowTitles.keys.map {
HomeRowLoadingState.Pending(
context.getString(it),
)
@ -72,53 +76,57 @@ class RecommendedTvShowViewModel
override fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val itemsPerRow =
preferencesDataStore.data
.firstOrNull()
?.homePagePreferences
?.maxItemsPerRow
?: AppPreference.HomePageItems.defaultValue.toInt()
try {
val resumeItemsDeferred =
viewModelScope.async {
val resumeItemsRequest =
GetResumeItemsRequest(
parentId = parentId,
fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.EPISODE),
enableUserData = true,
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val resumeItems =
ApiRequestPager(
api,
resumeItemsRequest,
GetResumeItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
).init()
if (resumeItems.isNotEmpty()) {
resumeItems.getBlocking(0)
GetResumeItemsRequestHandler
.execute(api, resumeItemsRequest)
.toBaseItems(api, true)
}
val nextUpItemsDeferred =
viewModelScope.async {
val nextUpRequest =
GetNextUpRequest(
parentId = parentId,
fields = SlimItemFields,
enableUserData = true,
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val nextUpItems =
ApiRequestPager(
api,
nextUpRequest,
GetNextUpRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
).init()
if (nextUpItems.isNotEmpty()) {
nextUpItems.getBlocking(0)
}
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,6 +143,7 @@ class RecommendedTvShowViewModel
}
}
update(R.string.recently_released) {
val recentlyReleasedRequest =
GetItemsRequest(
parentId = parentId,
@ -144,10 +153,17 @@ class RecommendedTvShowViewModel
enableUserData = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val recentlyReleasedItems =
ApiRequestPager(api, recentlyReleasedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
GetItemsRequestHandler
.execute(api, recentlyReleasedRequest)
.toBaseItems(api, true)
}
update(R.string.recently_added) {
val recentlyAddedRequest =
GetItemsRequest(
parentId = parentId,
@ -157,17 +173,32 @@ class RecommendedTvShowViewModel
enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val recentlyAddedItems =
ApiRequestPager(api, recentlyAddedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
GetItemsRequestHandler
.execute(api, recentlyAddedRequest)
.toBaseItems(api, true)
}
update(R.string.suggestions) {
val suggestionsRequest =
GetSuggestionsRequest(
userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.SERIES),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope)
GetSuggestionsRequestHandler
.execute(api, suggestionsRequest)
.toBaseItems(api, true)
}
update(R.string.top_unwatched) {
val unwatchedTopRatedRequest =
GetItemsRequest(
parentId = parentId,
@ -178,53 +209,27 @@ class RecommendedTvShowViewModel
isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
val unwatchedTopRatedItems =
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
GetItemsRequestHandler
.execute(api, unwatchedTopRatedRequest)
.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,
)
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()
}
}

View file

@ -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,
)

View file

@ -64,11 +64,9 @@ class ApiRequestPager<T>(
.maximumSize(cacheSize)
.build<Int, MutableList<BaseItem>>()
suspend fun init(): ApiRequestPager<T> {
suspend fun init(initialPosition: Int = 0): ApiRequestPager<T> {
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<T>(
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index)
fetchPage(index, false)
}
return item
} else {
@ -89,7 +87,7 @@ class ApiRequestPager<T>(
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index).join()
fetchPage(index, false).join()
return items[index]
}
return item
@ -112,7 +110,10 @@ class ApiRequestPager<T>(
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<T>(
request,
pageNumber * pageSize,
pageSize,
false,
setTotalCount,
)
val result = requestHandler.execute(api, newRequest).content
if (setTotalCount) {
totalCount = result.totalRecordCount
}
val data = mutableListOf<BaseItem>()
result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) }
cachedPages.put(pageNumber, data)

View file

@ -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;
}

View file

@ -328,6 +328,7 @@
<string name="dolby_atmos">Dolby Atmos</string>
<string name="subtitle_margin">Margin</string>
<string name="verbose_logging">Verbose logging</string>
<string name="image_cache_size">Image disk cache size (MB)</string>
<string-array name="theme_song_volume">
<item>Disabled</item>