Implement simple reader, with long strip and pager support

This commit is contained in:
Syer10
2021-04-27 22:55:53 -04:00
parent 808213dc3d
commit 5913bc45ea
11 changed files with 393 additions and 23 deletions

View File

@@ -12,13 +12,11 @@ import java.util.prefs.Preferences
class PreferenceStoreFactory {
fun create(name: String? = null): PreferenceStore {
val userPreferences: Preferences = Preferences.userRoot()
val jvmPreferences = if (!name.isNullOrBlank()) {
JvmPreferencesSettings(userPreferences.node(name))
} else {
JvmPreferencesSettings(userPreferences)
fun create(vararg names: String): PreferenceStore {
var preferences: Preferences = Preferences.userRoot()
names.forEach {
preferences = preferences.node(it)
}
return JvmPreferenceStore(jvmPreferences)
return JvmPreferenceStore(JvmPreferencesSettings(preferences))
}
}

View File

@@ -10,6 +10,7 @@ import ca.gosyer.core.prefs.PreferenceStoreFactory
import ca.gosyer.data.catalog.CatalogPreferences
import ca.gosyer.data.extension.ExtensionPreferences
import ca.gosyer.data.library.LibraryPreferences
import ca.gosyer.data.reader.ReaderPreferences
import ca.gosyer.data.server.Http
import ca.gosyer.data.server.HttpProvider
import ca.gosyer.data.server.ServerPreferences
@@ -44,6 +45,10 @@ val DataModule = module {
.toProviderInstance { LibraryPreferences(preferenceFactory.create("library")) }
.providesSingleton()
bind<ReaderPreferences>()
.toProviderInstance { ReaderPreferences(preferenceFactory.create("reader")) { name -> preferenceFactory.create("reader", name) } }
.providesSingleton()
bind<UiPreferences>()
.toProviderInstance { UiPreferences(preferenceFactory.create("ui")) }
.providesSingleton()

View File

@@ -0,0 +1,28 @@
/*
* 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.data.reader
import ca.gosyer.common.prefs.Preference
import ca.gosyer.common.prefs.PreferenceStore
import ca.gosyer.data.reader.model.Direction
class ReaderModePreferences(private val preferenceStore: PreferenceStore) {
constructor(mode: String, factory: (String) -> PreferenceStore) :
this(factory(mode))
fun continuous(): Preference<Boolean> {
return preferenceStore.getBoolean("continuous")
}
fun direction(): Preference<Direction> {
return preferenceStore.getJsonObject("direction", Direction.Down, Direction.serializer())
}
fun padding(): Preference<Float> {
return preferenceStore.getFloat("padding")
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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.data.reader
import ca.gosyer.common.prefs.Preference
import ca.gosyer.common.prefs.PreferenceStore
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
class ReaderPreferences(private val preferenceStore: PreferenceStore, val factory: (String) -> PreferenceStore) {
fun modes(): Preference<List<String>> {
return preferenceStore.getJsonObject(
"modes",
listOf("RTL", "LTR", "Vertical", "Continues Vertical", "Long Strip"),
ListSerializer(String.serializer())
)
}
fun mode(): Preference<String> {
return preferenceStore.getString("mode", "RTL")
}
fun getMode(mode: String): ReaderModePreferences {
return ReaderModePreferences(mode, factory)
}
}

View File

@@ -0,0 +1,17 @@
/*
* 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.data.reader.model
import kotlinx.serialization.Serializable
@Serializable
enum class Direction {
Up,
Down,
Left,
Right
}

View File

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

View File

@@ -21,7 +21,8 @@ import androidx.compose.ui.unit.min
fun LoadingScreen(
isLoading: Boolean = true,
modifier: Modifier = Modifier.fillMaxSize(),
errorMessage: String? = null
errorMessage: String? = null,
retry: (() -> Unit)? = null
) {
Surface(modifier) {
BoxWithConstraints {
@@ -31,7 +32,7 @@ fun LoadingScreen(
}
CircularProgressIndicator(Modifier.align(Alignment.Center).size(size))
} else {
ErrorScreen(errorMessage)
ErrorScreen(errorMessage, modifier, retry)
}
}
}

View File

@@ -8,6 +8,7 @@ package ca.gosyer.ui.manga
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -33,7 +34,6 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
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.text.font.FontWeight
@@ -47,6 +47,7 @@ import ca.gosyer.ui.base.components.Toolbar
import ca.gosyer.ui.base.components.mangaAspectRatio
import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.ui.main.Route
import ca.gosyer.ui.reader.openReaderMenu
import ca.gosyer.util.compose.ThemedWindow
import com.github.zsoltk.compose.router.BackStack
import java.util.Date
@@ -81,14 +82,13 @@ fun MangaMenu(mangaId: Long, backStack: BackStack<Route>? = null) {
manga?.let { manga ->
Box {
val state = rememberLazyListState()
val items = remember(manga, chapters) {
listOf(MangaMenu.MangaMenuManga(manga)) + chapters.map { MangaMenu.MangaMenuChapter(it) }
}
LazyColumn(state = state) {
items(items) {
when (it) {
is MangaMenu.MangaMenuManga -> MangaItem(it.manga, serverUrl)
is MangaMenu.MangaMenuChapter -> ChapterItem(it.chapter, dateFormat::format)
item {
MangaItem(manga, serverUrl)
}
items(chapters) { chapter ->
ChapterItem(chapter, dateFormat::format) {
openReaderMenu(it, manga.id)
}
}
}
@@ -178,8 +178,8 @@ private fun MangaInfo(manga: Manga, modifier: Modifier = Modifier) {
}
@Composable
fun ChapterItem(chapter: Chapter, format: (Date) -> String) {
Surface(modifier = Modifier.fillMaxWidth().height(70.dp).padding(4.dp), elevation = 1.dp) {
fun ChapterItem(chapter: Chapter, format: (Date) -> String, onClick: (Int) -> Unit) {
Surface(modifier = Modifier.fillMaxWidth().clickable { onClick(chapter.chapterIndex) }.height(70.dp).padding(4.dp), elevation = 1.dp) {
Column(Modifier.padding(4.dp)) {
Text(chapter.name, fontSize = 20.sp, maxLines = 1)
val description = mutableListOf<String>()

View File

@@ -0,0 +1,142 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.reader
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import ca.gosyer.data.models.Chapter
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.vm.viewModel
import ca.gosyer.util.compose.ThemedWindow
fun openReaderMenu(chapterIndex: Int, mangaId: Long) {
ThemedWindow("TachideskJUI - Reader") {
ReaderMenu(chapterIndex, mangaId)
}
}
@Composable
fun ReaderMenu(chapterIndex: Int, mangaId: Long) {
val vm = viewModel<ReaderMenuViewModel> {
ReaderMenuViewModel.Params(chapterIndex, mangaId)
}
val isLoading by vm.isLoading.collectAsState()
val chapter by vm.chapter.collectAsState()
val pages by vm.pages.collectAsState()
val continuous by vm.readerModeSettings.continuous.collectAsState()
val currentPage by vm.currentPage.collectAsState()
Surface {
if (!isLoading && chapter != null) {
chapter?.let { chapter ->
val pageModifier = Modifier.fillMaxWidth().aspectRatio(mangaAspectRatio)
if (pages.isNotEmpty()) {
if (continuous) {
ContinuesReader(vm, pages, pageModifier)
} else {
PagerReader(vm, chapter, currentPage, pages, pageModifier)
}
} else {
ErrorScreen("No pages found")
}
}
} else {
LoadingScreen(isLoading)
}
}
}
@Composable
fun ReaderImage(
imageIndex: Int,
drawable: ImageBitmap?,
loading: Boolean,
error: String?,
imageModifier: Modifier = Modifier.fillMaxSize(),
loadingModifier: Modifier = imageModifier,
contentScale: ContentScale = ContentScale.Fit,
onImage: (Int) -> Unit,
retry: (Int) -> Unit
) {
SideEffect {
onImage(imageIndex)
}
if (drawable != null) {
Image(
drawable,
modifier = imageModifier,
contentDescription = null,
contentScale = contentScale
)
} else {
LoadingScreen(loading, 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
)
}
LaunchedEffect(state.currentPage) {
if (state.currentPage != currentPage) {
readerVM.progress(state.currentPage)
}
}
Pager(state) {
val image = pages[this.currentPage - 1]
ReaderImage(
image.index,
image.bitmap.collectAsState().value,
image.loading.collectAsState().value,
image.error.collectAsState().value,
loadingModifier = pageModifier,
onImage = readerVM::progress,
retry = readerVM::retry
)
}
}
@Composable
fun ContinuesReader(readerVM: ReaderMenuViewModel, pages: List<ReaderImage>, pageModifier: Modifier) {
LazyColumn {
items(pages) { image ->
ReaderImage(
image.index,
image.bitmap.collectAsState().value,
image.loading.collectAsState().value,
image.error.collectAsState().value,
loadingModifier = pageModifier,
onImage = readerVM::progress,
retry = readerVM::retry
)
}
}
}

View File

@@ -0,0 +1,148 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.reader
import androidx.compose.ui.graphics.ImageBitmap
import ca.gosyer.data.models.Chapter
import ca.gosyer.data.reader.ReaderModePreferences
import ca.gosyer.data.reader.ReaderPreferences
import ca.gosyer.data.server.interactions.ChapterInteractionHandler
import ca.gosyer.ui.base.vm.ViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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
@OptIn(ExperimentalStdlibApi::class)
class ReaderMenuViewModel @Inject constructor(
params: Params,
readerPreferences: ReaderPreferences,
chapterHandler: ChapterInteractionHandler
) : ViewModel() {
private val _chapter = MutableStateFlow<Chapter?>(null)
val chapter = _chapter.asStateFlow()
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
private val _pages = MutableStateFlow(emptyList<ReaderImage>())
val pages = _pages.asStateFlow()
private val _currentPage = MutableStateFlow(1)
val currentPage = _currentPage.asStateFlow()
val readerModeSettings = ReaderModeWatch(readerPreferences, scope)
init {
scope.launch(Dispatchers.Default) {
val chapter: Chapter
_chapter.value = chapterHandler.getChapter(params.mangaId, params.chapterIndex).also { chapter = it }
_isLoading.value = false
val pageRange = 1..(chapter.pageCount ?: 1)
_pages.value = listOf(
*pageRange.map {
ReaderImage(
it,
MutableStateFlow(null),
MutableStateFlow(true),
MutableStateFlow(null)
)
}.toTypedArray()
)
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()
}
}
fun progress(index: Int) {
_currentPage.value = index
}
fun retry(index: Int) {
}
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?>
)
class ReaderModeWatch(
private val readerPreferences: ReaderPreferences,
private val scope: CoroutineScope,
initialPreferences: ReaderModePreferences = readerPreferences.getMode(
readerPreferences.mode().get()
)
) {
private val preferenceJobs = mutableListOf<Job>()
val direction = MutableStateFlow(initialPreferences.direction().get())
val continuous = MutableStateFlow(initialPreferences.continuous().get())
val padding = MutableStateFlow(initialPreferences.padding().get())
val mode = readerPreferences.mode().stateIn(scope)
init {
setupJobs(mode.value)
mode
.onEach { mode ->
setupJobs(mode)
}
.launchIn(scope)
}
private fun setupJobs(mode: String) {
preferenceJobs.forEach {
it.cancel()
}
preferenceJobs.clear()
val preferences = readerPreferences.getMode(mode)
preferenceJobs += preferences.direction().changes()
.onEach {
direction.value = it
}
.launchIn(scope)
preferenceJobs += preferences.continuous().changes()
.onEach {
continuous.value = it
}
.launchIn(scope)
preferenceJobs += preferences.padding().changes()
.onEach {
padding.value = it
}
.launchIn(scope)
}
}

View File

@@ -6,13 +6,13 @@
package ca.gosyer.util.compose
import androidx.compose.desktop.DesktopMaterialTheme
import androidx.compose.desktop.Window
import androidx.compose.desktop.WindowEvents
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.window.MenuBar
import ca.gosyer.ui.base.theme.AppTheme
import java.awt.image.BufferedImage
fun ThemedWindow(
@@ -29,7 +29,7 @@ fun ThemedWindow(
content: @Composable () -> Unit = { }
) {
Window(title, size, location, centered, icon, menuBar, undecorated, resizable, events, onDismissRequest) {
DesktopMaterialTheme {
AppTheme {
content()
}
}