diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
index 8a4d8845..4ed21896 100644
--- a/.idea/deploymentTargetDropDown.xml
+++ b/.idea/deploymentTargetDropDown.xml
@@ -1,9 +1,9 @@
-
+
-
+
@@ -11,7 +11,7 @@
-
-
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d36af219..7161bf0f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -136,7 +136,7 @@ dependencies {
implementation("ru.noties.markwon:core:3.1.0")
implementation("xyz.quaver:documentfilex:0.7.1")
- implementation("xyz.quaver:subsampledimage:0.0.1-alpha11-SNAPSHOT")
+ implementation("xyz.quaver:subsampledimage:0.0.1-alpha13-SNAPSHOT")
implementation("com.google.guava:guava:31.0.1-android")
diff --git a/app/src/main/java/xyz/quaver/pupil/Pupil.kt b/app/src/main/java/xyz/quaver/pupil/Pupil.kt
index 325cf7b8..72d1fe71 100644
--- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt
+++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt
@@ -38,6 +38,7 @@ import com.google.firebase.ktx.Firebase
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.features.*
+import io.ktor.client.features.cache.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import okhttp3.Protocol
@@ -73,8 +74,12 @@ class Pupil : Application(), DIAware {
socketTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
connectTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
}
+ install(HttpCache)
- BrowserUserAgent()
+ install(UserAgent) {
+ agent = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36"
+ }
+ //BrowserUserAgent()
}
} }
}
diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt
index 7e05f5aa..d5bfc82f 100644
--- a/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt
+++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt
@@ -29,9 +29,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
-import androidx.compose.material.icons.filled.Fullscreen
-import androidx.compose.material.icons.filled.Star
-import androidx.compose.material.icons.filled.StarOutline
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -39,7 +36,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@@ -47,19 +43,22 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.accompanist.insets.LocalWindowInsets
-import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.rememberInsetsPaddingValues
-import com.google.accompanist.insets.ui.Scaffold
-import com.google.accompanist.insets.ui.TopAppBar
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
+import org.kodein.log.LoggerFactory
+import org.kodein.log.newLogger
import xyz.quaver.graphics.subsampledimage.*
import xyz.quaver.io.FileX
import xyz.quaver.pupil.R
@@ -68,33 +67,47 @@ import xyz.quaver.pupil.ui.theme.Orange500
import xyz.quaver.pupil.util.NetworkCache
import xyz.quaver.pupil.util.activity
import xyz.quaver.pupil.util.rememberFileXImageSource
+import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
open class ReaderBaseViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by closestDI(app)
+ private val logger = newLogger(LoggerFactory.default)
+
private val cache: NetworkCache by instance()
- var isFullscreen by mutableStateOf(false)
+ var fullscreen by mutableStateOf(false)
private val database: AppDatabase by instance()
var error by mutableStateOf(false)
- var title by mutableStateOf(null)
-
var imageCount by mutableStateOf(0)
val imageList = mutableStateListOf()
val progressList = mutableStateListOf()
+ private val progressCollectJobs = ConcurrentHashMap()
+
private val totalProgressMutex = Mutex()
var totalProgress by mutableStateOf(0)
private set
+ private var urls: List? = null
+
+ var loadJob: Job? = null
@OptIn(ExperimentalCoroutinesApi::class)
fun load(urls: List, headerBuilder: HeadersBuilder.() -> Unit = { }) {
+ this.urls = urls
viewModelScope.launch {
+ loadJob?.cancelAndJoin()
+ progressList.clear()
+ imageList.clear()
+ totalProgressMutex.withLock {
+ totalProgress = 0
+ }
+
imageCount = urls.size
progressList.addAll(List(imageCount) { 0f })
@@ -103,79 +116,61 @@ open class ReaderBaseViewModel(app: Application) : AndroidViewModel(app), DIAwar
totalProgress = 0
}
- urls.forEachIndexed { index, url ->
- when (val scheme = url.takeWhile { it != ':' }) {
- "http", "https" -> {
- val (channel, file) = cache.load {
- url(url)
- headers(headerBuilder)
- }
+ loadJob = launch {
+ urls.forEachIndexed { index, url ->
+ when (val scheme = url.takeWhile { it != ':' }) {
+ "http", "https" -> {
+ val (flow, file) = cache.load {
+ url(url)
+ headers(headerBuilder)
+ }
- if (channel.isClosedForReceive) {
imageList[index] = Uri.fromFile(file)
- totalProgressMutex.withLock {
- totalProgress++
- }
- } else {
- channel.invokeOnClose { e ->
- viewModelScope.launch {
- if (e == null) {
- imageList[index] = Uri.fromFile(file)
-
- } else {
- error(index)
- }
- imageList[index] = Uri.fromFile(file)
- totalProgressMutex.withLock {
- totalProgress++
- }
+ progressCollectJobs[index] = launch {
+ flow.takeWhile { it.isFinite() }.collect {
+ progressList[index] = it
}
- }
- launch {
- kotlin.runCatching {
- for (progress in channel) {
- progressList[index] = progress
- }
- }
+ progressList[index] = flow.value
}
}
+ "content" -> {
+ imageList[index] = Uri.parse(url)
+ progressList[index] = Float.POSITIVE_INFINITY
+ }
+ else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'")
}
- "content" -> {
- imageList[index] = Uri.parse(url)
- progressList[index] = 1f
- }
- else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'")
}
}
}
}
fun error(index: Int) {
- progressList[index] = -1f
+ progressList[index] = Float.NEGATIVE_INFINITY
+ }
+
+ override fun onCleared() {
+ urls?.let { cache.free(it) }
+ cache.cleanup()
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReaderBase(
- model: ReaderBaseViewModel,
- icon: @Composable () -> Unit = { },
- bookmark: Boolean = false,
- onToggleBookmark: () -> Unit = { }
+ modifier: Modifier = Modifier,
+ model: ReaderBaseViewModel
) {
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
- var isFABExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) }
-
val scaffoldState = rememberScaffoldState()
val snackbarCoroutineScope = rememberCoroutineScope()
- LaunchedEffect(model.isFullscreen) {
+ LaunchedEffect(model.fullscreen) {
context.activity?.window?.let { window ->
ViewCompat.getWindowInsetsController(window.decorView)?.let {
- if (model.isFullscreen) {
+ if (model.fullscreen) {
it.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.systemBars())
@@ -195,130 +190,79 @@ fun ReaderBase(
}
}
- Scaffold(
- topBar = {
- if (!model.isFullscreen)
- TopAppBar(
- title = {
- Text(
- model.title ?: stringResource(R.string.reader_loading),
- color = MaterialTheme.colors.onSecondary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- actions = {
- IconButton(onClick = { }) {
- icon()
- }
+ Box(modifier) {
+ LazyColumn(
+ Modifier
+ .fillMaxSize()
+ .align(Alignment.TopStart),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
+ ) {
+ itemsIndexed(model.imageList) { i, uri ->
+ val state = rememberSubSampledImageState(ScaleTypes.FIT_WIDTH)
- IconButton(onClick = onToggleBookmark) {
- Icon(
- if (bookmark) Icons.Default.Star else Icons.Default.StarOutline,
- contentDescription = null,
- tint = Orange500
- )
- }
- },
- contentPadding = rememberInsetsPaddingValues(
- LocalWindowInsets.current.statusBars,
- applyBottom = false
- )
- )
- },
- floatingActionButton = {
- if (!model.isFullscreen)
- MultipleFloatingActionButton(
- modifier = Modifier.navigationBarsPadding(),
- items = listOf(
- SubFabItem(
- icon = Icons.Default.Fullscreen,
- label = stringResource(id = R.string.reader_fab_fullscreen)
+ Box(
+ Modifier
+ .wrapContentHeight(state, 500.dp)
+ .fillMaxWidth()
+ .border(1.dp, Color.Gray),
+ contentAlignment = Alignment.Center
+ ) {
+ val progress = model.progressList.getOrNull(i) ?: 0f
+
+ if (progress == Float.NEGATIVE_INFINITY)
+ Icon(Icons.Filled.BrokenImage, null, tint = Orange500)
+ else if (progress.isFinite())
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
) {
- model.isFullscreen = true
+ LinearProgressIndicator(progress)
+ Text((i + 1).toString())
}
- ),
- targetState = isFABExpanded,
- onStateChanged = {
- isFABExpanded = it
- }
- )
- },
- scaffoldState = scaffoldState,
- snackbarHost = { scaffoldState.snackbarHostState }
- ) { contentPadding ->
- Box(Modifier.padding(contentPadding)) {
- LazyColumn(
- Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
- ) {
- itemsIndexed(model.imageList) { i, uri ->
- val state = rememberSubSampledImageState(ScaleTypes.FIT_WIDTH)
+ else if (uri != null && progress == Float.POSITIVE_INFINITY) {
+ val imageSource = kotlin.runCatching {
+ rememberFileXImageSource(FileX(context, uri))
+ }.getOrNull()
- Box(
- Modifier
- .wrapContentHeight(state, 500.dp)
- .fillMaxWidth()
- .border(1.dp, Color.Gray),
- contentAlignment = Alignment.Center
- ) {
- if (uri == null)
- model.progressList.getOrNull(i)?.let { progress ->
- if (progress < 0f)
- Icon(Icons.Filled.BrokenImage, null)
- else
- Column(
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- LinearProgressIndicator(progress)
- Text((i + 1).toString())
- }
- }
- else {
- val imageSource = kotlin.runCatching {
- rememberFileXImageSource(FileX(context, uri))
- }.getOrNull()
+ if (imageSource != null)
+ SubSampledImage(
+ modifier = Modifier
+ .fillMaxSize()
+ .run {
+ if (model.fullscreen)
+ doubleClickCycleZoom(state, 2f)
+ else
+ combinedClickable(
+ onLongClick = {
- if (imageSource == null)
- Icon(Icons.Default.BrokenImage, contentDescription = null)
- else
- SubSampledImage(
- modifier = Modifier
- .fillMaxSize()
- .run {
- if (model.isFullscreen)
- doubleClickCycleZoom(state, 2f)
- else
- combinedClickable(
- onLongClick = {
-
- }
- ) {
- model.isFullscreen = true
}
- },
- imageSource = imageSource,
- state = state
- )
- }
+ ) {
+ model.fullscreen = true
+ }
+ },
+ imageSource = imageSource,
+ state = state,
+ onError = {
+ model.error(i)
+ }
+ )
}
}
}
-
- if (model.totalProgress != model.imageCount)
- LinearProgressIndicator(
- modifier = Modifier
- .fillMaxWidth()
- .align(Alignment.TopCenter),
- progress = model.progressList.map { abs(it) }.sum() / model.progressList.size,
- color = MaterialTheme.colors.secondary
- )
-
- SnackbarHost(
- scaffoldState.snackbarHostState,
- modifier = Modifier.align(Alignment.BottomCenter)
- )
}
+
+ if (model.progressList.any { it.isFinite() })
+ LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.TopCenter),
+ progress = model.progressList.map { if (it.isInfinite()) 1f else abs(it) }.sum() / model.progressList.size,
+ color = MaterialTheme.colors.secondary
+ )
+
+ SnackbarHost(
+ scaffoldState.snackbarHostState,
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
}
}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt
index 3a24a881..8594ac4f 100644
--- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt
+++ b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt
@@ -19,27 +19,32 @@
package xyz.quaver.pupil.sources.hitomi
import android.app.Application
+import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material.icons.filled.Shuffle
-import androidx.compose.material.icons.filled.Sort
+import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
+import com.google.accompanist.insets.LocalWindowInsets
+import com.google.accompanist.insets.rememberInsetsPaddingValues
+import com.google.accompanist.insets.ui.Scaffold
+import com.google.accompanist.insets.ui.TopAppBar
import io.ktor.client.*
import kotlinx.coroutines.launch
import org.kodein.di.DIAware
@@ -53,9 +58,11 @@ import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.sources.composable.*
import xyz.quaver.pupil.sources.hitomi.composable.DetailedSearchResult
+import xyz.quaver.pupil.sources.hitomi.lib.GalleryInfo
import xyz.quaver.pupil.sources.hitomi.lib.getGalleryInfo
import xyz.quaver.pupil.sources.hitomi.lib.getReferer
import xyz.quaver.pupil.sources.hitomi.lib.imageUrlFromImage
+import xyz.quaver.pupil.ui.theme.Orange500
class Hitomi(app: Application) : Source(), DIAware {
override val di by closestDI(app)
@@ -195,44 +202,76 @@ class Hitomi(app: Application) : Source(), DIAware {
val coroutineScope = rememberCoroutineScope()
- val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID") ?: ""
+ val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID")
- if (itemID.isEmpty()) model.error = true
+ if (itemID == null) model.error = true
- val bookmark by bookmarkDao.contains(name, itemID).observeAsState(false)
-
- LaunchedEffect(itemID) {
+ val bookmark by bookmarkDao.contains(name, itemID ?: "").observeAsState(false)
+ val galleryInfo by produceState(null) {
runCatching {
- val galleryID = itemID.toInt()
+ val galleryID = itemID!!.toInt()
- val galleryInfo = getGalleryInfo(client, galleryID)
-
- model.title = galleryInfo.title
-
- model.load(galleryInfo.files.map { imageUrlFromImage(galleryID, it, false) }) {
- append("Referer", getReferer(galleryID))
+ value = getGalleryInfo(client, galleryID).also {
+ model.load(it.files.map { imageUrlFromImage(galleryID, it, false) }) {
+ append("Referer", getReferer(galleryID))
+ }
}
}.onFailure {
model.error = true
}
}
- ReaderBase(
- model,
- icon = {
- Image(
- painter = painterResource(R.drawable.hitomi),
- contentDescription = null,
- modifier = Modifier.size(24.dp)
- )
- },
- bookmark = bookmark,
- onToggleBookmark = {
- coroutineScope.launch {
- if (itemID.isEmpty() || bookmark) bookmarkDao.delete(name, itemID)
- else bookmarkDao.insert(name, itemID)
- }
+ BackHandler {
+ if (model.fullscreen) model.fullscreen = false
+ else navController.popBackStack()
+ }
+
+ Scaffold(
+ topBar = {
+ if (!model.fullscreen)
+ TopAppBar(
+ title = {
+ Text(
+ galleryInfo?.title ?: stringResource(R.string.reader_loading),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ actions = {
+ IconButton({ }) {
+ Image(
+ painter = painterResource(R.drawable.hitomi),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ IconButton(onClick = {
+ itemID?.let {
+ coroutineScope.launch {
+ if (bookmark) bookmarkDao.delete(name, it)
+ else bookmarkDao.insert(name, it)
+ }
+ }
+ }) {
+ Icon(
+ if (bookmark) Icons.Default.Star else Icons.Default.StarOutline,
+ contentDescription = null,
+ tint = Orange500
+ )
+ }
+ },
+ contentPadding = rememberInsetsPaddingValues(
+ LocalWindowInsets.current.statusBars,
+ applyBottom = false
+ )
+ )
}
- )
+ ) { contentPadding ->
+ ReaderBase(
+ Modifier.padding(contentPadding),
+ model
+ )
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt
index a3decdf4..047b4b73 100644
--- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt
+++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt
@@ -18,24 +18,27 @@
package xyz.quaver.pupil.sources.manatoki
import android.app.Application
+import android.util.LruCache
import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
+import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.filled.StarOutline
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -50,11 +53,13 @@ import com.google.accompanist.insets.rememberInsetsPaddingValues
import com.google.accompanist.insets.ui.Scaffold
import com.google.accompanist.insets.ui.TopAppBar
import io.ktor.client.*
-import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.compose.rememberInstance
+import org.kodein.di.instance
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import xyz.quaver.pupil.R
@@ -67,17 +72,20 @@ import xyz.quaver.pupil.sources.manatoki.composable.BoardButton
import xyz.quaver.pupil.sources.manatoki.composable.MangaListingBottomSheet
import xyz.quaver.pupil.sources.manatoki.composable.Thumbnail
import xyz.quaver.pupil.sources.manatoki.viewmodel.MainViewModel
-import java.util.concurrent.ConcurrentHashMap
+import xyz.quaver.pupil.ui.theme.Orange500
class Manatoki(app: Application) : Source(), DIAware {
override val di by closestDI(app)
private val logger = newLogger(LoggerFactory.default)
+ private val client: HttpClient by instance()
+
override val name = "manatoki.net"
override val iconResID = R.drawable.manatoki
- private val readerInfoChannel = ConcurrentHashMap>()
+ private val readerInfoMutex = Mutex()
+ private val readerInfoCache = LruCache(25)
override fun NavGraphBuilder.navGraph(navController: NavController) {
navigation(route = name, startDestination = "manatoki.net/") {
@@ -91,8 +99,6 @@ class Manatoki(app: Application) : Source(), DIAware {
fun Main(navController: NavController) {
val model: MainViewModel = viewModel()
- val client: HttpClient by rememberInstance()
-
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) }
@@ -100,22 +106,16 @@ class Manatoki(app: Application) : Source(), DIAware {
val onListing: (MangaListing) -> Unit = {
mangaListing = it
- logger.info {
- it.toString()
- }
- coroutineScope.launch {
- sheetState.show()
- }
}
val onReader: (ReaderInfo) -> Unit = { readerInfo ->
- val channel = Channel()
- readerInfoChannel[readerInfo.itemID] = channel
-
coroutineScope.launch {
- channel.send(readerInfo)
+ readerInfoMutex.withLock {
+ readerInfoCache.put(readerInfo.itemID, readerInfo)
+ }
+ sheetState.snapTo(ModalBottomSheetValue.Hidden)
+ navController.navigate("manatoki.net/reader/${readerInfo.itemID}")
}
- navController.navigate("manatoki.net/reader/${readerInfo.itemID}")
}
var sourceSelectDialog by remember { mutableStateOf(false) }
@@ -124,11 +124,6 @@ class Manatoki(app: Application) : Source(), DIAware {
SourceSelectDialog(navController, name) { sourceSelectDialog = false }
LaunchedEffect(Unit) {
- navController.backQueue.forEach {
- logger.info {
- it.destination.route.toString()
- }
- }
model.load()
}
@@ -198,6 +193,10 @@ class Manatoki(app: Application) : Source(), DIAware {
) {
items(model.recentUpload) { item ->
Thumbnail(item) {
+ coroutineScope.launch {
+ mangaListing = null
+ sheetState.show()
+ }
coroutineScope.launch {
client.getItem(it, onListing, onReader)
}
@@ -237,6 +236,10 @@ class Manatoki(app: Application) : Source(), DIAware {
) {
items(model.mangaList) { item ->
Thumbnail(item) {
+ coroutineScope.launch {
+ mangaListing = null
+ sheetState.show()
+ }
coroutineScope.launch {
client.getItem(it, onListing, onReader)
}
@@ -250,29 +253,49 @@ class Manatoki(app: Application) : Source(), DIAware {
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
model.topWeekly.forEachIndexed { index, item ->
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ Card(
+ modifier = Modifier.clickable {
+ coroutineScope.launch {
+ mangaListing = null
+ sheetState.show()
+ }
+
+ coroutineScope.launch {
+ client.getItem(item.itemID, onListing, onReader)
+ }
+ }
) {
- Text(
- (index + 1).toString(),
- modifier = Modifier
- .background(Color(0xFF64C3F5))
- .width(24.dp),
- color = Color.White,
- textAlign = TextAlign.Center
- )
+ Row(
+ modifier = Modifier.height(IntrinsicSize.Min),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .background(Color(0xFF64C3F5))
+ .width(24.dp)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ (index + 1).toString(),
+ color = Color.White,
+ textAlign = TextAlign.Center
+ )
+ }
- Text(
- item.title,
- modifier = Modifier.weight(1f),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ Text(
+ item.title,
+ modifier = Modifier.weight(1f).padding(0.dp, 4.dp),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
- Text(
- item.count,
- color = Color(0xFFFF4500)
- )
+ Text(
+ item.count,
+ color = Color(0xFFFF4500)
+ )
+ }
}
}
}
@@ -284,6 +307,7 @@ class Manatoki(app: Application) : Source(), DIAware {
}
}
+ @OptIn(ExperimentalMaterialApi::class)
@Composable
fun Reader(navController: NavController) {
val model: ReaderBaseViewModel = viewModel()
@@ -294,46 +318,127 @@ class Manatoki(app: Application) : Source(), DIAware {
val coroutineScope = rememberCoroutineScope()
val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID")
+ var readerInfo: ReaderInfo? by rememberSaveable { mutableStateOf(null) }
- LaunchedEffect(Unit) {
- val channel = itemID?.let { readerInfoChannel.remove(it) }
-
- if (channel == null)
- model.error = true
- else {
- val readerInfo = channel.receive()
-
- model.title = readerInfo.title
- model.load(readerInfo.urls)
- }
+ LaunchedEffect(itemID) {
+ if (itemID != null)
+ readerInfoMutex.withLock {
+ readerInfoCache.get(itemID)?.let {
+ readerInfo = it
+ model.load(it.urls)
+ } ?: run {
+ model.error = true
+ }
+ }
}
val bookmark by bookmarkDao.contains(name, itemID ?: "").observeAsState(false)
+ val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
+ var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) }
+
BackHandler {
- if (model.isFullscreen)
- model.isFullscreen = false
- else
- navController.popBackStack()
+ when {
+ sheetState.isVisible -> coroutineScope.launch { sheetState.hide() }
+ model.fullscreen -> model.fullscreen = false
+ else -> navController.popBackStack()
+ }
}
- ReaderBase(
- model,
- icon = {
- Image(
- painter = painterResource(R.drawable.manatoki),
- contentDescription = null,
- modifier = Modifier.size(24.dp)
- )
- },
- bookmark = bookmark,
- onToggleBookmark = {
- if (itemID != null)
+ ModalBottomSheetLayout(
+ sheetState = sheetState,
+ sheetShape = RoundedCornerShape(32.dp, 32.dp, 0.dp, 0.dp),
+ sheetContent = {
+ MangaListingBottomSheet(mangaListing) {
coroutineScope.launch {
- if (bookmark) bookmarkDao.delete(name, itemID)
- else bookmarkDao.insert(name, itemID)
+ client.getItem(
+ it,
+ onReader = {
+ coroutineScope.launch {
+ readerInfoMutex.withLock {
+ readerInfoCache.put(it.itemID, it)
+ }
+ navController.navigate("manatoki.net/reader/${it.itemID}") {
+ popUpTo("manatoki.net/reader/$itemID") { inclusive = true }
+ }
+ }
+ }
+ )
}
+ }
}
- )
+ ) {
+ Scaffold(
+ topBar = {
+ if (!model.fullscreen)
+ TopAppBar(
+ title = {
+ Text(
+ readerInfo?.title ?: stringResource(R.string.reader_loading),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ actions = {
+ IconButton({ }) {
+ Image(
+ painter = painterResource(R.drawable.manatoki),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ IconButton(onClick = {
+ itemID?.let {
+ coroutineScope.launch {
+ if (bookmark) bookmarkDao.delete(name, it)
+ else bookmarkDao.insert(name, it)
+ }
+ }
+ }) {
+ Icon(
+ if (bookmark) Icons.Default.Star else Icons.Default.StarOutline,
+ contentDescription = null,
+ tint = Orange500
+ )
+ }
+ },
+ contentPadding = rememberInsetsPaddingValues(
+ LocalWindowInsets.current.statusBars,
+ applyBottom = false
+ )
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(
+ modifier = Modifier.navigationBarsPadding(),
+ onClick = {
+ readerInfo?.let {
+ coroutineScope.launch {
+ sheetState.show()
+ }
+
+ coroutineScope.launch {
+ if (mangaListing?.itemID != it.listingItemID)
+ client.getItem(it.listingItemID, onListing = {
+ mangaListing = it
+ })
+ }
+ }
+ }
+ ) {
+ Icon(
+ Icons.Default.List,
+ contentDescription = null
+ )
+ }
+ }
+ ) { contentPadding ->
+ ReaderBase(
+ Modifier.padding(contentPadding),
+ model
+ )
+ }
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt
index 4bc30ec9..c7e90ec0 100644
--- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt
+++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt
@@ -98,12 +98,16 @@ fun MangaListingBottomSheet(
Box(
modifier = Modifier.fillMaxWidth()
) {
- mangaListing?.run {
+ if (mangaListing == null)
+ CircularProgressIndicator(Modifier.navigationBarsPadding().padding(16.dp).align(Alignment.Center))
+ else
MangaListingBottomSheetLayout(
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text("첫화보기") },
- onClick = { entries.lastOrNull()?.let { onOpenItem(it.itemID) } }
+ onClick = {
+ mangaListing.entries.lastOrNull()?.let { onOpenItem(it.itemID) }
+ }
)
},
top = {
@@ -114,7 +118,7 @@ fun MangaListingBottomSheet(
.padding(0.dp, 0.dp, 0.dp, 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- val painter = rememberImagePainter(thumbnail)
+ val painter = rememberImagePainter(mangaListing.thumbnail)
Image(
modifier = Modifier
@@ -135,13 +139,13 @@ fun MangaListingBottomSheet(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
- title,
+ mangaListing.title,
style = MaterialTheme.typography.h5,
modifier = Modifier.weight(1f)
)
CompositionLocalProvider(LocalContentAlpha provides 0.7f) {
- Text("작가: $author")
+ Text("작가: ${mangaListing.author}")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("분류: ")
@@ -151,7 +155,7 @@ fun MangaListingBottomSheet(
modifier = Modifier.weight(1f),
mainAxisSpacing = 8.dp
) {
- tags.forEach {
+ mangaListing.tags.forEach {
Card(
elevation = 4.dp
) {
@@ -166,7 +170,7 @@ fun MangaListingBottomSheet(
}
}
- Text("발행구분: $type")
+ Text("발행구분: ${mangaListing.type}")
}
}
}
@@ -177,7 +181,7 @@ fun MangaListingBottomSheet(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
) {
- items(entries) { entry ->
+ items(mangaListing.entries) { entry ->
Row(
modifier = Modifier
.clickable {
@@ -200,10 +204,5 @@ fun MangaListingBottomSheet(
}
}
)
- } ?: run {
- CircularProgressIndicator(
- Modifier.align(Alignment.Center).navigationBarsPadding().padding(16.dp)
- )
- }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt
index 9c20f9b4..854f3f71 100644
--- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt
+++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt
@@ -71,13 +71,14 @@ data class MangaListing(
data class ReaderInfo(
val itemID: String,
val title: String,
- val urls: List
+ val urls: List,
+ val listingItemID: String
): Parcelable
suspend fun HttpClient.getItem(
itemID: String,
- onListing: (MangaListing) -> Unit,
- onReader: (ReaderInfo) -> Unit
+ onListing: (MangaListing) -> Unit = { },
+ onReader: (ReaderInfo) -> Unit = { }
) = coroutineScope {
waitForRateLimit()
val content: String = get("https://manatoki116.net/comic/$itemID")
@@ -110,7 +111,16 @@ suspend fun HttpClient.getItem(
val title = doc.getElementsByClass("toon-title").first()!!.ownText()
- onReader(ReaderInfo(itemID, title, urls))
+ val listingItemID = doc.select("a:contains(전체목록)").first()!!.attr("href").takeLastWhile { it != '/' }
+
+ onReader(
+ ReaderInfo(
+ itemID,
+ title,
+ urls,
+ listingItemID
+ )
+ )
} else {
val titleBlock = doc.selectFirst("div.view-title")!!
diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt
index befdd738..c5af8f31 100644
--- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt
+++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt
@@ -86,7 +86,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
}
misoPostGallery[1]
- .select(".post-image > a").also { logger.info { it.size.toString() } }
+ .select(".post-image > a")
.forEach { entry ->
val itemID = entry.attr("href").takeLastWhile { it != '/' }
val title = entry.selectFirst("div.in-subject")!!.ownText()
@@ -99,7 +99,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
val misoPostList = doc.select(".miso-post-list")
misoPostList[4]
- .select(".post-row > a")
+ .select(".post-row > a").also { logger.info { it.size.toString() } }
.forEach { entry ->
yield()
val itemID = entry.attr("href").takeLastWhile { it != '/' }
diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt
index d70c97cf..26a62379 100644
--- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt
+++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt
@@ -75,7 +75,7 @@ class MainActivity : ComponentActivity(), DIAware {
LaunchedEffect(Unit) {
if (!launched) {
- val source = it.arguments?.getString("source") ?: "hitomi.la"
+ val source = it.arguments?.getString("source") ?: "manatoki.net"
navController.navigate(source)
launched = true
} else {
diff --git a/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt b/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt
index 805b55c5..49973fa9 100644
--- a/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt
+++ b/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt
@@ -19,6 +19,7 @@
package xyz.quaver.pupil.util
import android.content.Context
+import android.util.Log
import com.google.firebase.crashlytics.FirebaseCrashlytics
import io.ktor.client.*
import io.ktor.client.call.*
@@ -30,14 +31,17 @@ import io.ktor.util.collections.*
import io.ktor.utils.io.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.*
-import kotlinx.coroutines.channels.BufferOverflow
-import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import java.io.File
+import java.io.IOException
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
@@ -55,8 +59,13 @@ class NetworkCache(context: Context) : DIAware {
private val cacheDir = File(context.cacheDir, "networkcache")
- private val channel = ConcurrentHashMap>()
+ private val flowMutex = Mutex()
+ private val flow = ConcurrentHashMap>()
+
+ private val requestsMutex = Mutex()
private val requests = ConcurrentHashMap()
+
+ private val activeFilesMutex = Mutex()
private val activeFiles = Collections.newSetFromMap(ConcurrentHashMap())
private fun urlToFilename(url: String): String {
@@ -69,21 +78,33 @@ class NetworkCache(context: Context) : DIAware {
cacheDir.listFiles { file -> file.name !in activeFiles }?.forEach { it.delete() }
}
- fun free(urls: List) = urls.forEach {
- requests[it]?.cancel()
- channel.remove(it)
- activeFiles.remove(urlToFilename(it))
+ fun free(urls: List) = CoroutineScope(Dispatchers.IO).launch {
+ requestsMutex.withLock {
+ urls.forEach {
+ requests[it]?.cancel()
+ }
+ }
+ flowMutex.withLock {
+ urls.forEach {
+ flow.remove(it)
+ }
+ }
+ activeFilesMutex.withLock {
+ urls.forEach {
+ activeFiles.remove(urlToFilename(it))
+ }
+ }
}
fun clear() = CoroutineScope(Dispatchers.IO).launch {
requests.values.forEach { it.cancel() }
- channel.clear()
+ flow.clear()
activeFiles.clear()
cacheDir.listFiles()?.forEach { it.delete() }
}
@OptIn(ExperimentalCoroutinesApi::class)
- suspend fun load(requestBuilder: HttpRequestBuilder.() -> Unit): Pair, File> = coroutineScope {
+ suspend fun load(force: Boolean = false, requestBuilder: HttpRequestBuilder.() -> Unit): Pair, File> = coroutineScope {
val request = HttpRequestBuilder().apply(requestBuilder)
val url = request.url.buildString()
@@ -92,56 +113,65 @@ class NetworkCache(context: Context) : DIAware {
val file = File(cacheDir, fileName)
activeFiles.add(fileName)
- val progressChannel = if (channel[url]?.isClosedForSend == false)
- channel[url]!!
- else
- Channel(1, BufferOverflow.DROP_OLDEST).also { channel[url] = it }
+ val progressFlow = flowMutex.withLock {
+ if (flow.contains(url)) {
+ flow[url]!!
+ } else MutableStateFlow(0f).also { flow[url] = it }
+ }
- if (file.exists())
- progressChannel.close()
- else
- requests[url] = networkScope.launch {
- kotlin.runCatching {
- cacheDir.mkdirs()
- file.createNewFile()
+ requestsMutex.withLock {
+ if (!requests.contains(url) || force) {
+ if (force) requests[url]?.cancelAndJoin()
- client.request(request).execute { httpResponse ->
- val responseChannel: ByteReadChannel = httpResponse.receive()
- val contentLength = httpResponse.contentLength() ?: -1
- var readBytes = 0f
+ requests[url] = networkScope.launch {
+ runCatching {
+ cacheDir.mkdirs()
+ file.createNewFile()
- file.outputStream().use { outputStream ->
- while (!responseChannel.isClosedForRead) {
- if (!isActive) {
- file.delete()
- break
- }
+ client.request(request).execute { httpResponse ->
+ if (!httpResponse.status.isSuccess()) throw IOException("${request.url} failed with code ${httpResponse.status.value}")
+ val responseChannel: ByteReadChannel = httpResponse.receive()
+ val contentLength = httpResponse.contentLength() ?: -1
+ var readBytes = 0f
- val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
- while (!packet.isEmpty) {
+ file.outputStream().use { outputStream ->
+ outputStream.channel.truncate(0)
+ while (!responseChannel.isClosedForRead) {
if (!isActive) {
file.delete()
break
}
- val bytes = packet.readBytes()
- outputStream.write(bytes)
+ val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
+ while (!packet.isEmpty) {
+ if (!isActive) {
+ file.delete()
+ break
+ }
- readBytes += bytes.size
- progressChannel.trySend(readBytes / contentLength)
+ val bytes = packet.readBytes()
+ outputStream.write(bytes)
+
+ readBytes += bytes.size
+ progressFlow.emit(readBytes / contentLength)
+ }
}
}
+ progressFlow.emit(Float.POSITIVE_INFINITY)
+ }
+ }.onFailure {
+ Log.d("PUPILD-NC", it.message.toString())
+ file.delete()
+ FirebaseCrashlytics.getInstance().recordException(it)
+ progressFlow.emit(Float.NEGATIVE_INFINITY)
+ requestsMutex.withLock {
+ requests.remove(url)
}
- progressChannel.close()
}
- }.onFailure {
- logger.warning(it)
- file.delete()
- FirebaseCrashlytics.getInstance().recordException(it)
- progressChannel.close(it)
}
}
+ }
- return@coroutineScope progressChannel to file
+ return@coroutineScope progressFlow to file
}
}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/util/misc.kt b/app/src/main/java/xyz/quaver/pupil/util/misc.kt
index ffc9cf62..f23e8407 100644
--- a/app/src/main/java/xyz/quaver/pupil/util/misc.kt
+++ b/app/src/main/java/xyz/quaver/pupil/util/misc.kt
@@ -67,7 +67,11 @@ fun View.show() {
}
class FileXImageSource(val file: FileX): ImageSource {
- private val decoder = newBitmapRegionDecoder(file.inputStream()!!)
+ private val decoder by lazy {
+ file.inputStream()!!.use {
+ newBitmapRegionDecoder(it)
+ }
+ }
override val imageSize by lazy { Size(decoder.width.toFloat(), decoder.height.toFloat()) }
diff --git a/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt b/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt
index 9e0c0674..233e4a8f 100644
--- a/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt
+++ b/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt
@@ -20,40 +20,33 @@
package xyz.quaver.pupil
+import io.ktor.client.*
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import xyz.quaver.pupil.sources.manatoki.getItem
+
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
-import kotlinx.coroutines.async
-import kotlinx.coroutines.runBlocking
-import kotlinx.serialization.*
-import kotlinx.serialization.json.Json
-import org.junit.Test
-import xyz.quaver.hitomi.getGalleryInfo
-import xyz.quaver.hitomi.imageUrlFromImage
-import xyz.quaver.pupil.sources.Hiyobi_io
-import java.lang.reflect.ParameterizedType
-import kotlin.reflect.KClass
-import kotlin.reflect.KType
-import kotlin.reflect.typeOf
-
class ExampleUnitTest {
@Test
fun test() {
- val galleryID = 479010
- val files = getGalleryInfo(galleryID).files
+ val itemID = 232566
- files.forEachIndexed { i, it ->
- println("$i: ${imageUrlFromImage(galleryID, it, true)}")
+ val client = HttpClient()
+
+ runBlocking {
+ client.getItem(
+ itemID.toString(),
+ onReader = {
+ print(it)
+ }
+ )
}
}
- @Test
- fun test2() {
- print(Hiyobi_io.parseQuery("female:loli female:big_breast tag:group"))
- }
-
}