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(compose.desktop.currentOs)
implementation("br.com.devsrsouza.compose.icons.jetbrains:font-awesome:0.2.0") implementation("br.com.devsrsouza.compose.icons.jetbrains:font-awesome:0.2.0")
implementation("com.github.Syer10:compose-router:45a8c4fe83") implementation("com.github.Syer10:compose-router:45a8c4fe83")
implementation("ca.gosyer:accompanist-pager:0.8.1")
// UI (Swing) // UI (Swing)
implementation("com.github.weisj:darklaf-core:2.5.5") implementation("com.github.weisj:darklaf-core:2.5.5")
@@ -82,7 +83,9 @@ tasks {
"-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi", "-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi", "-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-Xopt-in=com.russhwolf.settings.ExperimentalSettingsApi", "-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() private val _serverUrl = serverPreferences.server()
val serverUrl get() = _serverUrl.get() val serverUrl get() = _serverUrl.get()
protected suspend inline fun <reified T> Http.getRepeat( protected inline fun <T> repeat(block: () -> T): T {
urlString: String,
block: HttpRequestBuilder.() -> Unit = {}
): T {
var attempt = 1 var attempt = 1
var lastException: Exception var lastException: Exception
do { do {
try { try {
return get(urlString, block) return block()
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
lastException = e lastException = e
@@ -43,58 +40,40 @@ open class BaseInteractionHandler(
throw lastException 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( protected suspend inline fun <reified T> Http.deleteRepeat(
urlString: String, urlString: String,
block: HttpRequestBuilder.() -> Unit = {} noinline block: HttpRequestBuilder.() -> Unit = {}
): T { ): T {
var attempt = 1 return repeat {
var lastException: Exception delete(urlString, block)
do { }
try {
return delete(urlString, block)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
}
attempt++
} while (attempt <= 3)
throw lastException
} }
protected suspend inline fun <reified T> Http.patchRepeat( protected suspend inline fun <reified T> Http.patchRepeat(
urlString: String, urlString: String,
block: HttpRequestBuilder.() -> Unit = {} noinline block: HttpRequestBuilder.() -> Unit = {}
): T { ): T {
var attempt = 1 return repeat {
var lastException: Exception patch(urlString, block)
do { }
try {
return patch(urlString, block)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
}
attempt++
} while (attempt <= 3)
throw lastException
} }
protected suspend inline fun <reified T> Http.postRepeat( protected suspend inline fun <reified T> Http.postRepeat(
urlString: String, urlString: String,
block: HttpRequestBuilder.() -> Unit = {} noinline block: HttpRequestBuilder.() -> Unit = {}
): T { ): T {
var attempt = 1 return repeat {
var lastException: Exception post(urlString, block)
do { }
try {
return post(urlString, block)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
}
attempt++
} while (attempt <= 3)
throw lastException
} }
protected suspend inline fun <reified T> Http.submitFormRepeat( protected suspend inline fun <reified T> Http.submitFormRepeat(
@@ -103,32 +82,14 @@ open class BaseInteractionHandler(
encodeInQuery: Boolean = false, encodeInQuery: Boolean = false,
block: HttpRequestBuilder.() -> Unit = {} block: HttpRequestBuilder.() -> Unit = {}
): T { ): T {
var attempt = 1 return repeat {
var lastException: Exception submitForm(urlString, formParameters, encodeInQuery, block)
do { }
try {
return submitForm(urlString, formParameters, encodeInQuery, block)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
}
attempt++
} while (attempt <= 3)
throw lastException
} }
suspend fun imageFromUrl(client: Http, imageUrl: String): ImageBitmap { suspend fun imageFromUrl(client: Http, imageUrl: String): ImageBitmap {
var attempt = 1 return repeat {
var lastException: Exception ca.gosyer.util.compose.imageFromUrl(client, imageUrl)
do { }
try {
return ca.gosyer.util.compose.imageFromUrl(client, imageUrl)
} catch (e: Exception) {
if (e is CancellationException) throw e
lastException = e
}
attempt++
} while (attempt <= 3)
throw lastException
} }
} }

View File

@@ -21,7 +21,11 @@ import androidx.compose.ui.unit.sp
import kotlin.random.Random import kotlin.random.Random
@Composable @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) { Surface(modifier) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(modifier = Modifier.align(Alignment.Center)) { Column(modifier = Modifier.align(Alignment.Center)) {

View File

@@ -7,13 +7,11 @@
package ca.gosyer.ui.library package ca.gosyer.ui.library
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ScrollableTabRow import androidx.compose.material.ScrollableTabRow
import androidx.compose.material.Tab import androidx.compose.material.Tab
import androidx.compose.material.Text 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.Category
import ca.gosyer.data.models.Manga import ca.gosyer.data.models.Manga
import ca.gosyer.ui.base.components.LoadingScreen 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.base.vm.viewModel
import ca.gosyer.ui.manga.openMangaMenu import ca.gosyer.ui.manga.openMangaMenu
import ca.gosyer.util.compose.ThemedWindow import ca.gosyer.util.compose.ThemedWindow
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
fun openLibraryMenu() { fun openLibraryMenu() {
ThemedWindow { ThemedWindow {
@@ -41,7 +39,6 @@ fun openLibraryMenu() {
} }
} }
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun LibraryScreen(onClickManga: (Long) -> Unit = { openMangaMenu(it) }) { fun LibraryScreen(onClickManga: (Long) -> Unit = { openMangaMenu(it) }) {
val vm = viewModel<LibraryScreenViewModel>() val vm = viewModel<LibraryScreenViewModel>()
@@ -95,7 +92,6 @@ fun LibraryScreen(onClickManga: (Long) -> Unit = { openMangaMenu(it) }) {
} }
} }
@OptIn(ExperimentalAnimationApi::class)
@Composable @Composable
private fun LibraryTabs( private fun LibraryTabs(
visible: Boolean, visible: Boolean,
@@ -142,8 +138,7 @@ private fun LibraryPager(
val state = remember(categories.size, selectedPage) { val state = remember(categories.size, selectedPage) {
PagerState( PagerState(
currentPage = selectedPage, currentPage = selectedPage,
minPage = 0, pageCount = categories.lastIndex
maxPage = categories.lastIndex
) )
} }
LaunchedEffect(state.currentPage) { LaunchedEffect(state.currentPage) {
@@ -151,8 +146,8 @@ private fun LibraryPager(
onPageChanged(state.currentPage) onPageChanged(state.currentPage)
} }
} }
Pager(state = state, offscreenLimit = 1) { HorizontalPager(state = state, offscreenLimit = 1) {
val library by getLibraryForPage(page) val library by getLibraryForPage(it)
when (displayMode) { when (displayMode) {
DisplayMode.CompactGrid -> LibraryMangaCompactGrid( DisplayMode.CompactGrid -> LibraryMangaCompactGrid(
library = library, 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 package ca.gosyer.ui.reader
import androidx.compose.desktop.AppWindow
import androidx.compose.foundation.Image 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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap 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 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.ErrorScreen
import ca.gosyer.ui.base.components.LoadingScreen 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.components.mangaAspectRatio
import ca.gosyer.ui.base.theme.AppTheme
import ca.gosyer.ui.base.vm.viewModel 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) { fun openReaderMenu(chapterIndex: Int, mangaId: Long) {
ThemedWindow("TachideskJUI - Reader") { SwingUtilities.invokeLater {
ReaderMenu(chapterIndex, mangaId) 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 @Composable
fun ReaderMenu(chapterIndex: Int, mangaId: Long) { fun ReaderMenu(chapterIndex: Int, mangaId: Long, setHotkeys: (List<KeyboardShortcut>) -> Unit) {
val vm = viewModel<ReaderMenuViewModel> { val vm = viewModel<ReaderMenuViewModel> {
ReaderMenuViewModel.Params(chapterIndex, mangaId) 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 chapter by vm.chapter.collectAsState()
val nextChapter by vm.nextChapter.collectAsState()
val pages by vm.pages.collectAsState() val pages by vm.pages.collectAsState()
val continuous by vm.readerModeSettings.continuous.collectAsState() val continuous by vm.readerModeSettings.continuous.collectAsState()
val direction by vm.readerModeSettings.direction.collectAsState() val direction by vm.readerModeSettings.direction.collectAsState()
val padding by vm.readerModeSettings.padding.collectAsState() val padding by vm.readerModeSettings.padding.collectAsState()
val currentPage by vm.currentPage.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 { Surface {
if (!isLoading && chapter != null) { if (state is ReaderChapter.State.Loaded && chapter != null) {
chapter?.let { chapter -> chapter?.let { chapter ->
val pageModifier = Modifier.fillMaxWidth().aspectRatio(mangaAspectRatio) val pageModifier = Modifier.fillMaxWidth().aspectRatio(mangaAspectRatio)
if (pages.isNotEmpty()) { if (pages.isNotEmpty()) {
if (continuous) { if (continuous) {
ContinuesReader(vm, pages, pageModifier) ContinuesReader(
pages,
pageModifier,
vm::retry,
vm::progress
)
} else { } else {
PagerReader(vm, chapter, currentPage, pages, pageModifier) PagerReader(
direction,
currentPage,
pages,
previousChapter,
chapter,
nextChapter,
pageModifier,
vm::retry,
vm::progress
)
} }
} else { } else {
ErrorScreen("No pages found") ErrorScreen("No pages found")
} }
} }
} else { } 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( fun ReaderImage(
imageIndex: Int, imageIndex: Int,
drawable: ImageBitmap?, drawable: ImageBitmap?,
loading: Boolean, status: ReaderPage.Status,
error: String?, error: String?,
imageModifier: Modifier = Modifier.fillMaxSize(), imageModifier: Modifier = Modifier.fillMaxSize(),
loadingModifier: Modifier = imageModifier, loadingModifier: Modifier = imageModifier,
@@ -88,51 +151,131 @@ fun ReaderImage(
contentScale = contentScale contentScale = contentScale
) )
} else { } else {
LoadingScreen(loading, loadingModifier, error) { retry(imageIndex) } LoadingScreen(status == ReaderPage.Status.QUEUE, loadingModifier, error) { retry(imageIndex) }
} }
} }
@Composable @Composable
fun PagerReader(readerVM: ReaderMenuViewModel, chapter: Chapter, currentPage: Int, pages: List<ReaderImage>, pageModifier: Modifier) { fun PagerReader(
val state = remember(chapter.pageCount!!, currentPage) { direction: Direction,
PagerState( currentPage: Int,
currentPage = currentPage, pages: List<ReaderPage>,
minPage = 1, previousChapter: ReaderChapter?,
maxPage = chapter.pageCount - 1 currentChapter: ReaderChapter,
) nextChapter: ReaderChapter?,
} pageModifier: Modifier,
retry: (ReaderPage) -> Unit,
progress: (Int) -> Unit
) {
val state = rememberPagerState(pages.size + 1, initialPage = currentPage)
LaunchedEffect(state.currentPage) { LaunchedEffect(state.currentPage) {
if (state.currentPage != currentPage) { if (state.currentPage != currentPage) {
readerVM.progress(state.currentPage) progress(state.currentPage)
} }
} }
Pager(state) {
val image = pages[page - 1]
ReaderImage(
image.index,
image.bitmap.collectAsState().value,
image.loading.collectAsState().value,
image.error.collectAsState().value,
loadingModifier = pageModifier,
retry = readerVM::retry
)
}
}
@Composable if (direction == Direction.Down || direction == Direction.Up) {
fun ContinuesReader(readerVM: ReaderMenuViewModel, pages: List<ReaderImage>, pageModifier: Modifier) { VerticalPager(state, reverseLayout = direction == Direction.Up) {
LazyColumn { HandlePager(
items(pages) { image -> pages,
LaunchedEffect(image.index) { it,
readerVM.progress(image.index) previousChapter,
} currentChapter,
ReaderImage( nextChapter,
image.index, pageModifier,
image.bitmap.collectAsState().value, retry
image.loading.collectAsState().value, )
image.error.collectAsState().value, }
loadingModifier = pageModifier, } else {
retry = readerVM::retry 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.status.collectAsState().value,
image.error.collectAsState().value,
loadingModifier = pageModifier,
retry = { pageIndex ->
pages.find { it.index == pageIndex }?.let { retry(it) }
}
)
}
}
}
@Composable
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) {
progress(image.index)
}
ReaderImage(
image.index,
image.bitmap.collectAsState().value,
image.status.collectAsState().value,
image.error.collectAsState().value,
loadingModifier = pageModifier,
retry = { pageIndex ->
pages.find { it.index == pageIndex }?.let { retry(it) }
}
) )
} }
} }

View File

@@ -6,34 +6,42 @@
package ca.gosyer.ui.reader 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.ReaderModeWatch
import ca.gosyer.data.reader.ReaderPreferences import ca.gosyer.data.reader.ReaderPreferences
import ca.gosyer.data.server.interactions.ChapterInteractionHandler import ca.gosyer.data.server.interactions.ChapterInteractionHandler
import ca.gosyer.ui.base.vm.ViewModel 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.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import javax.inject.Inject import javax.inject.Inject
class ReaderMenuViewModel @Inject constructor( class ReaderMenuViewModel @Inject constructor(
params: Params, params: Params,
readerPreferences: ReaderPreferences, private val readerPreferences: ReaderPreferences,
chapterHandler: ChapterInteractionHandler private val chapterHandler: ChapterInteractionHandler
) : ViewModel() { ) : ViewModel() {
private val _chapter = MutableStateFlow<Chapter?>(null) private val viewerChapters = ViewerChapters(
val chapter = _chapter.asStateFlow() 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) private val _state = MutableStateFlow<ReaderChapter.State>(ReaderChapter.State.Wait)
val isLoading = _isLoading.asStateFlow() val state = _state.asStateFlow()
private val _pages = MutableStateFlow(emptyList<ReaderImage>()) private val _pages = MutableStateFlow(emptyList<ReaderPage>())
val pages = _pages.asStateFlow() val pages = _pages.asStateFlow()
private val _currentPage = MutableStateFlow(1) private val _currentPage = MutableStateFlow(1)
@@ -41,39 +49,11 @@ class ReaderMenuViewModel @Inject constructor(
val readerModeSettings = ReaderModeWatch(readerPreferences, scope) val readerModeSettings = ReaderModeWatch(readerPreferences, scope)
private val loader = ChapterLoader(scope.coroutineContext, readerPreferences, chapterHandler)
init { init {
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val chapter: Chapter init(params.mangaId, params.chapterIndex)
_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()
} }
} }
@@ -81,15 +61,62 @@ class ReaderMenuViewModel @Inject constructor(
_currentPage.value = index _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 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.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import ca.gosyer.ui.base.components.Toolbar 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.ui.main.Route
import ca.gosyer.util.system.filePicker
import com.github.zsoltk.compose.router.BackStack import com.github.zsoltk.compose.router.BackStack
import mu.KotlinLogging
import java.io.File
import javax.inject.Inject
@Composable class SettingsBackupViewModel @Inject constructor() : ViewModel() {
fun SettingsBackupScreen(navController: BackStack<Route>) { private val logger = KotlinLogging.logger {}
Column {
Toolbar("Backup Settings", navController, true) fun setFile(file: File?) {
LazyColumn { 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)
}
}
)
}
} }
} }
} }