Add chapter cache

This commit is contained in:
Syer10
2022-08-27 12:23:24 -04:00
parent 8cb8a86052
commit 3c0313b7fb
16 changed files with 290 additions and 25 deletions

View File

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

View File

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

View File

@@ -296,6 +296,9 @@
<!-- Advanced Settings -->
<string name="update_checker">Check for updates</string>
<string name="clear_image_cache">Clear image cache</string>
<string name="clear_chapter_cache">Clear chapter cache</string>
<string name="clear_cache_sub">Used: %1$s</string>
<!-- Android notifications -->
<string name="group_downloader">Downloader</string>

View File

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

View File

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

View File

@@ -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<ProvidedValue<out Any>>
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(

View File

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

View File

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

View File

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

View File

@@ -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<ImageBitmap?>,
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<ReaderPage.ImageDecodeState?>(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) }
}
}
}

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Immutable
data class ReaderPage(
val index: Int,
val bitmap: MutableStateFlow<StableHolder<ImageBitmap?>>,
val bitmap: MutableStateFlow<StableHolder<(suspend () -> ImageDecodeState)?>>,
val progress: MutableStateFlow<Float>,
val status: MutableStateFlow<Status>,
val error: MutableStateFlow<String?>,
@@ -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()
}
}

View File

@@ -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<Boolean>
updatesEnabled: PreferenceMutableStateFlow<Boolean>,
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),

View File

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

View File

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