Rewrite Reader, add side menu and many bugfixes

This commit is contained in:
Syer10
2021-11-28 12:59:14 -05:00
parent faf537b76c
commit a5cb36b6db
16 changed files with 511 additions and 97 deletions

View File

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

View File

@@ -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<String> = readerPreferences.mode().stateIn(scope),
initialPreferences: ReaderModePreferences = readerPreferences.getMode(
readerPreferences.mode().get()
mode.value
)
) {
private val preferenceJobs = mutableListOf<Job>()
@@ -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)

View File

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

View File

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

View File

@@ -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<Manga?>(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<Pair<MoveTo, Int>>()
private val _pageEmitter = MutableSharedFlow<PageMove>()
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({})
}

View File

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

View File

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

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.ui.reader.model
sealed class PageMove {
data class Direction(val moveTo: MoveTo, val currentPage: Int) : PageMove()
data class Page(val pageNumber: Int) : PageMove()
}

View File

@@ -14,13 +14,11 @@ data class ReaderPage(
val bitmap: MutableStateFlow<ImageBitmap?>,
val progress: MutableStateFlow<Float>,
val status: MutableStateFlow<Status>,
val error: MutableStateFlow<String?>
val error: MutableStateFlow<String?>,
val chapter: ReaderChapter
) {
lateinit var chapter: ReaderChapter
enum class Status {
QUEUE,
LOAD_PAGE,
DOWNLOAD_IMAGE,
READY,
ERROR
}

View File

@@ -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<MouseEvent?>(null) }
val window = LocalComposeWindow.current
var offsetEvent by remember { mutableStateOf<Offset?>(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()
}
}

View File

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

View File

@@ -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<ReaderPage>,
direction: Direction,
maxSize: Int,
@@ -51,21 +54,33 @@ fun ContinuousReader(
nextChapter: ReaderChapter?,
loadingModifier: Modifier,
pageContentScale: ContentScale,
pageEmitter: SharedFlow<Pair<MoveTo, Int>>,
pageEmitter: SharedFlow<PageMove>,
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)
}

View File

@@ -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<ReaderPage>,
@@ -34,21 +37,33 @@ fun PagerReader(
nextChapter: ReaderChapter?,
loadingModifier: Modifier,
pageContentScale: ContentScale,
pageEmitter: SharedFlow<Pair<MoveTo, Int>>,
pageEmitter: SharedFlow<PageMove>,
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(

View File

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

View File

@@ -12,6 +12,9 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
fun <T> Preference<T>.getAsFlow(action: suspend (T) -> Unit): Flow<T> {
return merge(flowOf(get()), changes()).onEach(action = action)
fun <T> Preference<T>.getAsFlow(action: (suspend (T) -> Unit)? = null): Flow<T> {
val flow = merge(flowOf(get()), changes())
return if (action != null) {
flow.onEach(action = action)
} else flow
}

View File

@@ -77,11 +77,15 @@
<string name="filter_source">Filter</string>
<!-- Reader Menu -->
<string name="default_reader_mode">Default</string>
<string name="reader_mode">Reader mode</string>
<string name="no_pages_found">No pages found</string>
<string name="no_previous_chapter">There is no previous chapter</string>
<string name="previous_chapter">Previous:\n %1$s</string>
<string name="next_chapter">Next:\n %1$s</string>
<string name="no_next_chapter">There is no next chapter</string>
<string name="nav_next_chapter">Next chapter</string>
<string name="nav_prev_chapter">Previous chapter</string>
<!-- Settings-->
<string name="settings_advanced">Advanced</string>