Reader improvements

Switch reader pager to accompanist pager, may change to something else later as it doesn't have the needed dragging
Implement proper page loading, add priority based queue, its based on the Tachi 0.x reader
This commit is contained in:
Syer10
2021-05-05 15:54:03 -04:00
parent 2ec7ed1f91
commit 03bdbc56ef
13 changed files with 725 additions and 184 deletions

View File

@@ -28,6 +28,7 @@ dependencies {
implementation(compose.desktop.currentOs)
implementation("br.com.devsrsouza.compose.icons.jetbrains:font-awesome:0.2.0")
implementation("com.github.Syer10:compose-router:45a8c4fe83")
implementation("ca.gosyer:accompanist-pager:0.8.1")
// UI (Swing)
implementation("com.github.weisj:darklaf-core:2.5.5")
@@ -82,7 +83,9 @@ tasks {
"-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-Xopt-in=com.russhwolf.settings.ExperimentalSettingsApi",
"-Xopt-in=com.russhwolf.settings.ExperimentalSettingsImplementation"
"-Xopt-in=com.russhwolf.settings.ExperimentalSettingsImplementation",
"-Xopt-in=com.google.accompanist.pager.ExperimentalPagerApi",
"-Xopt-in=androidx.compose.animation.ExperimentalAnimationApi"
)
}
}

View File

@@ -25,15 +25,12 @@ open class BaseInteractionHandler(
private val _serverUrl = serverPreferences.server()
val serverUrl get() = _serverUrl.get()
protected suspend inline fun <reified T> Http.getRepeat(
urlString: String,
block: HttpRequestBuilder.() -> Unit = {}
): T {
protected inline fun <T> repeat(block: () -> T): T {
var attempt = 1
var lastException: Exception
do {
try {
return get(urlString, block)
return block()
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
@@ -43,58 +40,40 @@ open class BaseInteractionHandler(
throw lastException
}
protected suspend inline fun <reified T> Http.getRepeat(
urlString: String,
noinline block: HttpRequestBuilder.() -> Unit = {}
): T {
return repeat {
get(urlString, block)
}
}
protected suspend inline fun <reified T> Http.deleteRepeat(
urlString: String,
block: HttpRequestBuilder.() -> Unit = {}
noinline block: HttpRequestBuilder.() -> Unit = {}
): T {
var attempt = 1
var lastException: Exception
do {
try {
return delete(urlString, block)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
return repeat {
delete(urlString, block)
}
attempt++
} while (attempt <= 3)
throw lastException
}
protected suspend inline fun <reified T> Http.patchRepeat(
urlString: String,
block: HttpRequestBuilder.() -> Unit = {}
noinline block: HttpRequestBuilder.() -> Unit = {}
): T {
var attempt = 1
var lastException: Exception
do {
try {
return patch(urlString, block)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
return repeat {
patch(urlString, block)
}
attempt++
} while (attempt <= 3)
throw lastException
}
protected suspend inline fun <reified T> Http.postRepeat(
urlString: String,
block: HttpRequestBuilder.() -> Unit = {}
noinline block: HttpRequestBuilder.() -> Unit = {}
): T {
var attempt = 1
var lastException: Exception
do {
try {
return post(urlString, block)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
return repeat {
post(urlString, block)
}
attempt++
} while (attempt <= 3)
throw lastException
}
protected suspend inline fun <reified T> Http.submitFormRepeat(
@@ -103,32 +82,14 @@ open class BaseInteractionHandler(
encodeInQuery: Boolean = false,
block: HttpRequestBuilder.() -> Unit = {}
): T {
var attempt = 1
var lastException: Exception
do {
try {
return submitForm(urlString, formParameters, encodeInQuery, block)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
return repeat {
submitForm(urlString, formParameters, encodeInQuery, block)
}
attempt++
} while (attempt <= 3)
throw lastException
}
suspend fun imageFromUrl(client: Http, imageUrl: String): ImageBitmap {
var attempt = 1
var lastException: Exception
do {
try {
return ca.gosyer.util.compose.imageFromUrl(client, imageUrl)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
return repeat {
ca.gosyer.util.compose.imageFromUrl(client, imageUrl)
}
attempt++
} while (attempt <= 3)
throw lastException
}
}

View File

@@ -21,7 +21,11 @@ import androidx.compose.ui.unit.sp
import kotlin.random.Random
@Composable
fun ErrorScreen(errorMessage: String? = null, modifier: Modifier = Modifier, retry: (() -> Unit)? = null) {
fun ErrorScreen(
errorMessage: String? = null,
modifier: Modifier = Modifier,
retry: (() -> Unit)? = null
) {
Surface(modifier) {
Box(Modifier.fillMaxSize()) {
Column(modifier = Modifier.align(Alignment.Center)) {

View File

@@ -7,13 +7,11 @@
package ca.gosyer.ui.library
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ScrollableTabRow
import androidx.compose.material.Tab
import androidx.compose.material.Text
@@ -29,11 +27,11 @@ import ca.gosyer.data.library.model.DisplayMode
import ca.gosyer.data.models.Category
import ca.gosyer.data.models.Manga
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.vm.viewModel
import ca.gosyer.ui.manga.openMangaMenu
import ca.gosyer.util.compose.ThemedWindow
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
fun openLibraryMenu() {
ThemedWindow {
@@ -41,7 +39,6 @@ fun openLibraryMenu() {
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun LibraryScreen(onClickManga: (Long) -> Unit = { openMangaMenu(it) }) {
val vm = viewModel<LibraryScreenViewModel>()
@@ -95,7 +92,6 @@ fun LibraryScreen(onClickManga: (Long) -> Unit = { openMangaMenu(it) }) {
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun LibraryTabs(
visible: Boolean,
@@ -142,8 +138,7 @@ private fun LibraryPager(
val state = remember(categories.size, selectedPage) {
PagerState(
currentPage = selectedPage,
minPage = 0,
maxPage = categories.lastIndex
pageCount = categories.lastIndex
)
}
LaunchedEffect(state.currentPage) {
@@ -151,8 +146,8 @@ private fun LibraryPager(
onPageChanged(state.currentPage)
}
}
Pager(state = state, offscreenLimit = 1) {
val library by getLibraryForPage(page)
HorizontalPager(state = state, offscreenLimit = 1) {
val library by getLibraryForPage(it)
when (displayMode) {
DisplayMode.CompactGrid -> LibraryMangaCompactGrid(
library = library,

View File

@@ -0,0 +1,59 @@
/*
* 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 ca.gosyer.data.reader.ReaderPreferences
import ca.gosyer.data.server.interactions.ChapterInteractionHandler
import ca.gosyer.ui.reader.loader.TachideskPageLoader
import ca.gosyer.ui.reader.model.ReaderChapter
import ca.gosyer.ui.reader.model.ReaderPage
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 mu.KotlinLogging
import kotlin.coroutines.CoroutineContext
class ChapterLoader(
val context: CoroutineContext,
private val readerPreferences: ReaderPreferences,
private val chapterHandler: ChapterInteractionHandler
) {
private val logger = KotlinLogging.logger {}
fun loadChapter(chapter: ReaderChapter): StateFlow<List<ReaderPage>> {
if (chapterIsReady(chapter)) {
return (chapter.state as ReaderChapter.State.Loaded).pages
} else {
chapter.state = ReaderChapter.State.Loading
logger.debug { "Loading pages for ${chapter.chapter.name}" }
val loader = TachideskPageLoader(context + Dispatchers.Default, chapter, readerPreferences, chapterHandler)
val pages = loader.getPages()
pages.drop(1).take(1).onEach { pages ->
if (pages.isEmpty()) {
chapter.state = ReaderChapter.State.Error(Exception("No pages found"))
}
}.launchIn(chapter.scope)
chapter.pageLoader = loader // Assign here to fix race with unref
chapter.state = ReaderChapter.State.Loaded(pages)
return pages
}
}
/**
* Checks [chapter] to be loaded based on present pages and loader in addition to state.
*/
private fun chapterIsReady(chapter: ReaderChapter): Boolean {
return chapter.state is ReaderChapter.State.Loaded && chapter.pageLoader != null
}
}

View File

@@ -6,65 +6,128 @@
package ca.gosyer.ui.reader
import androidx.compose.desktop.AppWindow
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.ImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeysSet
import androidx.compose.ui.layout.ContentScale
import ca.gosyer.data.models.Chapter
import androidx.compose.ui.unit.dp
import ca.gosyer.data.reader.model.Direction
import ca.gosyer.ui.base.KeyboardShortcut
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.theme.AppTheme
import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.util.compose.ThemedWindow
import ca.gosyer.ui.reader.model.ReaderChapter
import ca.gosyer.ui.reader.model.ReaderPage
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerScope
import com.google.accompanist.pager.VerticalPager
import com.google.accompanist.pager.rememberPagerState
import javax.swing.SwingUtilities
fun openReaderMenu(chapterIndex: Int, mangaId: Long) {
ThemedWindow("TachideskJUI - Reader") {
ReaderMenu(chapterIndex, mangaId)
SwingUtilities.invokeLater {
val window = AppWindow(
"TachideskJUI - Reader"
)
val setHotkeys: (List<KeyboardShortcut>) -> Unit = { shortcuts ->
shortcuts.forEach {
window.keyboard.setShortcut(it.key) { it.shortcut(window) }
}
}
window.show {
AppTheme {
ReaderMenu(chapterIndex, mangaId, setHotkeys)
}
}
}
}
@Composable
fun ReaderMenu(chapterIndex: Int, mangaId: Long) {
fun ReaderMenu(chapterIndex: Int, mangaId: Long, setHotkeys: (List<KeyboardShortcut>) -> Unit) {
val vm = viewModel<ReaderMenuViewModel> {
ReaderMenuViewModel.Params(chapterIndex, mangaId)
}
val isLoading by vm.isLoading.collectAsState()
val state by vm.state.collectAsState()
val previousChapter by vm.previousChapter.collectAsState()
val chapter by vm.chapter.collectAsState()
val nextChapter by vm.nextChapter.collectAsState()
val pages by vm.pages.collectAsState()
val continuous by vm.readerModeSettings.continuous.collectAsState()
val direction by vm.readerModeSettings.direction.collectAsState()
val padding by vm.readerModeSettings.padding.collectAsState()
val currentPage by vm.currentPage.collectAsState()
remember {
setHotkeys(
listOf(
KeyboardShortcut(KeysSet(setOf(Key.W, Key.DirectionUp))) {
vm.progress(currentPage + 1)
},
KeyboardShortcut(KeysSet(setOf(Key.S, Key.DirectionDown))) {
vm.progress(currentPage - 1)
}
)
)
}
Surface {
if (!isLoading && chapter != null) {
if (state is ReaderChapter.State.Loaded && chapter != null) {
chapter?.let { chapter ->
val pageModifier = Modifier.fillMaxWidth().aspectRatio(mangaAspectRatio)
if (pages.isNotEmpty()) {
if (continuous) {
ContinuesReader(vm, pages, pageModifier)
ContinuesReader(
pages,
pageModifier,
vm::retry,
vm::progress
)
} else {
PagerReader(vm, chapter, currentPage, pages, pageModifier)
PagerReader(
direction,
currentPage,
pages,
previousChapter,
chapter,
nextChapter,
pageModifier,
vm::retry,
vm::progress
)
}
} else {
ErrorScreen("No pages found")
}
}
} else {
LoadingScreen(isLoading)
LoadingScreen(
state is ReaderChapter.State.Wait || state is ReaderChapter.State.Loading,
errorMessage = (state as? ReaderChapter.State.Error)?.error?.message
)
}
}
}
@@ -73,7 +136,7 @@ fun ReaderMenu(chapterIndex: Int, mangaId: Long) {
fun ReaderImage(
imageIndex: Int,
drawable: ImageBitmap?,
loading: Boolean,
status: ReaderPage.Status,
error: String?,
imageModifier: Modifier = Modifier.fillMaxSize(),
loadingModifier: Modifier = imageModifier,
@@ -88,51 +151,131 @@ fun ReaderImage(
contentScale = contentScale
)
} else {
LoadingScreen(loading, loadingModifier, error) { retry(imageIndex) }
LoadingScreen(status == ReaderPage.Status.QUEUE, loadingModifier, error) { retry(imageIndex) }
}
}
@Composable
fun PagerReader(readerVM: ReaderMenuViewModel, chapter: Chapter, currentPage: Int, pages: List<ReaderImage>, pageModifier: Modifier) {
val state = remember(chapter.pageCount!!, currentPage) {
PagerState(
currentPage = currentPage,
minPage = 1,
maxPage = chapter.pageCount - 1
)
}
fun PagerReader(
direction: Direction,
currentPage: Int,
pages: List<ReaderPage>,
previousChapter: ReaderChapter?,
currentChapter: ReaderChapter,
nextChapter: ReaderChapter?,
pageModifier: Modifier,
retry: (ReaderPage) -> Unit,
progress: (Int) -> Unit
) {
val state = rememberPagerState(pages.size + 1, initialPage = currentPage)
LaunchedEffect(state.currentPage) {
if (state.currentPage != currentPage) {
readerVM.progress(state.currentPage)
progress(state.currentPage)
}
}
Pager(state) {
if (direction == Direction.Down || direction == Direction.Up) {
VerticalPager(state, reverseLayout = direction == Direction.Up) {
HandlePager(
pages,
it,
previousChapter,
currentChapter,
nextChapter,
pageModifier,
retry
)
}
} else {
HorizontalPager(state, reverseLayout = direction == Direction.Left) {
HandlePager(
pages,
it,
previousChapter,
currentChapter,
nextChapter,
pageModifier,
retry
)
}
}
}
@Composable
fun PagerScope.HandlePager(
pages: List<ReaderPage>,
page: Int,
previousChapter: ReaderChapter?,
currentChapter: ReaderChapter,
nextChapter: ReaderChapter?,
pageModifier: Modifier,
retry: (ReaderPage) -> Unit,
) {
when (page) {
0 -> ChapterSeperator(previousChapter, currentChapter)
pages.size -> ChapterSeperator(currentChapter, nextChapter)
else -> {
val image = pages[page - 1]
ReaderImage(
image.index,
image.bitmap.collectAsState().value,
image.loading.collectAsState().value,
image.status.collectAsState().value,
image.error.collectAsState().value,
loadingModifier = pageModifier,
retry = readerVM::retry
retry = { pageIndex ->
pages.find { it.index == pageIndex }?.let { retry(it) }
}
)
}
}
}
@Composable
fun ContinuesReader(readerVM: ReaderMenuViewModel, pages: List<ReaderImage>, pageModifier: Modifier) {
fun ChapterSeperator(
previousChapter: ReaderChapter?,
nextChapter: ReaderChapter?
) {
Box(contentAlignment = Alignment.Center) {
Column {
when {
previousChapter == null && nextChapter != null -> {
Text("There is no previous chapter")
}
previousChapter != null && nextChapter != null -> {
Text("Previous:\n ${previousChapter.chapter.name}")
Spacer(Modifier.height(8.dp))
Text("Next:\n ${nextChapter.chapter.name}")
}
previousChapter != null && nextChapter == null -> {
Text("There is no next chapter")
}
}
}
}
}
@Composable
fun ContinuesReader(
pages: List<ReaderPage>,
pageModifier: Modifier,
retry: (ReaderPage) -> Unit,
progress: (Int) -> Unit
) {
LazyColumn {
items(pages) { image ->
LaunchedEffect(image.index) {
readerVM.progress(image.index)
progress(image.index)
}
ReaderImage(
image.index,
image.bitmap.collectAsState().value,
image.loading.collectAsState().value,
image.status.collectAsState().value,
image.error.collectAsState().value,
loadingModifier = pageModifier,
retry = readerVM::retry
retry = { pageIndex ->
pages.find { it.index == pageIndex }?.let { retry(it) }
}
)
}
}

View File

@@ -6,34 +6,42 @@
package ca.gosyer.ui.reader
import androidx.compose.ui.graphics.ImageBitmap
import ca.gosyer.data.models.Chapter
import ca.gosyer.data.reader.ReaderModeWatch
import ca.gosyer.data.reader.ReaderPreferences
import ca.gosyer.data.server.interactions.ChapterInteractionHandler
import ca.gosyer.ui.base.vm.ViewModel
import ca.gosyer.ui.reader.model.ReaderChapter
import ca.gosyer.ui.reader.model.ReaderPage
import ca.gosyer.ui.reader.model.ViewerChapters
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
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
class ReaderMenuViewModel @Inject constructor(
params: Params,
readerPreferences: ReaderPreferences,
chapterHandler: ChapterInteractionHandler
private val readerPreferences: ReaderPreferences,
private val chapterHandler: ChapterInteractionHandler
) : ViewModel() {
private val _chapter = MutableStateFlow<Chapter?>(null)
val chapter = _chapter.asStateFlow()
private val viewerChapters = ViewerChapters(
MutableStateFlow(null),
MutableStateFlow(null),
MutableStateFlow(null)
)
val previousChapter = viewerChapters.prevChapter.asStateFlow()
val chapter = viewerChapters.currChapter.asStateFlow()
val nextChapter = viewerChapters.nextChapter.asStateFlow()
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
private val _state = MutableStateFlow<ReaderChapter.State>(ReaderChapter.State.Wait)
val state = _state.asStateFlow()
private val _pages = MutableStateFlow(emptyList<ReaderImage>())
private val _pages = MutableStateFlow(emptyList<ReaderPage>())
val pages = _pages.asStateFlow()
private val _currentPage = MutableStateFlow(1)
@@ -41,39 +49,11 @@ class ReaderMenuViewModel @Inject constructor(
val readerModeSettings = ReaderModeWatch(readerPreferences, scope)
private val loader = ChapterLoader(scope.coroutineContext, readerPreferences, chapterHandler)
init {
scope.launch(Dispatchers.Default) {
val chapter: Chapter
_chapter.value = chapterHandler.getChapter(params.mangaId, params.chapterIndex).also { chapter = it }
val pageRange = 1..(chapter.pageCount ?: 1)
_pages.value = pageRange.map {
ReaderImage(
it,
MutableStateFlow(null),
MutableStateFlow(true),
MutableStateFlow(null)
)
}
_isLoading.value = false
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()
init(params.mangaId, params.chapterIndex)
}
}
@@ -81,15 +61,62 @@ class ReaderMenuViewModel @Inject constructor(
_currentPage.value = index
}
fun retry(index: Int) {
fun retry(page: ReaderPage) {
chapter.value?.pageLoader?.retryPage(page)
}
private fun resetValues() {
_pages.value = emptyList()
_currentPage.value = 1
_state.value = ReaderChapter.State.Wait
viewerChapters.recycle()
}
suspend fun init(mangaId: Long, chapterIndex: Int) {
resetValues()
val chapter = ReaderChapter(
scope.coroutineContext + Dispatchers.Default,
chapterHandler.getChapter(mangaId, chapterIndex)
)
val pages = loader.loadChapter(chapter)
viewerChapters.currChapter.value = chapter
scope.launch(Dispatchers.Default) {
listOf(
async {
try {
viewerChapters.nextChapter.value = ReaderChapter(
scope.coroutineContext + Dispatchers.Default,
chapterHandler.getChapter(mangaId, chapterIndex + 1)
)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
},
async {
if (chapterIndex != 0) {
try {
viewerChapters.prevChapter.value = ReaderChapter(
scope.coroutineContext + Dispatchers.Default,
chapterHandler.getChapter(mangaId, chapterIndex - 1)
)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
}
).awaitAll()
}
chapter.stateObserver.onEach {
_state.value = it
}.launchIn(chapter.scope)
pages.onEach { pageList ->
pageList.forEach { it.chapter = chapter }
_pages.value = pageList
}.launchIn(chapter.scope)
_currentPage.onEach { index ->
pages.value.getOrNull(index - 1)?.let { chapter.pageLoader?.loadPage(it) }
}.launchIn(chapter.scope)
}
data class Params(val chapterIndex: Int, val mangaId: Long)
}
data class ReaderImage(
val index: Int,
val bitmap: MutableStateFlow<ImageBitmap?>,
val loading: MutableStateFlow<Boolean>,
val error: MutableStateFlow<String?>
)

View File

@@ -0,0 +1,48 @@
/*
* 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.loader
import ca.gosyer.ui.reader.model.ReaderPage
import kotlinx.coroutines.flow.StateFlow
/**
* A loader used to load pages into the reader. Any open resources must be cleaned up when the
* method [recycle] is called.
*/
abstract class PageLoader {
/**
* Whether this loader has been already recycled.
*/
var isRecycled = false
private set
/**
* Recycles this loader. Implementations must override this method to clean up any active
* resources.
*/
open fun recycle() {
isRecycled = true
}
/**
* Returns an [StateFlow] containing the list of pages of a chapter.
*/
abstract fun getPages(): StateFlow<List<ReaderPage>>
/**
* Returns an [StateFlow] that should inform of the progress of the page (see the Page class
* for the available states)
*/
abstract fun loadPage(page: ReaderPage)
/**
* Retries the given [page] in case it failed to load. This method only makes sense when an
* online source is used.
*/
open fun retryPage(page: ReaderPage) {}
}

View File

@@ -0,0 +1,160 @@
/*
* 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.loader
import ca.gosyer.data.reader.ReaderPreferences
import ca.gosyer.data.server.interactions.ChapterInteractionHandler
import ca.gosyer.ui.reader.model.ReaderChapter
import ca.gosyer.ui.reader.model.ReaderPage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.CoroutineContext
class TachideskPageLoader(
context: CoroutineContext,
val chapter: ReaderChapter,
readerPreferences: ReaderPreferences,
chapterHandler: ChapterInteractionHandler
) : PageLoader() {
/**
* A queue used to manage requests one by one while allowing priorities.
*/
private val queue = PriorityBlockingQueue<PriorityPage>()
val scope = CoroutineScope(SupervisorJob() + context)
private val preloadSize = 3
private val pagesFlow by lazy {
MutableStateFlow<List<ReaderPage>>(emptyList())
}
init {
repeat(3) {
scope.launch {
while (true) {
try {
val page = queue.take().page
if (page.status.value == ReaderPage.Status.QUEUE) {
try {
page.bitmap.value = chapterHandler.getPage(chapter.chapter, page.index)
page.status.value = ReaderPage.Status.READY
page.error.value = null
} catch (e: Exception) {
if (e is CancellationException) throw e
page.bitmap.value = null
page.status.value = ReaderPage.Status.ERROR
page.error.value = e.message
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
}
}
}
/**
* Preloads the given [amount] of pages after the [currentPage] with a lower priority.
* @return a list of [PriorityPage] that were added to the [queue]
*/
private fun preloadNextPages(currentPage: ReaderPage, amount: Int): List<PriorityPage> {
val pageIndex = currentPage.index
val pages = currentPage.chapter.pages ?: return emptyList()
if (pageIndex == pages.value.lastIndex) return emptyList()
return pages.value
.subList(pageIndex + 1, (pageIndex + 1 + amount).coerceAtMost(pages.value.size))
.mapNotNull {
if (it.status.value == ReaderPage.Status.QUEUE) {
PriorityPage(it, 0).apply { queue.offer(this) }
} else null
}
}
override fun getPages(): StateFlow<List<ReaderPage>> {
scope.launch {
if (pagesFlow.value.isNotEmpty()) return@launch
val pageRange = 0..(chapter.chapter.pageCount?.minus(1) ?: 0)
pagesFlow.value = pageRange.map {
ReaderPage(
it,
MutableStateFlow(null),
MutableStateFlow(ReaderPage.Status.QUEUE),
MutableStateFlow(null)
)
}
}
return pagesFlow.asStateFlow()
}
override fun loadPage(page: ReaderPage) {
scope.launch {
// Automatically retry failed pages when subscribed to this page
if (page.status.value == ReaderPage.Status.ERROR) {
page.status.value = ReaderPage.Status.QUEUE
}
val queuedPages = mutableListOf<PriorityPage>()
if (page.status.value == ReaderPage.Status.QUEUE) {
queuedPages += PriorityPage(page, 1).also { queue.offer(it) }
}
queuedPages += preloadNextPages(page, preloadSize)
page.status.onCompletion {
queuedPages.forEach {
if (it.page.status.value == ReaderPage.Status.QUEUE) {
queue.remove(it)
}
}
}.launchIn(scope)
}
}
/**
* Retries a page. This method is only called from user interaction on the viewer.
*/
override fun retryPage(page: ReaderPage) {
if (page.status.value == ReaderPage.Status.ERROR) {
page.status.value = ReaderPage.Status.QUEUE
}
queue.offer(PriorityPage(page, 2))
}
/**
* Data class used to keep ordering of pages in order to maintain priority.
*/
private class PriorityPage(
val page: ReaderPage,
val priority: Int
) : Comparable<PriorityPage> {
companion object {
private val idGenerator = AtomicInteger()
}
private val identifier = idGenerator.incrementAndGet()
override fun compareTo(other: PriorityPage): Int {
val p = other.priority.compareTo(priority)
return if (p != 0) p else identifier.compareTo(other.identifier)
}
}
override fun recycle() {
super.recycle()
scope.cancel()
queue.clear()
}
}

View File

@@ -0,0 +1,61 @@
/*
* 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
import ca.gosyer.data.models.Chapter
import ca.gosyer.ui.reader.loader.PageLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import mu.KotlinLogging
import kotlin.coroutines.CoroutineContext
data class ReaderChapter(val context: CoroutineContext, val chapter: Chapter) {
private val logger = KotlinLogging.logger {}
var scope = CoroutineScope(context + Job())
private set
var state: State =
State.Wait
set(value) {
field = value
stateRelay.value = value
}
private val stateRelay by lazy { MutableStateFlow(state) }
val stateObserver by lazy { stateRelay.asStateFlow() }
val pages: StateFlow<List<ReaderPage>>?
get() = (state as? State.Loaded)?.pages
var pageLoader: PageLoader? = null
var requestedPage: Int = 0
fun recycle() {
if (pageLoader != null) {
logger.debug { "Recycling chapter ${chapter.name}" }
}
pageLoader?.recycle()
pageLoader = null
state = State.Wait
scope.cancel()
scope = CoroutineScope(context + Job())
}
sealed class State {
object Wait : State()
object Loading : State()
class Error(val error: Throwable) : State()
class Loaded(val pages: StateFlow<List<ReaderPage>>) : State()
}
}

View File

@@ -0,0 +1,26 @@
/*
* 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
import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.coroutines.flow.MutableStateFlow
data class ReaderPage(
val index: Int,
val bitmap: MutableStateFlow<ImageBitmap?>,
val status: MutableStateFlow<Status>,
val error: MutableStateFlow<String?>
) {
lateinit var chapter: ReaderChapter
enum class Status {
QUEUE,
LOAD_PAGE,
DOWNLOAD_IMAGE,
READY,
ERROR
}
}

View File

@@ -0,0 +1,24 @@
/*
* 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
import kotlinx.coroutines.flow.MutableStateFlow
data class ViewerChapters(
val currChapter: MutableStateFlow<ReaderChapter?>,
val prevChapter: MutableStateFlow<ReaderChapter?>,
val nextChapter: MutableStateFlow<ReaderChapter?>
) {
fun recycle() {
currChapter.value?.recycle()
prevChapter.value?.recycle()
nextChapter.value?.recycle()
currChapter.value = null
prevChapter.value = null
nextChapter.value = null
}
}

View File

@@ -10,14 +10,44 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import ca.gosyer.ui.base.components.Toolbar
import ca.gosyer.ui.base.prefs.PreferenceRow
import ca.gosyer.ui.base.vm.ViewModel
import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.ui.main.Route
import ca.gosyer.util.system.filePicker
import com.github.zsoltk.compose.router.BackStack
import mu.KotlinLogging
import java.io.File
import javax.inject.Inject
@Composable
fun SettingsBackupScreen(navController: BackStack<Route>) {
Column {
Toolbar("Backup Settings", navController, true)
LazyColumn {
class SettingsBackupViewModel @Inject constructor() : ViewModel() {
private val logger = KotlinLogging.logger {}
fun setFile(file: File?) {
if (file == null || !file.exists()) {
logger.info { "Invalid file ${file?.absolutePath}" }
} else {
logger.info { file.absolutePath }
}
}
}
@Composable
fun SettingsBackupScreen(navController: BackStack<Route>) {
val vm = viewModel<SettingsBackupViewModel>()
Column {
Toolbar("Backup Settings", navController, true)
LazyColumn {
item {
PreferenceRow(
"Restore Backup",
onClick = {
filePicker {
vm.setFile(it.selectedFile)
}
}
)
}
}
}
}