From 5913bc45ea1bdb13ae1d223bda1ef0cb91e4346f Mon Sep 17 00:00:00 2001 From: Syer10 Date: Tue, 27 Apr 2021 22:55:53 -0400 Subject: [PATCH] Implement simple reader, with long strip and pager support --- .../core/prefs/PreferenceStoreProvider.kt | 12 +- src/main/kotlin/ca/gosyer/data/DataModule.kt | 5 + .../data/reader/ReaderModePreferences.kt | 28 ++++ .../gosyer/data/reader/ReaderPreferences.kt | 31 ++++ .../ca/gosyer/data/reader/model/Direction.kt | 17 ++ .../gosyer/ui/base/components/ErrorScreen.kt | 4 +- .../ui/base/components/LoadingScreen.kt | 5 +- .../kotlin/ca/gosyer/ui/manga/MangaMenu.kt | 20 +-- .../kotlin/ca/gosyer/ui/reader/ReaderMenu.kt | 142 +++++++++++++++++ .../gosyer/ui/reader/ReaderMenuViewModel.kt | 148 ++++++++++++++++++ .../kotlin/ca/gosyer/util/compose/Theme.kt | 4 +- 11 files changed, 393 insertions(+), 23 deletions(-) create mode 100644 src/main/kotlin/ca/gosyer/data/reader/ReaderModePreferences.kt create mode 100644 src/main/kotlin/ca/gosyer/data/reader/ReaderPreferences.kt create mode 100644 src/main/kotlin/ca/gosyer/data/reader/model/Direction.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt diff --git a/src/main/kotlin/ca/gosyer/core/prefs/PreferenceStoreProvider.kt b/src/main/kotlin/ca/gosyer/core/prefs/PreferenceStoreProvider.kt index 04256b39..a90b5088 100644 --- a/src/main/kotlin/ca/gosyer/core/prefs/PreferenceStoreProvider.kt +++ b/src/main/kotlin/ca/gosyer/core/prefs/PreferenceStoreProvider.kt @@ -12,13 +12,11 @@ import java.util.prefs.Preferences class PreferenceStoreFactory { - fun create(name: String? = null): PreferenceStore { - val userPreferences: Preferences = Preferences.userRoot() - val jvmPreferences = if (!name.isNullOrBlank()) { - JvmPreferencesSettings(userPreferences.node(name)) - } else { - JvmPreferencesSettings(userPreferences) + fun create(vararg names: String): PreferenceStore { + var preferences: Preferences = Preferences.userRoot() + names.forEach { + preferences = preferences.node(it) } - return JvmPreferenceStore(jvmPreferences) + return JvmPreferenceStore(JvmPreferencesSettings(preferences)) } } diff --git a/src/main/kotlin/ca/gosyer/data/DataModule.kt b/src/main/kotlin/ca/gosyer/data/DataModule.kt index 6b5ec00d..912d2382 100644 --- a/src/main/kotlin/ca/gosyer/data/DataModule.kt +++ b/src/main/kotlin/ca/gosyer/data/DataModule.kt @@ -10,6 +10,7 @@ import ca.gosyer.core.prefs.PreferenceStoreFactory import ca.gosyer.data.catalog.CatalogPreferences import ca.gosyer.data.extension.ExtensionPreferences import ca.gosyer.data.library.LibraryPreferences +import ca.gosyer.data.reader.ReaderPreferences import ca.gosyer.data.server.Http import ca.gosyer.data.server.HttpProvider import ca.gosyer.data.server.ServerPreferences @@ -44,6 +45,10 @@ val DataModule = module { .toProviderInstance { LibraryPreferences(preferenceFactory.create("library")) } .providesSingleton() + bind() + .toProviderInstance { ReaderPreferences(preferenceFactory.create("reader")) { name -> preferenceFactory.create("reader", name) } } + .providesSingleton() + bind() .toProviderInstance { UiPreferences(preferenceFactory.create("ui")) } .providesSingleton() diff --git a/src/main/kotlin/ca/gosyer/data/reader/ReaderModePreferences.kt b/src/main/kotlin/ca/gosyer/data/reader/ReaderModePreferences.kt new file mode 100644 index 00000000..31323730 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/reader/ReaderModePreferences.kt @@ -0,0 +1,28 @@ +/* + * 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.data.reader + +import ca.gosyer.common.prefs.Preference +import ca.gosyer.common.prefs.PreferenceStore +import ca.gosyer.data.reader.model.Direction + +class ReaderModePreferences(private val preferenceStore: PreferenceStore) { + constructor(mode: String, factory: (String) -> PreferenceStore) : + this(factory(mode)) + + fun continuous(): Preference { + return preferenceStore.getBoolean("continuous") + } + + fun direction(): Preference { + return preferenceStore.getJsonObject("direction", Direction.Down, Direction.serializer()) + } + + fun padding(): Preference { + return preferenceStore.getFloat("padding") + } +} diff --git a/src/main/kotlin/ca/gosyer/data/reader/ReaderPreferences.kt b/src/main/kotlin/ca/gosyer/data/reader/ReaderPreferences.kt new file mode 100644 index 00000000..428a1164 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/reader/ReaderPreferences.kt @@ -0,0 +1,31 @@ +/* + * 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.data.reader + +import ca.gosyer.common.prefs.Preference +import ca.gosyer.common.prefs.PreferenceStore +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer + +class ReaderPreferences(private val preferenceStore: PreferenceStore, val factory: (String) -> PreferenceStore) { + + fun modes(): Preference> { + return preferenceStore.getJsonObject( + "modes", + listOf("RTL", "LTR", "Vertical", "Continues Vertical", "Long Strip"), + ListSerializer(String.serializer()) + ) + } + + fun mode(): Preference { + return preferenceStore.getString("mode", "RTL") + } + + fun getMode(mode: String): ReaderModePreferences { + return ReaderModePreferences(mode, factory) + } +} diff --git a/src/main/kotlin/ca/gosyer/data/reader/model/Direction.kt b/src/main/kotlin/ca/gosyer/data/reader/model/Direction.kt new file mode 100644 index 00000000..b825880f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/reader/model/Direction.kt @@ -0,0 +1,17 @@ +/* + * 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.data.reader.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class Direction { + Up, + Down, + Left, + Right +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt b/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt index f69af1f2..e396c25f 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.unit.sp import kotlin.random.Random @Composable -fun ErrorScreen(errorMessage: String? = null, retry: (() -> Unit)? = null) { - Surface { +fun ErrorScreen(errorMessage: String? = null, modifier: Modifier = Modifier, retry: (() -> Unit)? = null) { + Surface(modifier) { Box(Modifier.fillMaxSize()) { Column(modifier = Modifier.align(Alignment.Center)) { val errorFace = remember { getRandomErrorFace() } diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/LoadingScreen.kt b/src/main/kotlin/ca/gosyer/ui/base/components/LoadingScreen.kt index 5ed708b5..853b8543 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/components/LoadingScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/components/LoadingScreen.kt @@ -21,7 +21,8 @@ import androidx.compose.ui.unit.min fun LoadingScreen( isLoading: Boolean = true, modifier: Modifier = Modifier.fillMaxSize(), - errorMessage: String? = null + errorMessage: String? = null, + retry: (() -> Unit)? = null ) { Surface(modifier) { BoxWithConstraints { @@ -31,7 +32,7 @@ fun LoadingScreen( } CircularProgressIndicator(Modifier.align(Alignment.Center).size(size)) } else { - ErrorScreen(errorMessage) + ErrorScreen(errorMessage, modifier, retry) } } } diff --git a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt index 4d64357f..4f3a17f7 100644 --- a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt @@ -8,6 +8,7 @@ package ca.gosyer.ui.manga import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -33,7 +34,6 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -47,6 +47,7 @@ import ca.gosyer.ui.base.components.Toolbar import ca.gosyer.ui.base.components.mangaAspectRatio import ca.gosyer.ui.base.vm.viewModel import ca.gosyer.ui.main.Route +import ca.gosyer.ui.reader.openReaderMenu import ca.gosyer.util.compose.ThemedWindow import com.github.zsoltk.compose.router.BackStack import java.util.Date @@ -81,14 +82,13 @@ fun MangaMenu(mangaId: Long, backStack: BackStack? = null) { manga?.let { manga -> Box { val state = rememberLazyListState() - val items = remember(manga, chapters) { - listOf(MangaMenu.MangaMenuManga(manga)) + chapters.map { MangaMenu.MangaMenuChapter(it) } - } LazyColumn(state = state) { - items(items) { - when (it) { - is MangaMenu.MangaMenuManga -> MangaItem(it.manga, serverUrl) - is MangaMenu.MangaMenuChapter -> ChapterItem(it.chapter, dateFormat::format) + item { + MangaItem(manga, serverUrl) + } + items(chapters) { chapter -> + ChapterItem(chapter, dateFormat::format) { + openReaderMenu(it, manga.id) } } } @@ -178,8 +178,8 @@ private fun MangaInfo(manga: Manga, modifier: Modifier = Modifier) { } @Composable -fun ChapterItem(chapter: Chapter, format: (Date) -> String) { - Surface(modifier = Modifier.fillMaxWidth().height(70.dp).padding(4.dp), elevation = 1.dp) { +fun ChapterItem(chapter: Chapter, format: (Date) -> String, onClick: (Int) -> Unit) { + Surface(modifier = Modifier.fillMaxWidth().clickable { onClick(chapter.chapterIndex) }.height(70.dp).padding(4.dp), elevation = 1.dp) { Column(Modifier.padding(4.dp)) { Text(chapter.name, fontSize = 20.sp, maxLines = 1) val description = mutableListOf() diff --git a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt new file mode 100644 index 00000000..3eea49e9 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt @@ -0,0 +1,142 @@ +/* + * 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.ui.reader + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import ca.gosyer.data.models.Chapter +import ca.gosyer.ui.base.components.ErrorScreen +import ca.gosyer.ui.base.components.LoadingScreen +import ca.gosyer.ui.base.components.Pager +import ca.gosyer.ui.base.components.PagerState +import ca.gosyer.ui.base.components.mangaAspectRatio +import ca.gosyer.ui.base.vm.viewModel +import ca.gosyer.util.compose.ThemedWindow + +fun openReaderMenu(chapterIndex: Int, mangaId: Long) { + ThemedWindow("TachideskJUI - Reader") { + ReaderMenu(chapterIndex, mangaId) + } +} + +@Composable +fun ReaderMenu(chapterIndex: Int, mangaId: Long) { + val vm = viewModel { + ReaderMenuViewModel.Params(chapterIndex, mangaId) + } + val isLoading by vm.isLoading.collectAsState() + val chapter by vm.chapter.collectAsState() + val pages by vm.pages.collectAsState() + val continuous by vm.readerModeSettings.continuous.collectAsState() + val currentPage by vm.currentPage.collectAsState() + + Surface { + if (!isLoading && chapter != null) { + chapter?.let { chapter -> + val pageModifier = Modifier.fillMaxWidth().aspectRatio(mangaAspectRatio) + if (pages.isNotEmpty()) { + if (continuous) { + ContinuesReader(vm, pages, pageModifier) + } else { + PagerReader(vm, chapter, currentPage, pages, pageModifier) + } + } else { + ErrorScreen("No pages found") + } + } + } else { + LoadingScreen(isLoading) + } + } +} + +@Composable +fun ReaderImage( + imageIndex: Int, + drawable: ImageBitmap?, + loading: Boolean, + error: String?, + imageModifier: Modifier = Modifier.fillMaxSize(), + loadingModifier: Modifier = imageModifier, + contentScale: ContentScale = ContentScale.Fit, + onImage: (Int) -> Unit, + retry: (Int) -> Unit +) { + SideEffect { + onImage(imageIndex) + } + + if (drawable != null) { + Image( + drawable, + modifier = imageModifier, + contentDescription = null, + contentScale = contentScale + ) + } else { + LoadingScreen(loading, loadingModifier, error) { retry(imageIndex) } + } +} + +@Composable +fun PagerReader(readerVM: ReaderMenuViewModel, chapter: Chapter, currentPage: Int, pages: List, pageModifier: Modifier) { + val state = remember(chapter.pageCount!!, currentPage) { + PagerState( + currentPage = currentPage, + minPage = 1, + maxPage = chapter.pageCount + ) + } + LaunchedEffect(state.currentPage) { + if (state.currentPage != currentPage) { + readerVM.progress(state.currentPage) + } + } + Pager(state) { + val image = pages[this.currentPage - 1] + ReaderImage( + image.index, + image.bitmap.collectAsState().value, + image.loading.collectAsState().value, + image.error.collectAsState().value, + loadingModifier = pageModifier, + onImage = readerVM::progress, + retry = readerVM::retry + ) + } +} + +@Composable +fun ContinuesReader(readerVM: ReaderMenuViewModel, pages: List, pageModifier: Modifier) { + LazyColumn { + items(pages) { image -> + ReaderImage( + image.index, + image.bitmap.collectAsState().value, + image.loading.collectAsState().value, + image.error.collectAsState().value, + loadingModifier = pageModifier, + onImage = readerVM::progress, + retry = readerVM::retry + ) + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt new file mode 100644 index 00000000..aa2ab02f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt @@ -0,0 +1,148 @@ +/* + * 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.ui.reader + +import androidx.compose.ui.graphics.ImageBitmap +import ca.gosyer.data.models.Chapter +import ca.gosyer.data.reader.ReaderModePreferences +import ca.gosyer.data.reader.ReaderPreferences +import ca.gosyer.data.server.interactions.ChapterInteractionHandler +import ca.gosyer.ui.base.vm.ViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import javax.inject.Inject + +@OptIn(ExperimentalStdlibApi::class) +class ReaderMenuViewModel @Inject constructor( + params: Params, + readerPreferences: ReaderPreferences, + chapterHandler: ChapterInteractionHandler +) : ViewModel() { + private val _chapter = MutableStateFlow(null) + val chapter = _chapter.asStateFlow() + + private val _isLoading = MutableStateFlow(true) + val isLoading = _isLoading.asStateFlow() + + private val _pages = MutableStateFlow(emptyList()) + val pages = _pages.asStateFlow() + + private val _currentPage = MutableStateFlow(1) + val currentPage = _currentPage.asStateFlow() + + val readerModeSettings = ReaderModeWatch(readerPreferences, scope) + + init { + scope.launch(Dispatchers.Default) { + val chapter: Chapter + _chapter.value = chapterHandler.getChapter(params.mangaId, params.chapterIndex).also { chapter = it } + _isLoading.value = false + + val pageRange = 1..(chapter.pageCount ?: 1) + _pages.value = listOf( + *pageRange.map { + ReaderImage( + it, + MutableStateFlow(null), + MutableStateFlow(true), + MutableStateFlow(null) + ) + }.toTypedArray() + ) + val semaphore = Semaphore(3) + pageRange.map { + async { + semaphore.withPermit { + val page = _pages.value[it - 1] + try { + page.bitmap.value = chapterHandler.getPage(chapter, it) + page.loading.value = false + page.error.value = null + } catch (e: Exception) { + page.bitmap.value = null + page.loading.value = false + page.error.value = e.message + } + } + } + }.awaitAll() + } + } + + fun progress(index: Int) { + _currentPage.value = index + } + + fun retry(index: Int) { + } + + data class Params(val chapterIndex: Int, val mangaId: Long) +} + +data class ReaderImage( + val index: Int, + val bitmap: MutableStateFlow, + val loading: MutableStateFlow, + val error: MutableStateFlow +) + +class ReaderModeWatch( + private val readerPreferences: ReaderPreferences, + private val scope: CoroutineScope, + initialPreferences: ReaderModePreferences = readerPreferences.getMode( + readerPreferences.mode().get() + ) +) { + private val preferenceJobs = mutableListOf() + val direction = MutableStateFlow(initialPreferences.direction().get()) + val continuous = MutableStateFlow(initialPreferences.continuous().get()) + val padding = MutableStateFlow(initialPreferences.padding().get()) + + val mode = readerPreferences.mode().stateIn(scope) + + init { + setupJobs(mode.value) + mode + .onEach { mode -> + setupJobs(mode) + } + .launchIn(scope) + } + + private fun setupJobs(mode: String) { + preferenceJobs.forEach { + it.cancel() + } + preferenceJobs.clear() + val preferences = readerPreferences.getMode(mode) + preferenceJobs += preferences.direction().changes() + .onEach { + direction.value = it + } + .launchIn(scope) + preferenceJobs += preferences.continuous().changes() + .onEach { + continuous.value = it + } + .launchIn(scope) + preferenceJobs += preferences.padding().changes() + .onEach { + padding.value = it + } + .launchIn(scope) + } +} diff --git a/src/main/kotlin/ca/gosyer/util/compose/Theme.kt b/src/main/kotlin/ca/gosyer/util/compose/Theme.kt index 36895c8b..195db0e2 100644 --- a/src/main/kotlin/ca/gosyer/util/compose/Theme.kt +++ b/src/main/kotlin/ca/gosyer/util/compose/Theme.kt @@ -6,13 +6,13 @@ package ca.gosyer.util.compose -import androidx.compose.desktop.DesktopMaterialTheme import androidx.compose.desktop.Window import androidx.compose.desktop.WindowEvents import androidx.compose.runtime.Composable import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.MenuBar +import ca.gosyer.ui.base.theme.AppTheme import java.awt.image.BufferedImage fun ThemedWindow( @@ -29,7 +29,7 @@ fun ThemedWindow( content: @Composable () -> Unit = { } ) { Window(title, size, location, centered, icon, menuBar, undecorated, resizable, events, onDismissRequest) { - DesktopMaterialTheme { + AppTheme { content() } }