diff --git a/build.gradle.kts b/build.gradle.kts index 8770f7c9..cd51cdaf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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" ) } } diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/BaseInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/BaseInteractionHandler.kt index cad87234..80926eaa 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/BaseInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/BaseInteractionHandler.kt @@ -25,15 +25,12 @@ open class BaseInteractionHandler( private val _serverUrl = serverPreferences.server() val serverUrl get() = _serverUrl.get() - protected suspend inline fun Http.getRepeat( - urlString: String, - block: HttpRequestBuilder.() -> Unit = {} - ): T { + protected inline fun 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 Http.getRepeat( + urlString: String, + noinline block: HttpRequestBuilder.() -> Unit = {} + ): T { + return repeat { + get(urlString, block) + } + } + protected suspend inline fun 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 - } - attempt++ - } while (attempt <= 3) - throw lastException + return repeat { + delete(urlString, block) + } } protected suspend inline fun 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 - } - attempt++ - } while (attempt <= 3) - throw lastException + return repeat { + patch(urlString, block) + } } protected suspend inline fun 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 - } - attempt++ - } while (attempt <= 3) - throw lastException + return repeat { + post(urlString, block) + } } protected suspend inline fun 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 - } - attempt++ - } while (attempt <= 3) - throw lastException + return repeat { + submitForm(urlString, formParameters, encodeInQuery, block) + } } 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 - } - attempt++ - } while (attempt <= 3) - throw lastException + return repeat { + ca.gosyer.util.compose.imageFromUrl(client, imageUrl) + } } } diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt b/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt index e396c25f..dff0832e 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt @@ -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)) { diff --git a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt index 29d78f93..99529e11 100644 --- a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt @@ -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() @@ -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, diff --git a/src/main/kotlin/ca/gosyer/ui/reader/ChapterLoader.kt b/src/main/kotlin/ca/gosyer/ui/reader/ChapterLoader.kt new file mode 100644 index 00000000..c739f5ec --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/ChapterLoader.kt @@ -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> { + 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 + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt index 149b2a0d..7fa111b2 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt @@ -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) -> 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) -> Unit) { val vm = viewModel { 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, 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, + 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) { - 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 -fun ContinuesReader(readerVM: ReaderMenuViewModel, pages: List, pageModifier: Modifier) { - LazyColumn { - items(pages) { image -> - LaunchedEffect(image.index) { - readerVM.progress(image.index) - } - ReaderImage( - image.index, - image.bitmap.collectAsState().value, - image.loading.collectAsState().value, - image.error.collectAsState().value, - loadingModifier = pageModifier, - retry = readerVM::retry + 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, + 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, + 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) } + } ) } } diff --git a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt index 11963012..14224300 100644 --- a/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt @@ -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(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.Wait) + val state = _state.asStateFlow() - private val _pages = MutableStateFlow(emptyList()) + private val _pages = MutableStateFlow(emptyList()) 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, - val loading: MutableStateFlow, - val error: MutableStateFlow -) diff --git a/src/main/kotlin/ca/gosyer/ui/reader/loader/PageLoader.kt b/src/main/kotlin/ca/gosyer/ui/reader/loader/PageLoader.kt new file mode 100644 index 00000000..b6c06fb8 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/loader/PageLoader.kt @@ -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> + + /** + * 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) {} +} diff --git a/src/main/kotlin/ca/gosyer/ui/reader/loader/TachideskPageLoader.kt b/src/main/kotlin/ca/gosyer/ui/reader/loader/TachideskPageLoader.kt new file mode 100644 index 00000000..942e4f81 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/loader/TachideskPageLoader.kt @@ -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() + val scope = CoroutineScope(SupervisorJob() + context) + private val preloadSize = 3 + private val pagesFlow by lazy { + MutableStateFlow>(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 { + 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> { + 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() + 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 { + 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() + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderChapter.kt b/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderChapter.kt new file mode 100644 index 00000000..905c0e1c --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderChapter.kt @@ -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>? + 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>) : State() + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderPage.kt b/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderPage.kt new file mode 100644 index 00000000..797acc34 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/model/ReaderPage.kt @@ -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, + val status: MutableStateFlow, + val error: MutableStateFlow +) { + lateinit var chapter: ReaderChapter + enum class Status { + QUEUE, + LOAD_PAGE, + DOWNLOAD_IMAGE, + READY, + ERROR + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/reader/model/ViewerChapters.kt b/src/main/kotlin/ca/gosyer/ui/reader/model/ViewerChapters.kt new file mode 100644 index 00000000..c334af7b --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/reader/model/ViewerChapters.kt @@ -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, + val prevChapter: MutableStateFlow, + val nextChapter: MutableStateFlow +) { + fun recycle() { + currChapter.value?.recycle() + prevChapter.value?.recycle() + nextChapter.value?.recycle() + currChapter.value = null + prevChapter.value = null + nextChapter.value = null + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt b/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt index 240d4341..35726ead 100644 --- a/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt @@ -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) { - 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) { + val vm = viewModel() + Column { + Toolbar("Backup Settings", navController, true) + LazyColumn { + item { + PreferenceRow( + "Restore Backup", + onClick = { + filePicker { + vm.setFile(it.selectedFile) + } + } + ) + } } } }