From 9037b41b4929fb6719b71b059fad908edbed7e84 Mon Sep 17 00:00:00 2001 From: tom5079 Date: Sat, 18 Dec 2021 20:19:06 +0900 Subject: [PATCH] WIP --- .idea/deploymentTargetDropDown.xml | 2 +- .idea/misc.xml | 1 + app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 160 --- app/src/main/java/xyz/quaver/pupil/Pupil.kt | 5 - .../main/java/xyz/quaver/pupil/db/Bookmark.kt | 5 - .../java/xyz/quaver/pupil/sources/Common.kt | 39 +- .../xyz/quaver/pupil/sources/Downloads.kt | 14 - .../java/xyz/quaver/pupil/sources/History.kt | 90 +- .../java/xyz/quaver/pupil/sources/Hitomi.kt | 506 ---------- .../xyz/quaver/pupil/sources/Hiyobi_io.kt | 934 +++++++++--------- .../composable/FloatingSearchBar.kt | 2 +- .../sources/composable/ListSearchResult.kt | 41 + .../MultipleFloatingActionButton.kt | 25 +- .../pupil/sources/composable/ReaderBase.kt | 311 ++++++ .../pupil/sources/composable/SearchBase.kt | 242 +++++ .../xyz/quaver/pupil/sources/hitomi/Hitomi.kt | 160 +++ .../sources/hitomi/HitomiSearchResult.kt | 33 + .../hitomi/HitomiSearchResultViewModel.kt | 71 ++ .../hitomi/composable/SearchResultEntry.kt | 311 ++++++ .../quaver/pupil/sources/manatoki/Manatoki.kt | 202 ++-- .../java/xyz/quaver/pupil/ui/MainActivity.kt | 298 +----- .../xyz/quaver/pupil/ui/ReaderActivity.kt | 278 ------ .../pupil/ui/composable/ProgressCard.kt | 28 - .../pupil/ui/viewmodel/MainViewModel.kt | 144 +-- .../pupil/ui/viewmodel/ReaderViewModel.kt | 224 ----- .../xyz/quaver/pupil/util/DownloadManager.kt | 115 --- .../main/java/xyz/quaver/pupil/util/misc.kt | 92 +- app/src/main/proto/settings.proto | 2 +- 29 files changed, 1831 insertions(+), 2506 deletions(-) delete mode 100644 app/src/main/java/xyz/quaver/pupil/sources/Hitomi.kt rename app/src/main/java/xyz/quaver/pupil/{ui => sources}/composable/FloatingSearchBar.kt (99%) create mode 100644 app/src/main/java/xyz/quaver/pupil/sources/composable/ListSearchResult.kt rename app/src/main/java/xyz/quaver/pupil/{ui => sources}/composable/MultipleFloatingActionButton.kt (90%) create mode 100644 app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt create mode 100644 app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt create mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt create mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResult.kt create mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResultViewModel.kt create mode 100644 app/src/main/java/xyz/quaver/pupil/sources/hitomi/composable/SearchResultEntry.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/ui/composable/ProgressCard.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt delete mode 100644 app/src/main/java/xyz/quaver/pupil/util/DownloadManager.kt diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml index 45c52bb6..fdd365df 100644 --- a/.idea/deploymentTargetDropDown.xml +++ b/.idea/deploymentTargetDropDown.xml @@ -12,6 +12,6 @@ - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 94226a6e..ea0fb3d8 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -30,6 +30,7 @@ + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7a4600d..3f11696b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,7 +83,7 @@ dependencies { implementation("androidx.compose.runtime:runtime-livedata:1.0.5") implementation("androidx.compose.ui:ui-util:1.0.5") implementation("androidx.activity:activity-compose:1.4.0") - implementation("androidx.navigation:navigation-compose:2.4.0-beta02") + implementation("androidx.navigation:navigation-compose:2.4.0-rc01") implementation("com.google.accompanist:accompanist-flowlayout:0.20.3") implementation("com.google.accompanist:accompanist-appcompat-theme:0.20.3") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e36b72d..15ee3fb5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,166 +46,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - fun contains(bookmark: Bookmark) = contains(bookmark.source, bookmark.itemID) - fun contains(itemInfo: ItemInfo) = contains(itemInfo.source, itemInfo.itemID) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(bookmark: Bookmark) suspend fun insert(source: String, itemID: String) = insert(Bookmark(source, itemID)) - suspend fun insert(itemInfo: ItemInfo) = insert(Bookmark(itemInfo.source, itemInfo.itemID)) @Delete suspend fun delete(bookmark: Bookmark) suspend fun delete(source: String, itemID: String) = delete(Bookmark(source, itemID)) - suspend fun delete(itemInfo: ItemInfo) = delete(Bookmark(itemInfo.source, itemInfo.itemID)) } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/Common.kt b/app/src/main/java/xyz/quaver/pupil/sources/Common.kt index f8d9b5a9..40c015f1 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/Common.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/Common.kt @@ -19,40 +19,23 @@ package xyz.quaver.pupil.sources import android.app.Application -import android.os.Parcelable import androidx.compose.runtime.Composable -import io.ktor.http.* -import kotlinx.coroutines.channels.Channel +import androidx.navigation.NavController import org.kodein.di.* -import xyz.quaver.pupil.sources.manatoki.Manatoki - -interface ItemInfo : Parcelable { - val source: String - val itemID: String - val title: String -} - -data class SearchResultEvent(val type: Type, val itemID: String, val payload: Parcelable? = null) { - enum class Type { - OPEN_READER, - OPEN_DETAILS, - NEW_QUERY - } -} +import xyz.quaver.pupil.sources.hitomi.Hitomi abstract class Source { abstract val name: String abstract val iconResID: Int - abstract val availableSortMode: List - - abstract suspend fun search(query: String, range: IntRange, sortMode: Int): Pair, Int> - abstract suspend fun images(itemID: String): List - abstract suspend fun info(itemID: String): ItemInfo @Composable - open fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit = { }) { } + open fun MainScreen(navController: NavController) { } - open fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = { } + @Composable + open fun Search(navController: NavController) { } + + @Composable + open fun Reader(navController: NavController) { } } typealias SourceEntry = Pair @@ -62,12 +45,12 @@ val sourceModule = DI.Module(name = "source") { listOf<(Application) -> (Source)>( { Hitomi(it) }, - { Hiyobi_io(it) }, - { Manatoki(it) } + //{ Hiyobi_io(it) }, + //{ Manatoki(it) } ).forEach { source -> inSet { singleton { source(instance()).let { it.name to it } } } } - bind { singleton { History(di) } } + //bind { singleton { History(di) } } // inSet { singleton { Downloads(di).let { it.name to it as Source } } } } \ 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 index 9180a3c3..efebcc1f 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/Downloads.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/Downloads.kt @@ -18,20 +18,6 @@ package xyz.quaver.pupil.sources -import androidx.compose.runtime.Composable -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import org.kodein.di.DI -import org.kodein.di.DIAware -import org.kodein.di.instance -import xyz.quaver.io.FileX -import xyz.quaver.io.util.getChild -import xyz.quaver.pupil.R -import xyz.quaver.pupil.util.DownloadManager -import kotlin.math.max -import kotlin.math.min /* class Downloads(override val di: DI) : Source(), DIAware { diff --git a/app/src/main/java/xyz/quaver/pupil/sources/History.kt b/app/src/main/java/xyz/quaver/pupil/sources/History.kt index ab0590c3..f443ed3e 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/History.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/History.kt @@ -18,53 +18,43 @@ package xyz.quaver.pupil.sources -import androidx.compose.runtime.Composable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import org.kodein.di.DI -import org.kodein.di.DIAware -import org.kodein.di.direct -import xyz.quaver.pupil.util.database - -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 +// +//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/Hitomi.kt b/app/src/main/java/xyz/quaver/pupil/sources/Hitomi.kt deleted file mode 100644 index 6f64556b..00000000 --- a/app/src/main/java/xyz/quaver/pupil/sources/Hitomi.kt +++ /dev/null @@ -1,506 +0,0 @@ -/* - * Pupil, Hitomi.la viewer for Android - * Copyright (C) 2020 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 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.Star -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.painterResource -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.http.* -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Serializable -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.hitomi.* -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.Preferences -import xyz.quaver.pupil.util.wordCapitalize -import kotlin.math.max -import kotlin.math.min - -@Serializable -@Parcelize -data class HitomiItemInfo( - override val itemID: String, - override val title: String, - val thumbnail: String, - val artists: List, - val series: List, - val type: String, - val language: String, - val tags: List, - private var groups: List? = null, - private var pageCount: Int? = null, - val characters: List? = null, - val preview: List? = null, - val relatedItem: List? = null -): ItemInfo { - - override val source: String - get() = "hitomi.la" - - @IgnoredOnParcel - private val groupMutex = Mutex() - suspend fun getGroups() = withContext(Dispatchers.IO) { - if (groups != null) groups - else groupMutex.withLock { runCatching { - getGallery(itemID.toInt()).groups - }.getOrNull() } - } - - @IgnoredOnParcel - private val pageCountMutex = Mutex() - suspend fun getPageCount() = withContext(Dispatchers.IO) { - if (pageCount != null) pageCount - - else pageCountMutex.withLock { runCatching { - getGalleryInfo(itemID.toInt()).files.size.also { pageCount = it } - }.getOrNull() } - } -} - -class Hitomi(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: String = "hitomi.la" - override val iconResID: Int = R.drawable.hitomi - override val availableSortMode: List = app.resources.getStringArray(R.array.hitomi_sort_mode).toList() - - var cachedQuery: String? = null - var cachedSortMode: Int = -1 - private val cache = mutableListOf() - - override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair, Int> = withContext(Dispatchers.IO) { - if (cachedQuery != query || cachedSortMode != sortMode || cache.isEmpty()) { - cachedQuery = null - cache.clear() - yield() - doSearch("$query ${Preferences["hitomi.default_query", ""]}", sortMode == 1).let { - yield() - cache.addAll(it) - } - cachedQuery = query - } - - val channel = Channel() - val sanitizedRange = max(0, range.first) .. min(range.last, cache.size-1) - - CoroutineScope(Dispatchers.IO).launch { - cache.slice(sanitizedRange).map { - async { - getGalleryBlock(it) - } - }.forEach { - channel.send(transform(it.await())) - } - - channel.close() - } - - channel to cache.size - } - - override suspend fun images(itemID: String): List { - val galleryID = itemID.toInt() - - val reader = getGalleryInfo(galleryID) - - return reader.files.map { - imageUrlFromImage(galleryID, it, false) - } - } - - override suspend fun info(itemID: String): HitomiItemInfo = withContext(Dispatchers.IO) { - kotlin.runCatching { - getGallery(itemID.toInt()).let { - HitomiItemInfo( - itemID, - it.title, - it.cover, - it.artists, - it.series, - it.type, - it.language, - it.tags, - it.groups, - it.thumbnails.size, - it.characters, - it.thumbnails, - it.related.map { it.toString() } - ) - } - }.getOrElse { - transform(getGalleryBlock(itemID.toInt())) - } - } - - @Composable - override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) { - itemInfo as HitomiItemInfo - - FullSearchResult(itemInfo = itemInfo, onEvent = onEvent) - } - - override fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = { - append("Referer", getReferer(itemID.toInt())) - } - - companion object { - 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 "日本語" - ) - - fun transform(galleryBlock: GalleryBlock) = - HitomiItemInfo( - galleryBlock.id.toString(), - galleryBlock.title, - galleryBlock.thumbnails.first(), - galleryBlock.artists, - galleryBlock.series, - galleryBlock.type, - galleryBlock.language, - galleryBlock.relatedTags - ) - } - - @OptIn(ExperimentalMaterialApi::class) - @Composable - fun TagChip(tag: String, isFavorite: Boolean, onClick: ((String) -> Unit)? = null, onFavoriteClick: ((String) -> Unit)? = null) { - 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?.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( - tagParts[1], - 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 = bookmarkedTags.toSet() intersect tags.toSet() - - 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 = { tag -> - val bookmarkTag = Bookmark(name, tag) - - CoroutineScope(Dispatchers.IO).launch { - if (bookmarkedTagsInList.contains(tag)) - 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 - fun FullSearchResult(itemInfo: HitomiItemInfo, onEvent: (SearchResultEvent) -> Unit) { - var group by remember { mutableStateOf(emptyList()) } - var pageCount by remember { mutableStateOf("-") } - - val bookmark by bookmarkDao.contains(itemInfo).observeAsState(false) - - LaunchedEffect(itemInfo) { - launch(Dispatchers.Default) { - itemInfo.getPageCount()?.let { - pageCount = "${it}P" - } - } - - launch(Dispatchers.Default) { - itemInfo.getGroups()?.run { - group = this - } - } - } - - 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.Companion.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 (group.isNotEmpty()) { - if (artistStringBuilder.isNotEmpty()) artistStringBuilder.append(" ") - - artistStringBuilder.append("(") - artistStringBuilder.append(group.joinToString(", ") { it.wordCapitalize() }) - artistStringBuilder.append(")") - } - - 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) - ) - - languageMap[itemInfo.language]?.run { - Text( - stringResource(id = R.string.galleryblock_language, this), - 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(pageCount) - - 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) - } - } - ) - } - } - } - -} \ 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 index 9eefef78..a21669f2 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/Hiyobi_io.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/Hiyobi_io.kt @@ -1,469 +1,465 @@ -/* - * 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, - range: IntRange, - 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 +///* +// * 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/ui/composable/FloatingSearchBar.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/FloatingSearchBar.kt similarity index 99% rename from app/src/main/java/xyz/quaver/pupil/ui/composable/FloatingSearchBar.kt rename to app/src/main/java/xyz/quaver/pupil/sources/composable/FloatingSearchBar.kt index 8fbad34b..ded774bd 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/FloatingSearchBar.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/FloatingSearchBar.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package xyz.quaver.pupil.ui.composable +package xyz.quaver.pupil.sources.composable import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/ListSearchResult.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/ListSearchResult.kt new file mode 100644 index 00000000..78af1e79 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/ListSearchResult.kt @@ -0,0 +1,41 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ListSearchResult(searchResults: List, content: @Composable (T) -> Unit) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(0.dp, 64.dp, 0.dp, 0.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(searchResults) { itemInfo -> + content(itemInfo) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MultipleFloatingActionButton.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/MultipleFloatingActionButton.kt similarity index 90% rename from app/src/main/java/xyz/quaver/pupil/ui/composable/MultipleFloatingActionButton.kt rename to app/src/main/java/xyz/quaver/pupil/sources/composable/MultipleFloatingActionButton.kt index 58f36f27..203adf21 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MultipleFloatingActionButton.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/MultipleFloatingActionButton.kt @@ -1,8 +1,24 @@ -package xyz.quaver.pupil.ui.composable +/* + * 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.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -16,11 +32,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.Modifier.Companion.any import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector @@ -29,7 +43,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastAll enum class FloatingActionButtonState(private val isExpanded: Boolean) { COLLAPSED(false), EXPANDED(true); 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 new file mode 100644 index 00000000..4fb4a514 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt @@ -0,0 +1,311 @@ +/* + * 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.content.Intent +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.ktor.client.request.* +import io.ktor.client.utils.* +import io.ktor.http.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import org.kodein.di.DIAware +import org.kodein.di.android.closestDI +import org.kodein.di.instance +import xyz.quaver.graphics.subsampledimage.* +import xyz.quaver.io.FileX +import xyz.quaver.pupil.R +import xyz.quaver.pupil.db.AppDatabase +import xyz.quaver.pupil.util.FileXImageSource +import xyz.quaver.pupil.util.NetworkCache +import kotlin.math.abs + +open class ReaderBaseViewModel(app: Application) : AndroidViewModel(app), DIAware { + override val di by closestDI(app) + + private val cache: NetworkCache by instance() + + var isFullscreen by mutableStateOf(false) + + private val database: AppDatabase by instance() + + private val historyDao = database.historyDao() + private val bookmarkDao = database.bookmarkDao() + + var error by mutableStateOf(false) + + var title by mutableStateOf(null) + + var imageCount by mutableStateOf(0) + + private var images: List? = null + val imageList = mutableStateListOf() + val progressList = mutableStateListOf() + + @OptIn(ExperimentalCoroutinesApi::class) + fun load(urls: List, headerBuilder: HeadersBuilder.() -> Unit = { }) { + viewModelScope.launch { + imageCount = urls.size + + progressList.addAll(List(imageCount) { 0f }) + imageList.addAll(List(imageCount) { null }) + + urls.forEachIndexed { index, url -> + when (val scheme = url.takeWhile { it != ':' }) { + "http", "https" -> { + val (channel, file) = cache.load { + url(url) + buildHeaders(headerBuilder) + } + + if (channel.isClosedForReceive) { + imageList[index] = Uri.fromFile(file) + } else { + channel.invokeOnClose { e -> + viewModelScope.launch { + if (e == null) { + imageList[index] = Uri.fromFile(file) + } else { + error(index) + } + } + } + + launch { + kotlin.runCatching { + for (progress in channel) { + progressList[index] = progress + } + } + } + } + } + "content" -> { + imageList[index] = Uri.parse(url) + progressList[index] = 1f + } + else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'") + } + } + } + } + + fun error(index: Int) { + progressList[index] = -1f + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ReaderBase( + model: ReaderBaseViewModel, + bookmark: Boolean = false, + onToggleBookmark: () -> Unit = { } +) { + val context = LocalContext.current + + var isFABExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) } + val imageSources = remember { mutableStateListOf() } + val states = remember { mutableStateListOf() } + + val scaffoldState = rememberScaffoldState() + val snackbarCoroutineScope = rememberCoroutineScope() + + LaunchedEffect(model.imageList.count { it != null }) { + if (imageSources.isEmpty() && model.imageList.isNotEmpty()) + imageSources.addAll(List(model.imageList.size) { null }) + + if (states.isEmpty() && model.imageList.isNotEmpty()) + states.addAll(List(model.imageList.size) { + SubSampledImageState(ScaleTypes.FIT_WIDTH, Bounds.FORCE_OVERLAP_OR_CENTER).apply { + isGestureEnabled = true + } + }) + + model.imageList.forEachIndexed { i, image -> + if (imageSources[i] == null && image != null) + imageSources[i] = kotlin.runCatching { + FileXImageSource(FileX(context, image)) + }.onFailure { + model.error(i) + }.getOrNull() + } + } + + if (model.error) + stringResource(R.string.reader_failed_to_find_gallery).let { + snackbarCoroutineScope.launch { + scaffoldState.snackbarHostState.showSnackbar( + it, + duration = SnackbarDuration.Indefinite + ) + } + } + + Scaffold( + topBar = { + if (!model.isFullscreen) + TopAppBar( + title = { + Text( + model.title ?: stringResource(R.string.reader_loading), + color = MaterialTheme.colors.onSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + //TODO + } + ) + }, + floatingActionButton = { + if (!model.isFullscreen) + MultipleFloatingActionButton( + items = listOf( + SubFabItem( + icon = Icons.Default.Fullscreen, + label = stringResource(id = R.string.reader_fab_fullscreen) + ) { + model.isFullscreen = true + } + ), + targetState = isFABExpanded, + onStateChanged = { + isFABExpanded = it + } + ) + }, + scaffoldState = scaffoldState, + snackbarHost = { scaffoldState.snackbarHostState } + ) { + Box { + LazyColumn( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + itemsIndexed(imageSources) { i, imageSource -> + Box( + Modifier + .wrapContentHeight(states[i], 500.dp) + .fillMaxWidth() + .border(1.dp, Color.Gray), + contentAlignment = Alignment.Center + ) { + if (imageSource == null) + model.progressList.getOrNull(i)?.let { progress -> + if (progress < 0f) + Icon(Icons.Filled.BrokenImage, null) + else + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + LinearProgressIndicator(progress) + Text((i + 1).toString()) + } + } + else { + val haptic = LocalHapticFeedback.current + + SubSampledImage( + modifier = Modifier + .fillMaxSize() + .run { + if (model.isFullscreen) + doubleClickCycleZoom(states[i], 2f) + else + combinedClickable( + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress + ) + + // TODO + val uri = FileProvider.getUriForFile( + context, + "xyz.quaver.pupil.fileprovider", + (imageSource as FileXImageSource).file + ) + context.startActivity( + Intent.createChooser( + Intent( + Intent.ACTION_SEND + ).apply { + type = "image/*" + putExtra( + Intent.EXTRA_STREAM, + uri + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, "Share image" + ) + ) + } + ) { + model.isFullscreen = true + } + }, + imageSource = imageSource, + state = states[i] + ) + } + } + } + } + + if (model.progressList.any { abs(it) != 1f }) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter), + progress = model.progressList.map { abs(it) }.sum() / model.progressList.size, + color = MaterialTheme.colors.secondary + ) + + SnackbarHost( + scaffoldState.snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..850d2562 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/SearchBase.kt @@ -0,0 +1,242 @@ +/* + * 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.updateTransition +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.Scaffold +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +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.unit.dp +import androidx.compose.ui.util.fastFirstOrNull +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +private enum class NavigationIconState { + MENU, + ARROW +} + +open class SearchBaseViewModel(app: Application) : AndroidViewModel(app) { + val searchResults = mutableStateListOf() + + var sortModeIndex by mutableStateOf(0) + private set + + var currentPage by mutableStateOf(1) + + var totalItems by mutableStateOf(0) + private set + + var maxPage by mutableStateOf(0) + private set + + val prevPageAvailable by derivedStateOf { currentPage > 1 } + val nextPageAvailable by derivedStateOf { currentPage <= maxPage } + + var query by mutableStateOf("") + + var loading by mutableStateOf(false) + private set + + //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.() -> 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 pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() } + val searchBarHeight = LocalDensity.current.run { 64.dp.roundToPx() } + + var overscroll: Float? by remember { mutableStateOf(null) } + + LaunchedEffect(navigationIconProgress) { + navigationIcon.progress = navigationIconProgress + } + + Scaffold( + floatingActionButton = { + MultipleFloatingActionButton( + items = fabSubMenu, + visible = model.isFabVisible, + targetState = isFabExpanded, + onStateChanged = { + isFabExpanded = it + } + ) + } + ) { + Box(Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .offset( + 0.dp, + overscroll?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } } + ?: 0.dp) + .nestedScroll(object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource + ): Offset { + val overscrollSnapshot = overscroll + + if (overscrollSnapshot == null || overscrollSnapshot == 0f) { + model.searchBarOffset = (model.searchBarOffset + available.y.roundToInt()).coerceIn(-searchBarHeight, 0) + + model.isFabVisible = available.y > 0f + + return 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 + + return Offset(0f, newOverscroll - overscrollSnapshot).also { + overscroll = newOverscroll + } + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (available.y == 0f || source == NestedScrollSource.Fling) return Offset.Zero + + return overscroll?.let { + val newOverscroll = (it + available.y).coerceIn( + -pageTurnIndicatorHeight, + pageTurnIndicatorHeight + ) + + Offset(0f, newOverscroll - it).also { + overscroll = newOverscroll + } + } ?: Offset.Zero + } + }).pointerInput(Unit) { + 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 = null + break + } else + pointer = otherDown.id + } + } + } + } + }, + content = content + ) + + if (model.loading) + CircularProgressIndicator(Modifier.align(Alignment.Center)) + + FloatingSearchBar( + modifier = Modifier.offset(0.dp, LocalDensity.current.run { model.searchBarOffset.toDp() }), + query = model.query, + onQueryChange = { model.query = it }, + navigationIcon = { + Icon( + painter = rememberDrawablePainter(navigationIcon), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false) + ) { + focusManager.clearFocus() + } + ) + }, + actions = actions, + onTextFieldFocused = { navigationIconState = NavigationIconState.ARROW }, + onTextFieldUnfocused = { navigationIconState = NavigationIconState.MENU; onSearch() } + ) + } + } +} \ 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 new file mode 100644 index 00000000..e6c88ac2 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt @@ -0,0 +1,160 @@ +/* + * 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.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +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.hitomi.getGalleryInfo +import xyz.quaver.hitomi.getReferer +import xyz.quaver.hitomi.imageUrlFromImage +import xyz.quaver.pupil.R +import xyz.quaver.pupil.db.AppDatabase +import xyz.quaver.pupil.sources.Source +import xyz.quaver.pupil.sources.composable.* +import xyz.quaver.pupil.sources.hitomi.composable.DetailedSearchResult + +class Hitomi(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: String = "hitomi.la" + override val iconResID: Int = R.drawable.hitomi + + @Composable + override fun MainScreen(navController: NavController) { + navController.navigate("search/hitomi.la") { + launchSingleTop = true + popUpTo("main") { inclusive = true } + } + } + + @Composable + override fun Search(navController: NavController) { + val model: HitomiSearchResultViewModel = viewModel() + val database: AppDatabase by rememberInstance() + val bookmarkDao = remember { database.bookmarkDao() } + val coroutineScope = rememberCoroutineScope() + + val bookmarks by bookmarkDao.getAll(name).observeAsState() + val bookmarkSet by derivedStateOf { + bookmarks?.toSet() ?: emptySet() + } + + SearchBase( + model, + fabSubMenu = listOf( + SubFabItem( + painterResource(R.drawable.ic_jump), + stringResource(R.string.main_jump_title) + ), + SubFabItem( + Icons.Default.Shuffle, + stringResource(R.string.main_fab_random) + ), + SubFabItem( + painterResource(R.drawable.numeric), + stringResource(R.string.main_open_gallery_by_id) + ) + ), + actions = { + + }, + onSearch = { model.search() } + ) { + ListSearchResult(model.searchResults) { + DetailedSearchResult( + it, + bookmarks = bookmarkSet, + onBookmarkToggle = { + coroutineScope.launch { + if (it in bookmarkSet) bookmarkDao.delete(name, it) + else bookmarkDao.insert(name, it) + } + } + ) { result -> + navController.navigate("reader/$name/${result.itemID}") + } + } + } + } + + @Composable + override fun Reader(navController: NavController) { + val model: ReaderBaseViewModel = viewModel() + + val database: AppDatabase by rememberInstance() + val bookmarkDao = database.bookmarkDao() + + val coroutineScope = rememberCoroutineScope() + + val itemID = navController.currentBackStackEntry?.arguments?.getString("itemID") ?: "" + + if (itemID.isEmpty()) model.error = true + + val bookmark by bookmarkDao.contains(name, itemID).observeAsState(false) + + LaunchedEffect(model) { + launch(Dispatchers.IO) { + kotlin.runCatching { + val galleryID = itemID.toInt() + + val galleryInfo = getGalleryInfo(galleryID) + + model.title = galleryInfo.title + + model.load(galleryInfo.files.map { imageUrlFromImage(galleryID, it, false) }) { + append("Referer", getReferer(galleryID)) + } + }.onFailure { + model.error = true + } + } + } + + ReaderBase( + model, + bookmark = bookmark, + onToggleBookmark = { + coroutineScope.launch { + if (itemID.isEmpty() || bookmark) bookmarkDao.delete(name, itemID) + else bookmarkDao.insert(name, itemID) + } + } + ) + } +} \ 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 new file mode 100644 index 00000000..68da8b44 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResult.kt @@ -0,0 +1,33 @@ +/* + * 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 new file mode 100644 index 00000000..0e72eaa0 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/HitomiSearchResultViewModel.kt @@ -0,0 +1,71 @@ +/* + * 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 kotlinx.coroutines.* +import org.kodein.di.DIAware +import org.kodein.di.android.closestDI +import org.kodein.di.instance +import xyz.quaver.hitomi.GalleryBlock +import xyz.quaver.hitomi.doSearch +import xyz.quaver.hitomi.getGalleryBlock +import xyz.quaver.pupil.db.AppDatabase +import xyz.quaver.pupil.sources.composable.SearchBaseViewModel + +class HitomiSearchResultViewModel(app: Application) : SearchBaseViewModel(app), DIAware { + override val di by closestDI(app) + + private val database: AppDatabase by instance() + private val bookmarkDao = database.bookmarkDao() + + init { + search() + } + + private var searchJob: Job? = null + fun search() { + searchJob?.cancel() + searchResults.clear() + searchJob = CoroutineScope(Dispatchers.IO).launch { + val result = doSearch("female:loli") + + yield() + + result.take(25).forEach { + yield() + searchResults.add(transform(getGalleryBlock(it))) + } + } + } + + 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 new file mode 100644 index 00000000..64e3a909 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/composable/SearchResultEntry.kt @@ -0,0 +1,311 @@ +/* + * 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(" ") +} + +@Composable +fun DetailedSearchResult( + result: HitomiSearchResult, + bookmarks: Set, + onBookmarkToggle: (String) -> Unit = { }, + onClick: (HitomiSearchResult) -> Unit = { } +) { + val painter = rememberImagePainter(result.thumbnail) + + Card( + modifier = Modifier + .padding(8.dp, 0.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, + bookmarks, + onBookmarkToggle = onBookmarkToggle + ) + } + } + } + + 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 bookmarks) Icons.Default.Star else Icons.Default.StarOutline, + contentDescription = null, + tint = Orange500, + modifier = Modifier.size(24.dp).clickable { + onBookmarkToggle(result.itemID) + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun TagGroup( + tags: List, + bookmarks: Set, + onBookmarkToggle: (String) -> Unit = { } +) { + var isFolded by remember { mutableStateOf(true) } + + val bookmarkedTagsInList = bookmarks intersect tags.toSet() + + 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 = onBookmarkToggle + ) + } + + 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/manatoki/Manatoki.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt index 72473d7e..1d9c8eac 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt @@ -1,101 +1,101 @@ -/* - * 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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize -import org.jsoup.Jsoup -import org.kodein.di.DIAware -import org.kodein.di.android.closestDI -import org.kodein.log.LoggerFactory -import org.kodein.log.newLogger -import xyz.quaver.pupil.R -import xyz.quaver.pupil.sources.ItemInfo -import xyz.quaver.pupil.sources.Source - -@Parcelize -class ManatokiItemInfo( - override val itemID: String, - override val title: String -) : ItemInfo { - override val source: String = "manatoki.net" -} - -class Manatoki(app: Application) : Source(), DIAware { - override val di by closestDI(app) - - private val logger = newLogger(LoggerFactory.default) - - override val name = "manatoki.net" - override val availableSortMode: List = emptyList() - override val iconResID: Int = R.drawable.manatoki - - override suspend fun search( - query: String, - range: IntRange, - sortMode: Int - ): Pair, Int> { - TODO("Not yet implemented") - } - - override suspend fun images(itemID: String): List = coroutineScope { - val jsoup = withContext(Dispatchers.IO) { - Jsoup.connect("https://manatoki116.net/comic/$itemID").get() - } - - val htmlData = jsoup - .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() - - Jsoup.parse(htmlData) - .select("img[^data-]:not([style])") - .map { - it.attributes() - .first { it.key.startsWith("data-") } - .value - } - } - - override suspend fun info(itemID: String): ItemInfo = coroutineScope { - val jsoup = withContext(Dispatchers.IO) { - Jsoup.connect("https://manatoki116.net/comic/$itemID").get() - } - - val title = jsoup.selectFirst(".toon-title")!!.ownText() - - ManatokiItemInfo( - itemID, - title - ) - } - -} \ No newline at end of file +///* +// * 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 kotlinx.coroutines.Dispatchers +//import kotlinx.coroutines.channels.Channel +//import kotlinx.coroutines.coroutineScope +//import kotlinx.coroutines.withContext +//import kotlinx.parcelize.Parcelize +//import org.jsoup.Jsoup +//import org.kodein.di.DIAware +//import org.kodein.di.android.closestDI +//import org.kodein.log.LoggerFactory +//import org.kodein.log.newLogger +//import xyz.quaver.pupil.R +//import xyz.quaver.pupil.sources.ItemInfo +//import xyz.quaver.pupil.sources.Source +// +//@Parcelize +//class ManatokiItemInfo( +// override val itemID: String, +// override val title: String +//) : ItemInfo { +// override val source: String = "manatoki.net" +//} +// +//class Manatoki(app: Application) : Source(), DIAware { +// override val di by closestDI(app) +// +// private val logger = newLogger(LoggerFactory.default) +// +// override val name = "manatoki.net" +// override val availableSortMode: List = emptyList() +// override val iconResID: Int = R.drawable.manatoki +// +// override suspend fun search( +// query: String, +// range: IntRange, +// sortMode: Int +// ): Pair, Int> { +// TODO("Not yet implemented") +// } +// +// override suspend fun images(itemID: String): List = coroutineScope { +// val jsoup = withContext(Dispatchers.IO) { +// Jsoup.connect("https://manatoki116.net/comic/$itemID").get() +// } +// +// val htmlData = jsoup +// .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() +// +// Jsoup.parse(htmlData) +// .select("img[^data-]:not([style])") +// .map { +// it.attributes() +// .first { it.key.startsWith("data-") } +// .value +// } +// } +// +// override suspend fun info(itemID: String): ItemInfo = coroutineScope { +// val jsoup = withContext(Dispatchers.IO) { +// Jsoup.connect("https://manatoki116.net/comic/$itemID").get() +// } +// +// val title = jsoup.selectFirst(".toon-title")!!.ownText() +// +// ManatokiItemInfo( +// itemID, +// title +// ) +// } +// +//} \ 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 a16f26ab..9a4c3384 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -18,60 +18,23 @@ package xyz.quaver.pupil.ui -import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.appcompat.graphics.drawable.DrawerArrowDrawable import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -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.material.ripple.rememberRipple -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -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.* -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastFirstOrNull -import com.google.accompanist.drawablepainter.rememberDrawablePainter -import kotlinx.coroutines.* +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import org.kodein.di.DIAware import org.kodein.di.android.closestDI +import org.kodein.di.direct import org.kodein.log.LoggerFactory import org.kodein.log.newLogger -import xyz.quaver.pupil.* -import xyz.quaver.pupil.R -import xyz.quaver.pupil.sources.SearchResultEvent -import xyz.quaver.pupil.ui.composable.* -import xyz.quaver.pupil.ui.dialog.OpenWithItemIDDialog -import xyz.quaver.pupil.ui.dialog.SourceSelectDialog import xyz.quaver.pupil.ui.theme.PupilTheme import xyz.quaver.pupil.ui.viewmodel.MainViewModel -import xyz.quaver.pupil.util.* -import kotlin.math.* +import xyz.quaver.pupil.util.source -private enum class NavigationIconState { - MENU, - ARROW -} class MainActivity : ComponentActivity(), DIAware { override val di by closestDI() @@ -86,251 +49,22 @@ class MainActivity : ComponentActivity(), DIAware { setContent { PupilTheme { - val focusManager = LocalFocusManager.current + val navController = rememberNavController() - val maxPage by model.maxPage.collectAsState(0) - - val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() } - - val prevPageAvailable by derivedStateOf { - model.currentPage > 1 - } - - val nextPageAvailable by derivedStateOf { - model.currentPage <= maxPage - } - - var overscroll: Float? by remember { mutableStateOf(null) } - - var isFabExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) } - var isFabVisible by remember { mutableStateOf(true) } - - val searchBarHeight = LocalDensity.current.run { 56.dp.roundToPx() } - var searchBarOffset by remember { mutableStateOf(0) } - - val navigationIcon = remember { DrawerArrowDrawable(this) } - 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 onSearchResultEvent: (SearchResultEvent) -> Unit = { event -> - when (event.type) { - SearchResultEvent.Type.OPEN_READER -> { - startActivity( - Intent( - this@MainActivity, - ReaderActivity::class.java - ).apply { - putExtra("source", model.source.name) - putExtra("id", event.itemID) - putExtra("payload", event.payload) - }) - } - else -> TODO("") - } - } - - var sourceSelectDialog by remember { mutableStateOf(false) } - var openWithItemIDDialog by remember { mutableStateOf(false) } - - LaunchedEffect(navigationIconProgress) { - navigationIcon.progress = navigationIconProgress - } - - if (sourceSelectDialog) - SourceSelectDialog( - currentSource = model.source.name, - onDismissRequest = { sourceSelectDialog = false } - ) { source -> - sourceSelectDialog = false - model.setSourceAndReset(source.name) + NavHost(navController, startDestination = "main/{source}") { + composable("main/{source}") { + direct.source(it.arguments?.getString("source") ?: "hitomi.la") + .MainScreen(navController) } - if (openWithItemIDDialog) - OpenWithItemIDDialog { - openWithItemIDDialog = false - - it?.let { - onSearchResultEvent(SearchResultEvent( - SearchResultEvent.Type.OPEN_READER, - it - )) - } + composable("search/{source}") { + direct.source(it.arguments?.getString("source") ?: "hitomi.la") + .Search(navController) } - Scaffold( - floatingActionButton = { - MultipleFloatingActionButton( - listOf( - SubFabItem( - Icons.Default.Block, - stringResource(R.string.main_fab_cancel) - ), - SubFabItem( - painterResource(R.drawable.ic_jump), - stringResource(R.string.main_jump_title) - ), - SubFabItem( - Icons.Default.Shuffle, - stringResource(R.string.main_fab_random) - ), - SubFabItem( - painterResource(R.drawable.numeric), - stringResource(R.string.main_open_gallery_by_id) - ) { - openWithItemIDDialog = true - } - ), - visible = isFabVisible, - targetState = isFabExpanded, - onStateChanged = { - isFabExpanded = it - } - ) - } - ) { - Box(Modifier.fillMaxSize()) { - LazyColumn( - Modifier - .fillMaxSize() - .offset(0.dp, overscroll?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } } ?: 0.dp) - .nestedScroll(object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource - ): Offset { - val overscrollSnapshot = overscroll - - if (overscrollSnapshot == null || overscrollSnapshot == 0f) { - searchBarOffset = - (searchBarOffset + available.y.roundToInt()).coerceIn( - -searchBarHeight, - 0 - ) - - isFabVisible = available.y > 0f - - return 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 - - return Offset(0f, newOverscroll - overscrollSnapshot).also { - overscroll = newOverscroll - } - } - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - if (available.y == 0f || source == NestedScrollSource.Fling) return Offset.Zero - - return overscroll?.let { - val newOverscroll = (it + available.y).coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight) - - Offset(0f, newOverscroll - it).also { - overscroll = newOverscroll - } - } ?: Offset.Zero - } - }).pointerInput(Unit) { - 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 = null - break - } - else - pointer = otherDown.id - } - } - } - } - }, - contentPadding = PaddingValues(0.dp, 56.dp, 0.dp, 0.dp) - ) { - items(model.searchResults, key = { it.itemID }) { itemInfo -> - ProgressCard( - progress = 0.5f - ) { - model.source.SearchResult(itemInfo = itemInfo, onEvent = onSearchResultEvent) - } - } - } - - if (model.loading) - CircularProgressIndicator(Modifier.align(Alignment.Center)) - - FloatingSearchBar( - modifier = Modifier.offset(0.dp, LocalDensity.current.run { searchBarOffset.toDp() }), - query = model.query, - onQueryChange = { model.query = it }, - navigationIcon = { - Icon( - painter = rememberDrawablePainter(navigationIcon), - contentDescription = null, - modifier = Modifier - .size(24.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ) { - focusManager.clearFocus() - } - ) - }, - actions = { - Image( - painterResource(model.source.iconResID), - contentDescription = null, - modifier = Modifier - .size(24.dp) - .clickable { - sourceSelectDialog = true - } - ) - Icon( - Icons.Default.Sort, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f) - ) - Icon( - Icons.Default.Settings, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f) - ) - }, - onTextFieldFocused = { navigationIconState = NavigationIconState.ARROW }, - onTextFieldUnfocused = { navigationIconState = NavigationIconState.MENU; model.resetAndQuery() } - ) + composable("reader/{source}/{itemID}") { + direct.source(it.arguments?.getString("source") ?: "hitomi.la") + .Reader(navController) } } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt deleted file mode 100644 index 0b73e7e9..00000000 --- a/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Pupil, Hitomi.la viewer for Android - * Copyright (C) 2019 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.ui - -import android.content.ClipData -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BrokenImage -import androidx.compose.material.icons.filled.Fullscreen -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.StarOutline -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalHapticFeedback -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.core.content.FileProvider -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import coil.annotation.ExperimentalCoilApi -import kotlinx.coroutines.launch -import org.kodein.di.DIAware -import org.kodein.di.android.closestDI -import org.kodein.log.LoggerFactory -import org.kodein.log.newLogger -import xyz.quaver.graphics.subsampledimage.* -import xyz.quaver.io.FileX -import xyz.quaver.pupil.R -import xyz.quaver.pupil.ui.composable.FloatingActionButtonState -import xyz.quaver.pupil.ui.composable.MultipleFloatingActionButton -import xyz.quaver.pupil.ui.composable.SubFabItem -import xyz.quaver.pupil.ui.theme.Orange500 -import xyz.quaver.pupil.ui.theme.PupilTheme -import xyz.quaver.pupil.ui.viewmodel.ReaderViewModel -import xyz.quaver.pupil.util.FileXImageSource -import kotlin.math.abs - -class ReaderActivity : ComponentActivity(), DIAware { - override val di by closestDI() - - private val model: ReaderViewModel by viewModels() - - private val logger = newLogger(LoggerFactory.default) - - @OptIn(ExperimentalCoilApi::class, ExperimentalFoundationApi::class) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - model.handleIntent(intent) - model.load() - - setContent { - var isFABExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) } - val imageSources = remember { mutableStateListOf() } - val states = remember { mutableStateListOf() } - val bookmark by model.bookmark.observeAsState(false) - - val scaffoldState = rememberScaffoldState() - val snackbarCoroutineScope = rememberCoroutineScope() - - LaunchedEffect(model.imageList.count { it != null }) { - if (imageSources.isEmpty() && model.imageList.isNotEmpty()) - imageSources.addAll(List(model.imageList.size) { null }) - - if (states.isEmpty() && model.imageList.isNotEmpty()) - states.addAll(List(model.imageList.size) { SubSampledImageState(ScaleTypes.FIT_WIDTH, Bounds.FORCE_OVERLAP_OR_CENTER).apply { - isGestureEnabled = true - } }) - - model.imageList.forEachIndexed { i, image -> - if (imageSources[i] == null && image != null) - imageSources[i] = kotlin.runCatching { - FileXImageSource(FileX(this@ReaderActivity, image)) - }.onFailure { - logger.warning(it) - model.error(i) - }.getOrNull() - } - } - - WindowInsetsControllerCompat(window, window.decorView).run { - if (model.isFullscreen) { - hide(WindowInsetsCompat.Type.systemBars()) - systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } else - 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) - } - } - - PupilTheme { - Scaffold( - topBar = { - if (!model.isFullscreen) - TopAppBar( - title = { - Text( - model.title ?: stringResource(R.string.reader_loading), - color = MaterialTheme.colors.onSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - actions = { - Row( - modifier = Modifier.padding(16.dp, 0.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(24.dp) - ) { - Icon( - if (bookmark) Icons.Default.Star else Icons.Default.StarOutline, - contentDescription = null, - tint = Orange500, - modifier = Modifier.size(24.dp).clickable { - model.toggleBookmark() - } - ) - model.sourceIcon?.let { sourceIcon -> - Image( - modifier = Modifier.size(24.dp), - painter = painterResource(id = sourceIcon), - contentDescription = null - ) - } - } - } - ) - }, - floatingActionButton = { - if (!model.isFullscreen) - MultipleFloatingActionButton( - items = listOf( - SubFabItem( - icon = Icons.Default.Fullscreen, - label = stringResource(id = R.string.reader_fab_fullscreen) - ) { - model.isFullscreen = true - } - ), - targetState = isFABExpanded, - onStateChanged = { - isFABExpanded = it - } - ) - }, - scaffoldState = scaffoldState, - snackbarHost = { scaffoldState.snackbarHostState } - ) { - Box { - LazyColumn( - Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - itemsIndexed(imageSources) { i, imageSource -> - Box( - Modifier - .wrapContentHeight(states[i], 500.dp) - .fillMaxWidth() - .border(1.dp, Color.Gray), - contentAlignment = Alignment.Center - ) { - if (imageSource == null) - model.progressList.getOrNull(i)?.let { progress -> - if (progress < 0f) - Icon(Icons.Filled.BrokenImage, null) - else - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - LinearProgressIndicator(progress) - Text((i + 1).toString()) - } - } - else { - val haptic = LocalHapticFeedback.current - - SubSampledImage( - modifier = Modifier - .fillMaxSize() - .run { - if (model.isFullscreen) - doubleClickCycleZoom(states[i], 2f) - else - combinedClickable( - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - - // TODO - val uri = FileProvider.getUriForFile(this@ReaderActivity, "xyz.quaver.pupil.fileprovider", (imageSource as FileXImageSource).file) - startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { - type = "image/*" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - }, "Share image")) - } - ) { - model.isFullscreen = true - } - }, - imageSource = imageSource, - state = states[i] - ) - } - } - } - } - - if (model.totalProgress != model.imageCount) - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopCenter), - progress = model.progressList.map { abs(it) } - .sum() / model.progressList.size, - color = MaterialTheme.colors.secondary - ) - - SnackbarHost( - scaffoldState.snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter) - ) - } - } - } - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - model.handleIntent(intent) - } - - override fun onBackPressed() { - when { - model.isFullscreen -> model.isFullscreen = false - else -> super.onBackPressed() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/ProgressCard.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/ProgressCard.kt deleted file mode 100644 index d713cbf0..00000000 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/ProgressCard.kt +++ /dev/null @@ -1,28 +0,0 @@ -package xyz.quaver.pupil.ui.composable - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ProgressCard(progress: Float? = null, content: @Composable () -> Unit) { - Card( - modifier = Modifier.padding(8.dp), - shape = RoundedCornerShape(4.dp), - elevation = 4.dp - ) { - Column { - progress?.run { LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.secondary) } - content() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt index 2d9afb69..6a0fd9bb 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/MainViewModel.kt @@ -19,26 +19,17 @@ package xyz.quaver.pupil.ui.viewmodel import android.app.Application -import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.asLiveData -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import org.kodein.di.DIAware import org.kodein.di.android.x.closestDI import org.kodein.di.direct -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.sources.History -import xyz.quaver.pupil.sources.ItemInfo import xyz.quaver.pupil.sources.Source import xyz.quaver.pupil.util.source -import kotlin.math.ceil -import kotlin.random.Random @Suppress("UNCHECKED_CAST") class MainViewModel(app: Application) : AndroidViewModel(app), DIAware { @@ -46,138 +37,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware { private val logger = newLogger(LoggerFactory.default) - val searchResults = mutableStateListOf() - - private val resultsPerPage = app.settingsDataStore.data.map { - it.resultsPerPage - } - - var loading by mutableStateOf(false) - private set - - private var queryJob: Job? = null - private var suggestionJob: Job? = null - - var query by mutableStateOf("") - private val queryStack = mutableListOf() - private val defaultSourceFactory: (String) -> Source = { direct.source(it) } private var sourceFactory: (String) -> Source = defaultSourceFactory var source by mutableStateOf(sourceFactory("hitomi.la")) - private set - - var sortModeIndex by mutableStateOf(0) - private set - - var currentPage by mutableStateOf(1) - - var totalItems by mutableStateOf(0) - private set - - val maxPage by derivedStateOf { - resultsPerPage.map { - ceil(totalItems / it.toDouble()).toInt() - } - } - - fun setSourceAndReset(sourceName: String) { - source = sourceFactory(sourceName) - sortModeIndex = 0 - - query = "" - resetAndQuery() - } - - fun resetAndQuery() { - queryStack.add(query) - currentPage = 1 - - query() - } - - fun setModeAndReset(mode: MainMode) { - sourceFactory = when (mode) { - MainMode.SEARCH, MainMode.DOWNLOADS -> defaultSourceFactory - MainMode.HISTORY -> { { direct.instance(arg = it) } } - else -> return - } - - setSourceAndReset( - when { - mode == MainMode.DOWNLOADS -> "downloads" - //source.value is Downloads -> "hitomi.la" - else -> source.name - } - ) - } - - fun query() { - suggestionJob?.cancel() - queryJob?.cancel() - - loading = true - searchResults.clear() - - queryJob = viewModelScope.launch { - val resultsPerPage = resultsPerPage.first() - - logger.info { - resultsPerPage.toString() - } - - val (channel, count) = source.search( - query, - (currentPage - 1) * resultsPerPage until currentPage * resultsPerPage, - sortModeIndex - ) - - totalItems = count - - for (result in channel) { - yield() - searchResults.add(result) - } - - loading = false - } - } - - fun random(callback: (ItemInfo) -> Unit) { - if (totalItems == 0) - return - - val random = Random.Default.nextInt(totalItems) - - viewModelScope.launch { - withContext(Dispatchers.IO) { - source.search( - query, - random .. random, - sortModeIndex - ).first.receive() - }.let(callback) - } - } - - /** - * @return true if backpress is consumed, false otherwise - */ - fun onBackPressed(): Boolean { - if (queryStack.removeLastOrNull() == null || queryStack.isEmpty()) - return false - - query = queryStack.removeLast() - resetAndQuery() - return true - } - - enum class MainMode { - SEARCH, - HISTORY, - DOWNLOADS, - FAVORITES - } - } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt deleted file mode 100644 index a6fe92e5..00000000 --- a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Pupil, Hitomi.la viewer for Android - * Copyright (C) 2020 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.ui.viewmodel - -import android.app.Application -import android.content.Intent -import android.net.Uri -import androidx.compose.runtime.* -import androidx.lifecycle.* -import io.ktor.client.request.* -import io.ktor.http.* -import kotlinx.coroutines.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.kodein.di.DIAware -import org.kodein.di.android.x.closestDI -import org.kodein.di.direct -import org.kodein.di.instance -import org.kodein.log.LoggerFactory -import org.kodein.log.newLogger -import xyz.quaver.pupil.db.AppDatabase -import xyz.quaver.pupil.db.History -import xyz.quaver.pupil.sources.ItemInfo -import xyz.quaver.pupil.sources.Source -import xyz.quaver.pupil.util.NetworkCache -import xyz.quaver.pupil.util.source - -@Suppress("UNCHECKED_CAST") -class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware { - - override val di by closestDI() - - private val cache: NetworkCache by instance() - - private val logger = newLogger(LoggerFactory.default) - - var isFullscreen by mutableStateOf(false) - - private val database: AppDatabase by instance() - - private val historyDao = database.historyDao() - private val bookmarkDao = database.bookmarkDao() - - lateinit var bookmark: LiveData - private set - - var error by mutableStateOf(false) - private set - - var source by mutableStateOf(null) - private set - var itemID by mutableStateOf(null) - private set - var title by mutableStateOf(null) - private set - - private val totalProgressMutex = Mutex() - var totalProgress by mutableStateOf(0) - private set - var imageCount by mutableStateOf(0) - private set - - private var images: List? = null - val imageList = mutableStateListOf() - val progressList = mutableStateListOf() - - val sourceIcon by derivedStateOf { - source?.iconResID - } - - /** - * Parses source and itemID from the intent - * - * @throws IllegalStateException when the intent has no recognizable source and/or itemID - */ - fun handleIntent(intent: Intent) { - if (intent.action == Intent.ACTION_VIEW) { - val uri = intent.data - val lastPathSegment = uri?.lastPathSegment - if (uri != null && lastPathSegment != null) { - source = uri.host?.let { direct.source(it) } ?: error("Invalid host") - itemID = when (uri.host) { - "hitomi.la" -> - Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1) ?: error("Invalid itemID") - "hiyobi.me" -> lastPathSegment - "e-hentai.org" -> uri.pathSegments[1] - else -> error("Invalid host") - } - } - } else { - source = intent.getStringExtra("source")?.let { direct.source(it) } ?: error("Invalid source") - itemID = intent.getStringExtra("id") ?: error("Invalid itemID") - title = intent.getParcelableExtra("payload")?.title - } - - bookmark = bookmarkDao.contains(source!!.name, itemID!!) - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun load() { - val source = source ?: return - val itemID = itemID ?: return - - viewModelScope.launch { - launch(Dispatchers.IO) { - historyDao.insert(History(source.name, itemID)) - } - } - - viewModelScope.launch { - if (title == null) - title = withContext(Dispatchers.IO) { - kotlin.runCatching { - source.info(itemID) - }.getOrNull() - }?.title - } - - viewModelScope.launch { - withContext(Dispatchers.IO) { - kotlin.runCatching { - source.images(itemID) - }.onFailure { - error = true - }.getOrNull() - }?.let { images -> - this@ReaderViewModel.images = images - - imageCount = images.size - - progressList.addAll(List(imageCount) { 0f }) - imageList.addAll(List(imageCount) { null }) - totalProgressMutex.withLock { - totalProgress = 0 - } - - images.forEachIndexed { index, image -> - logger.info { - progressList.toList().toString() - } - when (val scheme = image.takeWhile { it != ':' }) { - "http", "https" -> { - val (channel, file) = cache.load { - url(image) - headers(source.getHeadersBuilderForImage(itemID, image)) - } - - if (channel.isClosedForReceive) { - imageList[index] = Uri.fromFile(file) - totalProgressMutex.withLock { - totalProgress++ - } - } else { - channel.invokeOnClose { e -> - viewModelScope.launch { - if (e == null) { - imageList[index] = Uri.fromFile(file) - totalProgressMutex.withLock { - totalProgress++ - } - } else { - error(index) - } - } - } - - launch { - kotlin.runCatching { - for (progress in channel) { - progressList[index] = progress - } - } - } - } - } - "content" -> { - imageList[index] = Uri.parse(image) - progressList[index] = 1f - } - else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'") - } - } - } - } - } - - fun error(index: Int) { - progressList[index] = -1f - } - - fun toggleBookmark() { - source?.name?.let { source -> - itemID?.let { itemID -> - bookmark.value?.let { bookmark -> - CoroutineScope(Dispatchers.IO).launch { - if (bookmark) bookmarkDao.delete(source, itemID) - else bookmarkDao.insert(source, itemID) - } - } } } - } - - override fun onCleared() { - cache.cleanup() - images?.let { cache.free(it) } - } - -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/DownloadManager.kt b/app/src/main/java/xyz/quaver/pupil/util/DownloadManager.kt deleted file mode 100644 index 6ba525d2..00000000 --- a/app/src/main/java/xyz/quaver/pupil/util/DownloadManager.kt +++ /dev/null @@ -1,115 +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.content.ContextWrapper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.kodein.di.DIAware -import org.kodein.di.android.closestDI -import xyz.quaver.io.FileX -import xyz.quaver.io.util.* -import xyz.quaver.pupil.sources.Source - -class DownloadManager constructor(context: Context) : ContextWrapper(context), DIAware { - - override val di by closestDI(context) - - private val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!) - - val downloadFolder: FileX - get() = kotlin.runCatching { - FileX(this, Preferences.get("download_folder")) - }.getOrElse { - Preferences["download_folder"] = defaultDownloadFolder.uri.toString() - defaultDownloadFolder - } - - private var prevDownloadFolder: FileX? = null - private var downloadFolderMapInstance: MutableMap? = null - private val downloadFolderMap: MutableMap - @Synchronized - get() { - if (prevDownloadFolder != downloadFolder) { - prevDownloadFolder = downloadFolder - downloadFolderMapInstance = run { - val file = downloadFolder.getChild(".download") - val data = if (file.exists()) - kotlin.runCatching { - file.readText()?.let> { Json.decodeFromString(it) } - }.onFailure { file.delete() }.getOrNull() - else - null - data ?: run { - file.createNewFile() - mutableMapOf() - } - } - } - - return downloadFolderMapInstance ?: mutableMapOf() - } - - val downloads: Map - get() = downloadFolderMap - - @Synchronized - fun getDownloadFolder(source: String, itemID: String): FileX? = - downloadFolderMap["$source-$itemID"]?.let { downloadFolder.getChild(it) } - - @Synchronized - fun download(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch { - val source: Source by source(source) - val info = async { source.info(itemID) } - val images = async { source.images(itemID) } - - val name = info.await().formatDownloadFolder() - - val folder = downloadFolder.getChild("$source/$name") - - if (folder.exists()) - return@launch - - folder.mkdir() - - downloadFolderMap["$source/$itemID"] = folder.name - - downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() } - downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap)) - } - - @Synchronized - fun delete(source: String, itemID: String) { - downloadFolderMap["$source/$itemID"]?.let { - kotlin.runCatching { - downloadFolder.getChild(it).deleteRecursively() - downloadFolderMap.remove("$source/$itemID") - - downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() } - downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap)) - } - } - } -} \ 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 0a53cded..639ea60d 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/misc.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/misc.kt @@ -18,18 +18,15 @@ package xyz.quaver.pupil.util -import android.annotation.SuppressLint import android.graphics.BitmapFactory -import android.view.MenuItem import android.view.View -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.toAndroidRect -import androidx.lifecycle.MutableLiveData -import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.* import org.kodein.di.DIAware import org.kodein.di.DirectDIAware @@ -40,71 +37,7 @@ import xyz.quaver.graphics.subsampledimage.newBitmapRegionDecoder import xyz.quaver.io.FileX import xyz.quaver.io.util.inputStream import xyz.quaver.pupil.db.AppDatabase -import xyz.quaver.pupil.sources.ItemInfo import xyz.quaver.pupil.sources.SourceEntries -import java.io.InputStream -import java.io.OutputStream -import java.util.* -import kotlin.collections.ArrayList - -@OptIn(ExperimentalStdlibApi::class) -fun String.wordCapitalize() : String { - val result = ArrayList() - - @SuppressLint("DefaultLocale") - for (word in this.split(" ")) - result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }) - - return result.joinToString(" ") -} - -private val suffix = listOf( - "B", - "kB", - "MB", - "GB", - "TB" //really? -) - -fun byteToString(byte: Long, precision : Int = 1) : String { - var size = byte.toDouble(); var suffixIndex = 0 - - while (size >= 1024) { - size /= 1024 - suffixIndex++ - } - - return "%.${precision}f ${suffix[suffixIndex]}".format(size) -} - -/** - * Convert android generated ID to requestCode - * to prevent java.lang.IllegalArgumentException: Can only use lower 16 bits for requestCode - * - * https://stackoverflow.com/questions/38072322/generate-16-bit-unique-ids-in-android-for-startactivityforresult - */ -fun Int.normalizeID() = this.and(0xFFFF) - -val formatMap = mapOf (String)>( - "-id-" to { itemID }, - "-title-" to { title }, - // TODO -) -/** - * Formats download folder name with given Metadata - */ -fun ItemInfo.formatDownloadFolder(format: String = Preferences["download_folder_name", "[-id-] -title-"]): String = - format.let { - formatMap.entries.fold(it) { str, (k, v) -> - str.replace(k, v.invoke(this), true) - } - }.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127) - -fun String.ellipsize(n: Int): String = - if (this.length > n) - this.slice(0 until n) + "…" - else - this operator fun JsonElement.get(index: Int) = this.jsonArray[index] @@ -115,27 +48,6 @@ operator fun JsonElement.get(tag: String) = val JsonElement.content get() = this.jsonPrimitive.contentOrNull -fun List.findMenu(itemID: Int): MenuItem? { - return firstOrNull { it.itemId == itemID } -} - -fun MutableLiveData>.notify() { - this.value = this.value -} - -fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long, bytesJustCopied: Int) -> Unit): Long { - var bytesCopied: Long = 0 - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = read(buffer) - while (bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - onCopy(bytesCopied, bytes) - bytes = read(buffer) - } - return bytesCopied -} - fun DIAware.source(source: String) = lazy { direct.source(source) } fun DirectDIAware.source(source: String) = instance().toMap()[source]!! diff --git a/app/src/main/proto/settings.proto b/app/src/main/proto/settings.proto index 306259ae..431f0f90 100644 --- a/app/src/main/proto/settings.proto +++ b/app/src/main/proto/settings.proto @@ -4,5 +4,5 @@ option java_package = "xyz.quaver.pupil.proto"; option java_multiple_files = true; message Settings { - optional int32 results_per_page = 1 [default = 25]; + } \ No newline at end of file