mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2026-01-25 04:54:04 +01:00
Implement simple reader, with long strip and pager support
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
31
src/main/kotlin/ca/gosyer/data/reader/ReaderPreferences.kt
Normal file
31
src/main/kotlin/ca/gosyer/data/reader/ReaderPreferences.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
17
src/main/kotlin/ca/gosyer/data/reader/model/Direction.kt
Normal file
17
src/main/kotlin/ca/gosyer/data/reader/model/Direction.kt
Normal 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
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
142
src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt
Normal file
142
src/main/kotlin/ca/gosyer/ui/reader/ReaderMenu.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt
Normal file
148
src/main/kotlin/ca/gosyer/ui/reader/ReaderMenuViewModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user