External Sources
This commit is contained in:
@@ -135,6 +135,8 @@ dependencies {
|
||||
|
||||
implementation("ru.noties.markwon:core:3.1.0")
|
||||
|
||||
implementation("xyz.quaver.pupil.sources:core:0.0.1-alpha01-DEV09")
|
||||
|
||||
implementation("xyz.quaver:documentfilex:0.7.1")
|
||||
implementation("xyz.quaver:subsampledimage:0.0.1-alpha18-SNAPSHOT")
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.Assert.*
|
||||
import xyz.quaver.pupil.sources.isSourceFeatureEnabled
|
||||
import xyz.quaver.pupil.sources.loadSource
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SourceLoaderInstrumentedTest {
|
||||
|
||||
@Test
|
||||
fun getPackages() {
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val application: Application = appContext.applicationContext as Application
|
||||
|
||||
val packageManager = appContext.packageManager
|
||||
|
||||
val packages = packageManager.getInstalledPackages(
|
||||
PackageManager.GET_CONFIGURATIONS or
|
||||
PackageManager.GET_META_DATA
|
||||
)
|
||||
|
||||
val sources = packages.filter { it.isSourceFeatureEnabled }
|
||||
|
||||
assertEquals(1, sources.size)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
|
||||
<application
|
||||
android:name=".Pupil"
|
||||
|
||||
@@ -45,7 +45,9 @@ import okhttp3.Protocol
|
||||
import org.kodein.di.*
|
||||
import org.kodein.di.android.x.androidXModule
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.pupil.proto.settingsDataStore
|
||||
import xyz.quaver.pupil.sources.sourceModule
|
||||
import xyz.quaver.pupil.sources.util.NetworkCache
|
||||
import xyz.quaver.pupil.util.*
|
||||
import java.util.*
|
||||
|
||||
@@ -53,10 +55,12 @@ class Pupil : Application(), DIAware {
|
||||
|
||||
override val di: DI by DI.lazy {
|
||||
import(androidXModule(this@Pupil))
|
||||
import(sourceModule)
|
||||
import(sourceModule(this@Pupil))
|
||||
|
||||
bind { singleton { NetworkCache(applicationContext) } }
|
||||
|
||||
bindSingleton { settingsDataStore }
|
||||
|
||||
bind { singleton {
|
||||
HttpClient(OkHttp) {
|
||||
engine {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
package xyz.quaver.pupil.proto
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.DataStore
|
||||
@@ -41,7 +42,7 @@ object SettingsSerializer : Serializer<Settings> {
|
||||
override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output)
|
||||
}
|
||||
|
||||
val Context.settingsDataStore: DataStore<Settings> by dataStore(
|
||||
val Application.settingsDataStore: DataStore<Settings> by dataStore(
|
||||
fileName = "settings.proto",
|
||||
serializer = SettingsSerializer
|
||||
)
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
/*
|
||||
class Downloads(override val di: DI) : Source(), DIAware {
|
||||
|
||||
override val name: String
|
||||
get() = "downloads"
|
||||
override val iconResID: Int
|
||||
get() = R.drawable.ic_download
|
||||
override val preferenceID: Int
|
||||
get() = R.xml.download_preferences
|
||||
override val availableSortMode: List<String> = emptyList()
|
||||
|
||||
private val downloadManager: DownloadManager by instance()
|
||||
|
||||
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
|
||||
TODO()
|
||||
/*
|
||||
val downloads = downloadManager.downloads.toList()
|
||||
|
||||
val channel = Channel<ItemInfo>()
|
||||
val sanitizedRange = max(0, range.first) .. min(range.last, downloads.size - 1)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
downloads.slice(sanitizedRange).map { (_, folderName) ->
|
||||
transform(downloadManager.downloadFolder.getChild(folderName))
|
||||
}.forEach {
|
||||
channel.send(it)
|
||||
}
|
||||
|
||||
channel.close()
|
||||
}
|
||||
|
||||
return Pair(channel, downloads.size)*/
|
||||
}
|
||||
|
||||
override suspend fun suggestion(query: String): List<SearchSuggestion> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun images(itemID: String): List<String> {
|
||||
return downloadManager.downloadFolder.getChild(itemID).let {
|
||||
if (!it.exists()) null else images(it)
|
||||
}!!
|
||||
}
|
||||
|
||||
override suspend fun info(itemID: String): ItemInfo {
|
||||
TODO("Not yet implemented")
|
||||
/* return transform(downloadManager.downloadFolder.getChild(itemID))*/
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun images(folder: FileX): List<String>? =
|
||||
folder.list { _, name ->
|
||||
name.takeLastWhile { it != '.' } in listOf("jpg", "png", "gif", "webp")
|
||||
}?.toList()
|
||||
/*
|
||||
suspend fun transform(folder: FileX): ItemInfo = withContext(Dispatchers.Unconfined) {
|
||||
kotlin.runCatching {
|
||||
Json.decodeFromString<ItemInfo>(folder.getChild(".metadata").readText())
|
||||
}.getOrNull() ?: run {
|
||||
val images = images(folder)
|
||||
ItemInfo(
|
||||
"Downloads",
|
||||
folder.name,
|
||||
folder.name,
|
||||
images?.firstOrNull() ?: "",
|
||||
"",
|
||||
mapOf(
|
||||
ItemInfo.ExtraType.PAGECOUNT to async { images?.size?.toString() }
|
||||
)
|
||||
)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun compose(itemInfo: ItemInfo) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}*/
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
//
|
||||
//class History(override val di: DI) : Source(), DIAware {
|
||||
// private val historyDao = direct.database().historyDao()
|
||||
//
|
||||
// override val name: String
|
||||
// get() = "history"
|
||||
// override val iconResID: Int
|
||||
// get() = 0 //TODO
|
||||
// override val availableSortMode: List<String> = emptyList()
|
||||
//
|
||||
// private val history = direct.database().historyDao()
|
||||
//
|
||||
// override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
|
||||
// val channel = Channel<ItemInfo>()
|
||||
//
|
||||
// CoroutineScope(Dispatchers.IO).launch {
|
||||
//
|
||||
//
|
||||
// channel.close()
|
||||
// }
|
||||
//
|
||||
// throw NotImplementedError("")
|
||||
// //return Pair(channel, histories.map.size)
|
||||
// }
|
||||
//
|
||||
// override suspend fun images(itemID: String): List<String> {
|
||||
// throw NotImplementedError("")
|
||||
// }
|
||||
//
|
||||
// override suspend fun info(itemID: String): ItemInfo {
|
||||
// throw NotImplementedError("")
|
||||
// }
|
||||
//
|
||||
//
|
||||
// @Composable
|
||||
// override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
|
||||
//
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -1,465 +0,0 @@
|
||||
///*
|
||||
// * Pupil, Hitomi.la viewer for Android
|
||||
// * Copyright (C) 2021 tom5079
|
||||
// *
|
||||
// * This program is free software: you can redistribute it and/or modify
|
||||
// * it under the terms of the GNU General Public License as published by
|
||||
// * the Free Software Foundation, either version 3 of the License, or
|
||||
// * (at your option) any later version.
|
||||
// *
|
||||
// * This program is distributed in the hope that it will be useful,
|
||||
// * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// * GNU General Public License for more details.
|
||||
// *
|
||||
// * You should have received a copy of the GNU General Public License
|
||||
// * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
// */
|
||||
//
|
||||
//package xyz.quaver.pupil.sources
|
||||
//
|
||||
//import android.app.Application
|
||||
//import android.os.Parcelable
|
||||
//import androidx.compose.foundation.Image
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.shape.CircleShape
|
||||
//import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.material.icons.Icons
|
||||
//import androidx.compose.material.icons.filled.Female
|
||||
//import androidx.compose.material.icons.filled.Male
|
||||
//import androidx.compose.material.icons.filled.Star
|
||||
//import androidx.compose.material.icons.filled.StarOutline
|
||||
//import androidx.compose.material.icons.outlined.StarOutline
|
||||
//import androidx.compose.runtime.*
|
||||
//import androidx.compose.runtime.livedata.observeAsState
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.draw.clip
|
||||
//import androidx.compose.ui.geometry.Size
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.layout.ContentScale
|
||||
//import androidx.compose.ui.res.stringResource
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import coil.annotation.ExperimentalCoilApi
|
||||
//import coil.compose.rememberImagePainter
|
||||
//import com.google.accompanist.flowlayout.FlowRow
|
||||
//import io.ktor.client.*
|
||||
//import io.ktor.client.request.*
|
||||
//import io.ktor.http.*
|
||||
//import kotlinx.coroutines.*
|
||||
//import kotlinx.coroutines.channels.Channel
|
||||
//import kotlinx.parcelize.Parcelize
|
||||
//import kotlinx.serialization.Serializable
|
||||
//import kotlinx.serialization.json.JsonObject
|
||||
//import kotlinx.serialization.json.int
|
||||
//import kotlinx.serialization.json.jsonArray
|
||||
//import kotlinx.serialization.json.jsonPrimitive
|
||||
//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.pupil.R
|
||||
//import xyz.quaver.pupil.db.AppDatabase
|
||||
//import xyz.quaver.pupil.db.Bookmark
|
||||
//import xyz.quaver.pupil.ui.theme.Blue700
|
||||
//import xyz.quaver.pupil.ui.theme.Orange500
|
||||
//import xyz.quaver.pupil.ui.theme.Pink600
|
||||
//import xyz.quaver.pupil.util.content
|
||||
//import xyz.quaver.pupil.util.get
|
||||
//import xyz.quaver.pupil.util.wordCapitalize
|
||||
//
|
||||
//@Serializable
|
||||
//@Parcelize
|
||||
//data class Tag(
|
||||
// val male: Int?,
|
||||
// val female: Int?,
|
||||
// val tag: String
|
||||
//) : Parcelable {
|
||||
// override fun toString(): String {
|
||||
// val stringBuilder = StringBuilder()
|
||||
//
|
||||
// stringBuilder.append(when {
|
||||
// male != null -> "male"
|
||||
// female != null -> "female"
|
||||
// else -> "tag"
|
||||
// })
|
||||
// stringBuilder.append(':')
|
||||
// stringBuilder.append(tag)
|
||||
//
|
||||
// return stringBuilder.toString()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Serializable
|
||||
//@Parcelize
|
||||
//data class HiyobiItemInfo(
|
||||
// override val itemID: String,
|
||||
// override val title: String,
|
||||
// val thumbnail: String,
|
||||
// val artists: List<String>,
|
||||
// val series: List<String>,
|
||||
// val type: String,
|
||||
// val date: String,
|
||||
// val bookmark: Unit?,
|
||||
// val tags: List<Tag>,
|
||||
// val commentCount: Int,
|
||||
// val pageCount: Int
|
||||
//): ItemInfo {
|
||||
// override val source: String
|
||||
// get() = "hiyobi.io"
|
||||
//}
|
||||
//
|
||||
//@Serializable
|
||||
//data class Manga(
|
||||
// val mangaId: Int,
|
||||
// val title: String,
|
||||
// val artist: List<String>,
|
||||
// val thumbnail: String,
|
||||
// val series: List<String>,
|
||||
// val type: String,
|
||||
// val date: String,
|
||||
// val bookmark: Unit?,
|
||||
// val tags: List<Tag>,
|
||||
// val commentCount: Int,
|
||||
// val pageCount: Int
|
||||
//)
|
||||
//
|
||||
//@Serializable
|
||||
//data class QueryManga(
|
||||
// val nowPage: Int,
|
||||
// val maxPage: Int,
|
||||
// val manga: List<Manga>
|
||||
//)
|
||||
//
|
||||
//@Serializable
|
||||
//data class SearchResultData(
|
||||
// val queryManga: QueryManga
|
||||
//)
|
||||
//
|
||||
//@Serializable
|
||||
//data class SearchResult(
|
||||
// val data: SearchResultData
|
||||
//)
|
||||
//
|
||||
//class Hiyobi_io(app: Application): Source(), DIAware {
|
||||
// override val di by closestDI(app)
|
||||
//
|
||||
// private val logger = newLogger(LoggerFactory.default)
|
||||
//
|
||||
// private val database: AppDatabase by instance()
|
||||
// private val bookmarkDao = database.bookmarkDao()
|
||||
//
|
||||
// override val name = "hiyobi.io"
|
||||
// override val iconResID = R.drawable.hitomi
|
||||
// override val availableSortMode = emptyList<String>()
|
||||
//
|
||||
// private val client: HttpClient by instance()
|
||||
//
|
||||
// private suspend fun query(page: Int, tags: String): SearchResult {
|
||||
// val query = "{queryManga(page:$page,tags:$tags){nowPage maxPage manga{mangaId title artist thumbnail series type date bookmark tags{male female tag} commentCount pageCount}}}"
|
||||
//
|
||||
// return client.get("https://api.hiyobi.io/api?query=$query")
|
||||
// }
|
||||
//
|
||||
// private suspend fun totalCount(tags: String): Int {
|
||||
// val firstPageQuery = "{queryManga(page:1,tags:$tags){maxPage}}"
|
||||
// val maxPage = client.get<JsonObject>(
|
||||
// "https://api.hiyobi.io/api?query=$firstPageQuery"
|
||||
// )["data"]!!["queryManga"]!!["maxPage"]!!.jsonPrimitive.int
|
||||
//
|
||||
// val lastPageQuery = "{queryManga(page:$maxPage,tags:$tags){manga{mangaId}}}"
|
||||
// val lastPageCount = client.get<JsonObject>(
|
||||
// "https://api.hiyobi.io/api?query=$lastPageQuery"
|
||||
// )["data"]!!["queryManga"]!!["manga"]!!.jsonArray.size
|
||||
//
|
||||
// return (maxPage-1)*25+lastPageCount
|
||||
// }
|
||||
//
|
||||
// override suspend fun search(query: String, page: Int, sortMode: Int): Pair<Channel<ItemInfo>, Int> = withContext(Dispatchers.IO) {
|
||||
// val channel = Channel<ItemInfo>()
|
||||
//
|
||||
// val tags = parseQuery(query)
|
||||
//
|
||||
// logger.info {
|
||||
// tags
|
||||
// }
|
||||
//
|
||||
// CoroutineScope(Dispatchers.IO).launch {
|
||||
// (range.first/25+1 .. range.last/25+1).map { page ->
|
||||
// page to async { query(page, tags) }
|
||||
// }.forEach { (page, result) ->
|
||||
// result.await().data.queryManga.manga.forEachIndexed { index, manga ->
|
||||
// if ((page-1)*25+index in range) channel.send(transform(manga))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// channel.close()
|
||||
// }
|
||||
//
|
||||
// channel to totalCount(tags)
|
||||
// }
|
||||
//
|
||||
// override suspend fun images(itemID: String): List<String> = withContext(Dispatchers.IO) {
|
||||
// val query = "{getManga(mangaId:$itemID){urls}}"
|
||||
//
|
||||
// client.post<JsonObject>("https://api.hiyobi.io/api") {
|
||||
// contentType(ContentType.Application.Json)
|
||||
// body = mapOf("query" to query)
|
||||
// }["data"]!!["getManga"]!!["urls"]!!.jsonArray.map { "https://api.hiyobi.io/${it.content!!}" }
|
||||
// }
|
||||
//
|
||||
// override suspend fun info(itemID: String): ItemInfo {
|
||||
// TODO("Not yet implemented")
|
||||
// }
|
||||
//
|
||||
// @OptIn(ExperimentalMaterialApi::class)
|
||||
// @Composable
|
||||
// fun TagChip(tag: Tag, isFavorite: Boolean, onClick: ((Tag) -> Unit)? = null, onFavoriteClick: ((Tag) -> Unit)? = null) {
|
||||
// val icon = when {
|
||||
// tag.male != null -> Icons.Filled.Male
|
||||
// tag.female != null -> Icons.Filled.Female
|
||||
// else -> null
|
||||
// }
|
||||
//
|
||||
// val (surfaceColor, textTint) = when {
|
||||
// isFavorite -> Pair(Orange500, Color.White)
|
||||
// else -> when {
|
||||
// tag.male != null -> Pair(Blue700, Color.White)
|
||||
// tag.female != null -> Pair(Pink600, Color.White)
|
||||
// else -> Pair(MaterialTheme.colors.background, MaterialTheme.colors.onBackground)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// val starIcon = if (isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline
|
||||
//
|
||||
// Surface(
|
||||
// modifier = Modifier.padding(2.dp),
|
||||
// onClick = { onClick?.invoke(tag) },
|
||||
// shape = RoundedCornerShape(16.dp),
|
||||
// color = surfaceColor,
|
||||
// elevation = 2.dp
|
||||
// ) {
|
||||
// Row(
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// if (icon != null)
|
||||
// Icon(
|
||||
// icon,
|
||||
// contentDescription = "Icon",
|
||||
// modifier = Modifier
|
||||
// .padding(4.dp)
|
||||
// .size(24.dp),
|
||||
// tint = Color.White
|
||||
// )
|
||||
// else
|
||||
// Box(Modifier.size(16.dp))
|
||||
//
|
||||
// Text(
|
||||
// tag.tag,
|
||||
// color = textTint,
|
||||
// style = MaterialTheme.typography.body2
|
||||
// )
|
||||
//
|
||||
// Icon(
|
||||
// starIcon,
|
||||
// contentDescription = "Favorites",
|
||||
// modifier = Modifier
|
||||
// .padding(8.dp)
|
||||
// .size(16.dp)
|
||||
// .clip(CircleShape)
|
||||
// .clickable { onFavoriteClick?.invoke(tag) },
|
||||
// tint = textTint
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @OptIn(ExperimentalMaterialApi::class)
|
||||
// @Composable
|
||||
// fun TagGroup(tags: List<Tag>) {
|
||||
// var isFolded by remember { mutableStateOf(true) }
|
||||
// val bookmarkedTags by bookmarkDao.getAll(name).observeAsState(emptyList())
|
||||
//
|
||||
// val bookmarkedTagsInList = tags.filter { it.toString() in bookmarkedTags }
|
||||
//
|
||||
// FlowRow(Modifier.padding(0.dp, 16.dp)) {
|
||||
// tags.sortedBy { if (bookmarkedTagsInList.contains(it)) 0 else 1 }.let { (if (isFolded) it.take(10) else it) }.forEach { tag ->
|
||||
// TagChip(
|
||||
// tag = tag,
|
||||
// isFavorite = bookmarkedTagsInList.contains(tag),
|
||||
// onFavoriteClick = {
|
||||
// val bookmarkTag = Bookmark(name, it.toString())
|
||||
//
|
||||
// CoroutineScope(Dispatchers.IO).launch {
|
||||
// if (bookmarkedTagsInList.contains(it))
|
||||
// bookmarkDao.delete(bookmarkTag)
|
||||
// else
|
||||
// bookmarkDao.insert(bookmarkTag)
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// if (isFolded && tags.size > 10)
|
||||
// Surface(
|
||||
// modifier = Modifier.padding(2.dp),
|
||||
// color = MaterialTheme.colors.background,
|
||||
// shape = RoundedCornerShape(16.dp),
|
||||
// elevation = 2.dp,
|
||||
// onClick = { isFolded = false }
|
||||
// ) {
|
||||
// Text(
|
||||
// "…",
|
||||
// modifier = Modifier.padding(16.dp, 8.dp),
|
||||
// color = MaterialTheme.colors.onBackground,
|
||||
// style = MaterialTheme.typography.body2
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @OptIn(ExperimentalCoilApi::class)
|
||||
// @Composable
|
||||
// override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
|
||||
// itemInfo as HiyobiItemInfo
|
||||
//
|
||||
// val bookmark by bookmarkDao.contains(itemInfo).observeAsState(false)
|
||||
//
|
||||
// val painter = rememberImagePainter(itemInfo.thumbnail)
|
||||
//
|
||||
// Column(
|
||||
// modifier = Modifier.clickable {
|
||||
// onEvent(SearchResultEvent(SearchResultEvent.Type.OPEN_READER, itemInfo.itemID, itemInfo))
|
||||
// }
|
||||
// ) {
|
||||
// Row {
|
||||
// Image(
|
||||
// painter = painter,
|
||||
// contentDescription = null,
|
||||
// modifier = Modifier
|
||||
// .requiredWidth(150.dp)
|
||||
// .aspectRatio(
|
||||
// with(painter.intrinsicSize) { if (this == Size.Unspecified) 1f else width / height },
|
||||
// true
|
||||
// )
|
||||
// .padding(0.dp, 0.dp, 8.dp, 0.dp)
|
||||
// .align(Alignment.CenterVertically),
|
||||
// contentScale = ContentScale.FillWidth
|
||||
// )
|
||||
//
|
||||
// Column {
|
||||
// Text(
|
||||
// itemInfo.title,
|
||||
// style = MaterialTheme.typography.h6,
|
||||
// color = MaterialTheme.colors.onSurface
|
||||
// )
|
||||
//
|
||||
// val artistStringBuilder = StringBuilder()
|
||||
//
|
||||
// with(itemInfo.artists) {
|
||||
// if (this.isNotEmpty())
|
||||
// artistStringBuilder.append(this.joinToString(", ") { it.wordCapitalize() })
|
||||
// }
|
||||
//
|
||||
// if (artistStringBuilder.isNotEmpty())
|
||||
// Text(
|
||||
// artistStringBuilder.toString(),
|
||||
// style = MaterialTheme.typography.subtitle1,
|
||||
// color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
|
||||
// )
|
||||
//
|
||||
// if (itemInfo.series.isNotEmpty())
|
||||
// Text(
|
||||
// stringResource(
|
||||
// id = R.string.galleryblock_series,
|
||||
// itemInfo.series.joinToString { it.wordCapitalize() }
|
||||
// ),
|
||||
// style = MaterialTheme.typography.body2,
|
||||
// color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
|
||||
// )
|
||||
//
|
||||
// Text(
|
||||
// stringResource(id = R.string.galleryblock_type, itemInfo.type),
|
||||
// style = MaterialTheme.typography.body2,
|
||||
// color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
|
||||
// )
|
||||
//
|
||||
// key(itemInfo.tags) {
|
||||
// TagGroup(tags = itemInfo.tags)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Divider(
|
||||
// thickness = 1.dp,
|
||||
// modifier = Modifier.padding(0.dp, 8.dp, 0.dp, 0.dp)
|
||||
// )
|
||||
//
|
||||
// Row(
|
||||
// modifier = Modifier.padding(8.dp).fillMaxWidth(),
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// horizontalArrangement = Arrangement.SpaceBetween
|
||||
// ) {
|
||||
// Text(itemInfo.itemID)
|
||||
//
|
||||
// Text("${itemInfo.pageCount}P")
|
||||
//
|
||||
// Icon(
|
||||
// if (bookmark) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
// contentDescription = null,
|
||||
// tint = Orange500,
|
||||
// modifier = Modifier
|
||||
// .size(32.dp)
|
||||
// .clickable {
|
||||
// CoroutineScope(Dispatchers.IO).launch {
|
||||
// if (bookmark) bookmarkDao.delete(itemInfo)
|
||||
// else bookmarkDao.insert(itemInfo)
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// companion object {
|
||||
// private fun transform(manga: Manga) = HiyobiItemInfo(
|
||||
// manga.mangaId.toString(),
|
||||
// manga.title,
|
||||
// "https://api.hiyobi.io/${manga.thumbnail}",
|
||||
// manga.artist,
|
||||
// manga.series,
|
||||
// manga.type,
|
||||
// manga.date,
|
||||
// manga.bookmark,
|
||||
// manga.tags,
|
||||
// manga.commentCount,
|
||||
// manga.pageCount
|
||||
// )
|
||||
//
|
||||
// fun parseQuery(query: String): String {
|
||||
// val queryBuilder = StringBuilder("[")
|
||||
//
|
||||
// if (query.isNotBlank())
|
||||
// query.split(' ').filter { it.isNotBlank() }.forEach {
|
||||
// val tags = it.replace('_', ' ').split(':', limit = 2)
|
||||
//
|
||||
// if (queryBuilder.length != 1) queryBuilder.append(',')
|
||||
//
|
||||
// queryBuilder.append(
|
||||
// when {
|
||||
// tags.size == 1 -> "{tag:\"${tags[0]}\"}"
|
||||
// tags[0] == "male" -> "{male:1,tag:\"${tags[1]}\"}"
|
||||
// tags[0] == "female" -> "{female:1,tag:\"${tags[1]}\"}"
|
||||
// else -> "{tag:\"${tags[1]}\"}"
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// return queryBuilder.append(']').toString()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -19,32 +19,76 @@
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
import android.app.Application
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import dalvik.system.DexClassLoader
|
||||
import dalvik.system.PathClassLoader
|
||||
import org.kodein.di.*
|
||||
import xyz.quaver.pupil.sources.hitomi.Hitomi
|
||||
import xyz.quaver.pupil.sources.manatoki.Manatoki
|
||||
import org.kodein.di.bindings.NoArgBindingDI
|
||||
import org.kodein.di.bindings.NoArgDIBinding
|
||||
import java.util.*
|
||||
|
||||
abstract class Source {
|
||||
abstract val name: String
|
||||
abstract val iconResID: Int
|
||||
private const val SOURCES_FEATURE = "pupil.sources"
|
||||
private const val SOURCES_PACKAGE_PREFIX = "xyz.quaver.pupil.sources"
|
||||
private const val SOURCES_PATH = "pupil.sources.path"
|
||||
|
||||
open fun NavGraphBuilder.navGraph(navController: NavController) { }
|
||||
data class SourceEntry(
|
||||
val name: String,
|
||||
val source: Source,
|
||||
val icon: Drawable
|
||||
)
|
||||
typealias SourceEntries = Map<String, SourceEntry>
|
||||
|
||||
private val sources = mutableMapOf<String, SourceEntry>()
|
||||
|
||||
val PackageInfo.isSourceFeatureEnabled
|
||||
get() = this.reqFeatures.orEmpty().any { it.name == SOURCES_FEATURE }
|
||||
|
||||
fun loadSource(app: Application, packageInfo: PackageInfo) {
|
||||
val packageManager = app.packageManager
|
||||
val applicationInfo = packageInfo.applicationInfo
|
||||
|
||||
val classLoader = PathClassLoader(applicationInfo.sourceDir, null, app.classLoader)
|
||||
val packageName = packageInfo.packageName
|
||||
|
||||
val sourceName = packageManager.getApplicationLabel(applicationInfo).toString().substringAfter("[Pupil] ")
|
||||
|
||||
val icon = packageManager.getApplicationIcon(applicationInfo)
|
||||
|
||||
packageInfo
|
||||
.applicationInfo
|
||||
.metaData
|
||||
.getString(SOURCES_PATH)
|
||||
?.split(';')
|
||||
.orEmpty()
|
||||
.forEach { sourcePath ->
|
||||
sources[sourceName] = SourceEntry(
|
||||
sourceName,
|
||||
Class.forName("$packageName$sourcePath", false, classLoader)
|
||||
.getConstructor(Application::class.java)
|
||||
.newInstance(app) as Source,
|
||||
icon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
typealias SourceEntry = Pair<String, Source>
|
||||
typealias SourceEntries = Set<SourceEntry>
|
||||
val sourceModule = DI.Module(name = "source") {
|
||||
bindSet<SourceEntry>()
|
||||
fun loadSources(app: Application) {
|
||||
val packageManager = app.packageManager
|
||||
|
||||
listOf<(Application) -> (Source)>(
|
||||
{ Hitomi(it) },
|
||||
//{ Hiyobi_io(it) },
|
||||
{ Manatoki(it) }
|
||||
).forEach { source ->
|
||||
inSet { singleton { source(instance()).let { it.name to it } } }
|
||||
}
|
||||
val packages = packageManager.getInstalledPackages(
|
||||
PackageManager.GET_CONFIGURATIONS or
|
||||
PackageManager.GET_META_DATA
|
||||
)
|
||||
|
||||
//bind { singleton { History(di) } }
|
||||
// inSet { singleton { Downloads(di).let { it.name to it as Source } } }
|
||||
val sources = packages.filter { it.isSourceFeatureEnabled }
|
||||
|
||||
sources.forEach { loadSource(app, it) }
|
||||
}
|
||||
|
||||
fun sourceModule(app: Application) = DI.Module(name = "source") {
|
||||
loadSources(app)
|
||||
bindInstance { Collections.unmodifiableMap(sources) }
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -25,42 +25,39 @@ import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import xyz.quaver.pupil.sources.Source
|
||||
import xyz.quaver.pupil.sources.SourceEntries
|
||||
|
||||
@Composable
|
||||
fun SourceSelectDialog(navController: NavController, currentSource: String? = null, onDismissRequest: () -> Unit = { }) {
|
||||
SourceSelectDialog(currentSource = currentSource, onDismissRequest = onDismissRequest) {
|
||||
onDismissRequest()
|
||||
navController.navigate(it.name) {
|
||||
currentSource?.let { popUpTo(currentSource) { inclusive = true } }
|
||||
currentSource?.let { popUpTo("main") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceSelectDialogItem(source: Source, isSelected: Boolean, onSelected: (Source) -> Unit = { }) {
|
||||
fun SourceSelectDialogItem(sourceEntry: SourceEntry, isSelected: Boolean, onSelected: (Source) -> Unit = { }) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(source.iconResID),
|
||||
painter = rememberDrawablePainter(sourceEntry.icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
source.name,
|
||||
sourceEntry.name,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
@@ -73,7 +70,7 @@ fun SourceSelectDialogItem(source: Source, isSelected: Boolean, onSelected: (Sou
|
||||
Button(
|
||||
enabled = !isSelected,
|
||||
onClick = {
|
||||
onSelected(source)
|
||||
onSelected(sourceEntry.source)
|
||||
}
|
||||
) {
|
||||
Text("GO")
|
||||
@@ -92,8 +89,8 @@ fun SourceSelectDialog(currentSource: String? = null, onDismissRequest: () -> Un
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column() {
|
||||
sourceEntries.forEach { SourceSelectDialogItem(it.second, it.first == currentSource, onSelected) }
|
||||
sourceEntries.values.forEach { SourceSelectDialogItem(it, it.name == currentSource, onSelected) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.util.KeyboardManager
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun FloatingSearchBar(
|
||||
modifier: Modifier = Modifier,
|
||||
query: String = "",
|
||||
onQueryChange: (String) -> Unit = { },
|
||||
navigationIcon: @Composable () -> Unit = { },
|
||||
actions: @Composable RowScope.() -> Unit = { },
|
||||
onSearch: () -> Unit = { },
|
||||
onTextFieldFocused: () -> Unit = { },
|
||||
onTextFieldUnfocused: () -> Unit = { }
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(context) {
|
||||
val keyboardManager = KeyboardManager(context)
|
||||
keyboardManager.attachKeyboardDismissListener {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
onDispose {
|
||||
keyboardManager.release()
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.padding(8.dp, 8.dp)
|
||||
.background(Color.Transparent),
|
||||
elevation = 8.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
navigationIcon()
|
||||
|
||||
BasicTextField(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(16.dp, 0.dp)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) onTextFieldFocused()
|
||||
else onTextFieldUnfocused()
|
||||
|
||||
isFocused = it.isFocused
|
||||
},
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface),
|
||||
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = { onSearch() }
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
if (query.isEmpty())
|
||||
Text(
|
||||
stringResource(R.string.search_hint),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
innerTextField()
|
||||
}
|
||||
|
||||
if (query.isNotEmpty() && isFocused)
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.clickable { onQueryChange("") }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
|
||||
Row(
|
||||
Modifier.fillMaxHeight(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = actions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.unit.*
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.sources.composable.ModalTopSheetState.Expanded
|
||||
import xyz.quaver.pupil.sources.composable.ModalTopSheetState.Hidden
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ModalTopSheetLayoutShape(
|
||||
private val cornerRadius: Dp,
|
||||
private val handleRadius: Dp
|
||||
): Shape {
|
||||
|
||||
private fun drawDrawerPath(
|
||||
size: Size,
|
||||
cornerRadius: Float,
|
||||
handleRadius: Float
|
||||
) = Path().apply {
|
||||
reset()
|
||||
|
||||
lineTo(x = size.width, y = 0f)
|
||||
|
||||
lineTo(x = size.width, y = size.height - cornerRadius)
|
||||
|
||||
arcTo(
|
||||
Rect(
|
||||
left = size.width - 2*cornerRadius,
|
||||
top = size.height - 2*cornerRadius,
|
||||
right = size.width,
|
||||
bottom = size.height
|
||||
),
|
||||
startAngleDegrees = 0f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
lineTo(x = size.width / 2 + handleRadius, y = size.height)
|
||||
|
||||
arcTo(
|
||||
Rect(
|
||||
left = size.width/2 - handleRadius,
|
||||
top = size.height - handleRadius,
|
||||
right = size.width/2 + handleRadius,
|
||||
bottom = size.height + handleRadius
|
||||
),
|
||||
startAngleDegrees = 0f,
|
||||
sweepAngleDegrees = 180f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
lineTo(x = cornerRadius, y = size.height)
|
||||
|
||||
arcTo(
|
||||
Rect(
|
||||
left = 0f,
|
||||
top = size.height - 2*cornerRadius,
|
||||
right = 2*cornerRadius,
|
||||
bottom = size.height
|
||||
),
|
||||
startAngleDegrees = 90f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density
|
||||
): Outline = Outline.Generic(
|
||||
path = drawDrawerPath(
|
||||
size,
|
||||
density.run { cornerRadius.toPx() },
|
||||
density.run { handleRadius.toPx() }
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
enum class ModalTopSheetState {
|
||||
Hidden,
|
||||
Expanded
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Scrim(
|
||||
color: Color,
|
||||
onDismiss: () -> Unit,
|
||||
visible: Boolean
|
||||
) {
|
||||
if (color.isSpecified) {
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = TweenSpec()
|
||||
)
|
||||
val dismissModifier = if (visible) {
|
||||
Modifier.pointerInput(onDismiss) { detectTapGestures { onDismiss() } }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(dismissModifier)
|
||||
) {
|
||||
drawRect(color = color, alpha = alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterialApi
|
||||
fun ModalTopSheetLayout(
|
||||
drawerContent: @Composable ColumnScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerCornerRadius: Dp = SearchOptionDrawerDefaults.CornerRadius,
|
||||
drawerHandleRadius: Dp = SearchOptionDrawerDefaults.HandleRadius,
|
||||
drawerState: SwipeableState<ModalTopSheetState> = rememberSwipeableState(Hidden),
|
||||
drawerElevation: Dp = SearchOptionDrawerDefaults.Elevation,
|
||||
drawerBackgroundColor: Color = MaterialTheme.colors.surface,
|
||||
drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
|
||||
scrimColor: Color = SearchOptionDrawerDefaults.scrimColor,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val nestedScrollConnection = remember {
|
||||
object: NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val delta = available.y
|
||||
return if (delta > 0 && source == NestedScrollSource.Drag)
|
||||
Offset(0f, drawerState.performDrag(delta))
|
||||
else
|
||||
Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
return if (source == NestedScrollSource.Drag)
|
||||
Offset(0f, drawerState.performDrag(available.y))
|
||||
else
|
||||
Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
drawerState.performFling(available.y)
|
||||
return available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints {
|
||||
var sheetHeight by remember { mutableStateOf<Float?>(null) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
content()
|
||||
Scrim(
|
||||
color = scrimColor,
|
||||
onDismiss = {
|
||||
coroutineScope.launch { drawerState.animateTo(Hidden) }
|
||||
},
|
||||
visible = drawerState.targetValue != Hidden
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.offset {
|
||||
IntOffset(0, drawerState.offset.value.roundToInt())
|
||||
}
|
||||
.drawerSwipeable(drawerState, sheetHeight)
|
||||
.onGloballyPositioned {
|
||||
sheetHeight = it.size.height.toFloat()
|
||||
},
|
||||
shape = ModalTopSheetLayoutShape(drawerCornerRadius, drawerHandleRadius),
|
||||
elevation = drawerElevation,
|
||||
color = drawerBackgroundColor,
|
||||
contentColor = drawerContentColor
|
||||
) {
|
||||
Column(content = drawerContent)
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.offset(0.dp, drawerHandleRadius)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(2*drawerHandleRadius, drawerHandleRadius)
|
||||
.align(Alignment.TopCenter)
|
||||
.pointerInput(drawerState) {
|
||||
detectTapGestures {
|
||||
coroutineScope.launch {
|
||||
drawerState.animateTo(Expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { }
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
private fun Modifier.drawerSwipeable(
|
||||
drawerState: SwipeableState<ModalTopSheetState>,
|
||||
sheetHeight: Float?
|
||||
) = this.then(
|
||||
if (sheetHeight != null) {
|
||||
val anchors = mapOf(
|
||||
-sheetHeight to Hidden,
|
||||
0f to Expanded
|
||||
)
|
||||
|
||||
Modifier.swipeable(
|
||||
state = drawerState,
|
||||
anchors = anchors,
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = drawerState.currentValue != Hidden,
|
||||
resistance = null
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
|
||||
object SearchOptionDrawerDefaults {
|
||||
val Elevation = 16.dp
|
||||
val scrimColor: Color
|
||||
@Composable
|
||||
get() = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
val CornerRadius = 32.dp
|
||||
val HandleRadius = 32.dp
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
enum class FloatingActionButtonState(private val isExpanded: Boolean) {
|
||||
COLLAPSED(false), EXPANDED(true);
|
||||
|
||||
operator fun not() = lookupTable[!this.isExpanded]!!
|
||||
|
||||
companion object {
|
||||
private val lookupTable = mapOf(
|
||||
false to COLLAPSED,
|
||||
true to EXPANDED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SubFabItem(
|
||||
val label: String? = null,
|
||||
val onClick: ((SubFabItem) -> Unit)? = null,
|
||||
val icon: @Composable () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MiniFloatingActionButton(
|
||||
modifier: Modifier = Modifier,
|
||||
item: SubFabItem,
|
||||
buttonScale: Float = 1f,
|
||||
labelAlpha: Float = 1f,
|
||||
labelOffset: Dp = 0.dp,
|
||||
onClick: ((SubFabItem) -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
val elevation = FloatingActionButtonDefaults.elevation()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
item.label?.let { label ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.alpha(labelAlpha)
|
||||
.offset(x = labelOffset),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
elevation = elevation.elevation(interactionSource).value
|
||||
) {
|
||||
Text(modifier = Modifier.padding(8.dp, 4.dp), text = label)
|
||||
}
|
||||
}
|
||||
|
||||
if (buttonScale > 0f)
|
||||
FloatingActionButton(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.scale(buttonScale),
|
||||
onClick = { onClick?.invoke(item) },
|
||||
elevation = elevation,
|
||||
interactionSource = interactionSource,
|
||||
content = item.icon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MultipleFloatingActionButton(
|
||||
items: List<SubFabItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
fabIcon: ImageVector = Icons.Default.Add,
|
||||
visible: Boolean = true,
|
||||
targetState: FloatingActionButtonState = FloatingActionButtonState.COLLAPSED,
|
||||
onStateChanged: ((FloatingActionButtonState) -> Unit)? = null
|
||||
) {
|
||||
val transition = updateTransition(targetState = targetState, label = "expand")
|
||||
|
||||
val rotation by transition.animateFloat(
|
||||
label = "FABRotation",
|
||||
transitionSpec = {
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
}) { state ->
|
||||
when (state) {
|
||||
FloatingActionButtonState.COLLAPSED -> 0f
|
||||
FloatingActionButtonState.EXPANDED -> 45f
|
||||
}
|
||||
}
|
||||
|
||||
if (!visible) onStateChanged?.invoke(FloatingActionButtonState.COLLAPSED)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
var allCollapsed = true
|
||||
|
||||
items.forEachIndexed { index, item ->
|
||||
val delay = when (targetState) {
|
||||
FloatingActionButtonState.COLLAPSED -> index
|
||||
FloatingActionButtonState.EXPANDED -> (items.size - index)
|
||||
} * 50
|
||||
|
||||
val buttonScale by transition.animateFloat(
|
||||
label = "miniFAB scale",
|
||||
transitionSpec = {
|
||||
tween(
|
||||
durationMillis = 100,
|
||||
delayMillis = delay
|
||||
)
|
||||
}
|
||||
) { state ->
|
||||
when (state) {
|
||||
FloatingActionButtonState.COLLAPSED -> 0f
|
||||
FloatingActionButtonState.EXPANDED -> 1f
|
||||
}
|
||||
}
|
||||
|
||||
val labelAlpha by transition.animateFloat(
|
||||
label = "miniFAB alpha",
|
||||
transitionSpec = {
|
||||
tween(
|
||||
durationMillis = 150,
|
||||
delayMillis = delay,
|
||||
)
|
||||
}
|
||||
) { state ->
|
||||
when (state) {
|
||||
FloatingActionButtonState.COLLAPSED -> 0f
|
||||
FloatingActionButtonState.EXPANDED -> 1f
|
||||
}
|
||||
}
|
||||
|
||||
val labelOffset by transition.animateDp(
|
||||
label = "miniFAB offset",
|
||||
transitionSpec = {
|
||||
keyframes {
|
||||
durationMillis = 200
|
||||
delayMillis = delay
|
||||
|
||||
when (targetState) {
|
||||
FloatingActionButtonState.COLLAPSED -> {
|
||||
0.dp at 0
|
||||
64.dp at 200
|
||||
}
|
||||
FloatingActionButtonState.EXPANDED -> {
|
||||
64.dp at 0
|
||||
(-4).dp at 150 with LinearEasing
|
||||
0.dp at 200 with FastOutLinearInEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { state ->
|
||||
when (state) {
|
||||
FloatingActionButtonState.COLLAPSED -> 64.dp
|
||||
FloatingActionButtonState.EXPANDED -> 0.dp
|
||||
}
|
||||
}
|
||||
|
||||
MiniFloatingActionButton(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
item = item,
|
||||
buttonScale = buttonScale,
|
||||
labelAlpha = labelAlpha,
|
||||
labelOffset = labelOffset
|
||||
) {
|
||||
item.onClick?.invoke(it)
|
||||
}
|
||||
|
||||
if (buttonScale != 0f) allCollapsed = false
|
||||
}
|
||||
|
||||
val visibilityTransition = updateTransition(targetState = visible || !allCollapsed, label = "visible")
|
||||
|
||||
val scale by visibilityTransition.animateFloat(
|
||||
label = "main FAB scale"
|
||||
) { state ->
|
||||
if (state) 1f else 0f
|
||||
}
|
||||
|
||||
if (scale > 0f)
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.scale(scale),
|
||||
onClick = {
|
||||
onStateChanged?.invoke(!targetState)
|
||||
}
|
||||
) {
|
||||
Icon(modifier = Modifier.rotate(rotation), imageVector = fabIcon, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NavigateBefore
|
||||
import androidx.compose.material.icons.filled.NavigateNext
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.consumePositionChange
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.ui.theme.LightBlue300
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sign
|
||||
|
||||
@Composable
|
||||
fun OverscrollPager(
|
||||
currentPage: Int,
|
||||
prevPageAvailable: Boolean,
|
||||
nextPageAvailable: Boolean,
|
||||
onPageTurn: (Int) -> Unit,
|
||||
prevPageTurnIndicatorOffset: Dp = 0.dp,
|
||||
nextPageTurnIndicatorOffset: Dp = 0.dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
|
||||
|
||||
var overscroll: Float? by remember { mutableStateOf(null) }
|
||||
|
||||
var size: Size? by remember { mutableStateOf(null) }
|
||||
val circleRadius = (size?.width ?: 0f) / 2
|
||||
|
||||
val topCircleRadius by animateFloatAsState(if (overscroll?.let { it >= pageTurnIndicatorHeight } == true) circleRadius else 0f)
|
||||
val bottomCircleRadius by animateFloatAsState(if (overscroll?.let { it <= -pageTurnIndicatorHeight } == true) circleRadius else 0f)
|
||||
|
||||
val prevPageTurnIndicatorOffsetPx = LocalDensity.current.run { prevPageTurnIndicatorOffset.toPx() }
|
||||
val nextPageTurnIndicatorOffsetPx = LocalDensity.current.run { nextPageTurnIndicatorOffset.toPx() }
|
||||
|
||||
if (topCircleRadius != 0f || bottomCircleRadius != 0f)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(
|
||||
LightBlue300,
|
||||
center = Offset(this.center.x, prevPageTurnIndicatorOffsetPx),
|
||||
radius = topCircleRadius
|
||||
)
|
||||
drawCircle(
|
||||
LightBlue300,
|
||||
center = Offset(this.center.x, this.size.height-pageTurnIndicatorHeight-nextPageTurnIndicatorOffsetPx),
|
||||
radius = bottomCircleRadius
|
||||
)
|
||||
}
|
||||
|
||||
val isOverscrollOverHeight = overscroll?.let { abs(it) >= pageTurnIndicatorHeight } == true
|
||||
LaunchedEffect(isOverscrollOverHeight) {
|
||||
if (isOverscrollOverHeight) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier.onGloballyPositioned {
|
||||
size = it.size.toSize()
|
||||
}
|
||||
) {
|
||||
overscroll?.let { overscroll ->
|
||||
if (overscroll > 0f)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(0.dp, prevPageTurnIndicatorOffset),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.NavigateBefore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.main_move_to_page, currentPage - 1))
|
||||
}
|
||||
|
||||
if (overscroll < 0f)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.offset(0.dp, -nextPageTurnIndicatorOffset),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.main_move_to_page, currentPage + 1))
|
||||
Icon(
|
||||
Icons.Default.NavigateNext,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
0.dp,
|
||||
overscroll
|
||||
?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)
|
||||
?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } }
|
||||
?: 0.dp)
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val overscrollSnapshot = overscroll
|
||||
|
||||
return if (overscrollSnapshot == null || overscrollSnapshot == 0f) {
|
||||
Offset.Zero
|
||||
} else {
|
||||
val newOverscroll =
|
||||
if (overscrollSnapshot > 0f && available.y < 0f)
|
||||
max(overscrollSnapshot + available.y, 0f)
|
||||
else if (overscrollSnapshot < 0f && available.y > 0f)
|
||||
min(overscrollSnapshot + available.y, 0f)
|
||||
else
|
||||
overscrollSnapshot
|
||||
|
||||
Offset(0f, newOverscroll - overscrollSnapshot).also {
|
||||
overscroll = newOverscroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
if (
|
||||
available.y == 0f ||
|
||||
!prevPageAvailable && available.y > 0f ||
|
||||
!nextPageAvailable && available.y < 0f
|
||||
) return Offset.Zero
|
||||
|
||||
return overscroll?.let {
|
||||
overscroll = it + available.y
|
||||
Offset(0f, available.y)
|
||||
} ?: Offset.Zero
|
||||
}
|
||||
})
|
||||
.pointerInput(currentPage) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var pointer = down.id
|
||||
overscroll = 0f
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val dragEvent =
|
||||
event.changes.fastFirstOrNull { it.id == pointer }!!
|
||||
|
||||
if (dragEvent.changedToUpIgnoreConsumed()) {
|
||||
val otherDown = event.changes.fastFirstOrNull { it.pressed }
|
||||
if (otherDown == null) {
|
||||
dragEvent.consumePositionChange()
|
||||
overscroll?.let {
|
||||
if (abs(it) > pageTurnIndicatorHeight)
|
||||
onPageTurn(currentPage - it.sign.toInt())
|
||||
}
|
||||
overscroll = null
|
||||
break
|
||||
} else
|
||||
pointer = otherDown.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,823 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material.icons.materialIcon
|
||||
import androidx.compose.material.icons.materialPath
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import com.google.accompanist.insets.rememberInsetsPaddingValues
|
||||
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.map
|
||||
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.ImageSource
|
||||
import xyz.quaver.graphics.subsampledimage.SubSampledImage
|
||||
import xyz.quaver.graphics.subsampledimage.SubSampledImageState
|
||||
import xyz.quaver.graphics.subsampledimage.rememberSubSampledImageState
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.proto.ReaderOptions
|
||||
import xyz.quaver.pupil.proto.settingsDataStore
|
||||
import xyz.quaver.pupil.util.FileXImageSource
|
||||
import xyz.quaver.pupil.util.NetworkCache
|
||||
import xyz.quaver.pupil.util.activity
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
|
||||
private var _singleImage: ImageVector? = null
|
||||
val SingleImage: ImageVector
|
||||
get() {
|
||||
if (_singleImage != null) {
|
||||
return _singleImage!!
|
||||
}
|
||||
|
||||
_singleImage = materialIcon(name = "ReaderBase.SingleImage") {
|
||||
materialPath {
|
||||
moveTo(17.0f, 3.0f)
|
||||
lineTo(7.0f, 3.0f)
|
||||
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||
verticalLineToRelative(14.0f)
|
||||
curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f)
|
||||
horizontalLineToRelative(10.0f)
|
||||
curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f)
|
||||
lineTo(19.0f, 5.0f)
|
||||
curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f)
|
||||
close()
|
||||
moveTo(17.0f, 19.0f)
|
||||
lineTo(7.0f, 19.0f)
|
||||
lineTo(7.0f, 5.0f)
|
||||
horizontalLineToRelative(10.0f)
|
||||
verticalLineToRelative(14.0f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return _singleImage!!
|
||||
}
|
||||
|
||||
private var _doubleImage: ImageVector? = null
|
||||
val DoubleImage: ImageVector
|
||||
get() {
|
||||
if (_doubleImage != null) {
|
||||
return _doubleImage!!
|
||||
}
|
||||
|
||||
_doubleImage = materialIcon(name = "ReaderBase.DoubleImage") {
|
||||
materialPath {
|
||||
moveTo(9.0f, 3.0f)
|
||||
lineTo(2.0f, 3.0f)
|
||||
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||
verticalLineToRelative(14.0f)
|
||||
curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f)
|
||||
horizontalLineToRelative(7.0f)
|
||||
curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f)
|
||||
lineTo(11.0f, 5.0f)
|
||||
curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f)
|
||||
close()
|
||||
moveTo(9.0f, 19.0f)
|
||||
lineTo(2.0f, 19.0f)
|
||||
lineTo(2.0f, 5.0f)
|
||||
horizontalLineToRelative(7.0f)
|
||||
verticalLineToRelative(14.0f)
|
||||
close()
|
||||
moveTo(21.0f, 3.0f)
|
||||
lineTo(14.0f, 3.0f)
|
||||
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||
verticalLineToRelative(14.0f)
|
||||
curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f)
|
||||
horizontalLineToRelative(7.0f)
|
||||
curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f)
|
||||
lineTo(23.0f, 5.0f)
|
||||
curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f)
|
||||
close()
|
||||
moveTo(21.0f, 19.0f)
|
||||
lineTo(14.0f, 19.0f)
|
||||
lineTo(14.0f, 5.0f)
|
||||
horizontalLineToRelative(7.0f)
|
||||
verticalLineToRelative(14.0f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return _doubleImage!!
|
||||
}
|
||||
|
||||
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 fullscreen by mutableStateOf(false)
|
||||
|
||||
var error by mutableStateOf(false)
|
||||
|
||||
var imageCount by mutableStateOf(0)
|
||||
|
||||
val imageList = mutableStateListOf<Uri?>()
|
||||
val progressList = mutableStateListOf<Float>()
|
||||
|
||||
private val progressCollectJobs = ConcurrentHashMap<Int, Job>()
|
||||
|
||||
private val totalProgressMutex = Mutex()
|
||||
var totalProgress by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
private var urls: List<String>? = null
|
||||
|
||||
var loadJob: Job? = null
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun load(urls: List<String>, 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 })
|
||||
imageList.addAll(List(imageCount) { null })
|
||||
totalProgressMutex.withLock {
|
||||
totalProgress = 0
|
||||
}
|
||||
|
||||
loadJob = launch {
|
||||
urls.forEachIndexed { index, url ->
|
||||
when (val scheme = url.takeWhile { it != ':' }) {
|
||||
"http", "https" -> {
|
||||
val (flow, file) = cache.load {
|
||||
url(url)
|
||||
headers(headerBuilder)
|
||||
}
|
||||
|
||||
imageList[index] = Uri.fromFile(file)
|
||||
progressCollectJobs[index] = launch {
|
||||
flow.takeWhile { it.isFinite() }.collect {
|
||||
progressList[index] = it
|
||||
}
|
||||
|
||||
progressList[index] = flow.value
|
||||
totalProgressMutex.withLock {
|
||||
totalProgress++
|
||||
}
|
||||
}
|
||||
}
|
||||
"content" -> {
|
||||
imageList[index] = Uri.parse(url)
|
||||
progressList[index] = Float.POSITIVE_INFINITY
|
||||
totalProgressMutex.withLock {
|
||||
totalProgress++
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
logger.warning(IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'"))
|
||||
progressList[index] = Float.NEGATIVE_INFINITY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun error(index: Int) {
|
||||
progressList[index] = Float.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
urls?.let { cache.free(it) }
|
||||
cache.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
val ReaderOptions.Orientation.isVertical: Boolean
|
||||
get() =
|
||||
this == ReaderOptions.Orientation.VERTICAL_DOWN ||
|
||||
this == ReaderOptions.Orientation.VERTICAL_UP
|
||||
val ReaderOptions.Orientation.isReverse: Boolean
|
||||
get() =
|
||||
this == ReaderOptions.Orientation.VERTICAL_UP ||
|
||||
this == ReaderOptions.Orientation.HORIZONTAL_LEFT
|
||||
|
||||
@Composable
|
||||
fun ReaderOptionsSheet(readerOptions: ReaderOptions, onOptionsChange: (ReaderOptions.Builder.() -> Unit) -> Unit) {
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.h6) {
|
||||
Column(Modifier.padding(16.dp, 0.dp)) {
|
||||
val layout = readerOptions.layout
|
||||
val snap = readerOptions.snap
|
||||
val orientation = readerOptions.orientation
|
||||
val padding = readerOptions.padding
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Layout")
|
||||
|
||||
Row {
|
||||
listOf(
|
||||
ReaderOptions.Layout.SINGLE_PAGE to SingleImage,
|
||||
ReaderOptions.Layout.DOUBLE_PAGE to DoubleImage,
|
||||
ReaderOptions.Layout.AUTO to Icons.Default.AutoFixHigh
|
||||
).forEach { (option, icon) ->
|
||||
IconButton(onClick = {
|
||||
onOptionsChange {
|
||||
setLayout(option)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (layout == option) MaterialTheme.colors.secondary
|
||||
else LocalContentColor.current
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
|
||||
val isReverse = orientation.isReverse
|
||||
val isVertical = orientation.isVertical
|
||||
|
||||
val animationOrientation = if (isReverse) -1f else 1f
|
||||
val animationSpacing by animateFloatAsState(if (padding) 48f else 32f)
|
||||
val animationOffset by infiniteTransition.animateFloat(
|
||||
initialValue = animationOrientation * (if (snap) 0f else animationSpacing/2),
|
||||
targetValue = animationOrientation * (if (snap) -animationSpacing else -animationSpacing/2),
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = 1000,
|
||||
easing = if(snap) FastOutSlowInEasing else LinearEasing
|
||||
),
|
||||
repeatMode = RepeatMode.Restart
|
||||
)
|
||||
)
|
||||
val animationRotation by animateFloatAsState(if (isVertical) 90f else 0f)
|
||||
|
||||
val setOrientation: (Boolean, Boolean) -> Unit = { isVertical, isReverse ->
|
||||
val orientation = when {
|
||||
isVertical && !isReverse -> ReaderOptions.Orientation.VERTICAL_DOWN
|
||||
isVertical && isReverse -> ReaderOptions.Orientation.VERTICAL_UP
|
||||
!isVertical && !isReverse -> ReaderOptions.Orientation.HORIZONTAL_RIGHT
|
||||
!isVertical && isReverse -> ReaderOptions.Orientation.HORIZONTAL_LEFT
|
||||
else -> error("Invalid value")
|
||||
}
|
||||
|
||||
onOptionsChange {
|
||||
setOrientation(orientation)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clipToBounds()
|
||||
.rotate(animationRotation)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
for (i in 0..4)
|
||||
Icon(
|
||||
SingleImage,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.align(Alignment.CenterStart)
|
||||
.offset((animationOffset + animationSpacing * (i - 2)).dp, 0.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Orientation")
|
||||
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.caption) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("H")
|
||||
Switch(checked = isVertical, onCheckedChange = {
|
||||
setOrientation(!isVertical, isReverse)
|
||||
})
|
||||
Text("V")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Reverse")
|
||||
Switch(checked = isReverse, onCheckedChange = {
|
||||
setOrientation(isVertical, !isReverse)
|
||||
})
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Snap")
|
||||
|
||||
Switch(checked = snap, onCheckedChange = {
|
||||
onOptionsChange {
|
||||
setSnap(!snap)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Padding")
|
||||
|
||||
Switch(checked = padding, onCheckedChange = {
|
||||
onOptionsChange {
|
||||
setPadding(!padding)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.ReaderLazyList(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
orientation: ReaderOptions.Orientation,
|
||||
onScroll: (direction: Float) -> Unit,
|
||||
content: LazyListScope.() -> Unit
|
||||
) {
|
||||
val isReverse = orientation.isReverse
|
||||
|
||||
val nestedScrollConnection = remember(orientation) { object: NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
onScroll(
|
||||
when (orientation) {
|
||||
ReaderOptions.Orientation.VERTICAL_DOWN -> available.y.sign
|
||||
ReaderOptions.Orientation.VERTICAL_UP -> -(available.y.sign)
|
||||
ReaderOptions.Orientation.HORIZONTAL_RIGHT -> available.x.sign
|
||||
ReaderOptions.Orientation.HORIZONTAL_LEFT -> -(available.x.sign)
|
||||
}
|
||||
)
|
||||
|
||||
return Offset.Zero
|
||||
}
|
||||
} }
|
||||
|
||||
when (orientation) {
|
||||
ReaderOptions.Orientation.VERTICAL_DOWN,
|
||||
ReaderOptions.Orientation.VERTICAL_UP ->
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.TopStart)
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
state = state,
|
||||
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars),
|
||||
reverseLayout = isReverse,
|
||||
content = content
|
||||
)
|
||||
ReaderOptions.Orientation.HORIZONTAL_RIGHT,
|
||||
ReaderOptions.Orientation.HORIZONTAL_LEFT ->
|
||||
LazyRow(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.CenterStart)
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
state = state,
|
||||
reverseLayout = isReverse,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderItemData(
|
||||
val index: Int,
|
||||
val size: Size?,
|
||||
val imageSource: ImageSource?
|
||||
)
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun ReaderItem(
|
||||
model: ReaderBaseViewModel,
|
||||
readerOptions: ReaderOptions,
|
||||
listSize: Size,
|
||||
images: List<ReaderItemData>,
|
||||
onTap: () -> Unit = { }
|
||||
) {
|
||||
val (widthDp, heightDp) = LocalDensity.current.run { listSize.width.toDp() to listSize.height.toDp() }
|
||||
|
||||
Row(
|
||||
modifier = when {
|
||||
readerOptions.padding -> Modifier.size(widthDp, heightDp)
|
||||
readerOptions.orientation.isVertical -> Modifier.fillMaxWidth()
|
||||
else -> Modifier.fillMaxHeight()
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
images.let { if (readerOptions.orientation.isReverse) it.reversed() else it }.forEach { (index, imageSize, imageSource) ->
|
||||
val state = rememberSubSampledImageState().apply {
|
||||
isGestureEnabled = true
|
||||
}
|
||||
|
||||
val modifier = when {
|
||||
imageSize == null -> Modifier
|
||||
.weight(1f)
|
||||
.height(heightDp)
|
||||
readerOptions.padding -> Modifier
|
||||
.fillMaxHeight()
|
||||
.widthIn(0.dp, widthDp / images.size)
|
||||
.aspectRatio(imageSize.width / imageSize.height)
|
||||
readerOptions.orientation.isVertical -> Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(imageSize.width / imageSize.height)
|
||||
else -> Modifier.aspectRatio(imageSize.width/imageSize.height)
|
||||
}
|
||||
|
||||
|
||||
Box(
|
||||
modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val progress = model.progressList.getOrNull(index) ?: 0f
|
||||
|
||||
if (progress == Float.NEGATIVE_INFINITY)
|
||||
Icon(Icons.Filled.BrokenImage, null)
|
||||
else if (progress.isFinite())
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LinearProgressIndicator(progress)
|
||||
Text((index + 1).toString())
|
||||
}
|
||||
else if (progress == Float.POSITIVE_INFINITY) {
|
||||
SubSampledImage(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.run {
|
||||
if (model.fullscreen)
|
||||
doubleClickCycleZoom(state, 2f, onTap = onTap)
|
||||
else
|
||||
combinedClickable(
|
||||
onLongClick = {
|
||||
|
||||
}
|
||||
) {
|
||||
model.fullscreen = true
|
||||
}
|
||||
},
|
||||
imageSource = imageSource,
|
||||
state = state,
|
||||
onError = {
|
||||
model.error(index)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
fun LazyListScope.ReaderLazyListContent(
|
||||
model: ReaderBaseViewModel,
|
||||
listSize: Size,
|
||||
imageSources: List<ImageSource?>,
|
||||
imageSizes: List<Size?>,
|
||||
readerOptions: ReaderOptions,
|
||||
onTap: () -> Unit = { }
|
||||
) {
|
||||
when (readerOptions.layout) {
|
||||
ReaderOptions.Layout.SINGLE_PAGE ->
|
||||
itemsIndexed(imageSources) { index, source ->
|
||||
ReaderItem(model, readerOptions, listSize, listOf(ReaderItemData(index, imageSizes[index], source)))
|
||||
}
|
||||
ReaderOptions.Layout.DOUBLE_PAGE ->
|
||||
itemsIndexed(imageSources.chunked(2), key = { i, _ -> i*2 }) { chunkIndex, sourceList ->
|
||||
ReaderItem(model, readerOptions, listSize, sourceList.mapIndexed { i, it ->
|
||||
val index = chunkIndex*2+i
|
||||
ReaderItemData(index, imageSizes[index], it)
|
||||
}, onTap)
|
||||
}
|
||||
ReaderOptions.Layout.AUTO -> {
|
||||
val images = mutableListOf<List<Int>>()
|
||||
|
||||
var i = 0
|
||||
while (i < imageSizes.size) {
|
||||
val list = mutableListOf(i)
|
||||
|
||||
if (
|
||||
imageSizes[i] != null &&
|
||||
imageSizes.getOrNull(i+1) != null &&
|
||||
listSize != Size.Zero &&
|
||||
imageSizes[i]!!.width*listSize.height/imageSizes[i]!!.height +
|
||||
imageSizes[i+1]!!.width*listSize.height/imageSizes[i+1]!!.height < listSize.width
|
||||
) list.add(++i)
|
||||
|
||||
images.add(list)
|
||||
i++
|
||||
}
|
||||
|
||||
items(images, key = { it.first() }) { images ->
|
||||
ReaderItem(model, readerOptions, listSize, images.map { ReaderItemData(it, imageSizes[it], imageSources[it]) }, onTap)
|
||||
}
|
||||
}
|
||||
else -> itemsIndexed(imageSources) { index, source ->
|
||||
ReaderItem(model, readerOptions, listSize, listOf(ReaderItemData(index, imageSizes[index], source)), onTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalComposeUiApi
|
||||
@ExperimentalMaterialApi
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun ReaderBase(
|
||||
modifier: Modifier = Modifier,
|
||||
model: ReaderBaseViewModel,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
onScroll: (direction: Float) -> Unit = { }
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val snackbarCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
var scrollDirection by remember { mutableStateOf(0f) }
|
||||
val handleOffset by animateDpAsState(if (model.fullscreen || scrollDirection < 0f) (-36).dp else 0.dp)
|
||||
|
||||
val mainReaderOptions by remember {
|
||||
context.settingsDataStore.data.map { it.mainReaderOption }
|
||||
}.collectAsState(ReaderOptions.getDefaultInstance())
|
||||
|
||||
LaunchedEffect(scrollDirection) {
|
||||
onScroll(scrollDirection)
|
||||
}
|
||||
|
||||
LaunchedEffect(model.fullscreen) {
|
||||
context.activity?.window?.let { window ->
|
||||
ViewCompat.getWindowInsetsController(window.decorView)?.let {
|
||||
if (model.fullscreen) {
|
||||
it.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
} else
|
||||
it.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.error)
|
||||
stringResource(R.string.reader_failed_to_find_gallery).let {
|
||||
snackbarCoroutineScope.launch {
|
||||
scaffoldState.snackbarHostState.showSnackbar(
|
||||
it,
|
||||
duration = SnackbarDuration.Indefinite
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
ModalTopSheetLayout(
|
||||
modifier = Modifier.offset(0.dp, handleOffset),
|
||||
drawerContent = {
|
||||
ReaderOptionsSheet(mainReaderOptions) { readerOptionsBlock ->
|
||||
coroutineScope.launch {
|
||||
context.settingsDataStore.updateData {
|
||||
it.toBuilder().setMainReaderOption(
|
||||
mainReaderOptions.toBuilder().apply(readerOptionsBlock).build()
|
||||
).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
var listSize: Size? by remember { mutableStateOf(null) }
|
||||
|
||||
val nestedScrollConnection = remember { object: NestedScrollConnection {
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
return if (
|
||||
mainReaderOptions.snap &&
|
||||
listState.layoutInfo.visibleItemsInfo.size > 1
|
||||
) {
|
||||
val velocity = when (mainReaderOptions.orientation) {
|
||||
ReaderOptions.Orientation.VERTICAL_DOWN -> available.y
|
||||
ReaderOptions.Orientation.VERTICAL_UP -> -(available.y)
|
||||
ReaderOptions.Orientation.HORIZONTAL_RIGHT -> available.x
|
||||
ReaderOptions.Orientation.HORIZONTAL_LEFT -> -(available.x)
|
||||
}
|
||||
|
||||
val index = listState.firstVisibleItemIndex
|
||||
|
||||
coroutineScope.launch {
|
||||
when {
|
||||
velocity < 0f -> listState.animateScrollToItem(index+1)
|
||||
else -> listState.animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
|
||||
available
|
||||
} else Velocity.Zero
|
||||
|
||||
}
|
||||
} }
|
||||
|
||||
val imageSources = remember { mutableStateListOf<ImageSource?>() }
|
||||
val imageSizes = remember { mutableStateListOf<Size?>() }
|
||||
|
||||
LaunchedEffect(model.totalProgress) {
|
||||
val size = model.progressList.size
|
||||
|
||||
if (imageSources.size != size)
|
||||
imageSources.addAll(List (size-imageSources.size) { null })
|
||||
|
||||
if (imageSizes.size != size)
|
||||
imageSizes.addAll(List (size-imageSizes.size) { null })
|
||||
|
||||
coroutineScope.launch {
|
||||
repeat(size) { i ->
|
||||
val uri = model.imageList[i]
|
||||
|
||||
if (imageSources[i] == null && uri != null)
|
||||
imageSources[i] = FileXImageSource(FileX(context, uri))
|
||||
|
||||
if (imageSizes[i] == null && model.progressList[i] == Float.POSITIVE_INFINITY)
|
||||
imageSources[i]?.let {
|
||||
imageSizes[i] = runCatching { it.imageSize }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReaderLazyList(
|
||||
Modifier
|
||||
.onGloballyPositioned { listSize = it.size.toSize() }
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
listState,
|
||||
mainReaderOptions.orientation,
|
||||
onScroll = { scrollDirection = it },
|
||||
) {
|
||||
ReaderLazyListContent(
|
||||
model,
|
||||
listSize ?: Size.Zero,
|
||||
imageSources,
|
||||
imageSizes,
|
||||
mainReaderOptions
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(listState.firstVisibleItemIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.doubleClickCycleZoom(
|
||||
state: SubSampledImageState,
|
||||
scale: Float = 2f,
|
||||
animationSpec: AnimationSpec<Rect> = spring(),
|
||||
onTap: () -> Unit = { },
|
||||
) = composed {
|
||||
val initialImageRect by produceState<Rect?>(null, state.canvasSize, state.imageSize) {
|
||||
state.canvasSize?.let { canvasSize ->
|
||||
state.imageSize?.let { imageSize ->
|
||||
value = state.bound(state.scaleType(canvasSize, imageSize), canvasSize)
|
||||
} }
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { onTap() },
|
||||
onDoubleTap = { centroid ->
|
||||
val imageRect = state.imageRect
|
||||
coroutineScope.launch {
|
||||
if (imageRect == null || imageRect != initialImageRect)
|
||||
state.resetImageRect(animationSpec)
|
||||
else {
|
||||
state.setImageRectWithBound(
|
||||
Rect(
|
||||
Offset(
|
||||
centroid.x - (centroid.x - imageRect.left) * scale,
|
||||
centroid.y - (centroid.y - imageRect.top) * scale
|
||||
),
|
||||
imageRect.size * scale
|
||||
), animationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
|
||||
import android.app.Application
|
||||
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NavigateBefore
|
||||
import androidx.compose.material.icons.filled.NavigateNext
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.consumePositionChange
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsPadding
|
||||
import com.google.accompanist.insets.rememberInsetsPaddingValues
|
||||
import com.google.accompanist.insets.statusBarsPadding
|
||||
import com.google.accompanist.insets.ui.Scaffold
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.ui.theme.LightBlue300
|
||||
import kotlin.math.*
|
||||
|
||||
private enum class NavigationIconState {
|
||||
MENU,
|
||||
ARROW
|
||||
}
|
||||
|
||||
open class SearchBaseViewModel<T> : ViewModel() {
|
||||
val searchResults = mutableStateListOf<T>()
|
||||
|
||||
var sortModeIndex by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
var currentPage by mutableStateOf(1)
|
||||
|
||||
var totalItems by mutableStateOf(0)
|
||||
|
||||
var maxPage by mutableStateOf(0)
|
||||
|
||||
val prevPageAvailable by derivedStateOf { currentPage > 1 }
|
||||
val nextPageAvailable by derivedStateOf { currentPage <= maxPage }
|
||||
|
||||
var query by mutableStateOf("")
|
||||
|
||||
var loading by mutableStateOf(false)
|
||||
var error by mutableStateOf(false)
|
||||
|
||||
//region UI
|
||||
var isFabVisible by mutableStateOf(true)
|
||||
var searchBarOffset by mutableStateOf(0)
|
||||
//endregion
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> SearchBase(
|
||||
model: SearchBaseViewModel<T> = viewModel(),
|
||||
fabSubMenu: List<SubFabItem> = emptyList(),
|
||||
actions: @Composable RowScope.() -> Unit = { },
|
||||
onSearch: () -> Unit = { },
|
||||
content: @Composable BoxScope.(contentPadding: PaddingValues) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
var isFabExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) }
|
||||
|
||||
val navigationIcon = remember { DrawerArrowDrawable(context) }
|
||||
var navigationIconState by remember { mutableStateOf(NavigationIconState.MENU) }
|
||||
val navigationIconTransition = updateTransition(navigationIconState, label = "navigationIconTransition")
|
||||
val navigationIconProgress by navigationIconTransition.animateFloat(
|
||||
label = "navigationIconProgress"
|
||||
) { state ->
|
||||
when (state) {
|
||||
NavigationIconState.MENU -> 0f
|
||||
NavigationIconState.ARROW -> 1f
|
||||
}
|
||||
}
|
||||
|
||||
val statusBarsPaddingValues = rememberInsetsPaddingValues(insets = LocalWindowInsets.current.statusBars)
|
||||
|
||||
val searchBarDefaultOffset = statusBarsPaddingValues.calculateTopPadding() + 64.dp
|
||||
val searchBarDefaultOffsetPx = LocalDensity.current.run { searchBarDefaultOffset.roundToPx() }
|
||||
|
||||
LaunchedEffect(navigationIconProgress) {
|
||||
navigationIcon.progress = navigationIconProgress
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
MultipleFloatingActionButton(
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
items = fabSubMenu,
|
||||
visible = model.isFabVisible,
|
||||
targetState = isFabExpanded,
|
||||
onStateChanged = {
|
||||
isFabExpanded = it
|
||||
}
|
||||
)
|
||||
}
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding).fillMaxSize()) {
|
||||
OverscrollPager(
|
||||
currentPage = model.currentPage,
|
||||
prevPageAvailable = model.prevPageAvailable,
|
||||
nextPageAvailable = model.nextPageAvailable,
|
||||
onPageTurn = { model.currentPage = it },
|
||||
prevPageTurnIndicatorOffset = searchBarDefaultOffset,
|
||||
nextPageTurnIndicatorOffset = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars).calculateBottomPadding()
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.nestedScroll(object: NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
model.searchBarOffset =
|
||||
(model.searchBarOffset + available.y.roundToInt()).coerceIn(
|
||||
-searchBarDefaultOffsetPx,
|
||||
0
|
||||
)
|
||||
|
||||
model.isFabVisible = available.y > 0f
|
||||
|
||||
return Offset.Zero
|
||||
}
|
||||
})
|
||||
) {
|
||||
content(PaddingValues(0.dp, searchBarDefaultOffset, 0.dp, rememberInsetsPaddingValues(
|
||||
insets = LocalWindowInsets.current.navigationBars
|
||||
).calculateBottomPadding()))
|
||||
}
|
||||
}
|
||||
|
||||
if (model.loading)
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
|
||||
FloatingSearchBar(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.offset(0.dp, LocalDensity.current.run { model.searchBarOffset.toDp() }),
|
||||
query = model.query,
|
||||
onQueryChange = { model.query = it },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { focusManager.clearFocus() }) {
|
||||
Icon(
|
||||
painter = rememberDrawablePainter(navigationIcon),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = actions,
|
||||
onSearch = { onSearch(); focusManager.clearFocus() },
|
||||
onTextFieldFocused = { navigationIconState = NavigationIconState.ARROW },
|
||||
onTextFieldUnfocused = { navigationIconState = NavigationIconState.MENU }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Entity
|
||||
data class Favorite(
|
||||
@PrimaryKey val item: String,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface FavoritesDao {
|
||||
@Query("SELECT * FROM favorite")
|
||||
fun getAll(): Flow<List<Favorite>>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT * FROM favorite WHERE item = :item)")
|
||||
fun contains(item: String): Flow<Boolean>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(favorite: Favorite)
|
||||
suspend fun insert(item: String) = insert(Favorite(item))
|
||||
|
||||
@Delete
|
||||
suspend fun delete(favorite: Favorite)
|
||||
suspend fun delete(item: String) = delete(Favorite(item))
|
||||
}
|
||||
|
||||
@Database(entities = [Favorite::class], version = 1, exportSchema = false)
|
||||
abstract class HitomiDatabase : RoomDatabase() {
|
||||
abstract fun favoritesDao(): FavoritesDao
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
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.foundation.lazy.GridCells
|
||||
import androidx.compose.foundation.lazy.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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 androidx.room.Room
|
||||
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.*
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.di.android.subDI
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import org.kodein.di.compose.rememberViewModel
|
||||
import org.kodein.di.compose.withDI
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.proto.settingsDataStore
|
||||
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
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||
class Hitomi(app: Application) : Source(), DIAware {
|
||||
override val di by subDI(closestDI(app)) {
|
||||
bindSingleton {
|
||||
Room.databaseBuilder(app, HitomiDatabase::class.java, name).build()
|
||||
}
|
||||
|
||||
bindProvider { HitomiSearchResultViewModel(instance()) }
|
||||
}
|
||||
|
||||
private val client: HttpClient by instance()
|
||||
|
||||
private val logger = newLogger(LoggerFactory.default)
|
||||
|
||||
override val name: String = "hitomi.la"
|
||||
override val iconResID: Int = R.drawable.hitomi
|
||||
|
||||
override fun NavGraphBuilder.navGraph(navController: NavController) {
|
||||
navigation(startDestination = "hitomi.la/search", route = name) {
|
||||
composable("hitomi.la/search") { withDI(di) { Search(navController) } }
|
||||
composable("hitomi.la/reader/{itemID}") { withDI(di) { Reader(navController) } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Search(navController: NavController) {
|
||||
val model: HitomiSearchResultViewModel by rememberViewModel()
|
||||
val database: HitomiDatabase by rememberInstance()
|
||||
val favoritesDao = remember { database.favoritesDao() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val favorites by favoritesDao.getAll().collectAsState(emptyList())
|
||||
val favoritesSet by derivedStateOf {
|
||||
Collections.unmodifiableSet(favorites.mapTo(mutableSetOf()) { it.item })
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
context.settingsDataStore.updateData {
|
||||
it.toBuilder()
|
||||
.setRecentSource(name)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
var sourceSelectDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (sourceSelectDialog)
|
||||
SourceSelectDialog(navController, name) { sourceSelectDialog = false }
|
||||
|
||||
LaunchedEffect(model.currentPage, model.sortByPopularity) {
|
||||
model.search()
|
||||
}
|
||||
|
||||
SearchBase(
|
||||
model,
|
||||
fabSubMenu = listOf(
|
||||
SubFabItem(
|
||||
stringResource(R.string.main_jump_title)
|
||||
) {
|
||||
Icon(painterResource(R.drawable.ic_jump), contentDescription = null)
|
||||
},
|
||||
SubFabItem(
|
||||
stringResource(R.string.main_fab_random)
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, contentDescription = null)
|
||||
},
|
||||
SubFabItem(
|
||||
stringResource(R.string.main_open_gallery_by_id)
|
||||
) {
|
||||
Icon(painterResource(R.drawable.numeric), contentDescription = null)
|
||||
}
|
||||
),
|
||||
actions = {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(onClick = { sourceSelectDialog = true }) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.hitomi),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(Icons.Default.Sort, contentDescription = null)
|
||||
}
|
||||
|
||||
IconButton(onClick = { navController.navigate("settings") }) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
}
|
||||
|
||||
val onClick: (Boolean?) -> Unit = {
|
||||
expanded = false
|
||||
|
||||
it?.let {
|
||||
model.sortByPopularity = it
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded, onDismissRequest = { onClick(null) }) {
|
||||
DropdownMenuItem(onClick = { onClick(false) }) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.main_menu_sort_newest))
|
||||
RadioButton(selected = !model.sortByPopularity, onClick = { onClick(false) })
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
DropdownMenuItem(onClick = { onClick(true) }){
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.main_menu_sort_popular))
|
||||
RadioButton(selected = model.sortByPopularity, onClick = { onClick(true) })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSearch = { model.search() }
|
||||
) { contentPadding ->
|
||||
LazyVerticalGrid(
|
||||
cells = GridCells.Adaptive(minSize = 500.dp),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
items(model.searchResults) {
|
||||
DetailedSearchResult(
|
||||
it,
|
||||
favorites = favoritesSet,
|
||||
onFavoriteToggle = {
|
||||
coroutineScope.launch {
|
||||
if (it in favoritesSet) favoritesDao.delete(it)
|
||||
else favoritesDao.insert(it)
|
||||
}
|
||||
}
|
||||
) { result ->
|
||||
navController.navigate("hitomi.la/reader/${result.itemID}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Reader(navController: NavController) {
|
||||
val model: ReaderBaseViewModel = viewModel()
|
||||
|
||||
val database: HitomiDatabase by rememberInstance()
|
||||
val favoritesDao = remember { database.favoritesDao() }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID")
|
||||
|
||||
if (itemID == null) model.error = true
|
||||
|
||||
val isFavorite by favoritesDao.contains(itemID ?: "").collectAsState(false)
|
||||
val galleryInfo by produceState<GalleryInfo?>(null) {
|
||||
runCatching {
|
||||
val galleryID = itemID!!.toInt()
|
||||
|
||||
value = getGalleryInfo(client, galleryID).also {
|
||||
model.load(it.files.map { imageUrlFromImage(galleryID, it, false) }) {
|
||||
append("Referer", getReferer(galleryID))
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
model.error = true
|
||||
}
|
||||
}
|
||||
|
||||
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 (isFavorite) favoritesDao.delete(it)
|
||||
else favoritesDao.insert(it)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
if (isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
contentDescription = null,
|
||||
tint = Orange500
|
||||
)
|
||||
}
|
||||
},
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.statusBars,
|
||||
applyBottom = false
|
||||
)
|
||||
)
|
||||
}
|
||||
) { contentPadding ->
|
||||
ReaderBase(
|
||||
Modifier.padding(contentPadding),
|
||||
model
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class HitomiSearchResult(
|
||||
val itemID: String,
|
||||
val title: String,
|
||||
val thumbnail: String,
|
||||
val artists: List<String>,
|
||||
val series: List<String>,
|
||||
val type: String,
|
||||
val language: String,
|
||||
val tags: List<String>
|
||||
)
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.ktor.client.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import xyz.quaver.pupil.sources.composable.SearchBaseViewModel
|
||||
import xyz.quaver.pupil.sources.hitomi.lib.GalleryBlock
|
||||
import xyz.quaver.pupil.sources.hitomi.lib.doSearch
|
||||
import xyz.quaver.pupil.sources.hitomi.lib.getGalleryBlock
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class HitomiSearchResultViewModel(
|
||||
private val client: HttpClient
|
||||
) : SearchBaseViewModel<HitomiSearchResult>() {
|
||||
private var cachedQuery: String? = null
|
||||
private var cachedSortByPopularity: Boolean? = null
|
||||
private val cache = mutableListOf<Int>()
|
||||
|
||||
private val galleryBlockCache = LruCache<Int, GalleryBlock>(100)
|
||||
|
||||
var sortByPopularity by mutableStateOf(false)
|
||||
|
||||
private var searchJob: Job? = null
|
||||
fun search() {
|
||||
val resultsPerPage = 25
|
||||
|
||||
viewModelScope.launch {
|
||||
searchJob?.cancelAndJoin()
|
||||
|
||||
searchResults.clear()
|
||||
searchBarOffset = 0
|
||||
loading = true
|
||||
error = false
|
||||
|
||||
searchJob = launch {
|
||||
if (cachedQuery != query || cachedSortByPopularity != sortByPopularity || cache.isEmpty()) {
|
||||
cachedQuery = null
|
||||
cache.clear()
|
||||
|
||||
yield()
|
||||
|
||||
val result = runCatching {
|
||||
doSearch(client, query, sortByPopularity)
|
||||
}.onFailure {
|
||||
error = true
|
||||
}.getOrNull()
|
||||
|
||||
yield()
|
||||
|
||||
result?.let { cache.addAll(result) }
|
||||
cachedQuery = query
|
||||
totalItems = result?.size ?: 0
|
||||
maxPage =
|
||||
result?.let { ceil(result.size / resultsPerPage.toDouble()).toInt() }
|
||||
?: 0
|
||||
}
|
||||
|
||||
yield()
|
||||
|
||||
val range = max((currentPage-1)*resultsPerPage, 0) until min(currentPage*resultsPerPage, totalItems)
|
||||
|
||||
cache.slice(range)
|
||||
.forEach { galleryID ->
|
||||
yield()
|
||||
loading = false
|
||||
kotlin.runCatching {
|
||||
galleryBlockCache.get(galleryID) ?: getGalleryBlock(client, galleryID).also {
|
||||
galleryBlockCache.put(galleryID, it)
|
||||
}
|
||||
}.onFailure {
|
||||
error = true
|
||||
}.getOrNull()?.let {
|
||||
searchResults.add(transform(it))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
searchJob?.join()
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun transform(galleryBlock: GalleryBlock) =
|
||||
HitomiSearchResult(
|
||||
galleryBlock.id.toString(),
|
||||
galleryBlock.title,
|
||||
galleryBlock.thumbnails.first(),
|
||||
galleryBlock.artists,
|
||||
galleryBlock.series,
|
||||
galleryBlock.type,
|
||||
galleryBlock.language,
|
||||
galleryBlock.relatedTags
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi.composable
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Female
|
||||
import androidx.compose.material.icons.filled.Male
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarOutline
|
||||
import androidx.compose.material.icons.outlined.StarOutline
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberImagePainter
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.sources.hitomi.HitomiSearchResult
|
||||
import xyz.quaver.pupil.ui.theme.Blue700
|
||||
import xyz.quaver.pupil.ui.theme.Orange500
|
||||
import xyz.quaver.pupil.ui.theme.Pink600
|
||||
|
||||
private val languageMap = mapOf(
|
||||
"indonesian" to "Bahasa Indonesia",
|
||||
"catalan" to "català",
|
||||
"cebuano" to "Cebuano",
|
||||
"czech" to "Čeština",
|
||||
"danish" to "Dansk",
|
||||
"german" to "Deutsch",
|
||||
"estonian" to "eesti",
|
||||
"english" to "English",
|
||||
"spanish" to "Español",
|
||||
"esperanto" to "Esperanto",
|
||||
"french" to "Français",
|
||||
"italian" to "Italiano",
|
||||
"latin" to "Latina",
|
||||
"hungarian" to "magyar",
|
||||
"dutch" to "Nederlands",
|
||||
"norwegian" to "norsk",
|
||||
"polish" to "polski",
|
||||
"portuguese" to "Português",
|
||||
"romanian" to "română",
|
||||
"albanian" to "shqip",
|
||||
"slovak" to "Slovenčina",
|
||||
"finnish" to "Suomi",
|
||||
"swedish" to "Svenska",
|
||||
"tagalog" to "Tagalog",
|
||||
"vietnamese" to "tiếng việt",
|
||||
"turkish" to "Türkçe",
|
||||
"greek" to "Ελληνικά",
|
||||
"mongolian" to "Монгол",
|
||||
"russian" to "Русский",
|
||||
"ukrainian" to "Українська",
|
||||
"hebrew" to "עברית",
|
||||
"arabic" to "العربية",
|
||||
"persian" to "فارسی",
|
||||
"thai" to "ไทย",
|
||||
"korean" to "한국어",
|
||||
"chinese" to "中文",
|
||||
"japanese" to "日本語"
|
||||
)
|
||||
|
||||
private fun String.wordCapitalize() : String {
|
||||
val result = ArrayList<String>()
|
||||
|
||||
for (word in this.split(" "))
|
||||
result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() })
|
||||
|
||||
return result.joinToString(" ")
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun DetailedSearchResult(
|
||||
result: HitomiSearchResult,
|
||||
favorites: Set<String>,
|
||||
onFavoriteToggle: (String) -> Unit = { },
|
||||
onClick: (HitomiSearchResult) -> Unit = { }
|
||||
) {
|
||||
val painter = rememberImagePainter(result.thumbnail)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(8.dp, 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick(result) },
|
||||
elevation = 4.dp
|
||||
) {
|
||||
Column {
|
||||
Row {
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.width(150.dp)
|
||||
.aspectRatio(
|
||||
with(painter.intrinsicSize) { if (this == Size.Unspecified) 1f else width / height },
|
||||
true
|
||||
)
|
||||
.padding(0.dp, 0.dp, 8.dp, 0.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
result.title,
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
result.artists.joinToString { it.wordCapitalize() },
|
||||
style = MaterialTheme.typography.subtitle1,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
if (result.series.isNotEmpty())
|
||||
Text(
|
||||
stringResource(
|
||||
id = R.string.galleryblock_series,
|
||||
result.series.joinToString { it.wordCapitalize() }
|
||||
),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(id = R.string.galleryblock_type, result.type),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
languageMap[result.language]?.run {
|
||||
Text(
|
||||
stringResource(id = R.string.galleryblock_language, this),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
key(result.tags) {
|
||||
TagGroup(
|
||||
tags = result.tags,
|
||||
favorites,
|
||||
onFavoriteToggle = onFavoriteToggle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
result.itemID,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
Icon(
|
||||
if (result.itemID in favorites) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
contentDescription = null,
|
||||
tint = Orange500,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable {
|
||||
onFavoriteToggle(result.itemID)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun TagGroup(
|
||||
tags: List<String>,
|
||||
favorites: Set<String>,
|
||||
onFavoriteToggle: (String) -> Unit = { }
|
||||
) {
|
||||
var isFolded by remember { mutableStateOf(true) }
|
||||
|
||||
val favoriteTagsInList = favorites intersect tags.toSet()
|
||||
|
||||
FlowRow(Modifier.padding(0.dp, 16.dp)) {
|
||||
tags.sortedBy { if (favoriteTagsInList.contains(it)) 0 else 1 }
|
||||
.let { (if (isFolded) it.take(10) else it) }.forEach { tag ->
|
||||
TagChip(
|
||||
tag = tag,
|
||||
isFavorite = favoriteTagsInList.contains(tag),
|
||||
onFavoriteClick = onFavoriteToggle
|
||||
)
|
||||
}
|
||||
|
||||
if (isFolded && tags.size > 10)
|
||||
Surface(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
color = MaterialTheme.colors.background,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
elevation = 2.dp,
|
||||
onClick = { isFolded = false }
|
||||
) {
|
||||
Text(
|
||||
"…",
|
||||
modifier = Modifier.padding(16.dp, 8.dp),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun TagChip(
|
||||
tag: String,
|
||||
isFavorite: Boolean,
|
||||
onClick: (String) -> Unit = { },
|
||||
onFavoriteClick: (String) -> Unit = { }
|
||||
) {
|
||||
val tagParts = tag.split(":", limit = 2).let {
|
||||
if (it.size == 1) listOf("", it.first())
|
||||
else it
|
||||
}
|
||||
|
||||
val icon = when (tagParts[0]) {
|
||||
"male" -> Icons.Filled.Male
|
||||
"female" -> Icons.Filled.Female
|
||||
else -> null
|
||||
}
|
||||
|
||||
val (surfaceColor, textTint) = when {
|
||||
isFavorite -> Pair(Orange500, Color.White)
|
||||
else -> when (tagParts[0]) {
|
||||
"male" -> Pair(Blue700, Color.White)
|
||||
"female" -> Pair(Pink600, Color.White)
|
||||
else -> Pair(MaterialTheme.colors.background, MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
val starIcon = if (isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
onClick = { onClick(tag) },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = surfaceColor,
|
||||
elevation = 2.dp
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (icon != null)
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = "Icon",
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(24.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
else
|
||||
Box(Modifier.size(16.dp))
|
||||
|
||||
Text(
|
||||
tagParts[1],
|
||||
color = textTint,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
|
||||
Icon(
|
||||
starIcon,
|
||||
contentDescription = "Favorites",
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { onFavoriteClick(tag) },
|
||||
tint = textTint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi.lib
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
const val protocol = "https:"
|
||||
|
||||
private val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
allowSpecialFloatingPointValues = true
|
||||
useArrayPolymorphism = true
|
||||
}
|
||||
|
||||
suspend fun getGalleryInfo(client: HttpClient, galleryID: Int): GalleryInfo = withContext(Dispatchers.IO) {
|
||||
json.decodeFromString(
|
||||
client.get<String>("$protocol//$domain/galleries/$galleryID.js")
|
||||
.replace("var galleryinfo = ", "")
|
||||
)
|
||||
}
|
||||
|
||||
//common.js
|
||||
const val domain = "ltn.hitomi.la"
|
||||
const val galleryblockextension = ".html"
|
||||
const val galleryblockdir = "galleryblock"
|
||||
const val nozomiextension = ".nozomi"
|
||||
|
||||
fun subdomainFromGalleryID(g: Int, numberOfFrontends: Int) : String {
|
||||
val o = g % numberOfFrontends
|
||||
|
||||
return (97+o).toChar().toString()
|
||||
}
|
||||
|
||||
fun subdomainFromURL(url: String, base: String? = null) : String {
|
||||
var retval = "b"
|
||||
|
||||
if (!base.isNullOrBlank())
|
||||
retval = base
|
||||
|
||||
var numberOfFrontends = 2
|
||||
val b = 16
|
||||
|
||||
val r = Regex("""/[0-9a-f]/([0-9a-f]{2})/""")
|
||||
val m = r.find(url) ?: return "a"
|
||||
|
||||
val g = m.groupValues[1].toIntOrNull(b)
|
||||
|
||||
if (g != null) {
|
||||
val o = when {
|
||||
g < 0x7c -> 1
|
||||
else -> 0
|
||||
}
|
||||
|
||||
// retval = subdomainFromGalleryID(g, numberOfFrontends) + retval
|
||||
retval = (97+o).toChar().toString() + retval
|
||||
}
|
||||
|
||||
return retval
|
||||
}
|
||||
|
||||
fun urlFromURL(url: String, base: String? = null) : String {
|
||||
return url.replace(Regex("""//..?\.hitomi\.la/"""), "//${subdomainFromURL(url, base)}.hitomi.la/")
|
||||
}
|
||||
|
||||
|
||||
fun fullPathFromHash(hash: String?) : String? {
|
||||
return when {
|
||||
(hash?.length ?: 0) < 3 -> hash
|
||||
else -> hash!!.replace(Regex("^.*(..)(.)$"), "$2/$1/$hash")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NAME_SHADOWING", "UNUSED_PARAMETER")
|
||||
fun urlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null) : String {
|
||||
val ext = ext ?: dir ?: image.name.split('.').last()
|
||||
val dir = dir ?: "images"
|
||||
return "$protocol//a.hitomi.la/$dir/${fullPathFromHash(image.hash)}.$ext"
|
||||
}
|
||||
|
||||
fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null) =
|
||||
urlFromURL(urlFromHash(galleryID, image, dir, ext), base)
|
||||
|
||||
fun rewriteTnPaths(html: String) =
|
||||
html.replace(Regex("""//tn\.hitomi\.la/[^/]+/[0-9a-f]/[0-9a-f]{2}/""")) { url ->
|
||||
urlFromURL(url.value, "tn")
|
||||
}
|
||||
|
||||
fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String {
|
||||
return when {
|
||||
noWebp ->
|
||||
urlFromUrlFromHash(galleryID, image)
|
||||
//image.hasavif != 0 ->
|
||||
// urlFromUrlFromHash(galleryID, image, "avif", null, "a")
|
||||
image.haswebp != 0 ->
|
||||
urlFromUrlFromHash(galleryID, image, "webp", null, "a")
|
||||
else ->
|
||||
urlFromUrlFromHash(galleryID, image)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi.lib
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import java.net.URLDecoder
|
||||
|
||||
@Serializable
|
||||
data class Gallery(
|
||||
val related: List<Int>,
|
||||
val langList: List<Pair<String, String>>,
|
||||
val cover: String,
|
||||
val title: String,
|
||||
val artists: List<String>,
|
||||
val groups: List<String>,
|
||||
val type: String,
|
||||
val language: String,
|
||||
val series: List<String>,
|
||||
val characters: List<String>,
|
||||
val tags: List<String>,
|
||||
val thumbnails: List<String>
|
||||
)
|
||||
suspend fun getGallery(client: HttpClient, galleryID: Int) : Gallery = withContext(Dispatchers.IO) {
|
||||
val url = Jsoup.parse(client.get("https://hitomi.la/galleries/$galleryID.html"))
|
||||
.select("link").attr("href")
|
||||
|
||||
val doc = Jsoup.parse(client.get(url))
|
||||
|
||||
val related = Regex("\\d+")
|
||||
.findAll(doc.select("script").first()!!.html())
|
||||
.map {
|
||||
it.value.toInt()
|
||||
}.toList()
|
||||
|
||||
val langList = doc.select("#lang-list a").map {
|
||||
Pair(it.text(), "$protocol//hitomi.la${it.attr("href")}")
|
||||
}
|
||||
|
||||
val cover = protocol + doc.selectFirst(".cover img")!!.attr("src")
|
||||
val title = doc.selectFirst(".gallery h1 a")!!.text()
|
||||
val artists = doc.select(".gallery h2 a").map { it.text() }
|
||||
val groups = doc.select(".gallery-info a[href~=^/group/]").map { it.text() }
|
||||
val type = doc.selectFirst(".gallery-info a[href~=^/type/]")!!.text()
|
||||
|
||||
val language = run {
|
||||
val href = doc.select(".gallery-info a[href~=^/index.+\\.html\$]").attr("href")
|
||||
Regex("""index-([^-]+)(-.+)?\.html""").find(href)?.groupValues?.getOrNull(1) ?: ""
|
||||
}
|
||||
|
||||
val series = doc.select(".gallery-info a[href~=^/series/]").map { it.text() }
|
||||
val characters = doc.select(".gallery-info a[href~=^/character/]").map { it.text() }
|
||||
|
||||
val tags = doc.select(".gallery-info a[href~=^/tag/]").map {
|
||||
val href = URLDecoder.decode(it.attr("href"), "UTF-8")
|
||||
href.slice(5 until href.indexOf('-'))
|
||||
}
|
||||
|
||||
val thumbnails = getGalleryInfo(client, galleryID).files.map { galleryInfo ->
|
||||
urlFromUrlFromHash(galleryID, galleryInfo, "smalltn", "jpg", "tn")
|
||||
}
|
||||
|
||||
Gallery(related, langList, cover, title, artists, groups, type, language, series, characters, tags, thumbnails)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi.lib
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import java.net.URLDecoder
|
||||
|
||||
//galleryblock.js
|
||||
suspend fun fetchNozomi(
|
||||
client: HttpClient,
|
||||
area: String? = null,
|
||||
tag: String = "index",
|
||||
language: String = "all",
|
||||
start: Int = -1,
|
||||
count: Int = -1
|
||||
) : Pair<List<Int>, Int> = withContext(Dispatchers.IO) {
|
||||
val url =
|
||||
when(area) {
|
||||
null -> "$protocol//$domain/$tag-$language$nozomiextension"
|
||||
else -> "$protocol//$domain/$area/$tag-$language$nozomiextension"
|
||||
}
|
||||
|
||||
val response: HttpResponse = client.get(url) {
|
||||
headers {
|
||||
if (start != -1 && count != -1) {
|
||||
val startByte = start*4
|
||||
val endByte = (start+count)*4-1
|
||||
|
||||
set("Range", "bytes=$startByte-$endByte")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val totalItems = response.headers["Content-Range"]!!
|
||||
.replace(Regex("^[Bb]ytes \\d+-\\d+/"), "").toInt() / 4
|
||||
|
||||
response.readBytes().asIterable().chunked(4) {
|
||||
(it[0].toInt() and 0xFF) or
|
||||
((it[1].toInt() and 0xFF) shl 8) or
|
||||
((it[2].toInt() and 0xFF) shl 16) or
|
||||
((it[3].toInt() and 0xFF) shl 24)
|
||||
} to totalItems
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GalleryBlock(
|
||||
val id: Int,
|
||||
val galleryUrl: String,
|
||||
val thumbnails: List<String>,
|
||||
val title: String,
|
||||
val artists: List<String>,
|
||||
val series: List<String>,
|
||||
val type: String,
|
||||
val language: String,
|
||||
val relatedTags: List<String>
|
||||
)
|
||||
|
||||
suspend fun getGalleryBlock(client: HttpClient, galleryID: Int) : GalleryBlock = withContext(Dispatchers.IO) {
|
||||
val url = "$protocol//$domain/$galleryblockdir/$galleryID$extension"
|
||||
|
||||
val doc = Jsoup.parse(rewriteTnPaths(client.get(url)))
|
||||
|
||||
val galleryUrl = doc.selectFirst("h1 > a")!!.attr("href")
|
||||
|
||||
val thumbnails = doc.select(".dj-img-cont img").map { protocol + it.attr("src") }
|
||||
|
||||
val title = doc.selectFirst("h1 > a")!!.text()
|
||||
val artists = doc.select(".artist-list a").map{ it.text() }
|
||||
val series = doc.select(".dj-content a[href~=^/series/]").map { it.text() }
|
||||
val type = doc.selectFirst("a[href~=^/type/]")!!.text()
|
||||
|
||||
val language = run {
|
||||
val href = doc.select("a[href~=^/index.+\\.html\$]").attr("href")
|
||||
Regex("""index-([^-]+)(-.+)?\.html""").find(href)?.groupValues?.getOrNull(1) ?: ""
|
||||
}
|
||||
|
||||
val relatedTags = doc.select(".relatedtags a").map {
|
||||
val href = URLDecoder.decode(it.attr("href"), "UTF-8")
|
||||
href.slice(5 until href.indexOf("-all"))
|
||||
}
|
||||
|
||||
GalleryBlock(galleryID, galleryUrl, thumbnails, title, artists, series, type, language, relatedTags)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi.lib
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
fun getReferer(galleryID: Int) = "https://hitomi.la/reader/$galleryID.html"
|
||||
|
||||
@Serializable
|
||||
data class Tag(
|
||||
val male: String? = null,
|
||||
val female: String? = null,
|
||||
val url: String,
|
||||
val tag: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryInfo(
|
||||
val id: Int? = null,
|
||||
val language_localname: String? = null,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val title: String? = null,
|
||||
val files: List<GalleryFiles>,
|
||||
val date: String? = null,
|
||||
val type: String? = null,
|
||||
val language: String? = null,
|
||||
val japanese_title: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryFiles(
|
||||
val width: Int,
|
||||
val hash: String? = null,
|
||||
val haswebp: Int = 0,
|
||||
val name: String,
|
||||
val height: Int,
|
||||
val hasavif: Int = 0,
|
||||
val hasavifsmalltn: Int? = 0
|
||||
)
|
||||
@@ -1,76 +0,0 @@
|
||||
package xyz.quaver.pupil.sources.hitomi.lib
|
||||
|
||||
import io.ktor.client.*
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.util.*
|
||||
|
||||
suspend fun doSearch(
|
||||
client: HttpClient,
|
||||
query: String,
|
||||
sortByPopularity: Boolean = false
|
||||
) : Set<Int> = coroutineScope {
|
||||
val terms = query
|
||||
.trim()
|
||||
.replace(Regex("""^\?"""), "")
|
||||
.lowercase()
|
||||
.split(Regex("\\s+"))
|
||||
.map {
|
||||
it.replace('_', ' ')
|
||||
}
|
||||
|
||||
val positiveTerms = LinkedList<String>()
|
||||
val negativeTerms = LinkedList<String>()
|
||||
|
||||
for (term in terms) {
|
||||
if (term.matches(Regex("^-.+")))
|
||||
negativeTerms.push(term.replace(Regex("^-"), ""))
|
||||
else if (term.isNotBlank())
|
||||
positiveTerms.push(term)
|
||||
}
|
||||
|
||||
val positiveResults = positiveTerms.map {
|
||||
async {
|
||||
runCatching {
|
||||
getGalleryIDsForQuery(client, it)
|
||||
}.getOrElse { emptySet() }
|
||||
}
|
||||
}
|
||||
|
||||
val negativeResults = negativeTerms.map {
|
||||
async {
|
||||
runCatching {
|
||||
getGalleryIDsForQuery(client, it)
|
||||
}.getOrElse { emptySet() }
|
||||
}
|
||||
}
|
||||
|
||||
var results = when {
|
||||
sortByPopularity -> getGalleryIDsFromNozomi(client, null, "popular", "all")
|
||||
positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(client, null, "index", "all")
|
||||
else -> emptySet()
|
||||
}
|
||||
|
||||
fun filterPositive(newResults: Set<Int>) {
|
||||
results = when {
|
||||
results.isEmpty() -> newResults
|
||||
else -> results intersect newResults
|
||||
}
|
||||
}
|
||||
|
||||
fun filterNegative(newResults: Set<Int>) {
|
||||
results = results subtract newResults
|
||||
}
|
||||
|
||||
//positive results
|
||||
positiveResults.forEach {
|
||||
filterPositive(it.await())
|
||||
}
|
||||
|
||||
//negative results
|
||||
negativeResults.forEach {
|
||||
filterNegative(it.await())
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.hitomi.lib
|
||||
|
||||
import android.util.Log
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.min
|
||||
|
||||
//searchlib.js
|
||||
const val separator = "-"
|
||||
const val extension = ".html"
|
||||
const val index_dir = "tagindex"
|
||||
const val galleries_index_dir = "galleriesindex"
|
||||
const val max_node_size = 464
|
||||
const val B = 16
|
||||
const val compressed_nozomi_prefix = "n"
|
||||
|
||||
var _tag_index_version: String? = null
|
||||
suspend fun getTagIndexVersion(client: HttpClient): String = _tag_index_version ?: getIndexVersion(client, "tagindex").also {
|
||||
_tag_index_version = it
|
||||
}
|
||||
|
||||
var _galleries_index_version: String? = null
|
||||
suspend fun getGalleriesIndexVersion(client: HttpClient): String = _galleries_index_version ?: getIndexVersion(client, "galleriesindex").also {
|
||||
_galleries_index_version = it
|
||||
}
|
||||
|
||||
fun sha256(data: ByteArray) : ByteArray {
|
||||
return MessageDigest.getInstance("SHA-256").digest(data)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
fun hashTerm(term: String) : UByteArray {
|
||||
return sha256(term.toByteArray()).toUByteArray().sliceArray(0 until 4)
|
||||
}
|
||||
|
||||
fun sanitize(input: String) : String {
|
||||
return input.replace(Regex("[/#]"), "")
|
||||
}
|
||||
|
||||
suspend fun getIndexVersion(client: HttpClient, name: String): String =
|
||||
client.get("$protocol//$domain/$name/version?_=${System.currentTimeMillis()}")
|
||||
|
||||
//search.js
|
||||
suspend fun getGalleryIDsForQuery(client: HttpClient, query: String) : Set<Int> {
|
||||
query.replace("_", " ").let {
|
||||
if (it.indexOf(':') > -1) {
|
||||
val sides = it.split(":")
|
||||
val ns = sides[0]
|
||||
var tag = sides[1]
|
||||
|
||||
var area : String? = ns
|
||||
var language = "all"
|
||||
when (ns) {
|
||||
"female", "male" -> {
|
||||
area = "tag"
|
||||
tag = it
|
||||
}
|
||||
"language" -> {
|
||||
area = null
|
||||
language = tag
|
||||
tag = "index"
|
||||
}
|
||||
}
|
||||
|
||||
return getGalleryIDsFromNozomi(client, area, tag, language)
|
||||
}
|
||||
|
||||
val key = hashTerm(it)
|
||||
val field = "galleries"
|
||||
|
||||
val node = getNodeAtAddress(client, field, 0) ?: return emptySet()
|
||||
|
||||
val data = bSearch(client, field, key, node)
|
||||
|
||||
if (data != null)
|
||||
return getGalleryIDsFromData(client, data)
|
||||
|
||||
return emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSuggestionsForQuery(client: HttpClient, query: String) : List<Suggestion> {
|
||||
query.replace('_', ' ').let {
|
||||
var field = "global"
|
||||
var term = it
|
||||
|
||||
if (term.indexOf(':') > -1) {
|
||||
val sides = it.split(':')
|
||||
field = sides[0]
|
||||
term = sides[1]
|
||||
}
|
||||
|
||||
val key = hashTerm(term)
|
||||
val node = getNodeAtAddress(client, field, 0) ?: return emptyList()
|
||||
val data = bSearch(client, field, key, node)
|
||||
|
||||
if (data != null)
|
||||
return getSuggestionsFromData(client, field, data)
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
data class Suggestion(val s: String, val t: Int, val u: String, val n: String)
|
||||
suspend fun getSuggestionsFromData(client: HttpClient, field: String, data: Pair<Long, Int>) : List<Suggestion> {
|
||||
val url = "$protocol//$domain/$index_dir/$field.${getTagIndexVersion(client)}.data"
|
||||
val (offset, length) = data
|
||||
if (length > 10000 || length <= 0)
|
||||
throw Exception("length $length is too long")
|
||||
|
||||
val inbuf = getURLAtRange(client, url, offset.until(offset+length))
|
||||
|
||||
val suggestions = ArrayList<Suggestion>()
|
||||
|
||||
val buffer = ByteBuffer
|
||||
.wrap(inbuf)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
val numberOfSuggestions = buffer.int
|
||||
|
||||
if (numberOfSuggestions > 100 || numberOfSuggestions <= 0)
|
||||
throw Exception("number of suggestions $numberOfSuggestions is too long")
|
||||
|
||||
for (i in 0.until(numberOfSuggestions)) {
|
||||
var top = buffer.int
|
||||
|
||||
val ns = inbuf.sliceArray(buffer.position().until(buffer.position()+top)).toString(charset("UTF-8"))
|
||||
buffer.position(buffer.position()+top)
|
||||
|
||||
top = buffer.int
|
||||
|
||||
val tag = inbuf.sliceArray(buffer.position().until(buffer.position()+top)).toString(charset("UTF-8"))
|
||||
buffer.position(buffer.position()+top)
|
||||
|
||||
val count = buffer.int
|
||||
|
||||
val tagname = sanitize(tag)
|
||||
val u =
|
||||
when(ns) {
|
||||
"female", "male" -> "/tag/$ns:$tagname${separator}1$extension"
|
||||
"language" -> "/index-$tagname${separator}1$extension"
|
||||
else -> "/$ns/$tagname${separator}all${separator}1$extension"
|
||||
}
|
||||
|
||||
suggestions.add(Suggestion(tag, count, u, ns))
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
suspend fun getGalleryIDsFromNozomi(client: HttpClient, area: String?, tag: String, language: String) : Set<Int> = withContext(Dispatchers.IO) {
|
||||
val nozomiAddress =
|
||||
when(area) {
|
||||
null -> "$protocol//$domain/$compressed_nozomi_prefix/$tag-$language$nozomiextension"
|
||||
else -> "$protocol//$domain/$compressed_nozomi_prefix/$area/$tag-$language$nozomiextension"
|
||||
}
|
||||
|
||||
val bytes: ByteArray = try {
|
||||
client.get(nozomiAddress)
|
||||
} catch (e: Exception) {
|
||||
return@withContext emptySet()
|
||||
}
|
||||
|
||||
val nozomi = mutableSetOf<Int>()
|
||||
|
||||
val arrayBuffer = ByteBuffer
|
||||
.wrap(bytes)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
|
||||
while (arrayBuffer.hasRemaining())
|
||||
nozomi.add(arrayBuffer.int)
|
||||
|
||||
nozomi
|
||||
}
|
||||
|
||||
suspend fun getGalleryIDsFromData(client: HttpClient, data: Pair<Long, Int>) : Set<Int> {
|
||||
val url = "$protocol//$domain/$galleries_index_dir/galleries.${getGalleriesIndexVersion(client)}.data"
|
||||
val (offset, length) = data
|
||||
if (length > 100000000 || length <= 0)
|
||||
throw Exception("length $length is too long")
|
||||
|
||||
val inbuf = getURLAtRange(client, url, offset.until(offset+length))
|
||||
|
||||
val galleryIDs = mutableSetOf<Int>()
|
||||
|
||||
val buffer = ByteBuffer
|
||||
.wrap(inbuf)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
|
||||
val numberOfGalleryIDs = buffer.int
|
||||
|
||||
val expectedLength = numberOfGalleryIDs*4+4
|
||||
|
||||
if (numberOfGalleryIDs > 10000000 || numberOfGalleryIDs <= 0)
|
||||
throw Exception("number_of_galleryids $numberOfGalleryIDs is too long")
|
||||
else if (inbuf.size != expectedLength)
|
||||
throw Exception("inbuf.byteLength ${inbuf.size} != expected_length $expectedLength")
|
||||
|
||||
for (i in 0.until(numberOfGalleryIDs))
|
||||
galleryIDs.add(buffer.int)
|
||||
|
||||
return galleryIDs
|
||||
}
|
||||
|
||||
suspend fun getNodeAtAddress(client: HttpClient, field: String, address: Long) : Node? {
|
||||
val url =
|
||||
when(field) {
|
||||
"galleries" -> "$protocol//$domain/$galleries_index_dir/galleries.${getGalleriesIndexVersion(client)}.index"
|
||||
"languages" -> "$protocol//$domain/$galleries_index_dir/languages.${getGalleriesIndexVersion(client)}.index"
|
||||
"nozomiurl" -> "$protocol//$domain/$galleries_index_dir/nozomiurl.${getGalleriesIndexVersion(client)}.index"
|
||||
else -> "$protocol//$domain/$index_dir/$field.${getTagIndexVersion(client)}.index"
|
||||
}
|
||||
|
||||
val nodedata = getURLAtRange(client, url, address.until(address+max_node_size))
|
||||
|
||||
return decodeNode(nodedata)
|
||||
}
|
||||
|
||||
suspend fun getURLAtRange(client: HttpClient, url: String, range: LongRange) : ByteArray = withContext(Dispatchers.IO) {
|
||||
client.get(url) {
|
||||
headers {
|
||||
set("Range", "bytes=${range.first}-${range.last}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
data class Node(val keys: List<UByteArray>, val datas: List<Pair<Long, Int>>, val subNodeAddresses: List<Long>)
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
fun decodeNode(data: ByteArray) : Node {
|
||||
val buffer = ByteBuffer
|
||||
.wrap(data)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
|
||||
val uData = data.toUByteArray()
|
||||
|
||||
val numberOfKeys = buffer.int
|
||||
val keys = ArrayList<UByteArray>()
|
||||
|
||||
for (i in 0.until(numberOfKeys)) {
|
||||
val keySize = buffer.int
|
||||
|
||||
if (keySize == 0 || keySize > 32)
|
||||
throw Exception("fatal: !keySize || keySize > 32")
|
||||
|
||||
keys.add(uData.sliceArray(buffer.position().until(buffer.position()+keySize)))
|
||||
buffer.position(buffer.position()+keySize)
|
||||
}
|
||||
|
||||
val numberOfDatas = buffer.int
|
||||
val datas = ArrayList<Pair<Long, Int>>()
|
||||
|
||||
for (i in 0.until(numberOfDatas)) {
|
||||
val offset = buffer.long
|
||||
val length = buffer.int
|
||||
|
||||
datas.add(Pair(offset, length))
|
||||
}
|
||||
|
||||
val numberOfSubNodeAddresses = B+1
|
||||
val subNodeAddresses = ArrayList<Long>()
|
||||
|
||||
for (i in 0.until(numberOfSubNodeAddresses)) {
|
||||
val subNodeAddress = buffer.long
|
||||
subNodeAddresses.add(subNodeAddress)
|
||||
}
|
||||
|
||||
return Node(keys, datas, subNodeAddresses)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
suspend fun bSearch(client: HttpClient, field: String, key: UByteArray, node: Node) : Pair<Long, Int>? {
|
||||
fun compareArrayBuffers(dv1: UByteArray, dv2: UByteArray) : Int {
|
||||
val top = min(dv1.size, dv2.size)
|
||||
|
||||
for (i in 0.until(top)) {
|
||||
if (dv1[i] < dv2[i])
|
||||
return -1
|
||||
else if (dv1[i] > dv2[i])
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
fun locateKey(key: UByteArray, node: Node) : Pair<Boolean, Int> {
|
||||
for (i in node.keys.indices) {
|
||||
val cmpResult = compareArrayBuffers(key, node.keys[i])
|
||||
|
||||
if (cmpResult <= 0)
|
||||
return Pair(cmpResult==0, i)
|
||||
}
|
||||
|
||||
return Pair(false, node.keys.size)
|
||||
}
|
||||
|
||||
fun isLeaf(node: Node) : Boolean {
|
||||
for (subnode in node.subNodeAddresses)
|
||||
if (subnode != 0L)
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.keys.isEmpty())
|
||||
return null
|
||||
|
||||
val (there, where) = locateKey(key, node)
|
||||
if (there)
|
||||
return node.datas[where]
|
||||
else if (isLeaf(node))
|
||||
return null
|
||||
|
||||
val nextNode = getNodeAtAddress(client, field, node.subNodeAddresses[where]) ?: return null
|
||||
return bSearch(client, field, key, nextNode)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.sql.Timestamp
|
||||
|
||||
@Entity
|
||||
data class Favorite(
|
||||
@PrimaryKey val itemID: String
|
||||
)
|
||||
|
||||
@Entity
|
||||
data class Bookmark(
|
||||
@PrimaryKey val itemID: String,
|
||||
val page: Int
|
||||
)
|
||||
|
||||
@Entity
|
||||
data class History(
|
||||
@PrimaryKey val itemID: String,
|
||||
val parent: String,
|
||||
val page: Int,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface FavoriteDao {
|
||||
@Query("SELECT EXISTS(SELECT * FROM favorite WHERE itemID = :itemID)")
|
||||
fun contains(itemID: String): Flow<Boolean>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(favorite: Favorite)
|
||||
suspend fun insert(itemID: String) = insert(Favorite(itemID))
|
||||
|
||||
@Delete
|
||||
suspend fun delete(favorite: Favorite)
|
||||
suspend fun delete(itemID: String) = delete(Favorite(itemID))
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface BookmarkDao {
|
||||
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface HistoryDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(history: History)
|
||||
suspend fun insert(itemID: String, parent: String, page: Int) = insert(History(itemID, parent, page))
|
||||
|
||||
@Query("DELETE FROM history WHERE itemID = :itemID")
|
||||
suspend fun delete(itemID: String)
|
||||
|
||||
@Query("SELECT parent FROM (SELECT parent, max(timestamp) as t FROM history GROUP BY parent) ORDER BY t DESC")
|
||||
fun getRecentManga(): Flow<List<String>>
|
||||
|
||||
@Query("SELECT itemID FROM history WHERE parent = :parent ORDER BY timestamp DESC")
|
||||
suspend fun getAll(parent: String): List<String>
|
||||
}
|
||||
|
||||
@Database(entities = [Favorite::class, Bookmark::class, History::class], version = 1, exportSchema = false)
|
||||
abstract class ManatokiDatabase: RoomDatabase() {
|
||||
abstract fun favoriteDao(): FavoriteDao
|
||||
abstract fun bookmarkDao(): BookmarkDao
|
||||
abstract fun historyDao(): HistoryDao
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package xyz.quaver.pupil.sources.manatoki
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navigation
|
||||
import androidx.room.Room
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.di.android.subDI
|
||||
import org.kodein.di.bindProvider
|
||||
import org.kodein.di.bindSingleton
|
||||
import org.kodein.di.compose.withDI
|
||||
import org.kodein.di.instance
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.frontend.defaultLogFrontend
|
||||
import org.kodein.log.newLogger
|
||||
import org.kodein.log.withShortPackageKeepLast
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.sources.Source
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.Main
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.Reader
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.Recent
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.Search
|
||||
import xyz.quaver.pupil.sources.manatoki.viewmodel.MainViewModel
|
||||
import xyz.quaver.pupil.sources.manatoki.viewmodel.RecentViewModel
|
||||
import xyz.quaver.pupil.sources.manatoki.viewmodel.SearchViewModel
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterialApi::class,
|
||||
ExperimentalFoundationApi::class,
|
||||
ExperimentalAnimationApi::class,
|
||||
ExperimentalComposeUiApi::class
|
||||
)
|
||||
class Manatoki(app: Application) : Source(), DIAware {
|
||||
override val di by subDI(closestDI(app)) {
|
||||
bindSingleton {
|
||||
Room.databaseBuilder(
|
||||
app, ManatokiDatabase::class.java, name
|
||||
).build()
|
||||
}
|
||||
|
||||
bindProvider { MainViewModel(instance()) }
|
||||
bindProvider { RecentViewModel(instance()) }
|
||||
bindProvider { SearchViewModel(instance()) }
|
||||
}
|
||||
|
||||
override val name = "manatoki.net"
|
||||
override val iconResID = R.drawable.manatoki
|
||||
|
||||
override fun NavGraphBuilder.navGraph(navController: NavController) {
|
||||
navigation(route = name, startDestination = "manatoki.net/") {
|
||||
composable("manatoki.net/") { withDI(di) { Main(navController) } }
|
||||
composable("manatoki.net/reader/{itemID}") { withDI(di) { Reader(navController) } }
|
||||
composable("manatoki.net/search") { withDI(di) { Search(navController) } }
|
||||
composable("manatoki.net/recent") { withDI(di) { Recent(navController) } }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun RowScope.BoardButton(
|
||||
text: String,
|
||||
color: Color
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.height(64.dp).weight(1f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = 8.dp
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier.padding(8.dp, 0.dp).weight(1f),
|
||||
style = MaterialTheme.typography.h6
|
||||
)
|
||||
|
||||
Icon(
|
||||
Icons.Default.ArrowForward,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.width(48.dp)
|
||||
.fillMaxHeight()
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.composable
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
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.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import org.kodein.di.compose.rememberViewModel
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.proto.settingsDataStore
|
||||
import xyz.quaver.pupil.sources.composable.SourceSelectDialog
|
||||
import xyz.quaver.pupil.sources.manatoki.ManatokiDatabase
|
||||
import xyz.quaver.pupil.sources.manatoki.MangaListing
|
||||
import xyz.quaver.pupil.sources.manatoki.ReaderInfo
|
||||
import xyz.quaver.pupil.sources.manatoki.getItem
|
||||
import xyz.quaver.pupil.sources.manatoki.viewmodel.MainViewModel
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun Main(navController: NavController) {
|
||||
val model: MainViewModel by rememberViewModel()
|
||||
|
||||
val client: HttpClient by rememberInstance()
|
||||
|
||||
val database: ManatokiDatabase by rememberInstance()
|
||||
val historyDao = remember { database.historyDao() }
|
||||
val recent by remember { historyDao.getRecentManga() }.collectAsState(emptyList())
|
||||
val recentManga = remember { mutableStateListOf<Thumbnail>() }
|
||||
|
||||
LaunchedEffect(recent) {
|
||||
recentManga.clear()
|
||||
|
||||
recent.forEach {
|
||||
if (isActive)
|
||||
client.getItem(it, onListing = {
|
||||
recentManga.add(
|
||||
Thumbnail(it.itemID, it.title, it.thumbnail)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
context.settingsDataStore.updateData {
|
||||
it.toBuilder()
|
||||
.setRecentSource("manatoki.net")
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
var sourceSelectDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (sourceSelectDialog)
|
||||
SourceSelectDialog(navController, "manatoki.net") { sourceSelectDialog = false }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
model.load()
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
if (sheetState.currentValue == ModalBottomSheetValue.Hidden)
|
||||
navController.popBackStack()
|
||||
else
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
}
|
||||
|
||||
var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) }
|
||||
var recentItem: String? by rememberSaveable { mutableStateOf(null) }
|
||||
val mangaListingListState = rememberLazyListState()
|
||||
var mangaListingListSize: Size? by remember { mutableStateOf(null) }
|
||||
val mangaListingInteractionSource = remember { mutableStateMapOf<String, MutableInteractionSource>() }
|
||||
val navigationBarsPadding = LocalDensity.current.run {
|
||||
rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.navigationBars
|
||||
).calculateBottomPadding().toPx()
|
||||
}
|
||||
|
||||
val onListing: (MangaListing) -> Unit = {
|
||||
mangaListing = it
|
||||
|
||||
coroutineScope.launch {
|
||||
val recentItemID = historyDao.getAll(it.itemID).firstOrNull() ?: return@launch
|
||||
recentItem = recentItemID
|
||||
|
||||
while (mangaListingListState.layoutInfo.totalItemsCount != it.entries.size) {
|
||||
delay(100)
|
||||
}
|
||||
|
||||
val interactionSource = mangaListingInteractionSource.getOrPut(recentItemID) {
|
||||
MutableInteractionSource()
|
||||
}
|
||||
|
||||
val targetIndex =
|
||||
it.entries.indexOfFirst { entry -> entry.itemID == recentItemID }
|
||||
|
||||
mangaListingListState.scrollToItem(targetIndex)
|
||||
|
||||
mangaListingListSize?.let { sheetSize ->
|
||||
val targetItem =
|
||||
mangaListingListState.layoutInfo.visibleItemsInfo.first {
|
||||
it.key == recentItemID
|
||||
}
|
||||
|
||||
if (targetItem.offset == 0) {
|
||||
mangaListingListState.animateScrollBy(
|
||||
-(sheetSize.height - navigationBarsPadding - targetItem.size)
|
||||
)
|
||||
}
|
||||
|
||||
delay(200)
|
||||
|
||||
with(interactionSource) {
|
||||
val interaction =
|
||||
PressInteraction.Press(
|
||||
Offset(
|
||||
sheetSize.width / 2,
|
||||
targetItem.size / 2f
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
emit(interaction)
|
||||
emit(PressInteraction.Release(interaction))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onReader: (ReaderInfo) -> Unit = { readerInfo ->
|
||||
coroutineScope.launch {
|
||||
sheetState.snapTo(ModalBottomSheetValue.Hidden)
|
||||
navController.navigate("manatoki.net/reader/${readerInfo.itemID}")
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
sheetState = sheetState,
|
||||
sheetShape = RoundedCornerShape(32.dp, 32.dp, 0.dp, 0.dp),
|
||||
sheetContent = {
|
||||
MangaListingBottomSheet(
|
||||
mangaListing,
|
||||
onListSize = { mangaListingListSize = it },
|
||||
rippleInteractionSource = mangaListingInteractionSource,
|
||||
listState = mangaListingListState,
|
||||
recentItem = recentItem
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
client.getItem(it, onListing, onReader)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("마나토끼")
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { sourceSelectDialog = true }) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.manatoki),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { navController.navigate("settings") }) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
}
|
||||
},
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
insets = LocalWindowInsets.current.statusBars,
|
||||
applyBottom = false
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
onClick = {
|
||||
navController.navigate("manatoki.net/search")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(8.dp, 0.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (recentManga.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"이어 보기",
|
||||
style = MaterialTheme.typography.h5
|
||||
)
|
||||
|
||||
IconButton(onClick = { navController.navigate("manatoki.net/recent") }) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(210.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(recentManga) { item ->
|
||||
Thumbnail(
|
||||
item,
|
||||
Modifier
|
||||
.width(180.dp)
|
||||
.aspectRatio(6 / 7f)
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
mangaListing = null
|
||||
sheetState.animateTo(ModalBottomSheetValue.Expanded)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
client.getItem(it, onListing, onReader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"최신화",
|
||||
style = MaterialTheme.typography.h5
|
||||
)
|
||||
|
||||
IconButton(onClick = { navController.navigate("manatoki.net/recent") }) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(210.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(model.recentUpload) { item ->
|
||||
Thumbnail(item,
|
||||
Modifier
|
||||
.width(180.dp)
|
||||
.aspectRatio(6 / 7f)) {
|
||||
coroutineScope.launch {
|
||||
mangaListing = null
|
||||
sheetState.animateTo(ModalBottomSheetValue.Expanded)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
client.getItem(it, onListing, onReader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
BoardButton("마나게시판", Color(0xFF007DB4))
|
||||
BoardButton("유머/가십", Color(0xFFF09614))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
BoardButton("역식자게시판", Color(0xFFA0C850))
|
||||
BoardButton("원본게시판", Color(0xFFFF4500))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("만화 목록", style = MaterialTheme.typography.h5)
|
||||
|
||||
IconButton(onClick = { navController.navigate("manatoki.net/search") }) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(210.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(model.mangaList) { item ->
|
||||
Thumbnail(item,
|
||||
Modifier
|
||||
.width(180.dp)
|
||||
.aspectRatio(6f / 7)) {
|
||||
coroutineScope.launch {
|
||||
mangaListing = null
|
||||
sheetState.animateTo(ModalBottomSheetValue.Expanded)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
client.getItem(it, onListing, onReader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("주간 베스트", style = MaterialTheme.typography.h5)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
model.topWeekly.forEachIndexed { index, item ->
|
||||
Card(
|
||||
modifier = Modifier.clickable {
|
||||
coroutineScope.launch {
|
||||
mangaListing = null
|
||||
sheetState.animateTo(ModalBottomSheetValue.Expanded)
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
client.getItem(item.itemID, onListing, onReader)
|
||||
}
|
||||
}
|
||||
) {
|
||||
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)
|
||||
.padding(0.dp, 4.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
item.count,
|
||||
color = Color(0xFFFF4500)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.composable
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowRight
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import coil.compose.rememberImagePainter
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsPadding
|
||||
import com.google.accompanist.insets.rememberInsetsPaddingValues
|
||||
import xyz.quaver.pupil.sources.manatoki.MangaListing
|
||||
|
||||
private val FabSpacing = 8.dp
|
||||
private val HeightPercentage = 75 // take 75% of the available space
|
||||
private enum class MangaListingBottomSheetLayoutContent { Top, Bottom, Fab }
|
||||
|
||||
@Composable
|
||||
fun MangaListingBottomSheetLayout(
|
||||
floatingActionButton: @Composable () -> Unit,
|
||||
top: @Composable () -> Unit,
|
||||
bottom: @Composable () -> Unit
|
||||
) {
|
||||
SubcomposeLayout { constraints ->
|
||||
val layoutWidth = constraints.maxWidth
|
||||
val layoutHeight = constraints.maxHeight * HeightPercentage / 100
|
||||
|
||||
layout(layoutWidth, layoutHeight) {
|
||||
val topPlaceables = subcompose(MangaListingBottomSheetLayoutContent.Top, top).map {
|
||||
it.measure(constraints)
|
||||
}
|
||||
|
||||
val topPlaceableHeight = topPlaceables.maxOfOrNull { it.height } ?: 0
|
||||
|
||||
val bottomConstraints = constraints.copy(
|
||||
maxHeight = layoutHeight - topPlaceableHeight
|
||||
)
|
||||
|
||||
val bottomPlaceables = subcompose(MangaListingBottomSheetLayoutContent.Bottom, bottom).map {
|
||||
it.measure(bottomConstraints)
|
||||
}
|
||||
|
||||
val fabPlaceables = subcompose(MangaListingBottomSheetLayoutContent.Fab, floatingActionButton).mapNotNull {
|
||||
it.measure(constraints).takeIf { it.height != 0 && it.width != 0 }
|
||||
}
|
||||
|
||||
topPlaceables.forEach { it.place(0, 0) }
|
||||
bottomPlaceables.forEach { it.place(0, topPlaceableHeight) }
|
||||
|
||||
if (fabPlaceables.isNotEmpty()) {
|
||||
val fabWidth = fabPlaceables.maxOf { it.width }
|
||||
val fabHeight = fabPlaceables.maxOf { it.height }
|
||||
|
||||
fabPlaceables.forEach {
|
||||
it.place(
|
||||
layoutWidth - fabWidth - FabSpacing.roundToPx(),
|
||||
topPlaceableHeight - fabHeight / 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun MangaListingBottomSheet(
|
||||
mangaListing: MangaListing? = null,
|
||||
currentItemID: String? = null,
|
||||
onListSize: (Size) -> Unit = { },
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
rippleInteractionSource: Map<String, MutableInteractionSource> = emptyMap(),
|
||||
recentItem: String? = null,
|
||||
nextItem: String? = null,
|
||||
onOpenItem: (String) -> Unit = { },
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (mangaListing == null)
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(16.dp)
|
||||
.align(Alignment.Center))
|
||||
else
|
||||
MangaListingBottomSheetLayout(
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(
|
||||
when {
|
||||
mangaListing.entries.any { it.itemID == recentItem } -> "이어보기"
|
||||
mangaListing.entries.any { it.itemID == nextItem } -> "다음화보기"
|
||||
else -> "첫화보기"
|
||||
}
|
||||
) },
|
||||
onClick = {
|
||||
when {
|
||||
mangaListing.entries.any { it.itemID == recentItem } -> onOpenItem(recentItem!!)
|
||||
mangaListing.entries.any { it.itemID == nextItem } -> onOpenItem(nextItem!!)
|
||||
else -> mangaListing.entries.lastOrNull()?.let { onOpenItem(it.itemID) }
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
top = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(MaterialTheme.colors.primary)
|
||||
.padding(0.dp, 0.dp, 0.dp, 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val painter = rememberImagePainter(mangaListing.thumbnail)
|
||||
|
||||
Box(Modifier.fillMaxHeight()) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.width(150.dp)
|
||||
.aspectRatio(
|
||||
with(painter.intrinsicSize) { if (this == Size.Unspecified) 1f else width / height }
|
||||
).align(Alignment.Center),
|
||||
painter = painter,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(0.dp, 8.dp)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
mangaListing.title,
|
||||
style = MaterialTheme.typography.h5,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalContentAlpha provides 0.7f) {
|
||||
Text("작가: ${mangaListing.author}")
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("분류: ")
|
||||
|
||||
CompositionLocalProvider(LocalContentAlpha provides 1f) {
|
||||
FlowRow(
|
||||
modifier = Modifier.weight(1f),
|
||||
mainAxisSpacing = 8.dp
|
||||
) {
|
||||
mangaListing.tags.forEach {
|
||||
Card(
|
||||
elevation = 4.dp,
|
||||
backgroundColor = Color.White
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("발행구분: ${mangaListing.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
bottom = {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned {
|
||||
onListSize(it.size.toSize())
|
||||
},
|
||||
state = listState,
|
||||
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
|
||||
) {
|
||||
itemsIndexed(mangaListing.entries, key = { _, entry -> entry.itemID }) { index, entry ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
onOpenItem(entry.itemID)
|
||||
}
|
||||
.run {
|
||||
rippleInteractionSource[entry.itemID]?.let {
|
||||
indication(it, rememberRipple())
|
||||
} ?: this
|
||||
}
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (entry.itemID == currentItemID)
|
||||
Icon(
|
||||
Icons.Default.ArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
|
||||
Text(
|
||||
entry.title,
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text("★ ${entry.starRating}")
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.composable
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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 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.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import org.kodein.di.compose.rememberViewModel
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.sources.composable.ReaderBase
|
||||
import xyz.quaver.pupil.sources.composable.ReaderBaseViewModel
|
||||
import xyz.quaver.pupil.sources.manatoki.ManatokiDatabase
|
||||
import xyz.quaver.pupil.sources.manatoki.MangaListing
|
||||
import xyz.quaver.pupil.sources.manatoki.ReaderInfo
|
||||
import xyz.quaver.pupil.sources.manatoki.getItem
|
||||
import xyz.quaver.pupil.ui.theme.Orange500
|
||||
import kotlin.math.max
|
||||
|
||||
private val imageUserAgent = "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"
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalMaterialApi
|
||||
@ExperimentalComposeUiApi
|
||||
@Composable
|
||||
fun Reader(navController: NavController) {
|
||||
val model: ReaderBaseViewModel = viewModel()
|
||||
|
||||
val client: HttpClient by rememberInstance()
|
||||
|
||||
val database: ManatokiDatabase by rememberInstance()
|
||||
val favoriteDao = remember { database.favoriteDao() }
|
||||
val bookmarkDao = remember { database.bookmarkDao() }
|
||||
val historyDao = remember { database.historyDao() }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID")
|
||||
var readerInfo: ReaderInfo? by rememberSaveable { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (itemID != null)
|
||||
client.getItem(itemID, onReader = {
|
||||
coroutineScope.launch {
|
||||
historyDao.insert(it.itemID, it.listingItemID, 1)
|
||||
}
|
||||
readerInfo = it
|
||||
model.load(it.urls) {
|
||||
set("User-Agent", imageUserAgent)
|
||||
}
|
||||
})
|
||||
else model.error = true
|
||||
}
|
||||
|
||||
val isFavorite by favoriteDao.contains(itemID ?: "").collectAsState(false)
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
|
||||
val navigationBarsPadding = LocalDensity.current.run {
|
||||
rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.navigationBars
|
||||
).calculateBottomPadding().toPx()
|
||||
}
|
||||
|
||||
val readerListState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(readerListState.firstVisibleItemIndex) {
|
||||
readerInfo?.let { readerInfo ->
|
||||
historyDao.insert(
|
||||
readerInfo.itemID,
|
||||
readerInfo.listingItemID,
|
||||
readerListState.firstVisibleItemIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var scrollDirection by remember { mutableStateOf(0f) }
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
sheetState.isVisible -> coroutineScope.launch { sheetState.hide() }
|
||||
model.fullscreen -> model.fullscreen = false
|
||||
else -> navController.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) }
|
||||
val mangaListingListState = rememberLazyListState()
|
||||
var mangaListingListSize: Size? by remember { mutableStateOf(null) }
|
||||
val mangaListingRippleInteractionSource = remember { MutableInteractionSource() }
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
sheetState = sheetState,
|
||||
sheetShape = RoundedCornerShape(32.dp, 32.dp, 0.dp, 0.dp),
|
||||
sheetContent = {
|
||||
MangaListingBottomSheet(
|
||||
mangaListing,
|
||||
currentItemID = itemID,
|
||||
onListSize = { mangaListingListSize = it },
|
||||
rippleInteractionSource = if (itemID == null) emptyMap() else mapOf(itemID to mangaListingRippleInteractionSource),
|
||||
listState = mangaListingListState,
|
||||
nextItem = readerInfo?.nextItemID
|
||||
) {
|
||||
navController.navigate("manatoki.net/reader/$it") {
|
||||
popUpTo("manatoki.net/")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!model.fullscreen)
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
readerInfo?.title ?: stringResource(R.string.reader_loading),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
Icons.Default.NavigateBefore,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton({ }) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.manatoki),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
itemID?.let {
|
||||
coroutineScope.launch {
|
||||
if (isFavorite) favoriteDao.delete(it)
|
||||
else favoriteDao.insert(it)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
if (isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
contentDescription = null,
|
||||
tint = Orange500
|
||||
)
|
||||
}
|
||||
},
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.statusBars,
|
||||
applyBottom = false
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val showNextButton by derivedStateOf {
|
||||
(readerInfo?.nextItemID?.isNotEmpty() == true) && with (readerListState.layoutInfo) {
|
||||
visibleItemsInfo.lastOrNull()?.index == totalItemsCount-1
|
||||
}
|
||||
}
|
||||
val scale by animateFloatAsState(if (!showNextButton && (model.fullscreen || scrollDirection < 0f)) 0f else 1f)
|
||||
|
||||
if (scale > 0f)
|
||||
FloatingActionButton(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.scale(scale),
|
||||
onClick = {
|
||||
readerInfo?.let {
|
||||
if (showNextButton) {
|
||||
navController.navigate("manatoki.net/reader/${it.nextItemID}") {
|
||||
popUpTo("manatoki.net/")
|
||||
}
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
sheetState.animateTo(ModalBottomSheetValue.Expanded)
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
if (mangaListing?.itemID != it.listingItemID)
|
||||
client.getItem(it.listingItemID, onListing = {
|
||||
mangaListing = it
|
||||
|
||||
coroutineScope.launch {
|
||||
while (mangaListingListState.layoutInfo.totalItemsCount != it.entries.size) {
|
||||
delay(100)
|
||||
}
|
||||
|
||||
val targetIndex =
|
||||
it.entries.indexOfFirst { it.itemID == itemID }
|
||||
|
||||
mangaListingListState.scrollToItem(targetIndex)
|
||||
|
||||
mangaListingListSize?.let { sheetSize ->
|
||||
val targetItem =
|
||||
mangaListingListState.layoutInfo.visibleItemsInfo.first {
|
||||
it.key == itemID
|
||||
}
|
||||
|
||||
if (targetItem.offset == 0) {
|
||||
mangaListingListState.animateScrollBy(
|
||||
-(sheetSize.height - navigationBarsPadding - targetItem.size)
|
||||
)
|
||||
}
|
||||
|
||||
delay(200)
|
||||
|
||||
with(mangaListingRippleInteractionSource) {
|
||||
val interaction =
|
||||
PressInteraction.Press(
|
||||
Offset(
|
||||
sheetSize.width / 2,
|
||||
targetItem.size / 2f
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
emit(interaction)
|
||||
emit(
|
||||
PressInteraction.Release(
|
||||
interaction
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
if (showNextButton) Icons.Default.NavigateNext else Icons.Default.List,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
) { contentPadding ->
|
||||
ReaderBase(
|
||||
Modifier.padding(contentPadding),
|
||||
model = model,
|
||||
listState = readerListState,
|
||||
onScroll = { scrollDirection = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.composable
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.GridCells
|
||||
import androidx.compose.foundation.lazy.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NavigateBefore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
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.compose.rememberInstance
|
||||
import org.kodein.di.compose.rememberViewModel
|
||||
import xyz.quaver.pupil.sources.composable.OverscrollPager
|
||||
import xyz.quaver.pupil.sources.manatoki.getItem
|
||||
import xyz.quaver.pupil.sources.manatoki.viewmodel.RecentViewModel
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun Recent(navController: NavController) {
|
||||
val model: RecentViewModel by rememberViewModel()
|
||||
|
||||
val client: HttpClient by rememberInstance()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
model.load()
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("최신 업데이트")
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
Icons.Default.NavigateBefore,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.statusBars,
|
||||
applyBottom = false
|
||||
)
|
||||
)
|
||||
}
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
OverscrollPager(
|
||||
currentPage = model.page,
|
||||
prevPageAvailable = model.page > 1,
|
||||
nextPageAvailable = model.page < 10,
|
||||
nextPageTurnIndicatorOffset = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.navigationBars
|
||||
).calculateBottomPadding(),
|
||||
onPageTurn = {
|
||||
model.page = it
|
||||
model.load()
|
||||
}
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
GridCells.Adaptive(minSize = 200.dp),
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.navigationBars
|
||||
)
|
||||
) {
|
||||
items(model.result) {
|
||||
Thumbnail(
|
||||
it,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(3f / 4)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
client.getItem(it, onReader = {
|
||||
navController.navigate("manatoki.net/reader/${it.itemID}")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.loading)
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.composable
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.NavigateBefore
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
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.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import org.kodein.di.compose.rememberViewModel
|
||||
import xyz.quaver.pupil.sources.composable.ModalTopSheetLayout
|
||||
import xyz.quaver.pupil.sources.composable.ModalTopSheetState
|
||||
import xyz.quaver.pupil.sources.composable.OverscrollPager
|
||||
import xyz.quaver.pupil.sources.manatoki.*
|
||||
import xyz.quaver.pupil.sources.manatoki.viewmodel.*
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun Search(navController: NavController) {
|
||||
val model: SearchViewModel by rememberViewModel()
|
||||
|
||||
val client: HttpClient by rememberInstance()
|
||||
|
||||
val database: ManatokiDatabase by rememberInstance()
|
||||
val historyDao = remember { database.historyDao() }
|
||||
|
||||
var searchFocused by remember { mutableStateOf(false) }
|
||||
val handleOffset by animateDpAsState(if (searchFocused) 0.dp else (-36).dp)
|
||||
|
||||
val drawerState = rememberSwipeableState(ModalTopSheetState.Hidden)
|
||||
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
model.search()
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
sheetState.isVisible -> coroutineScope.launch { sheetState.hide() }
|
||||
drawerState.currentValue != ModalTopSheetState.Hidden ->
|
||||
coroutineScope.launch { drawerState.animateTo(ModalTopSheetState.Hidden) }
|
||||
else -> navController.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
var mangaListing: MangaListing? by rememberSaveable { mutableStateOf(null) }
|
||||
var recentItem: String? by rememberSaveable { mutableStateOf(null) }
|
||||
val mangaListingListState = rememberLazyListState()
|
||||
var mangaListingListSize: Size? by remember { mutableStateOf(null) }
|
||||
val mangaListingInteractionSource = remember { mutableStateMapOf<String, MutableInteractionSource>() }
|
||||
val navigationBarsPadding = LocalDensity.current.run {
|
||||
rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.navigationBars
|
||||
).calculateBottomPadding().toPx()
|
||||
}
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
sheetState = sheetState,
|
||||
sheetShape = RoundedCornerShape(32.dp, 32.dp, 0.dp, 0.dp),
|
||||
sheetContent = {
|
||||
MangaListingBottomSheet(
|
||||
mangaListing,
|
||||
onListSize = { mangaListingListSize = it },
|
||||
rippleInteractionSource = mangaListingInteractionSource,
|
||||
listState = mangaListingListState,
|
||||
recentItem = recentItem
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
client.getItem(it, onReader = {
|
||||
launch {
|
||||
sheetState.snapTo(ModalBottomSheetValue.Hidden)
|
||||
navController.navigate("manatoki.net/reader/${it.itemID}")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { focusManager.clearFocus() }
|
||||
},
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
TextField(
|
||||
model.stx,
|
||||
modifier = Modifier
|
||||
.onFocusChanged {
|
||||
searchFocused = it.isFocused
|
||||
}
|
||||
.fillMaxWidth(),
|
||||
onValueChange = { model.stx = it },
|
||||
placeholder = { Text("제목") },
|
||||
textStyle = MaterialTheme.typography.subtitle1,
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (model.stx != "" && searchFocused)
|
||||
IconButton(onClick = { model.stx = "" }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = contentColorFor(MaterialTheme.colors.primarySurface)
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = {
|
||||
focusManager.clearFocus()
|
||||
coroutineScope.launch {
|
||||
drawerState.animateTo(ModalTopSheetState.Hidden)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
model.search()
|
||||
}
|
||||
}
|
||||
),
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
textColor = contentColorFor(MaterialTheme.colors.primarySurface),
|
||||
placeholderColor = contentColorFor(MaterialTheme.colors.primarySurface).copy(alpha = 0.75f),
|
||||
backgroundColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colors.secondary,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
Icons.Default.NavigateBefore,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.statusBars,
|
||||
applyBottom = false
|
||||
)
|
||||
)
|
||||
}
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
ModalTopSheetLayout(
|
||||
modifier = Modifier.run {
|
||||
if (drawerState.currentValue == ModalTopSheetState.Hidden)
|
||||
offset(0.dp, handleOffset)
|
||||
else
|
||||
navigationBarsWithImePadding()
|
||||
},
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 0.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text("작가")
|
||||
TextField(model.artist, onValueChange = { model.artist = it })
|
||||
|
||||
Text("발행")
|
||||
FlowRow(mainAxisSpacing = 4.dp, crossAxisSpacing = 4.dp) {
|
||||
Chip("전체", model.publish.isEmpty()) {
|
||||
model.publish = ""
|
||||
}
|
||||
availablePublish.forEach {
|
||||
Chip(it, model.publish == it) {
|
||||
model.publish = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("초성")
|
||||
FlowRow(mainAxisSpacing = 4.dp, crossAxisSpacing = 4.dp) {
|
||||
Chip("전체", model.jaum.isEmpty()) {
|
||||
model.jaum = ""
|
||||
}
|
||||
availableJaum.forEach {
|
||||
Chip(it, model.jaum == it) {
|
||||
model.jaum = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("장르")
|
||||
FlowRow(mainAxisSpacing = 4.dp, crossAxisSpacing = 4.dp) {
|
||||
Chip("전체", model.tag.isEmpty()) {
|
||||
model.tag.clear()
|
||||
}
|
||||
availableTag.forEach {
|
||||
Chip(it, model.tag.contains(it)) {
|
||||
if (model.tag.contains(it))
|
||||
model.tag.remove(it)
|
||||
else
|
||||
model.tag[it] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("정렬")
|
||||
FlowRow(mainAxisSpacing = 4.dp, crossAxisSpacing = 4.dp) {
|
||||
Chip("기본", model.sst.isEmpty()) {
|
||||
model.sst = ""
|
||||
}
|
||||
availableSst.entries.forEach { (k, v) ->
|
||||
Chip(v, model.sst == k) {
|
||||
model.sst = k
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp))
|
||||
}
|
||||
}
|
||||
) {
|
||||
OverscrollPager(
|
||||
currentPage = model.page,
|
||||
prevPageAvailable = model.page > 1,
|
||||
nextPageAvailable = model.page < model.maxPage,
|
||||
nextPageTurnIndicatorOffset = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.navigationBars
|
||||
).calculateBottomPadding(),
|
||||
onPageTurn = {
|
||||
model.page = it
|
||||
coroutineScope.launch {
|
||||
model.search(resetPage = false)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
GridCells.Adaptive(minSize = 200.dp),
|
||||
contentPadding = rememberInsetsPaddingValues(
|
||||
LocalWindowInsets.current.navigationBars
|
||||
)
|
||||
) {
|
||||
items(model.result) { item ->
|
||||
Thumbnail(
|
||||
Thumbnail(item.itemID, item.title, item.thumbnail),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(3f / 4)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
mangaListing = null
|
||||
sheetState.animateTo(ModalBottomSheetValue.Expanded)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
client.getItem(it, onListing = {
|
||||
mangaListing = it
|
||||
|
||||
coroutineScope.launch {
|
||||
val recentItemID = historyDao.getAll(it.itemID).firstOrNull() ?: return@launch
|
||||
recentItem = recentItemID
|
||||
|
||||
while (mangaListingListState.layoutInfo.totalItemsCount != it.entries.size) {
|
||||
delay(100)
|
||||
}
|
||||
|
||||
val interactionSource = mangaListingInteractionSource.getOrPut(recentItemID) {
|
||||
MutableInteractionSource()
|
||||
}
|
||||
|
||||
val targetIndex =
|
||||
it.entries.indexOfFirst { entry -> entry.itemID == recentItemID }
|
||||
|
||||
mangaListingListState.scrollToItem(targetIndex)
|
||||
|
||||
mangaListingListSize?.let { sheetSize ->
|
||||
val targetItem =
|
||||
mangaListingListState.layoutInfo.visibleItemsInfo.first {
|
||||
it.key == recentItemID
|
||||
}
|
||||
|
||||
if (targetItem.offset == 0) {
|
||||
mangaListingListState.animateScrollBy(
|
||||
-(sheetSize.height - navigationBarsPadding - targetItem.size)
|
||||
)
|
||||
}
|
||||
|
||||
delay(200)
|
||||
|
||||
with(interactionSource) {
|
||||
val interaction =
|
||||
PressInteraction.Press(
|
||||
Offset(
|
||||
sheetSize.width / 2,
|
||||
targetItem.size / 2f
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
emit(interaction)
|
||||
emit(PressInteraction.Release(interaction))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.loading)
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.composable
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberImagePainter
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class Thumbnail(
|
||||
val itemID: String,
|
||||
val title: String,
|
||||
val thumbnail: String
|
||||
): Parcelable
|
||||
|
||||
@Composable
|
||||
fun Thumbnail(
|
||||
thumbnail: Thumbnail,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (String) -> Unit = { }
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = 8.dp,
|
||||
modifier = modifier.clickable { onClick(thumbnail.itemID) }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.width(IntrinsicSize.Min)
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
painter = rememberImagePainter(thumbnail.thumbnail),
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
Text(
|
||||
thumbnail.title,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(8.dp),
|
||||
softWrap = true,
|
||||
style = MaterialTheme.typography.subtitle1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.util.concurrent.RateLimiter
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
val manatokiUrl = "https://manatoki118.net"
|
||||
|
||||
private val rateLimitCoroutineDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
private val rateLimiter = RateLimiter.create(10.0)
|
||||
|
||||
suspend fun waitForRateLimit() {
|
||||
withContext(rateLimitCoroutineDispatcher) {
|
||||
rateLimiter.acquire()
|
||||
}
|
||||
yield()
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class MangaListingEntry(
|
||||
val itemID: String,
|
||||
val episode: Int,
|
||||
val title: String,
|
||||
val starRating: Float,
|
||||
val date: String,
|
||||
val viewCount: Int,
|
||||
val thumbsUpCount: Int
|
||||
): Parcelable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class MangaListing(
|
||||
val itemID: String,
|
||||
val title: String,
|
||||
val thumbnail: String,
|
||||
val author: String,
|
||||
val tags: List<String>,
|
||||
val type: String,
|
||||
val thumbsUpCount: Int,
|
||||
val entries: List<MangaListingEntry>
|
||||
): Parcelable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class ReaderInfo(
|
||||
val itemID: String,
|
||||
val title: String,
|
||||
val urls: List<String>,
|
||||
val listingItemID: String,
|
||||
val prevItemID: String,
|
||||
val nextItemID: String
|
||||
): Parcelable
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun Chip(text: String, selected: Boolean = false, onClick: () -> Unit = { }) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
backgroundColor = if (selected) MaterialTheme.colors.secondary else MaterialTheme.colors.surface,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
elevation = 4.dp
|
||||
) {
|
||||
Text(text, modifier = Modifier.padding(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private val cache = LruCache<String, Any>(100)
|
||||
suspend fun HttpClient.getItem(
|
||||
itemID: String,
|
||||
onListing: (MangaListing) -> Unit = { },
|
||||
onReader: (ReaderInfo) -> Unit = { },
|
||||
onError: (Throwable) -> Unit = { throw it }
|
||||
) = coroutineScope {
|
||||
val cachedValue = synchronized(cache) {
|
||||
cache.get(itemID)
|
||||
}
|
||||
|
||||
if (cachedValue != null) {
|
||||
when (cachedValue) {
|
||||
is MangaListing -> onListing(cachedValue)
|
||||
is ReaderInfo -> onReader(cachedValue)
|
||||
else -> onError(IllegalStateException("Cached value is not MangaListing nor ReaderInfo"))
|
||||
}
|
||||
} else {
|
||||
runCatching {
|
||||
waitForRateLimit()
|
||||
val content: String = get("$manatokiUrl/comic/$itemID")
|
||||
|
||||
val doc = Jsoup.parse(content)
|
||||
|
||||
yield()
|
||||
|
||||
if (doc.getElementsByClass("serial-list").size == 0) {
|
||||
val htmlData = doc
|
||||
.selectFirst(".view-padding > script")!!
|
||||
.data()
|
||||
.splitToSequence('\n')
|
||||
.fold(StringBuilder()) { sb, line ->
|
||||
if (!line.startsWith("html_data")) return@fold sb
|
||||
|
||||
line.drop(12).dropLast(2).split('.').forEach {
|
||||
if (it.isNotBlank()) sb.appendCodePoint(it.toInt(16))
|
||||
}
|
||||
sb
|
||||
}.toString()
|
||||
|
||||
val urls = Jsoup.parse(htmlData)
|
||||
.select("img[^data-]:not([style])")
|
||||
.map {
|
||||
it.attributes()
|
||||
.first { it.key.startsWith("data-") }
|
||||
.value
|
||||
}
|
||||
|
||||
val title = doc.getElementsByClass("toon-title").first()!!.ownText()
|
||||
|
||||
val listingItemID = doc.select("a:contains(전체목록)").first()!!.attr("href")
|
||||
.takeLastWhile { it != '/' }
|
||||
|
||||
val prevItemID = doc.getElementById("goPrevBtn")!!.attr("href")
|
||||
.let {
|
||||
if (it.contains('?'))
|
||||
it.dropLastWhile { it != '?' }.drop(1)
|
||||
else it
|
||||
}
|
||||
.takeLastWhile { it != '/' }
|
||||
|
||||
val nextItemID = doc.getElementById("goNextBtn")!!.attr("href")
|
||||
.let {
|
||||
if (it.contains('?'))
|
||||
it.dropLastWhile { it != '?' }.drop(1)
|
||||
else it
|
||||
}
|
||||
.takeLastWhile { it != '/' }
|
||||
|
||||
val readerInfo = ReaderInfo(
|
||||
itemID,
|
||||
title,
|
||||
urls,
|
||||
listingItemID,
|
||||
prevItemID,
|
||||
nextItemID
|
||||
)
|
||||
|
||||
synchronized(cache) {
|
||||
cache.put(itemID, readerInfo)
|
||||
}
|
||||
|
||||
onReader(readerInfo)
|
||||
} else {
|
||||
val titleBlock = doc.selectFirst("div.view-title")!!
|
||||
|
||||
val title = titleBlock.select("div.view-content:not([itemprop])").first()!!.text()
|
||||
|
||||
val author =
|
||||
titleBlock
|
||||
.select("div.view-content:not([itemprop]):contains(작가)")
|
||||
.first()!!
|
||||
.getElementsByTag("a")
|
||||
.first()!!
|
||||
.text()
|
||||
|
||||
val tags =
|
||||
titleBlock
|
||||
.select("div.view-content:not([itemprop]):contains(분류)")
|
||||
.first()!!
|
||||
.getElementsByTag("a")
|
||||
.map { it.text() }
|
||||
|
||||
val type =
|
||||
titleBlock
|
||||
.select("div.view-content:not([itemprop]):contains(발행구분)")
|
||||
.first()!!
|
||||
.getElementsByTag("a")
|
||||
.first()!!
|
||||
.text()
|
||||
|
||||
val thumbnail =
|
||||
titleBlock.getElementsByTag("img").first()!!.attr("src")
|
||||
|
||||
val thumbsUpCount =
|
||||
titleBlock.select("i.fa-thumbs-up + b").text().toInt()
|
||||
|
||||
val entries =
|
||||
doc.select("div.serial-list .list-item").map {
|
||||
val episode = it.getElementsByClass("wr-num").first()!!.text().toInt()
|
||||
val (itemID, title) = it.getElementsByClass("item-subject").first()!!
|
||||
.let { subject ->
|
||||
subject.attr("href").dropLastWhile { it != '?' }.dropLast(1)
|
||||
.takeLastWhile { it != '/' } to subject.ownText()
|
||||
}
|
||||
val starRating = it.getElementsByClass("wr-star").first()!!.text().drop(1)
|
||||
.takeWhile { it != ')' }.toFloat()
|
||||
val date = it.getElementsByClass("wr-date").first()!!.text()
|
||||
val viewCount =
|
||||
it.getElementsByClass("wr-hit").first()!!.text().replace(",", "")
|
||||
.toInt()
|
||||
val thumbsUpCount =
|
||||
it.getElementsByClass("wr-good").first()!!.text().replace(",", "")
|
||||
.toInt()
|
||||
|
||||
MangaListingEntry(
|
||||
itemID,
|
||||
episode,
|
||||
title,
|
||||
starRating,
|
||||
date,
|
||||
viewCount,
|
||||
thumbsUpCount
|
||||
)
|
||||
}
|
||||
|
||||
val mangaListing = MangaListing(
|
||||
itemID,
|
||||
title,
|
||||
thumbnail,
|
||||
author,
|
||||
tags,
|
||||
type,
|
||||
thumbsUpCount,
|
||||
entries
|
||||
)
|
||||
|
||||
synchronized(cache) {
|
||||
cache.put(itemID, mangaListing)
|
||||
}
|
||||
|
||||
onListing(mangaListing)
|
||||
}
|
||||
}.onFailure(onError)
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
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.pupil.sources.manatoki.HistoryDao
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.Thumbnail
|
||||
import xyz.quaver.pupil.sources.manatoki.manatokiUrl
|
||||
import xyz.quaver.pupil.sources.manatoki.waitForRateLimit
|
||||
|
||||
@Serializable
|
||||
data class TopWeekly(
|
||||
val itemID: String,
|
||||
val title: String,
|
||||
val count: String
|
||||
)
|
||||
|
||||
class MainViewModel(
|
||||
private val client: HttpClient
|
||||
) : ViewModel() {
|
||||
val recentUpload = mutableStateListOf<Thumbnail>()
|
||||
val mangaList = mutableStateListOf<Thumbnail>()
|
||||
val topWeekly = mutableStateListOf<TopWeekly>()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
loadJob?.cancelAndJoin()
|
||||
recentUpload.clear()
|
||||
mangaList.clear()
|
||||
topWeekly.clear()
|
||||
|
||||
loadJob = launch {
|
||||
runCatching {
|
||||
waitForRateLimit()
|
||||
val doc = Jsoup.parse(client.get(manatokiUrl))
|
||||
|
||||
yield()
|
||||
|
||||
val misoPostGallery = doc.select(".miso-post-gallery")
|
||||
|
||||
misoPostGallery[0]
|
||||
.select(".post-image > a")
|
||||
.forEach { entry ->
|
||||
val itemID = entry.attr("href").takeLastWhile { it != '/' }
|
||||
val title = entry.selectFirst("div.in-subject > b")!!.ownText()
|
||||
val thumbnail = entry.selectFirst("img")!!.attr("src")
|
||||
|
||||
yield()
|
||||
recentUpload.add(Thumbnail(itemID, title, thumbnail))
|
||||
}
|
||||
|
||||
misoPostGallery[1]
|
||||
.select(".post-image > a")
|
||||
.forEach { entry ->
|
||||
val itemID = entry.attr("href").takeLastWhile { it != '/' }
|
||||
val title = entry.selectFirst("div.in-subject")!!.ownText()
|
||||
val thumbnail = entry.selectFirst("img")!!.attr("src")
|
||||
|
||||
yield()
|
||||
mangaList.add(Thumbnail(itemID, title, thumbnail))
|
||||
}
|
||||
|
||||
val misoPostList = doc.select(".miso-post-list")
|
||||
|
||||
misoPostList[4]
|
||||
.select(".post-row > a")
|
||||
.forEach { entry ->
|
||||
yield()
|
||||
val itemID = entry.attr("href").takeLastWhile { it != '/' }
|
||||
val title = entry.ownText()
|
||||
val count = entry.selectFirst("span.count")!!.text()
|
||||
topWeekly.add(TopWeekly(itemID, title, count))
|
||||
}
|
||||
}.onFailure {
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jsoup.Jsoup
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.di.instance
|
||||
import xyz.quaver.pupil.sources.manatoki.composable.Thumbnail
|
||||
import xyz.quaver.pupil.sources.manatoki.manatokiUrl
|
||||
|
||||
class RecentViewModel(
|
||||
private val client: HttpClient
|
||||
): ViewModel() {
|
||||
var page by mutableStateOf(1)
|
||||
|
||||
var loading by mutableStateOf(false)
|
||||
private set
|
||||
var error by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val result = mutableStateListOf<Thumbnail>()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
loadJob?.cancelAndJoin()
|
||||
result.clear()
|
||||
loading = true
|
||||
|
||||
loadJob = launch {
|
||||
runCatching {
|
||||
val doc = Jsoup.parse(client.get("$manatokiUrl/bbs/page.php?hid=update&page=$page"))
|
||||
|
||||
doc.getElementsByClass("post-list").forEach {
|
||||
val (itemID, title) = it.selectFirst(".post-subject > a")!!.let {
|
||||
it.attr("href").takeLastWhile { it != '/' } to it.ownText()
|
||||
}
|
||||
val thumbnail = it.getElementsByTag("img").attr("src")
|
||||
|
||||
loading = false
|
||||
result.add(Thumbnail(itemID, title, thumbnail))
|
||||
}
|
||||
}.onFailure {
|
||||
loading = false
|
||||
error = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
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.pupil.sources.manatoki.manatokiUrl
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class SearchResult(
|
||||
val itemID: String,
|
||||
val title: String,
|
||||
val thumbnail: String,
|
||||
val artist: String,
|
||||
val type: String,
|
||||
val lastUpdate: String
|
||||
): Parcelable
|
||||
|
||||
val availablePublish = listOf(
|
||||
"주간",
|
||||
"격주",
|
||||
"월간",
|
||||
"단편",
|
||||
"단행본",
|
||||
"완결"
|
||||
)
|
||||
|
||||
val availableJaum = listOf(
|
||||
"ㄱ",
|
||||
"ㄴ",
|
||||
"ㄷ",
|
||||
"ㄹ",
|
||||
"ㅁ",
|
||||
"ㅂ",
|
||||
"ㅅ",
|
||||
"ㅇ",
|
||||
"ㅈ",
|
||||
"ㅊ",
|
||||
"ㅋ",
|
||||
"ㅌ",
|
||||
"ㅍ",
|
||||
"ㅎ",
|
||||
"0-9",
|
||||
"a-z"
|
||||
)
|
||||
|
||||
val availableTag = listOf(
|
||||
"17",
|
||||
"BL",
|
||||
"SF",
|
||||
"TS",
|
||||
"개그",
|
||||
"게임",
|
||||
"도박",
|
||||
"드라마",
|
||||
"라노벨",
|
||||
"러브코미디",
|
||||
"먹방",
|
||||
"백합",
|
||||
"붕탁",
|
||||
"순정",
|
||||
"스릴러",
|
||||
"스포츠",
|
||||
"시대",
|
||||
"애니화",
|
||||
"액션",
|
||||
"음악",
|
||||
"이세계",
|
||||
"일상",
|
||||
"전생",
|
||||
"추리",
|
||||
"판타지",
|
||||
"학원",
|
||||
"호러"
|
||||
)
|
||||
|
||||
val availableSst = mapOf(
|
||||
"as_view" to "인기순",
|
||||
"as_good" to "추천순",
|
||||
"as_comment" to "댓글순",
|
||||
"as_bookmark" to "북마크순"
|
||||
)
|
||||
|
||||
class SearchViewModel(
|
||||
private val client: HttpClient
|
||||
) : ViewModel() {
|
||||
private val logger = newLogger(LoggerFactory.default)
|
||||
|
||||
// 발행
|
||||
var publish by mutableStateOf("")
|
||||
// 초성
|
||||
var jaum by mutableStateOf("")
|
||||
// 장르
|
||||
val tag = mutableStateMapOf<String, String>()
|
||||
// 정렬
|
||||
var sst by mutableStateOf("")
|
||||
// 오름/내림
|
||||
var sod by mutableStateOf("")
|
||||
// 제목
|
||||
var stx by mutableStateOf("")
|
||||
// 작가
|
||||
var artist by mutableStateOf("")
|
||||
|
||||
var page by mutableStateOf(1)
|
||||
var maxPage by mutableStateOf(0)
|
||||
|
||||
val availableArtists = mutableStateListOf<String>()
|
||||
|
||||
var loading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var error by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val result = mutableStateListOf<SearchResult>()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
suspend fun search(resetPage: Boolean = true) = coroutineScope {
|
||||
searchJob?.cancelAndJoin()
|
||||
|
||||
loading = true
|
||||
result.clear()
|
||||
availableArtists.clear()
|
||||
if (resetPage) page = 1
|
||||
|
||||
searchJob = launch {
|
||||
runCatching {
|
||||
val urlBuilder = StringBuilder("$manatokiUrl/comic")
|
||||
|
||||
if (page != 1) urlBuilder.append("/p$page")
|
||||
|
||||
val args = mutableListOf<String>()
|
||||
|
||||
if (publish.isNotEmpty()) args.add("publish=$publish")
|
||||
if (jaum.isNotEmpty()) args.add("jaum=$jaum")
|
||||
if (tag.isNotEmpty()) args.add("tag=${tag.keys.joinToString(",")}")
|
||||
if (sst.isNotEmpty()) args.add("sst=$sst&sod=desc")
|
||||
if (stx.isNotEmpty()) args.add("stx=$stx")
|
||||
if (artist.isNotEmpty()) args.add("artist=$artist")
|
||||
|
||||
if (args.isNotEmpty()) urlBuilder.append('?')
|
||||
|
||||
urlBuilder.append(args.joinToString("&"))
|
||||
|
||||
val doc = Jsoup.parse(client.get(urlBuilder.toString()))
|
||||
|
||||
maxPage = doc.getElementsByClass("pagination").first()!!.getElementsByTag("a").maxOf { it.text().toIntOrNull() ?: 0 }
|
||||
|
||||
doc.select("select > option").forEach {
|
||||
val value = it.ownText()
|
||||
|
||||
if (value.isNotEmpty())
|
||||
availableArtists.add(value)
|
||||
}
|
||||
|
||||
doc.getElementsByClass("list-item").forEach {
|
||||
val itemID =
|
||||
it.selectFirst(".img-item > a")!!.attr("href").takeLastWhile { it != '/' }
|
||||
val title = it.getElementsByClass("title").first()!!.text()
|
||||
val thumbnail = it.getElementsByTag("img").first()!!.attr("src")
|
||||
val artist = it.getElementsByClass("list-artist").first()!!.text()
|
||||
val type = it.getElementsByClass("list-publish").first()!!.text()
|
||||
val lastUpdate = it.getElementsByClass("list-date").first()!!.text()
|
||||
|
||||
loading = false
|
||||
result.add(
|
||||
SearchResult(
|
||||
itemID,
|
||||
title,
|
||||
thumbnail,
|
||||
artist,
|
||||
type,
|
||||
lastUpdate
|
||||
)
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
loading = false
|
||||
error = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
@@ -36,15 +36,15 @@ import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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.proto.settingsDataStore
|
||||
import xyz.quaver.pupil.proto.Settings
|
||||
import xyz.quaver.pupil.sources.SourceEntries
|
||||
import xyz.quaver.pupil.sources.composable.SourceSelectDialog
|
||||
import xyz.quaver.pupil.sources.SourceSelectDialog
|
||||
import xyz.quaver.pupil.ui.theme.PupilTheme
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class)
|
||||
@@ -78,7 +78,7 @@ class MainActivity : ComponentActivity(), DIAware {
|
||||
NavHost(navController, startDestination = "main") {
|
||||
composable("main") {
|
||||
var launched by rememberSaveable { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val settingsDataStore: DataStore<Settings> by rememberInstance()
|
||||
|
||||
var sourceSelectDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -86,7 +86,9 @@ class MainActivity : ComponentActivity(), DIAware {
|
||||
SourceSelectDialog(navController, null)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val recentSource = context.settingsDataStore.data.map { it.recentSource }.first()
|
||||
val recentSource =
|
||||
settingsDataStore.data.map { it.recentSource }
|
||||
.first()
|
||||
|
||||
if (recentSource.isEmpty()) {
|
||||
sourceSelectDialog = true
|
||||
@@ -104,8 +106,8 @@ class MainActivity : ComponentActivity(), DIAware {
|
||||
composable("settings") {
|
||||
|
||||
}
|
||||
sources.forEach {
|
||||
it.second.run {
|
||||
sources.values.forEach {
|
||||
it.source.run {
|
||||
navGraph(navController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.util.*
|
||||
import io.ktor.util.collections.*
|
||||
import io.ktor.utils.io.*
|
||||
import io.ktor.utils.io.core.*
|
||||
import kotlinx.coroutines.*
|
||||
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
|
||||
import kotlin.text.toByteArray
|
||||
|
||||
private const val CACHE_LIMIT = 100*1024*1024 // 100M
|
||||
|
||||
class NetworkCache(context: Context) : DIAware {
|
||||
override val di by closestDI(context)
|
||||
|
||||
private val logger = newLogger(LoggerFactory.default)
|
||||
|
||||
private val client: HttpClient by instance()
|
||||
private val networkScope = CoroutineScope(Executors.newFixedThreadPool(4).asCoroutineDispatcher())
|
||||
|
||||
private val cacheDir = File(context.cacheDir, "networkcache")
|
||||
|
||||
private val flowMutex = Mutex()
|
||||
private val flow = ConcurrentHashMap<String, MutableStateFlow<Float>>()
|
||||
|
||||
private val requestsMutex = Mutex()
|
||||
private val requests = ConcurrentHashMap<String, Job>()
|
||||
|
||||
private val activeFilesMutex = Mutex()
|
||||
private val activeFiles = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
|
||||
|
||||
private fun urlToFilename(url: String): String =
|
||||
sha256(url.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
|
||||
fun cleanup() = CoroutineScope(Dispatchers.IO).launch {
|
||||
if (cacheDir.size() > CACHE_LIMIT)
|
||||
cacheDir.listFiles { file -> file.name !in activeFiles }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
fun free(urls: List<String>) = 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() }
|
||||
flow.clear()
|
||||
activeFiles.clear()
|
||||
cacheDir.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun load(force: Boolean = false, requestBuilder: HttpRequestBuilder.() -> Unit): Pair<StateFlow<Float>, File> = coroutineScope {
|
||||
val request = HttpRequestBuilder().apply(requestBuilder)
|
||||
|
||||
val url = request.url.buildString()
|
||||
|
||||
val fileName = urlToFilename(url)
|
||||
val file = File(cacheDir, fileName)
|
||||
activeFiles.add(fileName)
|
||||
|
||||
val progressFlow = flowMutex.withLock {
|
||||
if (flow.contains(url)) {
|
||||
flow[url]!!
|
||||
} else MutableStateFlow(0f).also { flow[url] = it }
|
||||
}
|
||||
|
||||
requestsMutex.withLock {
|
||||
if (!requests.contains(url) || force) {
|
||||
if (force) requests[url]?.cancelAndJoin()
|
||||
|
||||
requests[url] = networkScope.launch {
|
||||
runCatching {
|
||||
if (!force && file.exists()) {
|
||||
progressFlow.emit(Float.POSITIVE_INFINITY)
|
||||
} else {
|
||||
cacheDir.mkdirs()
|
||||
file.createNewFile()
|
||||
|
||||
client.request<HttpStatement>(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
|
||||
|
||||
file.outputStream().use { outputStream ->
|
||||
outputStream.channel.truncate(0)
|
||||
while (!responseChannel.isClosedForRead) {
|
||||
if (!isActive) {
|
||||
file.delete()
|
||||
break
|
||||
}
|
||||
|
||||
val packet =
|
||||
responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
|
||||
while (!packet.isEmpty) {
|
||||
if (!isActive) {
|
||||
file.delete()
|
||||
break
|
||||
}
|
||||
|
||||
val bytes = packet.readBytes()
|
||||
outputStream.write(bytes)
|
||||
|
||||
readBytes += bytes.size
|
||||
progressFlow.emit(readBytes / contentLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
progressFlow.emit(Float.POSITIVE_INFINITY)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
logger.warning(it)
|
||||
file.delete()
|
||||
FirebaseCrashlytics.getInstance().recordException(it)
|
||||
progressFlow.emit(Float.NEGATIVE_INFINITY)
|
||||
requestsMutex.withLock {
|
||||
requests.remove(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return@coroutineScope progressFlow to file
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ val JsonElement.content
|
||||
get() = this.jsonPrimitive.contentOrNull
|
||||
|
||||
fun DIAware.source(source: String) = lazy { direct.source(source) }
|
||||
fun DirectDIAware.source(source: String) = instance<SourceEntries>().toMap()[source]!!
|
||||
fun DirectDIAware.source(source: String) = instance<SourceEntries>()[source]!!
|
||||
|
||||
class FileXImageSource(val file: FileX): ImageSource {
|
||||
private val decoder by lazy {
|
||||
|
||||
Reference in New Issue
Block a user