diff --git a/src/main/kotlin/ca/gosyer/data/models/Manga.kt b/src/main/kotlin/ca/gosyer/data/models/Manga.kt index 78d5b00a..e3794cec 100644 --- a/src/main/kotlin/ca/gosyer/data/models/Manga.kt +++ b/src/main/kotlin/ca/gosyer/data/models/Manga.kt @@ -6,6 +6,7 @@ package ca.gosyer.data.models +import ca.gosyer.data.server.interactions.MangaInteractionHandler import kotlinx.serialization.Serializable @Serializable @@ -29,9 +30,22 @@ data class Manga( val inLibraryAt: Long, val unreadCount: Int?, val downloadCount: Int? -) +) { + suspend fun updateRemote( + mangaHandler: MangaInteractionHandler, + readerMode: String = meta.juiReaderMode + ) { + if (readerMode != meta.juiReaderMode) { + mangaHandler.updateMangaMeta(this, "juiReaderMode", readerMode) + } + } +} @Serializable data class MangaMeta( - val jui: Int? = null -) + val juiReaderMode: String = DEFAULT_READER_MODE +) { + companion object { + const val DEFAULT_READER_MODE = "default" + } +} diff --git a/src/main/kotlin/ca/gosyer/data/reader/ReaderModeWatch.kt b/src/main/kotlin/ca/gosyer/data/reader/ReaderModeWatch.kt index dc44b9cb..d513c883 100644 --- a/src/main/kotlin/ca/gosyer/data/reader/ReaderModeWatch.kt +++ b/src/main/kotlin/ca/gosyer/data/reader/ReaderModeWatch.kt @@ -10,14 +10,16 @@ import ca.gosyer.util.system.getAsFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.mapLatest class ReaderModeWatch( private val readerPreferences: ReaderPreferences, private val scope: CoroutineScope, + private val mode: StateFlow = readerPreferences.mode().stateIn(scope), initialPreferences: ReaderModePreferences = readerPreferences.getMode( - readerPreferences.mode().get() + mode.value ) ) { private val preferenceJobs = mutableListOf() @@ -29,12 +31,10 @@ class ReaderModeWatch( val maxSize = MutableStateFlow(initialPreferences.maxSize().get()) val navigationMode = MutableStateFlow(initialPreferences.navigationMode().get()) - private val mode = readerPreferences.mode().stateIn(scope) - init { setupJobs(mode.value) mode - .onEach { mode -> + .mapLatest { mode -> setupJobs(mode) } .launchIn(scope) diff --git a/src/main/kotlin/ca/gosyer/ui/reader/ChapterLoader.kt b/src/main/kotlin/ca/gosyer/ui/reader/ChapterLoader.kt index 0a081129..c6107f2d 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/ChapterLoader.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/ChapterLoader.kt @@ -12,16 +12,13 @@ import ca.gosyer.ui.reader.loader.TachideskPageLoader import ca.gosyer.ui.reader.model.ReaderChapter import ca.gosyer.ui.reader.model.ReaderPage import ca.gosyer.util.system.CKLogger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take -import kotlin.coroutines.CoroutineContext class ChapterLoader( - val context: CoroutineContext, private val readerPreferences: ReaderPreferences, private val chapterHandler: ChapterInteractionHandler ) { @@ -32,7 +29,7 @@ class ChapterLoader( chapter.state = ReaderChapter.State.Loading debug { "Loading pages for ${chapter.chapter.name}" } - val loader = TachideskPageLoader(context + Dispatchers.Default, chapter, readerPreferences, chapterHandler) + val loader = TachideskPageLoader(chapter, readerPreferences, chapterHandler) val pages = loader.getPages() diff --git a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt index b78ee1c3..65376838 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt @@ -6,7 +6,13 @@ package ca.gosyer.ui.reader +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,8 +21,13 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -139,6 +150,8 @@ fun ReaderMenu( val chapter by vm.chapter.collectAsState() val nextChapter by vm.nextChapter.collectAsState() val pages by vm.pages.collectAsState() + val readerModes by vm.readerModes.collectAsState() + val readerMode by vm.readerMode.collectAsState() val continuous by vm.readerModeSettings.continuous.collectAsState() val direction by vm.readerModeSettings.direction.collectAsState() val padding by vm.readerModeSettings.padding.collectAsState() @@ -177,16 +190,49 @@ fun ReaderMenu( Surface { Crossfade(state to chapter) { (state, chapter) -> if (state is ReaderChapter.State.Loaded && chapter != null) { - Box( - Modifier.fillMaxSize() - .navigationClickable(navigationMode.toNavigation()) { - vm.navigate(it) + if (pages.isNotEmpty()) { + var sideMenuOpen by remember { mutableStateOf(true) } + val sideMenuSize by animateDpAsState( + targetValue = if (sideMenuOpen) { + 260.dp + } else { + 0.dp } - ) { + ) + val loadingModifier = Modifier.fillMaxWidth().aspectRatio(mangaAspectRatio) - if (pages.isNotEmpty()) { + AnimatedVisibility( + sideMenuOpen, + enter = fadeIn() + slideInHorizontally(), + exit = fadeOut() + slideOutHorizontally() + ) { + ReaderSideMenu( + chapter = chapter, + currentPage = currentPage, + readerModes = readerModes, + selectedMode = readerMode, + onNewPageClicked = vm::navigate, + onCloseSideMenuClicked = { + sideMenuOpen = false + }, + onSetReaderMode = vm::setMangaReaderMode, + onPrevChapterClicked = vm::prevChapter, + onNextChapterClicked = vm::nextChapter + ) + } + + Box( + Modifier.padding(start = sideMenuSize).fillMaxSize() + ) { + val readerModifier = Modifier + .navigationClickable( + navigation = navigationMode.toNavigation(), + onClick = vm::navigate + ) + if (continuous) { ContinuousReader( + readerModifier, pages, direction, maxSize, @@ -213,6 +259,7 @@ fun ReaderMenu( ) } else { PagerReader( + readerModifier, direction, currentPage, pages, @@ -226,9 +273,10 @@ fun ReaderMenu( vm::progress ) } - } else { - ErrorScreen(stringResource("no_pages_found")) + SideMenuButton(sideMenuOpen, onOpenSideMenuClicked = { sideMenuOpen = true }) } + } else { + ErrorScreen(stringResource("no_pages_found")) } } else { LoadingScreen( @@ -241,6 +289,19 @@ fun ReaderMenu( } } +@Composable +fun SideMenuButton(sideMenuOpen: Boolean, onOpenSideMenuClicked: () -> Unit) { + AnimatedVisibility( + !sideMenuOpen, + enter = fadeIn() + slideInHorizontally(), + exit = fadeOut() + slideOutHorizontally() + ) { + IconButton(onOpenSideMenuClicked) { + Icon(Icons.Rounded.ChevronRight, null) + } + } +} + @Composable fun ReaderImage( imageIndex: Int, diff --git a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt index 01c996b6..04fc6ffe 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt @@ -6,34 +6,48 @@ package ca.gosyer.ui.reader +import ca.gosyer.data.models.Chapter +import ca.gosyer.data.models.Manga +import ca.gosyer.data.models.MangaMeta import ca.gosyer.data.reader.ReaderModeWatch import ca.gosyer.data.reader.ReaderPreferences import ca.gosyer.data.reader.model.Direction import ca.gosyer.data.server.interactions.ChapterInteractionHandler +import ca.gosyer.data.server.interactions.MangaInteractionHandler +import ca.gosyer.ui.base.prefs.asStateIn import ca.gosyer.ui.base.vm.ViewModel import ca.gosyer.ui.reader.model.MoveTo import ca.gosyer.ui.reader.model.Navigation +import ca.gosyer.ui.reader.model.PageMove import ca.gosyer.ui.reader.model.ReaderChapter import ca.gosyer.ui.reader.model.ReaderPage import ca.gosyer.ui.reader.model.ViewerChapters import ca.gosyer.util.lang.throwIfCancellation +import ca.gosyer.util.system.CKLogger +import ca.gosyer.util.system.getAsFlow import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject class ReaderMenuViewModel @Inject constructor( private val params: Params, private val readerPreferences: ReaderPreferences, + private val mangaHandler: MangaInteractionHandler, private val chapterHandler: ChapterInteractionHandler ) : ViewModel() { + private val _manga = MutableStateFlow(null) private val viewerChapters = ViewerChapters( MutableStateFlow(null), MutableStateFlow(null), @@ -55,12 +69,25 @@ class ReaderMenuViewModel @Inject constructor( private val _currentPageOffset = MutableStateFlow(1) val currentPageOffset = _currentPageOffset.asStateFlow() - private val _pageEmitter = MutableSharedFlow>() + private val _pageEmitter = MutableSharedFlow() val pageEmitter = _pageEmitter.asSharedFlow() - val readerModeSettings = ReaderModeWatch(readerPreferences, scope) + val readerModes = readerPreferences.modes().asStateIn(scope) + val readerMode = combine(merge(readerPreferences.mode().getAsFlow()), _manga) { mode, manga -> + if ( + manga != null && + manga.meta.juiReaderMode != MangaMeta.DEFAULT_READER_MODE && + manga.meta.juiReaderMode in readerModes.value + ) { + manga.meta.juiReaderMode + } else { + mode + } + }.stateIn(scope, SharingStarted.Eagerly, readerPreferences.mode().get()) - private val loader = ChapterLoader(scope.coroutineContext, readerPreferences, chapterHandler) + val readerModeSettings = ReaderModeWatch(readerPreferences, scope, readerMode) + + private val loader = ChapterLoader(readerPreferences, chapterHandler) init { init() @@ -68,7 +95,8 @@ class ReaderMenuViewModel @Inject constructor( fun init() { scope.launch(Dispatchers.Default) { - init(params.mangaId, params.chapterIndex) + initManga(params.mangaId) + initChapters(params.mangaId, params.chapterIndex) } } @@ -88,31 +116,81 @@ class ReaderMenuViewModel @Inject constructor( } } if (moveTo != null) { - moveDirection(moveTo) + _pageEmitter.emit(PageMove.Direction(moveTo, currentPage.value)) } } } - private suspend fun moveDirection(direction: MoveTo) { - _pageEmitter.emit(direction to currentPage.value) + fun navigate(page: Int) { + info { "Navigate to $page" } + scope.launch { + _pageEmitter.emit(PageMove.Page(page)) + } } fun progress(index: Int) { + info { "Progressed to $index" } _currentPage.value = index } fun retry(page: ReaderPage) { + info { "Retrying $page" } chapter.value?.pageLoader?.retryPage(page) } private fun resetValues() { + viewerChapters.recycle() _pages.value = emptyList() _currentPage.value = 1 - _state.value = ReaderChapter.State.Wait - viewerChapters.recycle() } - suspend fun init(mangaId: Long, chapterIndex: Int) { + fun setMangaReaderMode(mode: String) { + scope.launch(Dispatchers.Default) { + _manga.value?.updateRemote( + mangaHandler, + mode + ) + initManga(params.mangaId) + } + } + + fun prevChapter() { + scope.launch(Dispatchers.Default) { + val prevChapter = previousChapter.value ?: return@launch + try { + _state.value = ReaderChapter.State.Wait + sendProgress() + initChapters(params.mangaId, prevChapter.chapter.index) + } catch (e: Exception) { + info(e) { "Error loading prev chapter" } + } + } + } + + fun nextChapter() { + scope.launch(Dispatchers.Default) { + val nextChapter = nextChapter.value ?: return@launch + try { + _state.value = ReaderChapter.State.Wait + sendProgress() + initChapters(params.mangaId, nextChapter.chapter.index) + } catch (e: Exception) { + info(e) { "Error loading next chapter" } + } + } + } + + private suspend fun initManga(mangaId: Long) { + try { + _manga.value = mangaHandler.getManga(mangaId) + } catch (e: Exception) { + e.throwIfCancellation() + _state.value = ReaderChapter.State.Error(e) + throw e + } + } + + private suspend fun initChapters(mangaId: Long, chapterIndex: Int) { resetValues() val chapter = ReaderChapter( try { @@ -147,12 +225,12 @@ class ReaderMenuViewModel @Inject constructor( } val lastPageRead = chapter.chapter.lastPageRead if (lastPageRead != 0) { - _currentPage.value = lastPageRead + _currentPage.value = lastPageRead.coerceAtMost(chapter.chapter.pageCount!!) } val lastPageReadOffset = chapter.chapter.meta.juiPageOffset if (lastPageReadOffset != 0) { - _currentPage.value = lastPageReadOffset + _currentPageOffset.value = lastPageReadOffset } chapter.stateObserver @@ -162,17 +240,16 @@ class ReaderMenuViewModel @Inject constructor( .launchIn(chapter.scope) pages .onEach { pageList -> - pageList.forEach { it.chapter = chapter } _pages.value = pageList + pageList.getOrNull(_currentPage.value - 1)?.let { chapter.pageLoader?.loadPage(it) } } .launchIn(chapter.scope) _currentPage .onEach { index -> + pages.value.getOrNull(_currentPage.value - 1)?.let { chapter.pageLoader?.loadPage(it) } if (index == pages.value.size) { markChapterRead(mangaId, chapter) - } else { - pages.value.getOrNull(index - 1)?.let { chapter.pageLoader?.loadPage(it) } } } .launchIn(chapter.scope) @@ -183,18 +260,22 @@ class ReaderMenuViewModel @Inject constructor( } @OptIn(DelicateCoroutinesApi::class) - fun sendProgress() { - val chapter = chapter.value?.chapter ?: return + fun sendProgress(chapter: Chapter? = this.chapter.value?.chapter, lastPageRead: Int = currentPage.value) { + chapter ?: return if (chapter.read) return GlobalScope.launch { - chapterHandler.updateChapter(chapter.mangaId, chapter.index, lastPageRead = currentPage.value) + chapterHandler.updateChapter(chapter.mangaId, chapter.index, lastPageRead = lastPageRead) } } - @OptIn(DelicateCoroutinesApi::class) fun updateLastPageReadOffset(offset: Int) { + updateLastPageReadOffset(chapter.value?.chapter ?: return, offset) + } + + @OptIn(DelicateCoroutinesApi::class) + private fun updateLastPageReadOffset(chapter: Chapter, offset: Int) { GlobalScope.launch { - chapter.value?.chapter?.updateRemote(chapterHandler, offset) + chapter.updateRemote(chapterHandler, offset) } } @@ -203,4 +284,6 @@ class ReaderMenuViewModel @Inject constructor( } data class Params(val chapterIndex: Int, val mangaId: Long) + + private companion object : CKLogger({}) } diff --git a/src/main/kotlin/ca/gosyer/ui/reader/ReaderSideMenu.kt b/src/main/kotlin/ca/gosyer/ui/reader/ReaderSideMenu.kt new file mode 100644 index 00000000..df883a3e --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/ReaderSideMenu.kt @@ -0,0 +1,197 @@ +/* + * 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.animation.core.animateFloatAsState +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.OutlinedButton +import androidx.compose.material.ProgressIndicatorDefaults +import androidx.compose.material.Slider +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronLeft +import androidx.compose.material.icons.rounded.NavigateBefore +import androidx.compose.material.icons.rounded.NavigateNext +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gosyer.common.util.replace +import ca.gosyer.data.models.Chapter +import ca.gosyer.data.models.ChapterMeta +import ca.gosyer.data.models.MangaMeta +import ca.gosyer.ui.base.components.Spinner +import ca.gosyer.ui.base.resources.stringResource +import ca.gosyer.ui.reader.model.ReaderChapter +import ca.gosyer.util.lang.milliseconds +import ca.gosyer.util.system.kLogger +import kotlin.math.roundToInt + +private val logger = kLogger {} + +@Composable +fun ReaderSideMenu( + chapter: ReaderChapter, + currentPage: Int, + readerModes: List, + selectedMode: String, + onNewPageClicked: (Int) -> Unit, + onCloseSideMenuClicked: () -> Unit, + onSetReaderMode: (String) -> Unit, + onPrevChapterClicked: () -> Unit, + onNextChapterClicked: () -> Unit +) { + Surface(Modifier.fillMaxHeight().width(260.dp)) { + Column(Modifier.fillMaxSize()) { + val pageCount = chapter.chapter.pageCount!! + ReaderMenuToolbar(onCloseSideMenuClicked) + ReaderModeSetting(readerModes, selectedMode, onSetReaderMode) + ReaderProgressSlider(currentPage, pageCount, onNewPageClicked) + NavigateChapters(onPrevChapterClicked, onNextChapterClicked) + } + } +} + +@Composable +fun ReaderModeSetting(readerModes: List, selectedMode: String, onSetReaderMode: (String) -> Unit) { + val modes = remember(readerModes) { listOf(MangaMeta.DEFAULT_READER_MODE) + readerModes } + val defaultModeString = stringResource("default_reader_mode") + val displayModes = remember(modes, defaultModeString) { modes.replace(0, defaultModeString) } + val selectedModeIndex = remember(modes, selectedMode) { modes.indexOf(selectedMode) } + Row( + Modifier.fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource("reader_mode"), Modifier.weight(0.25f), maxLines = 2, fontSize = 14.sp) + Spacer(Modifier.width(8.dp)) + Spinner( + modifier = Modifier.weight(0.75f), + items = displayModes, + selectedItemIndex = selectedModeIndex + ) { + onSetReaderMode(modes[it]) + } + } +} + +@Composable +private fun ReaderMenuToolbar(onCloseSideMenuClicked: () -> Unit) { + Surface(elevation = 2.dp) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + IconButton(onCloseSideMenuClicked) { + Icon(Icons.Rounded.ChevronLeft, null) + } + } + } +} + +@Composable +private fun ReaderProgressSlider( + currentPage: Int, + pageCount: Int, + onNewPageClicked: (Int) -> Unit, +) { + val animatedProgress by animateFloatAsState( + targetValue = currentPage.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec + ) + var isValueChanging by remember { mutableStateOf(false) } + Row { + Slider( + animatedProgress, + onValueChange = { + if (!isValueChanging) { + isValueChanging = true + onNewPageClicked(it.roundToInt()) + } + }, + valueRange = 0F..pageCount.toFloat(), + steps = pageCount, + onValueChangeFinished = { isValueChanging = false } + ) + } +} + +@Composable +private fun NavigateChapters(loadPrevChapter: () -> Unit, loadNextChapter: () -> Unit) { + Divider(Modifier.padding(horizontal = 4.dp, vertical = 8.dp)) + Row(horizontalArrangement = Arrangement.SpaceBetween,) { + OutlinedButton(loadPrevChapter, Modifier.weight(0.5F)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val nextChapter = stringResource("nav_prev_chapter") + Icon(Icons.Rounded.NavigateBefore, nextChapter) + Text(nextChapter, fontSize = 10.sp) + } + } + OutlinedButton(loadNextChapter, Modifier.weight(0.5F)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val nextChapter = stringResource("nav_next_chapter") + Text(nextChapter, fontSize = 10.sp) + Icon(Icons.Rounded.NavigateNext, nextChapter) + } + } + } +} + +@Preview +@Composable +private fun ReaderSideMenuPreview() { + ReaderSideMenu( + chapter = remember { + ReaderChapter( + Chapter( + url = "", + name = "Test Chapter", + uploadDate = System.currentTimeMillis(), + chapterNumber = 15.5F, + scanlator = "No Group", + mangaId = 100L, + read = false, + bookmarked = false, + lastPageRead = 11, + index = 10, + fetchedAt = System.currentTimeMillis(), + chapterCount = null, + pageCount = 20, + lastReadAt = System.currentTimeMillis().milliseconds.inWholeSeconds.toInt(), + downloaded = false, + ChapterMeta() + ) + ) + }, + currentPage = 11, + readerModes = listOf("Vertical"), + selectedMode = "Vertical", + onNewPageClicked = {}, + onCloseSideMenuClicked = {}, + onSetReaderMode = {}, + onPrevChapterClicked = {}, + onNextChapterClicked = {} + ) +} diff --git a/src/main/kotlin/ca/gosyer/ui/reader/loader/TachideskPageLoader.kt b/src/main/kotlin/ca/gosyer/ui/reader/loader/TachideskPageLoader.kt index f124382d..7d7b6193 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/loader/TachideskPageLoader.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/loader/TachideskPageLoader.kt @@ -15,6 +15,7 @@ import ca.gosyer.util.system.CKLogger import io.github.kerubistan.kroki.coroutines.priorityChannel import io.ktor.client.features.onDownload import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow @@ -22,15 +23,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicInteger -import kotlin.coroutines.CoroutineContext class TachideskPageLoader( - context: CoroutineContext, val chapter: ReaderChapter, readerPreferences: ReaderPreferences, chapterHandler: ChapterInteractionHandler ) : PageLoader() { - val scope = CoroutineScope(SupervisorJob() + context) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) /** * A channel used to manage requests one by one while allowing priorities. @@ -107,14 +106,15 @@ class TachideskPageLoader( override fun getPages(): StateFlow> { scope.launch { if (pagesFlow.value.isNotEmpty()) return@launch - val pageRange = chapter.chapter.pageCount?.let { 0..it } ?: IntRange.EMPTY + val pageRange = chapter.chapter.pageCount?.let { 0..it.minus(1) } ?: IntRange.EMPTY pagesFlow.value = pageRange.map { ReaderPage( index = it, bitmap = MutableStateFlow(null), progress = MutableStateFlow(0.0F), status = MutableStateFlow(ReaderPage.Status.QUEUE), - error = MutableStateFlow(null) + error = MutableStateFlow(null), + chapter = chapter ) } } diff --git a/src/main/kotlin/ca/gosyer/ui/reader/model/PageMove.kt b/src/main/kotlin/ca/gosyer/ui/reader/model/PageMove.kt new file mode 100644 index 00000000..d818eae5 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/model/PageMove.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.ui.reader.model + +sealed class PageMove { + data class Direction(val moveTo: MoveTo, val currentPage: Int) : PageMove() + data class Page(val pageNumber: Int) : PageMove() +} diff --git a/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderPage.kt b/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderPage.kt index e4c28e14..53e96f6e 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderPage.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderPage.kt @@ -14,13 +14,11 @@ data class ReaderPage( val bitmap: MutableStateFlow, val progress: MutableStateFlow, val status: MutableStateFlow, - val error: MutableStateFlow + val error: MutableStateFlow, + val chapter: ReaderChapter ) { - lateinit var chapter: ReaderChapter enum class Status { QUEUE, - LOAD_PAGE, - DOWNLOAD_IMAGE, READY, ERROR } diff --git a/src/main/kotlin/ca/gosyer/ui/reader/navigation/NavigationClickable.kt b/src/main/kotlin/ca/gosyer/ui/reader/navigation/NavigationClickable.kt index 3b4efb4a..00ff2ff8 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/navigation/NavigationClickable.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/navigation/NavigationClickable.kt @@ -7,6 +7,7 @@ package ca.gosyer.ui.reader.navigation import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.getValue @@ -15,27 +16,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed -import androidx.compose.ui.input.pointer.AwaitPointerEventScope -import androidx.compose.ui.input.pointer.PointerEvent -import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import ca.gosyer.ui.base.components.LocalComposeWindow +import androidx.compose.ui.unit.toSize import ca.gosyer.ui.reader.model.Navigation -import java.awt.event.MouseEvent - -private suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent { - var event: PointerEvent - do { - event = awaitPointerEvent() - } while ( - !event.changes.all { it.changedToDown() } - ) - return event -} +import ca.gosyer.util.compose.contains fun Modifier.navigationClickable( navigation: ViewerNavigation, @@ -72,19 +61,23 @@ fun Modifier.navigationClickable( properties["interactionSource"] = interactionSource } ) { - var lastEvent by remember { mutableStateOf(null) } - val window = LocalComposeWindow.current + var offsetEvent by remember { mutableStateOf(null) } + var layoutSize by remember { mutableStateOf(Size.Zero) } Modifier .clickable(interactionSource, null, enabled, onClickLabel, role) { - val savedLastEvent = lastEvent ?: return@clickable - val offset = savedLastEvent.let { IntOffset(it.x, it.y) } - onClick(navigation.getAction(offset, IntSize(window.width, window.height))) + val offset = offsetEvent ?: return@clickable + if (offset in layoutSize) { + onClick(navigation.getAction(offset, layoutSize)) + } } .pointerInput(interactionSource) { forEachGesture { awaitPointerEventScope { - lastEvent = awaitEventFirstDown().awtEvent + offsetEvent = awaitFirstDown().position } } } + .onGloballyPositioned { + layoutSize = it.size.toSize() + } } diff --git a/src/main/kotlin/ca/gosyer/ui/reader/navigation/ViewerNavigation.kt b/src/main/kotlin/ca/gosyer/ui/reader/navigation/ViewerNavigation.kt index 9e418e60..28df6fa3 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/navigation/ViewerNavigation.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/navigation/ViewerNavigation.kt @@ -6,8 +6,9 @@ package ca.gosyer.ui.reader.navigation +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize import ca.gosyer.data.reader.model.TappingInvertMode import ca.gosyer.ui.reader.model.Navigation @@ -54,9 +55,9 @@ abstract class ViewerNavigation { var invertMode: TappingInvertMode = TappingInvertMode.NONE - fun getAction(pos: IntOffset, windowSize: IntSize): Navigation { - val realX = pos.x / (windowSize.width * 0.01F) - val realY = pos.y / (windowSize.height * 0.01F) + fun getAction(pos: Offset, layoutSize: Size): Navigation { + val realX = pos.x / (layoutSize.width * 0.01F) + val realY = pos.y / (layoutSize.height * 0.01F) val realPos = IntOffset(realX.toInt(), realY.toInt()) val region = regions.map { it.invert(invertMode) } diff --git a/src/main/kotlin/ca/gosyer/ui/reader/viewer/Continuous.kt b/src/main/kotlin/ca/gosyer/ui/reader/viewer/Continuous.kt index 27b38392..a94ad373 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/viewer/Continuous.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/viewer/Continuous.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -32,6 +33,7 @@ import ca.gosyer.data.reader.model.Direction import ca.gosyer.ui.reader.ChapterSeparator import ca.gosyer.ui.reader.ReaderImage import ca.gosyer.ui.reader.model.MoveTo +import ca.gosyer.ui.reader.model.PageMove import ca.gosyer.ui.reader.model.ReaderChapter import ca.gosyer.ui.reader.model.ReaderPage import kotlinx.coroutines.flow.SharedFlow @@ -40,6 +42,7 @@ import kotlinx.coroutines.flow.mapLatest @Composable fun ContinuousReader( + modifier: Modifier, pages: List, direction: Direction, maxSize: Int, @@ -51,21 +54,33 @@ fun ContinuousReader( nextChapter: ReaderChapter?, loadingModifier: Modifier, pageContentScale: ContentScale, - pageEmitter: SharedFlow>, + pageEmitter: SharedFlow, retry: (ReaderPage) -> Unit, progress: (Int) -> Unit, updateLastPageReadOffset: (Int) -> Unit ) { - BoxWithConstraints { + BoxWithConstraints(modifier then Modifier.fillMaxSize()) { val state = rememberLazyListState(currentPage, currentPageOffset) LaunchedEffect(Unit) { pageEmitter - .mapLatest { (moveTo) -> - val by = when (moveTo) { - MoveTo.Previous -> -maxHeight - MoveTo.Next -> maxHeight + .mapLatest { pageMove -> + when (pageMove) { + is PageMove.Direction -> { + val (moveTo) = pageMove + val by = when (moveTo) { + MoveTo.Previous -> -maxHeight + MoveTo.Next -> maxHeight + } + state.animateScrollBy(by.value) + Unit + } + is PageMove.Page -> { + val (pageNumber) = pageMove + if (pageNumber in 0..pages.size) { + state.animateScrollToItem(pageNumber) + } + } } - state.animateScrollBy(by.value) } .launchIn(this) } diff --git a/src/main/kotlin/ca/gosyer/ui/reader/viewer/Pager.kt b/src/main/kotlin/ca/gosyer/ui/reader/viewer/Pager.kt index b22e43ae..7a0ab699 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/viewer/Pager.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/viewer/Pager.kt @@ -6,6 +6,7 @@ package ca.gosyer.ui.reader.viewer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -15,6 +16,7 @@ import ca.gosyer.data.reader.model.Direction import ca.gosyer.ui.reader.ChapterSeparator import ca.gosyer.ui.reader.ReaderImage import ca.gosyer.ui.reader.model.MoveTo +import ca.gosyer.ui.reader.model.PageMove import ca.gosyer.ui.reader.model.ReaderChapter import ca.gosyer.ui.reader.model.ReaderPage import com.google.accompanist.pager.HorizontalPager @@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.mapLatest @Composable fun PagerReader( + parentModifier: Modifier, direction: Direction, currentPage: Int, pages: List, @@ -34,21 +37,33 @@ fun PagerReader( nextChapter: ReaderChapter?, loadingModifier: Modifier, pageContentScale: ContentScale, - pageEmitter: SharedFlow>, + pageEmitter: SharedFlow, retry: (ReaderPage) -> Unit, progress: (Int) -> Unit ) { - val state = rememberPagerState(pages.size + 1, initialPage = currentPage) + val state = rememberPagerState(pages.size + 2, initialPage = currentPage) LaunchedEffect(Unit) { + val pageRange = 0..(pages.size + 1) pageEmitter - .mapLatest { (moveTo, currentPage) -> - val page = when (moveTo) { - MoveTo.Previous -> currentPage - 1 - MoveTo.Next -> currentPage + 1 - } - if (page in 0..pages.size) { - state.animateScrollToPage(page) + .mapLatest { pageMove -> + when (pageMove) { + is PageMove.Direction -> { + val (moveTo, currentPage) = pageMove + val page = when (moveTo) { + MoveTo.Previous -> currentPage - 1 + MoveTo.Next -> currentPage + 1 + } + if (page in pageRange) { + state.animateScrollToPage(page) + } + } + is PageMove.Page -> { + val (pageNumber) = pageMove + if (pageNumber in pageRange) { + state.animateScrollToPage(pageNumber) + } + } } } .launchIn(this) @@ -59,9 +74,10 @@ fun PagerReader( progress(state.currentPage) } } + val modifier = parentModifier then Modifier.fillMaxSize() if (direction == Direction.Down || direction == Direction.Up) { - VerticalPager(state, reverseLayout = direction == Direction.Up) { + VerticalPager(state, reverseLayout = direction == Direction.Up, modifier = modifier) { HandlePager( pages, it, @@ -74,7 +90,7 @@ fun PagerReader( ) } } else { - HorizontalPager(state, reverseLayout = direction == Direction.Left) { + HorizontalPager(state, reverseLayout = direction == Direction.Left, modifier = modifier) { HandlePager( pages, it, @@ -102,7 +118,7 @@ fun HandlePager( ) { when (page) { 0 -> ChapterSeparator(previousChapter, currentChapter) - pages.size -> ChapterSeparator(currentChapter, nextChapter) + pages.size + 1 -> ChapterSeparator(currentChapter, nextChapter) else -> { val image = pages[page - 1] ReaderImage( diff --git a/src/main/kotlin/ca/gosyer/util/compose/Offset.kt b/src/main/kotlin/ca/gosyer/util/compose/Offset.kt new file mode 100644 index 00000000..4b029245 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/compose/Offset.kt @@ -0,0 +1,20 @@ +/* + * 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.util.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize + +operator fun IntSize.contains(offset: IntOffset): Boolean { + return offset.x <= width && offset.y <= height +} + +operator fun Size.contains(offset: Offset): Boolean { + return offset.x <= width && offset.y <= height +} diff --git a/src/main/kotlin/ca/gosyer/util/system/Preference.kt b/src/main/kotlin/ca/gosyer/util/system/Preference.kt index 5c860681..caf181ab 100644 --- a/src/main/kotlin/ca/gosyer/util/system/Preference.kt +++ b/src/main/kotlin/ca/gosyer/util/system/Preference.kt @@ -12,6 +12,9 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach -fun Preference.getAsFlow(action: suspend (T) -> Unit): Flow { - return merge(flowOf(get()), changes()).onEach(action = action) +fun Preference.getAsFlow(action: (suspend (T) -> Unit)? = null): Flow { + val flow = merge(flowOf(get()), changes()) + return if (action != null) { + flow.onEach(action = action) + } else flow } diff --git a/src/main/resources/values/values/strings.xml b/src/main/resources/values/values/strings.xml index 2e66804a..58755126 100644 --- a/src/main/resources/values/values/strings.xml +++ b/src/main/resources/values/values/strings.xml @@ -77,11 +77,15 @@ Filter + Default + Reader mode No pages found There is no previous chapter Previous:\n %1$s Next:\n %1$s There is no next chapter + Next chapter + Previous chapter Advanced