From f271e61ea2e1e35eb9a5c7b139f5b7235873376d Mon Sep 17 00:00:00 2001 From: tom5079 Date: Fri, 31 Dec 2021 14:03:52 +0900 Subject: [PATCH] External Sources --- app/build.gradle.kts | 2 + .../pupil/SourceLoaderInstrumentedTest.kt | 52 ++ app/src/main/AndroidManifest.xml | 2 + app/src/main/java/xyz/quaver/pupil/Pupil.kt | 6 +- .../java/xyz/quaver/pupil/proto/Settings.kt | 3 +- .../xyz/quaver/pupil/sources/Downloads.kt | 100 --- .../java/xyz/quaver/pupil/sources/History.kt | 60 -- .../xyz/quaver/pupil/sources/Hiyobi_io.kt | 465 ---------- .../xyz/quaver/pupil/sources/SourceLoader.kt | 86 +- .../{composable => }/SourceSelectDialog.kt | 21 +- .../sources/composable/FloatingSearchBar.kt | 140 --- .../sources/composable/ModalTopSheetLayout.kt | 287 ------ .../MultipleFloatingActionButton.kt | 229 ----- .../sources/composable/OverscrollPager.kt | 224 ----- .../pupil/sources/composable/ReaderBase.kt | 823 ------------------ .../pupil/sources/composable/SearchBase.kt | 202 ----- .../quaver/pupil/sources/hitomi/Database.kt | 50 -- .../xyz/quaver/pupil/sources/hitomi/Hitomi.kt | 304 ------- .../sources/hitomi/HitomiSearchResult.kt | 33 - .../hitomi/HitomiSearchResultViewModel.kt | 126 --- .../hitomi/composable/SearchResultEntry.kt | 317 ------- .../quaver/pupil/sources/hitomi/lib/common.kt | 121 --- .../pupil/sources/hitomi/lib/galleries.kt | 84 -- .../pupil/sources/hitomi/lib/galleryblock.kt | 105 --- .../quaver/pupil/sources/hitomi/lib/reader.kt | 55 -- .../pupil/sources/hitomi/lib/results.kt | 76 -- .../quaver/pupil/sources/hitomi/lib/search.kt | 341 -------- .../quaver/pupil/sources/manatoki/Database.kt | 84 -- .../quaver/pupil/sources/manatoki/Manatoki.kt | 88 -- .../manatoki/composable/BoardButton.kt | 66 -- .../pupil/sources/manatoki/composable/Main.kt | 461 ---------- .../composable/MangaListingBottomSheet.kt | 261 ------ .../sources/manatoki/composable/Reader.kt | 303 ------- .../sources/manatoki/composable/Recent.kt | 131 --- .../sources/manatoki/composable/Search.kt | 382 -------- .../sources/manatoki/composable/Thumbnail.kt | 80 -- .../xyz/quaver/pupil/sources/manatoki/util.kt | 271 ------ .../manatoki/viewmodel/MainViewModel.kt | 116 --- .../manatoki/viewmodel/RecentViewModel.kt | 80 -- .../manatoki/viewmodel/SearchViewModel.kt | 218 ----- .../java/xyz/quaver/pupil/ui/MainActivity.kt | 18 +- .../xyz/quaver/pupil/util/NetworkCache.kt | 180 ---- .../main/java/xyz/quaver/pupil/util/misc.kt | 2 +- 43 files changed, 148 insertions(+), 6907 deletions(-) create mode 100644 app/src/androidTest/java/xyz/quaver/pupil/SourceLoaderInstrumentedTest.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/Downloads.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/History.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/Hiyobi_io.kt rename app/src/main/java/xyz/quaver/pupil/sources/{composable => }/SourceSelectDialog.kt (80%) delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/composable/FloatingSearchBar.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/composable/ModalTopSheetLayout.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/composable/MultipleFloatingActionButton.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/composable/OverscrollPager.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/Database.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResult.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResultViewModel.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/composable/SearchResultEntry.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/common.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/galleries.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/galleryblock.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/reader.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/results.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/search.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/Database.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/BoardButton.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Main.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Reader.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Recent.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Search.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Thumbnail.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/RecentViewModel.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/SearchViewModel.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eaeaa25b..f07a75fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/androidTest/java/xyz/quaver/pupil/SourceLoaderInstrumentedTest.kt b/app/src/androidTest/java/xyz/quaver/pupil/SourceLoaderInstrumentedTest.kt new file mode 100644 index 00000000..094248a0 --- /dev/null +++ b/app/src/androidTest/java/xyz/quaver/pupil/SourceLoaderInstrumentedTest.kt @@ -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 . + */ + +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) + } + +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a55ffe31..c643728e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ + + { override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output) } -val Context.settingsDataStore: DataStore by dataStore( +val Application.settingsDataStore: DataStore by dataStore( fileName = "settings.proto", serializer = SettingsSerializer ) \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/Downloads.kt b/app/src/main/java/xyz/quaver/pupil/sources/Downloads.kt deleted file mode 100644 index efebcc1f..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/Downloads.kt +++ /dev/null @@ -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 . - */ - -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 = emptyList() - - private val downloadManager: DownloadManager by instance() - - override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair, Int> { - TODO() - /* - val downloads = downloadManager.downloads.toList() - - val channel = Channel() - 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 { - return emptyList() - } - - override suspend fun images(itemID: String): List { - 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? = - 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(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") - } - -}*/ \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/History.kt b/app/src/main/java/xyz/quaver/pupil/sources/History.kt deleted file mode 100644 index f443ed3e..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/History.kt +++ /dev/null @@ -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 . - */ - -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 = emptyList() -// -// private val history = direct.database().historyDao() -// -// override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair, Int> { -// val channel = Channel() -// -// CoroutineScope(Dispatchers.IO).launch { -// -// -// channel.close() -// } -// -// throw NotImplementedError("") -// //return Pair(channel, histories.map.size) -// } -// -// override suspend fun images(itemID: String): List { -// throw NotImplementedError("") -// } -// -// override suspend fun info(itemID: String): ItemInfo { -// throw NotImplementedError("") -// } -// -// -// @Composable -// override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) { -// -// } -// -//} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/Hiyobi_io.kt b/app/src/main/java/xyz/quaver/pupil/sources/Hiyobi_io.kt deleted file mode 100644 index a21669f2..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/Hiyobi_io.kt +++ /dev/null @@ -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 . -// */ -// -//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, -// val series: List, -// val type: String, -// val date: String, -// val bookmark: Unit?, -// val tags: List, -// 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, -// val thumbnail: String, -// val series: List, -// val type: String, -// val date: String, -// val bookmark: Unit?, -// val tags: List, -// val commentCount: Int, -// val pageCount: Int -//) -// -//@Serializable -//data class QueryManga( -// val nowPage: Int, -// val maxPage: Int, -// val manga: List -//) -// -//@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() -// -// 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( -// "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( -// "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, Int> = withContext(Dispatchers.IO) { -// val channel = Channel() -// -// 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 = withContext(Dispatchers.IO) { -// val query = "{getManga(mangaId:$itemID){urls}}" -// -// client.post("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) { -// 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() -// } -// } -// -//} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/SourceLoader.kt b/app/src/main/java/xyz/quaver/pupil/sources/SourceLoader.kt index 33975834..957105a8 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/SourceLoader.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/SourceLoader.kt @@ -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 + +private val sources = mutableMapOf() + +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 -typealias SourceEntries = Set -val sourceModule = DI.Module(name = "source") { - bindSet() +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) } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/SourceSelectDialog.kt b/app/src/main/java/xyz/quaver/pupil/sources/SourceSelectDialog.kt similarity index 80% rename from app/src/main/java/xyz/quaver/pupil/sources/composable/SourceSelectDialog.kt rename to app/src/main/java/xyz/quaver/pupil/sources/SourceSelectDialog.kt index 648e341a..434333eb 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/SourceSelectDialog.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/SourceSelectDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -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) } } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/FloatingSearchBar.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/FloatingSearchBar.kt deleted file mode 100644 index 2eee3d82..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/FloatingSearchBar.kt +++ /dev/null @@ -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 . - */ - -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 - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/ModalTopSheetLayout.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/ModalTopSheetLayout.kt deleted file mode 100644 index e3580786..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/ModalTopSheetLayout.kt +++ /dev/null @@ -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 . - */ - -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 = 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(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, - 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 -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/MultipleFloatingActionButton.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/MultipleFloatingActionButton.kt deleted file mode 100644 index ee6c1d17..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/MultipleFloatingActionButton.kt +++ /dev/null @@ -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 . - */ - -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, - 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) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/OverscrollPager.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/OverscrollPager.kt deleted file mode 100644 index 8e554a17..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/OverscrollPager.kt +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Pupil, Hitomi.la viewer for Android - * Copyright (C) 2021 tom5079 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -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() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt deleted file mode 100644 index 2d144ad4..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt +++ /dev/null @@ -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 . - */ - -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() - val progressList = mutableStateListOf() - - private val progressCollectJobs = ConcurrentHashMap() - - private val totalProgressMutex = Mutex() - var totalProgress by mutableStateOf(0) - private set - - private var urls: List? = null - - var loadJob: Job? = null - @OptIn(ExperimentalCoroutinesApi::class) - fun load(urls: List, headerBuilder: HeadersBuilder.() -> Unit = { }) { - this.urls = urls - viewModelScope.launch { - loadJob?.cancelAndJoin() - progressList.clear() - imageList.clear() - totalProgressMutex.withLock { - totalProgress = 0 - } - - imageCount = urls.size - - progressList.addAll(List(imageCount) { 0f }) - 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, - 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, - imageSizes: List, - 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>() - - 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() } - val imageSizes = remember { mutableStateListOf() } - - 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 = spring(), - onTap: () -> Unit = { }, -) = composed { - val initialImageRect by produceState(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 - ) - } - } - } - ) - } -} diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt deleted file mode 100644 index 78ac47e0..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt +++ /dev/null @@ -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 . - */ - -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 : ViewModel() { - val searchResults = mutableStateListOf() - - 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 SearchBase( - model: SearchBaseViewModel = viewModel(), - fabSubMenu: List = 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 } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Database.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Database.kt deleted file mode 100644 index 43990fd0..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Database.kt +++ /dev/null @@ -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 . - */ - -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> - - @Query("SELECT EXISTS(SELECT * FROM favorite WHERE item = :item)") - fun contains(item: String): Flow - - @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 -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt deleted file mode 100644 index 674d7f6d..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt +++ /dev/null @@ -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 . - */ - -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(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 - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResult.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResult.kt deleted file mode 100644 index 68da8b44..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResult.kt +++ /dev/null @@ -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 . - */ - -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, - val series: List, - val type: String, - val language: String, - val tags: List -) diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResultViewModel.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResultViewModel.kt deleted file mode 100644 index bf63237a..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResultViewModel.kt +++ /dev/null @@ -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 . - */ - -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() { - private var cachedQuery: String? = null - private var cachedSortByPopularity: Boolean? = null - private val cache = mutableListOf() - - private val galleryBlockCache = LruCache(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 - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/composable/SearchResultEntry.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/composable/SearchResultEntry.kt deleted file mode 100644 index 99b7667c..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/composable/SearchResultEntry.kt +++ /dev/null @@ -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 . - */ - -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() - - 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, - 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, - favorites: Set, - 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 - ) - } - } -} diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/common.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/common.kt deleted file mode 100644 index 19708ae3..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/common.kt +++ /dev/null @@ -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 . - */ - -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("$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) - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/galleries.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/galleries.kt deleted file mode 100644 index 7ffe0d58..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/galleries.kt +++ /dev/null @@ -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 . - */ - -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, - val langList: List>, - val cover: String, - val title: String, - val artists: List, - val groups: List, - val type: String, - val language: String, - val series: List, - val characters: List, - val tags: List, - val thumbnails: List -) -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) -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/galleryblock.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/galleryblock.kt deleted file mode 100644 index bc4e1a9a..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/galleryblock.kt +++ /dev/null @@ -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 . - */ - -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, 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, - val title: String, - val artists: List, - val series: List, - val type: String, - val language: String, - val relatedTags: List -) - -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) -} diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/reader.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/reader.kt deleted file mode 100644 index 70e4d833..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/reader.kt +++ /dev/null @@ -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 . - */ - -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 = emptyList(), - val title: String? = null, - val files: List, - 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 -) \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/results.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/results.kt deleted file mode 100644 index ea8bb28a..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/results.kt +++ /dev/null @@ -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 = coroutineScope { - val terms = query - .trim() - .replace(Regex("""^\?"""), "") - .lowercase() - .split(Regex("\\s+")) - .map { - it.replace('_', ' ') - } - - val positiveTerms = LinkedList() - val negativeTerms = LinkedList() - - 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) { - results = when { - results.isEmpty() -> newResults - else -> results intersect newResults - } - } - - fun filterNegative(newResults: Set) { - results = results subtract newResults - } - - //positive results - positiveResults.forEach { - filterPositive(it.await()) - } - - //negative results - negativeResults.forEach { - filterNegative(it.await()) - } - - results -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/search.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/search.kt deleted file mode 100644 index 07f836db..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/lib/search.kt +++ /dev/null @@ -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 . - */ - -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 { - 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 { - 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) : List { - 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() - - 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 = 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() - - val arrayBuffer = ByteBuffer - .wrap(bytes) - .order(ByteOrder.BIG_ENDIAN) - - while (arrayBuffer.hasRemaining()) - nozomi.add(arrayBuffer.int) - - nozomi -} - -suspend fun getGalleryIDsFromData(client: HttpClient, data: Pair) : Set { - 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() - - 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, val datas: List>, val subNodeAddresses: List) -@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() - - 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>() - - 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() - - 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? { - 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 { - 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) -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Database.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Database.kt deleted file mode 100644 index 2280970d..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Database.kt +++ /dev/null @@ -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 . - */ - -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 - - @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> - - @Query("SELECT itemID FROM history WHERE parent = :parent ORDER BY timestamp DESC") - suspend fun getAll(parent: String): List -} - -@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 -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt deleted file mode 100644 index d0871c17..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt +++ /dev/null @@ -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 . - */ -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) } } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/BoardButton.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/BoardButton.kt deleted file mode 100644 index 628fa27e..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/BoardButton.kt +++ /dev/null @@ -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 . - */ - -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) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Main.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Main.kt deleted file mode 100644 index bc1d188d..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Main.kt +++ /dev/null @@ -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 . - */ - -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() } - - 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() } - 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()) - } - } - } - } -} diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt deleted file mode 100644 index 494695d1..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/MangaListingBottomSheet.kt +++ /dev/null @@ -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 . - */ - -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 = 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() - } - } - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Reader.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Reader.kt deleted file mode 100644 index e37cc522..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Reader.kt +++ /dev/null @@ -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 . - */ - -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 } - ) - } - } -} diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Recent.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Recent.kt deleted file mode 100644 index 7be0e7d7..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Recent.kt +++ /dev/null @@ -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 . - */ - -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)) - } - } - } - } -} diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Search.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Search.kt deleted file mode 100644 index e990f7f2..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Search.kt +++ /dev/null @@ -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 . - */ - -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() } - 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)) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Thumbnail.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Thumbnail.kt deleted file mode 100644 index 5194f571..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/composable/Thumbnail.kt +++ /dev/null @@ -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 . - */ - -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 - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt deleted file mode 100644 index f698bc68..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/util.kt +++ /dev/null @@ -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 . - */ - -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, - val type: String, - val thumbsUpCount: Int, - val entries: List -): Parcelable - -@Parcelize -@Serializable -data class ReaderInfo( - val itemID: String, - val title: String, - val urls: List, - 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(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) - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt deleted file mode 100644 index c6c64cc7..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/MainViewModel.kt +++ /dev/null @@ -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 . - */ - -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() - val mangaList = mutableStateListOf() - val topWeekly = mutableStateListOf() - - 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() - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/RecentViewModel.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/RecentViewModel.kt deleted file mode 100644 index 5dc42d49..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/RecentViewModel.kt +++ /dev/null @@ -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 . - */ - -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() - - 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 - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/SearchViewModel.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/SearchViewModel.kt deleted file mode 100644 index d7d40246..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/viewmodel/SearchViewModel.kt +++ /dev/null @@ -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 . - */ - -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() - // 정렬 - 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() - - var loading by mutableStateOf(false) - private set - - var error by mutableStateOf(false) - private set - - val result = mutableStateListOf() - - 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() - - 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 - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt index f0be83fa..5068117b 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -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 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) } } diff --git a/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt b/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt deleted file mode 100644 index 2c2ca63d..00000000 --- a/app/src/main/java/xyz/quaver/pupil/util/NetworkCache.kt +++ /dev/null @@ -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 . - */ - -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>() - - private val requestsMutex = Mutex() - private val requests = ConcurrentHashMap() - - private val activeFilesMutex = Mutex() - private val activeFiles = Collections.newSetFromMap(ConcurrentHashMap()) - - 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) = 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, 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(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 - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/misc.kt b/app/src/main/java/xyz/quaver/pupil/util/misc.kt index c7759a49..7450876f 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/misc.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/misc.kt @@ -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().toMap()[source]!! +fun DirectDIAware.source(source: String) = instance()[source]!! class FileXImageSource(val file: FileX): ImageSource { private val decoder by lazy {