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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -27,6 +28,8 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository 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.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -85,9 +88,31 @@ class MainActivity : AppCompatActivity() {
appUpgradeHandler.copySubfont(false) appUpgradeHandler.copySubfont(false)
} }
setContent { 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) val appPreferences by userPreferencesDataStore.data.collectAsState(null)
appPreferences?.let { appPreferences -> 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) { LaunchedEffect(appPreferences.debugLogging) {
DebugLogTree.INSTANCE.enabled = 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.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
@Serializable @Serializable
@ -94,6 +95,17 @@ data class BaseItem(
} }
companion object { 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( fun from(
dto: BaseItemDto, dto: BaseItemDto,
api: ApiClient, api: ApiClient,
@ -111,18 +123,25 @@ data class BaseItem(
api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP) api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP)
} }
val primaryImageUrl = val primaryImageUrl =
if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE) { if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE && dto.seriesId != null) {
val seriesId = dto.seriesId api.imageApi.getItemImageUrl(
if (seriesId != null) { dto.seriesId!!,
api.imageApi.getItemImageUrl(seriesId, ImageType.PRIMARY) ImageType.PRIMARY,
} else { maxHeight = primaryMaxHeight,
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY) maxWidth = primaryMaxWidth,
} quality = 96,
)
} else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) { } else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) {
// TODO is this a bad assumption? // TODO is this a bad assumption?
null null
} else { } else {
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY) api.imageApi.getItemImageUrl(
dto.id,
ImageType.PRIMARY,
maxHeight = primaryMaxHeight,
maxWidth = primaryMaxWidth,
quality = 96,
)
} }
val logoImageUrl = val logoImageUrl =
if (dto.type == BaseItemKind.EPISODE || dto.type == BaseItemKind.SEASON) { 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 const val DEFAULT_BITRATE = 100 * MEGA_BIT
private val bitrateValues = private val bitrateValues =
listOf( listOf(
@ -713,6 +713,30 @@ sealed interface AppPreference<T> {
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.disabled, 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.SendAppLogs,
AppPreference.SendCrashReports, AppPreference.SendCrashReports,
AppPreference.DebugLogging, AppPreference.DebugLogging,
AppPreference.ImageDiskCacheSize,
AppPreference.ClearImageCache, AppPreference.ClearImageCache,
AppPreference.OssLicenseInfo, AppPreference.OssLicenseInfo,
), ),

View file

@ -95,6 +95,14 @@ class AppPreferencesSerializer
resetSubtitles() resetSubtitles()
}.build() }.build()
}.build() }.build()
advancedPreferences =
AdvancedPreferences
.newBuilder()
.apply {
imageDiskCacheSizeBytes =
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
}.build()
}.build() }.build()
override suspend fun readFrom(input: InputStream): AppPreferences { override suspend fun readFrom(input: InputStream): AppPreferences {
@ -143,6 +151,11 @@ inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.B
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build() 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() { fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() 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.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences 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.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences 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 package com.github.damontecres.wholphin.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import coil3.ImageLoader import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi import coil3.annotation.ExperimentalCoilApi
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
@ -12,6 +13,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import coil3.util.DebugLogger import coil3.util.DebugLogger
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
/** /**
@ -20,26 +22,53 @@ import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class, ExperimentalCoilApi::class) @OptIn(ExperimentalTime::class, ExperimentalCoilApi::class)
@Composable @Composable
fun CoilConfig( fun CoilConfig(
diskCacheSizeBytes: Long,
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
debugLogging: Boolean, 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 -> setSingletonImageLoaderFactory { ctx ->
Timber.i("Image diskCacheSizeBytes=$diskCacheSizeBytes")
ImageLoader ImageLoader
.Builder(ctx) .Builder(ctx)
.memoryCache(MemoryCache.Builder().maxSizePercent(ctx).build()) .apply {
.diskCache( if (enableCache) {
memoryCache(MemoryCache.Builder().maxSizePercent(ctx).build())
diskCache(
DiskCache DiskCache
.Builder() .Builder()
.directory(ctx.cacheDir.resolve("coil3_image_cache")) .directory(ctx.cacheDir.resolve("coil3_image_cache"))
.maxSizeBytes(100L * 1024 * 1024) .maxSizeBytes(diskCacheSizeBytes)
.build(), .build(),
).crossfade(false) )
} else {
memoryCache(null)
diskCache(null)
}
}.crossfade(false)
.logger(if (debugLogging) DebugLogger() else null) .logger(if (debugLogging) DebugLogger() else null)
.components { .components {
add( add(
OkHttpNetworkFetcherFactory( OkHttpNetworkFetcherFactory(
cacheStrategy = { CacheControlCacheStrategy() }, cacheStrategy = { CacheControlCacheStrategy() },
callFactory = { okHttpClient }, callFactory = { client },
), ),
) )
}.build() }.build()

View file

@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.media3.common.Player import androidx.media3.common.Player
import coil3.request.ErrorResult 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.RowColumn
import com.github.damontecres.wholphin.ui.data.RowColumnSaver import com.github.damontecres.wholphin.ui.data.RowColumnSaver
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -39,7 +40,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.acra.ACRA 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.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -392,3 +396,8 @@ fun logTab(
} }
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) 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, played: Boolean = false,
favorite: Boolean = false, favorite: Boolean = false,
playPercent: Double = 0.0, playPercent: Double = 0.0,
cardHeight: Dp = 140.dp * .85f, cardHeight: Dp = 120.dp,
aspectRatio: Float = AspectRatios.WIDE, aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
) { ) {

View file

@ -32,7 +32,7 @@ fun ChapterCard(
onClick: () -> Unit, onClick: () -> Unit,
aspectRatio: Float, aspectRatio: Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cardHeight: Dp = 140.dp * .85f, cardHeight: Dp = 120.dp,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
) { ) {

View file

@ -1,5 +1,7 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID import java.util.UUID
abstract class RecommendedViewModel( abstract class RecommendedViewModel(
val context: Context,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager, val favoriteWatchManager: FavoriteWatchManager,
) : ViewModel() { ) : ViewModel() {
@ -79,6 +84,27 @@ abstract class RecommendedViewModel(
refreshItem(position, itemId) 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 @Composable

View file

@ -1,19 +1,23 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain 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.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -46,13 +49,14 @@ import java.util.UUID
class RecommendedMovieViewModel class RecommendedMovieViewModel
@AssistedInject @AssistedInject
constructor( constructor(
@param:ApplicationContext private val context: Context, @ApplicationContext context: Context,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(navigationManager, favoriteWatchManager) { ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel fun create(parentId: UUID): RecommendedMovieViewModel
@ -60,8 +64,7 @@ class RecommendedMovieViewModel
override val rows = override val rows =
MutableStateFlow<List<HomeRowLoadingState>>( MutableStateFlow<List<HomeRowLoadingState>>(
rowTitles rowTitles.keys.map {
.map {
HomeRowLoadingState.Pending( HomeRowLoadingState.Pending(
context.getString(it), context.getString(it),
) )
@ -70,6 +73,12 @@ class RecommendedMovieViewModel
override fun init() { override fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val itemsPerRow =
preferencesDataStore.data
.firstOrNull()
?.homePagePreferences
?.maxItemsPerRow
?: AppPreference.HomePageItems.defaultValue.toInt()
try { try {
val resumeItemsRequest = val resumeItemsRequest =
GetResumeItemsRequest( GetResumeItemsRequest(
@ -77,20 +86,16 @@ class RecommendedMovieViewModel
fields = SlimItemFields, fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE), includeItemTypes = listOf(BaseItemKind.MOVIE),
enableUserData = true, enableUserData = true,
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
) )
val resumeItems = val resumeItems =
ApiRequestPager( GetResumeItemsRequestHandler
api, .execute(api, resumeItemsRequest)
resumeItemsRequest, .toBaseItems(api, false)
GetResumeItemsRequestHandler,
viewModelScope,
).init()
if (resumeItems.isNotEmpty()) {
resumeItems.getBlocking(0)
}
update( update(
0, R.string.continue_watching,
HomeRowLoadingState.Success( HomeRowLoadingState.Success(
context.getString(R.string.continue_watching), context.getString(R.string.continue_watching),
resumeItems, resumeItems,
@ -107,6 +112,7 @@ class RecommendedMovieViewModel
} }
} }
update(R.string.recently_released) {
val recentlyReleasedRequest = val recentlyReleasedRequest =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
@ -116,10 +122,16 @@ class RecommendedMovieViewModel
enableUserData = true, enableUserData = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE), sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING), sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
) )
val recentlyReleasedItems = GetItemsRequestHandler
ApiRequestPager(api, recentlyReleasedRequest, GetItemsRequestHandler, viewModelScope) .execute(api, recentlyReleasedRequest)
.toBaseItems(api, false)
}
update(R.string.recently_added) {
val recentlyAddedRequest = val recentlyAddedRequest =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
@ -129,17 +141,30 @@ class RecommendedMovieViewModel
enableUserData = true, enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_CREATED), sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING), sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
) )
val recentlyAddedItems = GetItemsRequestHandler
ApiRequestPager(api, recentlyAddedRequest, GetItemsRequestHandler, viewModelScope) .execute(api, recentlyAddedRequest)
.toBaseItems(api, false)
}
update(R.string.suggestions) {
val suggestionsRequest = val suggestionsRequest =
GetSuggestionsRequest( GetSuggestionsRequest(
userId = serverRepository.currentUser.value?.id, userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.MOVIE), 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 = val unwatchedTopRatedRequest =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
@ -150,52 +175,27 @@ class RecommendedMovieViewModel
isPlayed = false, isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING), sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING), sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
) )
val unwatchedTopRatedItems = GetItemsRequestHandler
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) .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) { if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
loading.setValueOnMain(LoadingState.Success) loading.setValueOnMain(LoadingState.Success)
} }
} }
} }
private fun update( override fun update(
position: Int, @StringRes title: Int,
row: HomeRowLoadingState, row: HomeRowLoadingState,
) { ) {
rows.update { current -> 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.recently_added,
R.string.suggestions, R.string.suggestions,
R.string.top_unwatched, R.string.top_unwatched,
) ).mapIndexed { index, i -> i to index }.toMap()
} }
} }

View file

@ -1,19 +1,23 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain 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.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
@ -28,8 +32,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -48,13 +52,14 @@ import java.util.UUID
class RecommendedTvShowViewModel class RecommendedTvShowViewModel
@AssistedInject @AssistedInject
constructor( constructor(
@param:ApplicationContext private val context: Context, @ApplicationContext context: Context,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(navigationManager, favoriteWatchManager) { ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel fun create(parentId: UUID): RecommendedTvShowViewModel
@ -62,8 +67,7 @@ class RecommendedTvShowViewModel
override val rows = override val rows =
MutableStateFlow<List<HomeRowLoadingState>>( MutableStateFlow<List<HomeRowLoadingState>>(
rowTitles rowTitles.keys.map {
.map {
HomeRowLoadingState.Pending( HomeRowLoadingState.Pending(
context.getString(it), context.getString(it),
) )
@ -72,53 +76,57 @@ class RecommendedTvShowViewModel
override fun init() { override fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val itemsPerRow =
preferencesDataStore.data
.firstOrNull()
?.homePagePreferences
?.maxItemsPerRow
?: AppPreference.HomePageItems.defaultValue.toInt()
try { try {
val resumeItemsDeferred =
viewModelScope.async {
val resumeItemsRequest = val resumeItemsRequest =
GetResumeItemsRequest( GetResumeItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.EPISODE), includeItemTypes = listOf(BaseItemKind.EPISODE),
enableUserData = true, enableUserData = true,
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
) )
val resumeItems = GetResumeItemsRequestHandler
ApiRequestPager( .execute(api, resumeItemsRequest)
api, .toBaseItems(api, true)
resumeItemsRequest,
GetResumeItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
).init()
if (resumeItems.isNotEmpty()) {
resumeItems.getBlocking(0)
} }
val nextUpItemsDeferred =
viewModelScope.async {
val nextUpRequest = val nextUpRequest =
GetNextUpRequest( GetNextUpRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
enableUserData = true, 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( update(
0, R.string.continue_watching,
HomeRowLoadingState.Success( HomeRowLoadingState.Success(
context.getString(R.string.continue_watching), context.getString(R.string.continue_watching),
resumeItems, resumeItems,
), ),
) )
val nextUpItems = nextUpItemsDeferred.await()
update( update(
1, R.string.next_up,
HomeRowLoadingState.Success( HomeRowLoadingState.Success(
context.getString(R.string.next_up), context.getString(R.string.next_up),
nextUpItems, nextUpItems,
@ -135,6 +143,7 @@ class RecommendedTvShowViewModel
} }
} }
update(R.string.recently_released) {
val recentlyReleasedRequest = val recentlyReleasedRequest =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
@ -144,10 +153,17 @@ class RecommendedTvShowViewModel
enableUserData = true, enableUserData = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE), sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING), 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 = val recentlyAddedRequest =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
@ -157,17 +173,32 @@ class RecommendedTvShowViewModel
enableUserData = true, enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_CREATED), sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING), 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 = val suggestionsRequest =
GetSuggestionsRequest( GetSuggestionsRequest(
userId = serverRepository.currentUser.value?.id, userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.SERIES), 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 = val unwatchedTopRatedRequest =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
@ -178,53 +209,27 @@ class RecommendedTvShowViewModel
isPlayed = false, isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING), sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING), sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
) )
val unwatchedTopRatedItems = GetItemsRequestHandler
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) .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) { if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
loading.setValueOnMain(LoadingState.Success) loading.setValueOnMain(LoadingState.Success)
} }
} }
} }
private fun update( override fun update(
position: Int, @StringRes title: Int,
row: HomeRowLoadingState, row: HomeRowLoadingState,
) { ) {
rows.update { current -> 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.recently_added,
R.string.suggestions, R.string.suggestions,
R.string.top_unwatched, 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.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.imageLoader
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -79,12 +80,21 @@ fun PreferencesContent(
var preferences by remember { mutableStateOf(initialPreferences) } var preferences by remember { mutableStateOf(initialPreferences) }
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect { viewModel.preferenceDataStore.data.collect {
preferences = it 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) val release by updateVM.release.observeAsState(null)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -284,16 +294,28 @@ fun PreferencesContent(
} }
AppPreference.ClearImageCache -> { 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( ClickPreference(
title = stringResource(pref.title), title = stringResource(pref.title),
onClick = { onClick = {
SingletonImageLoader.get(context).let { SingletonImageLoader.get(context).let {
it.memoryCache?.clear() it.memoryCache?.clear()
it.diskCache?.clear() it.diskCache?.clear()
updateCache = true
} }
}, },
modifier = Modifier, modifier = Modifier,
summary = null, summary = summary,
onLongClick = {}, onLongClick = {},
interactionSource = interactionSource, 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) .maximumSize(cacheSize)
.build<Int, MutableList<BaseItem>>() .build<Int, MutableList<BaseItem>>()
suspend fun init(): ApiRequestPager<T> { suspend fun init(initialPosition: Int = 0): ApiRequestPager<T> {
if (totalCount < 0) { if (totalCount < 0) {
val newRequest = requestHandler.prepare(request, 0, 1, true) fetchPage(initialPosition, true).join()
val result = requestHandler.execute(api, newRequest).content
totalCount = result.totalRecordCount
} }
return this return this
} }
@ -77,7 +75,7 @@ class ApiRequestPager<T>(
if (index in 0..<totalCount) { if (index in 0..<totalCount) {
val item = items[index] val item = items[index]
if (item == null) { if (item == null) {
fetchPage(index) fetchPage(index, false)
} }
return item return item
} else { } else {
@ -89,7 +87,7 @@ class ApiRequestPager<T>(
if (index in 0..<totalCount) { if (index in 0..<totalCount) {
val item = items[index] val item = items[index]
if (item == null) { if (item == null) {
fetchPage(index).join() fetchPage(index, false).join()
return items[index] return items[index]
} }
return item return item
@ -112,7 +110,10 @@ class ApiRequestPager<T>(
override val size: Int override val size: Int
get() = totalCount get() = totalCount
private fun fetchPage(position: Int): Job = private fun fetchPage(
position: Int,
setTotalCount: Boolean,
): Job =
scope.launch(ExceptionHandler() + Dispatchers.IO) { scope.launch(ExceptionHandler() + Dispatchers.IO) {
mutex.withLock { mutex.withLock {
val pageNumber = position / pageSize val pageNumber = position / pageSize
@ -123,9 +124,12 @@ class ApiRequestPager<T>(
request, request,
pageNumber * pageSize, pageNumber * pageSize,
pageSize, pageSize,
false, setTotalCount,
) )
val result = requestHandler.execute(api, newRequest).content val result = requestHandler.execute(api, newRequest).content
if (setTotalCount) {
totalCount = result.totalRecordCount
}
val data = mutableListOf<BaseItem>() val data = mutableListOf<BaseItem>()
result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) }
cachedPages.put(pageNumber, data) cachedPages.put(pageNumber, data)

View file

@ -132,6 +132,10 @@ message InterfacePreferences {
SubtitlePreferences subtitles_preferences = 7; SubtitlePreferences subtitles_preferences = 7;
} }
message AdvancedPreferences {
int64 image_disk_cache_size_bytes = 1;
}
message AppPreferences { message AppPreferences {
// The currently signed in server and user IDs, mostly for restoring a session // The currently signed in server and user IDs, mostly for restoring a session
string current_server_id = 1; string current_server_id = 1;
@ -145,4 +149,5 @@ message AppPreferences {
string update_url = 7; string update_url = 7;
bool send_crash_reports = 8; bool send_crash_reports = 8;
bool debug_logging = 9; bool debug_logging = 9;
AdvancedPreferences advanced_preferences = 10;
} }

View file

@ -328,6 +328,7 @@
<string name="dolby_atmos">Dolby Atmos</string> <string name="dolby_atmos">Dolby Atmos</string>
<string name="subtitle_margin">Margin</string> <string name="subtitle_margin">Margin</string>
<string name="verbose_logging">Verbose logging</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"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>