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