External Sources

This commit is contained in:
tom5079
2021-12-31 14:03:52 +09:00
parent 2e11a4907a
commit f271e61ea2
43 changed files with 148 additions and 6907 deletions

View File

@@ -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")

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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
)

View File

@@ -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")
}
}*/

View File

@@ -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) {
//
// }
//
//}

View File

@@ -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()
// }
// }
//
//}

View File

@@ -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) }
}

View File

@@ -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) }
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
)
}
}
}
)
}
}

View File

@@ -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 }
)
}
}
}

View File

@@ -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
}

View File

@@ -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
)
}
}
}

View File

@@ -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>
)

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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) } }
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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())
}
}
}
}
}

View File

@@ -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()
}
}
}
)
}
}

View File

@@ -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 }
)
}
}
}

View File

@@ -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))
}
}
}
}
}

View File

@@ -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))
}
}
}
}
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View 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 {