diff --git a/core/src/commonMain/kotlin/ca/gosyer/jui/core/lang/Humanize.kt b/core/src/commonMain/kotlin/ca/gosyer/jui/core/lang/Humanize.kt new file mode 100644 index 00000000..93289e44 --- /dev/null +++ b/core/src/commonMain/kotlin/ca/gosyer/jui/core/lang/Humanize.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.core.lang + +fun Long.bytesIntoHumanReadable(si: Boolean = true): String { + val bytes = this + val unit = if (si) 1000L else 1024 + val i = if (si) "" else "i" + val kilobyte: Long = unit + val megabyte = kilobyte * unit + val gigabyte = megabyte * unit + val terabyte = gigabyte * unit + return if (bytes in 0 until kilobyte) { + "$bytes B" + } else if (bytes in kilobyte until megabyte) { + "${(bytes / kilobyte)} K${i}B" + } else if (bytes in megabyte until gigabyte) { + "${(bytes / megabyte)} M${i}B" + } else if (bytes in gigabyte until terabyte) { + "${(bytes / gigabyte)} G${i}B" + } else if (bytes >= terabyte) { + "${(bytes / terabyte)} T${i}B" + } else { + "$bytes Bytes" + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5dd3052e..ceed83aa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,13 +7,13 @@ coroutines = "1.6.4" json = "1.4.0" # Compose -composeGradle = "1.2.0-alpha01-dev764" +composeGradle = "1.2.0-alpha01-dev770" composeCompiler = "1.3.0" composeAndroid = "1.2.1" voyager = "1.0.0-beta16" accompanist = "0.25.1" googleAccompanist = "0.25.1" -imageloader = "1.1.4" +imageloader = "1.1.7" materialDialogs = "0.8.0" # Android diff --git a/i18n/src/commonMain/resources/MR/values/base/strings.xml b/i18n/src/commonMain/resources/MR/values/base/strings.xml index 9e49e364..fc434966 100644 --- a/i18n/src/commonMain/resources/MR/values/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/values/base/strings.xml @@ -296,6 +296,9 @@ Check for updates + Clear image cache + Clear chapter cache + Used: %1$s Downloader diff --git a/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/base/image/AndroidBitmapDecoderFactory.kt b/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/base/image/AndroidBitmapDecoderFactory.kt new file mode 100644 index 00000000..db3d68b8 --- /dev/null +++ b/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/base/image/AndroidBitmapDecoderFactory.kt @@ -0,0 +1,14 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ui.base.image + +import ca.gosyer.jui.uicore.vm.ContextWrapper +import com.seiko.imageloader.component.decoder.BitmapFactoryDecoder +import com.seiko.imageloader.component.decoder.Decoder + +actual class BitmapDecoderFactory actual constructor(contextWrapper: ContextWrapper) + : Decoder.Factory by BitmapFactoryDecoder.Factory(contextWrapper) \ No newline at end of file diff --git a/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/base/image/AndroidImageLoaderBuilder.kt b/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/base/image/AndroidImageLoaderBuilder.kt index faeb758f..1befcc4c 100644 --- a/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/base/image/AndroidImageLoaderBuilder.kt +++ b/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/base/image/AndroidImageLoaderBuilder.kt @@ -18,9 +18,9 @@ actual fun imageLoaderBuilder(contextWrapper: ContextWrapper): ImageLoaderBuilde return ImageLoaderBuilder(contextWrapper) } -actual fun diskCache(contextWrapper: ContextWrapper): DiskCache { +actual fun diskCache(contextWrapper: ContextWrapper, cacheDir: String): DiskCache { return DiskCacheBuilder() - .directory(contextWrapper.cacheDir.toOkioPath() / "image_cache") + .directory(contextWrapper.cacheDir.toOkioPath() / cacheDir) .maxSizeBytes(1024 * 1024 * 150) // 150 MB .build() } diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/UiComponent.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/UiComponent.kt index 044974a7..6186651a 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/UiComponent.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/UiComponent.kt @@ -11,11 +11,17 @@ import androidx.compose.runtime.compositionLocalOf import ca.gosyer.jui.core.di.AppScope import ca.gosyer.jui.ui.ViewModelComponent import ca.gosyer.jui.ui.base.image.ImageLoaderProvider +import ca.gosyer.jui.ui.base.image.diskCache import ca.gosyer.jui.uicore.vm.ContextWrapper import com.seiko.imageloader.ImageLoader import com.seiko.imageloader.LocalImageLoader +import com.seiko.imageloader.cache.disk.DiskCache import me.tatarka.inject.annotations.Provides +typealias ImageCache = DiskCache + +typealias ChapterCache = DiskCache + interface UiComponent { val imageLoader: ImageLoader @@ -23,9 +29,21 @@ interface UiComponent { val hooks: Array> + val imageCache: ImageCache + + val chapterCache: ChapterCache + @AppScope @Provides - fun imageLoaderFactory(imageLoaderProvider: ImageLoaderProvider): ImageLoader = imageLoaderProvider.get() + fun imageLoaderFactory(imageLoaderProvider: ImageLoaderProvider, imageCache: ImageCache): ImageLoader = imageLoaderProvider.get(imageCache) + + @AppScope + @Provides + fun imageCacheFactory(): ImageCache = diskCache(contextWrapper, "image_cache") + + @AppScope + @Provides + fun chapterCacheFactory(): ChapterCache = diskCache(contextWrapper, "chapter_cache") @Provides fun getHooks(viewModelComponent: ViewModelComponent) = arrayOf( diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/image/BitmapDecoderFactory.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/image/BitmapDecoderFactory.kt new file mode 100644 index 00000000..81649431 --- /dev/null +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/image/BitmapDecoderFactory.kt @@ -0,0 +1,12 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ui.base.image + +import ca.gosyer.jui.uicore.vm.ContextWrapper +import com.seiko.imageloader.component.decoder.Decoder + +expect class BitmapDecoderFactory constructor(contextWrapper: ContextWrapper) : Decoder.Factory \ No newline at end of file diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/image/ImageLoaderProvider.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/image/ImageLoaderProvider.kt index 4d23668f..8fb958e9 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/image/ImageLoaderProvider.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/image/ImageLoaderProvider.kt @@ -11,6 +11,7 @@ import ca.gosyer.jui.domain.manga.model.Manga import ca.gosyer.jui.domain.server.Http import ca.gosyer.jui.domain.server.service.ServerPreferences import ca.gosyer.jui.domain.source.model.Source +import ca.gosyer.jui.ui.base.ImageCache import ca.gosyer.jui.uicore.vm.ContextWrapper import com.seiko.imageloader.ImageLoader import com.seiko.imageloader.ImageLoaderBuilder @@ -31,7 +32,7 @@ class ImageLoaderProvider @Inject constructor( @OptIn(DelicateCoroutinesApi::class) val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope) - fun get(): ImageLoader { + fun get(imageCache: ImageCache): ImageLoader { return imageLoaderBuilder(context).apply { httpClient { http } components { @@ -48,7 +49,7 @@ class ImageLoaderProvider @Inject constructor( ) ) diskCache { - diskCache(context) + imageCache } memoryCache { memoryCache(context) @@ -104,6 +105,6 @@ class ImageLoaderProvider @Inject constructor( expect fun imageLoaderBuilder(contextWrapper: ContextWrapper): ImageLoaderBuilder -expect fun diskCache(contextWrapper: ContextWrapper): DiskCache +expect fun diskCache(contextWrapper: ContextWrapper, cacheDir: String): DiskCache expect fun memoryCache(contextWrapper: ContextWrapper): MemoryCache diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ChapterLoader.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ChapterLoader.kt index a46765e6..34a21bed 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ChapterLoader.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ChapterLoader.kt @@ -8,9 +8,11 @@ package ca.gosyer.jui.ui.reader import ca.gosyer.jui.domain.chapter.interactor.GetChapterPage import ca.gosyer.jui.domain.reader.service.ReaderPreferences +import ca.gosyer.jui.ui.base.image.BitmapDecoderFactory import ca.gosyer.jui.ui.reader.loader.PagesState import ca.gosyer.jui.ui.reader.loader.TachideskPageLoader import ca.gosyer.jui.ui.reader.model.ReaderChapter +import com.seiko.imageloader.cache.disk.DiskCache import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filterIsInstance @@ -21,7 +23,9 @@ import org.lighthousegames.logging.logging class ChapterLoader( private val readerPreferences: ReaderPreferences, - private val getChapterPage: GetChapterPage + private val getChapterPage: GetChapterPage, + private val chapterCache: DiskCache, + private val bitmapDecoderFactory: BitmapDecoderFactory ) { fun loadChapter(chapter: ReaderChapter): StateFlow { if (chapterIsReady(chapter)) { @@ -30,7 +34,7 @@ class ChapterLoader( chapter.state = ReaderChapter.State.Loading log.debug { "Loading pages for ${chapter.chapter.name}" } - val loader = TachideskPageLoader(chapter, readerPreferences, getChapterPage) + val loader = TachideskPageLoader(chapter, readerPreferences, getChapterPage, chapterCache, bitmapDecoderFactory) val pages = loader.getPages() diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ReaderMenu.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ReaderMenu.kt index 3a435d02..3f050765 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ReaderMenu.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ReaderMenu.kt @@ -40,17 +40,18 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.key import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import ca.gosyer.jui.core.lang.withIOContext import ca.gosyer.jui.domain.reader.model.Direction import ca.gosyer.jui.domain.reader.model.ImageScale import ca.gosyer.jui.domain.reader.model.NavigationMode @@ -513,7 +514,7 @@ fun SideMenuButton(sideMenuOpen: Boolean, onOpenSideMenuClicked: () -> Unit) { @Composable fun ReaderImage( imageIndex: Int, - drawableHolder: StableHolder, + drawableHolder: StableHolder<(suspend () -> ReaderPage.ImageDecodeState)?>, progress: Float, status: ReaderPage.Status, error: String?, @@ -523,17 +524,35 @@ fun ReaderImage( retry: (Int) -> Unit ) { Crossfade(drawableHolder to status) { (drawableHolder, status) -> - val drawable = drawableHolder.item - if (drawable != null) { + val drawableCallback = drawableHolder.item + val decodeState = produceState(null, drawableCallback) { + if (drawableCallback != null) { + withIOContext { + value = drawableCallback() + } + } + } + val decode = decodeState.value + if (decode != null && decode is ReaderPage.ImageDecodeState.Success) { Image( - bitmap = drawable, + bitmap = decode.bitmap, modifier = imageModifier, contentDescription = null, contentScale = contentScale, filterQuality = FilterQuality.High ) } else { - LoadingScreen(status == ReaderPage.Status.QUEUE, loadingModifier, progress, error) { retry(imageIndex) } + LoadingScreen( + status == ReaderPage.Status.QUEUE, + loadingModifier, + progress, + error ?: when (decode) { + is ReaderPage.ImageDecodeState.FailedToDecode -> decode.exception.message + ReaderPage.ImageDecodeState.UnknownDecoder -> "Unknown decoder" + ReaderPage.ImageDecodeState.FailedToGetSnapShot -> "Failed to get snapshot" + else -> null + } + ) { retry(imageIndex) } } } } diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ReaderMenuViewModel.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ReaderMenuViewModel.kt index 7bec9c97..23c2902d 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ReaderMenuViewModel.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/ReaderMenuViewModel.kt @@ -21,6 +21,8 @@ import ca.gosyer.jui.domain.manga.model.MangaMeta import ca.gosyer.jui.domain.reader.ReaderModeWatch import ca.gosyer.jui.domain.reader.model.Direction import ca.gosyer.jui.domain.reader.service.ReaderPreferences +import ca.gosyer.jui.ui.base.ChapterCache +import ca.gosyer.jui.ui.base.image.BitmapDecoderFactory import ca.gosyer.jui.ui.base.model.StableHolder import ca.gosyer.jui.ui.reader.loader.PagesState import ca.gosyer.jui.ui.reader.model.MoveTo @@ -67,6 +69,7 @@ class ReaderMenuViewModel @Inject constructor( private val updateChapterFlags: UpdateChapterFlags, private val updateMangaMeta: UpdateMangaMeta, private val updateChapterMeta: UpdateChapterMeta, + private val chapterCache: ChapterCache, contextWrapper: ContextWrapper, private val params: Params ) : ViewModel(contextWrapper) { @@ -116,7 +119,12 @@ class ReaderMenuViewModel @Inject constructor( val readerModeSettings = ReaderModeWatch(readerPreferences, scope, readerMode) - private val loader = ChapterLoader(readerPreferences, getChapterPage) + private val loader = ChapterLoader( + readerPreferences = readerPreferences, + getChapterPage = getChapterPage, + chapterCache = chapterCache, + bitmapDecoderFactory = BitmapDecoderFactory(contextWrapper) + ) init { init() diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/loader/TachideskPageLoader.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/loader/TachideskPageLoader.kt index 5ab8a05f..92dee0d4 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/loader/TachideskPageLoader.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/loader/TachideskPageLoader.kt @@ -6,16 +6,24 @@ package ca.gosyer.jui.ui.reader.loader +import androidx.compose.ui.graphics.asComposeImageBitmap import ca.gosyer.jui.core.lang.throwIfCancellation import ca.gosyer.jui.domain.chapter.interactor.GetChapterPage import ca.gosyer.jui.domain.reader.service.ReaderPreferences +import ca.gosyer.jui.ui.base.image.BitmapDecoderFactory import ca.gosyer.jui.ui.base.model.StableHolder import ca.gosyer.jui.ui.reader.model.ReaderChapter import ca.gosyer.jui.ui.reader.model.ReaderPage -import ca.gosyer.jui.ui.util.compose.toImageBitmap import ca.gosyer.jui.ui.util.lang.priorityChannel import cafe.adriel.voyager.core.concurrent.AtomicInt32 +import com.seiko.imageloader.cache.disk.DiskCache +import com.seiko.imageloader.component.decoder.DecodeImageResult +import com.seiko.imageloader.request.ImageRequestBuilder +import com.seiko.imageloader.request.Options +import com.seiko.imageloader.request.SourceResult import io.ktor.client.plugins.onDownload +import io.ktor.client.statement.bodyAsChannel +import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -28,12 +36,18 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import okio.BufferedSource +import okio.FileSystem +import okio.buffer +import okio.source import org.lighthousegames.logging.logging class TachideskPageLoader( val chapter: ReaderChapter, readerPreferences: ReaderPreferences, - getChapterPage: GetChapterPage + getChapterPage: GetChapterPage, + private val chapterCache: DiskCache, + private val bitmapDecoderFactory: BitmapDecoderFactory ) : PageLoader() { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -69,7 +83,39 @@ class TachideskPageLoader( } } .onEach { - page.bitmap.value = StableHolder(it.toImageBitmap()) + val editor = chapterCache.edit(page.cacheKey) + ?: throw Exception("Couldn't open cache") + try { + FileSystem.SYSTEM.write(editor.data) { + it.bodyAsChannel().toInputStream().source().use { + writeAll(it) + } + } + editor.commit() + } catch (e: Exception) { + editor.abortQuietly() + throw e + } + page.bitmap.value = StableHolder { + chapterCache[page.cacheKey]?.use { + val decoder = bitmapDecoderFactory.create( + SourceResult( + ImageRequestBuilder().build(), + it.source() + ), + Options() + ) + if (decoder != null) { + runCatching { decoder.decode() as DecodeImageResult } + .mapCatching { + ReaderPage.ImageDecodeState.Success(it.image.asComposeImageBitmap()) + } + .getOrElse { + ReaderPage.ImageDecodeState.FailedToDecode(it) + } + } else ReaderPage.ImageDecodeState.UnknownDecoder + } ?: ReaderPage.ImageDecodeState.FailedToGetSnapShot + } page.status.value = ReaderPage.Status.READY page.error.value = null } @@ -195,4 +241,17 @@ class TachideskPageLoader( private companion object { private val log = logging() } + + private val ReaderPage.cacheKey + get() = "${chapter.chapter.mangaId}-${chapter.chapter.index}-${index}" + + private fun DiskCache.Snapshot.source(): BufferedSource { + return FileSystem.SYSTEM.source(data).buffer() + } + + private fun DiskCache.Editor.abortQuietly() { + try { + abort() + } catch (_: Exception) {} + } } diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/model/ReaderPage.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/model/ReaderPage.kt index e2f922e7..a260f2ad 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/model/ReaderPage.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/reader/model/ReaderPage.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @Immutable data class ReaderPage( val index: Int, - val bitmap: MutableStateFlow>, + val bitmap: MutableStateFlow ImageDecodeState)?>>, val progress: MutableStateFlow, val status: MutableStateFlow, val error: MutableStateFlow, @@ -25,4 +25,16 @@ data class ReaderPage( READY, ERROR } + + @Immutable + sealed class ImageDecodeState { + @Immutable + data class Success(val bitmap: ImageBitmap) : ImageDecodeState() + @Immutable + object UnknownDecoder : ImageDecodeState() + @Immutable + object FailedToGetSnapShot : ImageDecodeState() + @Immutable + data class FailedToDecode(val exception: Throwable) : ImageDecodeState() + } } diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsAdvancedScreen.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsAdvancedScreen.kt index 7a4daf2c..61f91d37 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsAdvancedScreen.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsAdvancedScreen.kt @@ -18,13 +18,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Divider import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import ca.gosyer.jui.core.lang.bytesIntoHumanReadable +import ca.gosyer.jui.core.lang.launchIO import ca.gosyer.jui.domain.updates.service.UpdatePreferences import ca.gosyer.jui.i18n.MR +import ca.gosyer.jui.ui.base.ChapterCache +import ca.gosyer.jui.ui.base.ImageCache import ca.gosyer.jui.ui.base.navigation.Toolbar +import ca.gosyer.jui.ui.base.prefs.PreferenceRow import ca.gosyer.jui.ui.base.prefs.SwitchPreference import ca.gosyer.jui.ui.main.components.bottomNav import ca.gosyer.jui.ui.viewModel @@ -40,7 +47,15 @@ import ca.gosyer.jui.uicore.vm.ViewModel import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive import me.tatarka.inject.annotations.Inject +import org.lighthousegames.logging.logging +import kotlin.time.Duration.Companion.seconds class SettingsAdvancedScreen : Screen { override val key: ScreenKey = uniqueScreenKey @@ -49,21 +64,60 @@ class SettingsAdvancedScreen : Screen { override fun Content() { val vm = viewModel { settingsAdvancedViewModel() } SettingsAdvancedScreenContent( - updatesEnabled = vm.updatesEnabled + updatesEnabled = vm.updatesEnabled, + imageCacheSize = vm.imageCacheSize.collectAsState().value, + clearImageCache = vm::clearImageCache, + chapterCacheSize = vm.chapterCacheSize.collectAsState().value, + clearChapterCache = vm::clearChapterCache ) } } class SettingsAdvancedViewModel @Inject constructor( updatePreferences: UpdatePreferences, + private val imageCache: ImageCache, + private val chapterCache: ChapterCache, contextWrapper: ContextWrapper ) : ViewModel(contextWrapper) { val updatesEnabled = updatePreferences.enabled().asStateFlow() + + val imageCacheSize = flow { + while (currentCoroutineContext().isActive) { + emit(imageCache.size.bytesIntoHumanReadable()) + delay(1.seconds) + } + }.stateIn(scope, SharingStarted.Eagerly, "") + + val chapterCacheSize = flow { + while (currentCoroutineContext().isActive) { + emit(chapterCache.size.bytesIntoHumanReadable()) + delay(1.seconds) + } + }.stateIn(scope, SharingStarted.Eagerly, "") + + fun clearImageCache() { + scope.launchIO { + imageCache.clear() + } + } + + fun clearChapterCache() { + scope.launchIO { + chapterCache.clear() + } + } + companion object { + private val log = logging() + } } @Composable fun SettingsAdvancedScreenContent( - updatesEnabled: PreferenceMutableStateFlow + updatesEnabled: PreferenceMutableStateFlow, + imageCacheSize: String, + clearImageCache: () -> Unit, + chapterCacheSize: String, + clearChapterCache: () -> Unit, ) { Scaffold( modifier = Modifier.windowInsetsPadding( @@ -89,6 +143,23 @@ fun SettingsAdvancedScreenContent( item { SwitchPreference(preference = updatesEnabled, title = stringResource(MR.strings.update_checker)) } + item { + Divider() + } + item { + PreferenceRow( + title = stringResource(MR.strings.clear_image_cache), + subtitle = stringResource(MR.strings.clear_cache_sub, imageCacheSize), + onClick = clearImageCache + ) + } + item { + PreferenceRow( + title = stringResource(MR.strings.clear_chapter_cache), + subtitle = stringResource(MR.strings.clear_cache_sub, chapterCacheSize), + onClick = clearChapterCache + ) + } } VerticalScrollbar( rememberScrollbarAdapter(state), diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/base/image/DesktopBitmapDecoderFactory.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/base/image/DesktopBitmapDecoderFactory.kt new file mode 100644 index 00000000..0f5b891d --- /dev/null +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/base/image/DesktopBitmapDecoderFactory.kt @@ -0,0 +1,14 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ui.base.image + +import ca.gosyer.jui.uicore.vm.ContextWrapper +import com.seiko.imageloader.component.decoder.Decoder +import com.seiko.imageloader.component.decoder.SkiaImageDecoder + +actual class BitmapDecoderFactory actual constructor(contextWrapper: ContextWrapper) + : Decoder.Factory by SkiaImageDecoder.Factory() \ No newline at end of file diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/base/image/DesktopImageLoaderBuilder.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/base/image/DesktopImageLoaderBuilder.kt index 8e2717d0..7f0ecbec 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/base/image/DesktopImageLoaderBuilder.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/base/image/DesktopImageLoaderBuilder.kt @@ -18,9 +18,9 @@ actual fun imageLoaderBuilder(contextWrapper: ContextWrapper): ImageLoaderBuilde return ImageLoaderBuilder() } -actual fun diskCache(contextWrapper: ContextWrapper): DiskCache { +actual fun diskCache(contextWrapper: ContextWrapper, cacheDir: String): DiskCache { return DiskCacheBuilder() - .directory(userDataDir / "image_cache") + .directory(userDataDir / cacheDir) .maxSizeBytes(1024 * 1024 * 150) // 150 MB .build() }