This commit is contained in:
tom5079
2021-12-18 20:19:06 +09:00
parent 02751233f8
commit 9037b41b49
29 changed files with 1831 additions and 2506 deletions

View File

@@ -12,6 +12,6 @@
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2021-12-18T04:42:03.889339Z" />
<timeTargetWasSelectedWithDropDown value="2021-12-18T09:43:21.798655Z" />
</component>
</project>

1
.idea/misc.xml generated
View File

@@ -30,6 +30,7 @@
<entry key="../../../../layout/compose-model-1639538998660.xml" value="0.30277777777777776" />
<entry key="../../../../layout/compose-model-1639625734547.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1639629588722.xml" value="0.3472222222222222" />
<entry key="../../../../layout/compose-model-1639809297022.xml" value="0.1" />
<entry key="../../../../layout/custom_preview.xml" value="0.518974358974359" />
<entry key="app/src/main/res/drawable/avd_star.xml" value="0.2722222222222222" />
<entry key="app/src/main/res/drawable/close.xml" value="0.31614583333333335" />

View File

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

View File

@@ -46,166 +46,6 @@
</intent-filter>
</receiver>
<activity
android:name=".ui.ReaderActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/galleries"
android:scheme="http" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/manga"
android:scheme="http" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/doujinshi"
android:scheme="http" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/cg"
android:scheme="http" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/reader"
android:scheme="http" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/galleries"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/manga"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/doujinshi"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/cg"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/reader"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hiyobi.me"
android:scheme="http"
android:pathPrefix="/reader" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hiyobi.me"
android:pathPrefix="/reader"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="e-hentai.org"
android:pathPrefix="/g"
android:scheme="http" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="e-hentai.org"
android:pathPrefix="/g"
android:scheme="https" />
</intent-filter>
</activity>
<activity
android:name=".ui.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize"

View File

@@ -43,15 +43,11 @@ import io.ktor.client.features.json.serializer.*
import okhttp3.Protocol
import org.kodein.di.*
import org.kodein.di.android.x.androidXModule
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import xyz.quaver.io.FileX
import xyz.quaver.pupil.db.databaseModule
import xyz.quaver.pupil.sources.sourceModule
import xyz.quaver.pupil.util.*
import java.io.File
import java.util.*
import java.util.concurrent.TimeUnit
class Pupil : Application(), DIAware {
@@ -60,7 +56,6 @@ class Pupil : Application(), DIAware {
import(databaseModule)
import(sourceModule)
bind { singleton { DownloadManager(applicationContext) } }
bind { singleton { NetworkCache(applicationContext) } }
bind { singleton {

View File

@@ -2,7 +2,6 @@ package xyz.quaver.pupil.db
import androidx.lifecycle.LiveData
import androidx.room.*
import xyz.quaver.pupil.sources.ItemInfo
@Entity(primaryKeys = ["source", "itemID"])
data class Bookmark(
@@ -21,19 +20,15 @@ interface BookmarkDao {
@Query("SELECT EXISTS(SELECT * FROM bookmark WHERE source = :source AND itemID = :itemID)")
fun contains(source: String, itemID: String): LiveData<Boolean>
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))
}

View File

@@ -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<String>
abstract suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int>
abstract suspend fun images(itemID: String): List<String>
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<String, Source>
@@ -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 } } }
}

View File

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

View File

@@ -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<String> = emptyList()
private val history = direct.database().historyDao()
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
val channel = Channel<ItemInfo>()
CoroutineScope(Dispatchers.IO).launch {
channel.close()
}
throw NotImplementedError("")
//return Pair(channel, histories.map.size)
}
override suspend fun images(itemID: String): List<String> {
throw NotImplementedError("")
}
override suspend fun info(itemID: String): ItemInfo {
throw NotImplementedError("")
}
@Composable
override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
}
}
//
//class History(override val di: DI) : Source(), DIAware {
// private val historyDao = direct.database().historyDao()
//
// override val name: String
// get() = "history"
// override val iconResID: Int
// get() = 0 //TODO
// override val availableSortMode: List<String> = emptyList()
//
// private val history = direct.database().historyDao()
//
// override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
// val channel = Channel<ItemInfo>()
//
// CoroutineScope(Dispatchers.IO).launch {
//
//
// channel.close()
// }
//
// throw NotImplementedError("")
// //return Pair(channel, histories.map.size)
// }
//
// override suspend fun images(itemID: String): List<String> {
// throw NotImplementedError("")
// }
//
// override suspend fun info(itemID: String): ItemInfo {
// throw NotImplementedError("")
// }
//
//
// @Composable
// override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
//
// }
//
//}

View File

@@ -1,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 <http://www.gnu.org/licenses/>.
*/
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<String>,
val series: List<String>,
val type: String,
val language: String,
val tags: List<String>,
private var groups: List<String>? = null,
private var pageCount: Int? = null,
val characters: List<String>? = null,
val preview: List<String>? = null,
val relatedItem: List<String>? = 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<String> = app.resources.getStringArray(R.array.hitomi_sort_mode).toList()
var cachedQuery: String? = null
var cachedSortMode: Int = -1
private val cache = mutableListOf<Int>()
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, 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<ItemInfo>()
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<String> {
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<String>) {
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<String>()) }
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)
}
}
)
}
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources
import android.app.Application
import android.os.Parcelable
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Female
import androidx.compose.material.icons.filled.Male
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarOutline
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import com.google.accompanist.flowlayout.FlowRow
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import xyz.quaver.pupil.R
import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.db.Bookmark
import xyz.quaver.pupil.ui.theme.Blue700
import xyz.quaver.pupil.ui.theme.Orange500
import xyz.quaver.pupil.ui.theme.Pink600
import xyz.quaver.pupil.util.content
import xyz.quaver.pupil.util.get
import xyz.quaver.pupil.util.wordCapitalize
@Serializable
@Parcelize
data class Tag(
val male: Int?,
val female: Int?,
val tag: String
) : Parcelable {
override fun toString(): String {
val stringBuilder = StringBuilder()
stringBuilder.append(when {
male != null -> "male"
female != null -> "female"
else -> "tag"
})
stringBuilder.append(':')
stringBuilder.append(tag)
return stringBuilder.toString()
}
}
@Serializable
@Parcelize
data class HiyobiItemInfo(
override val itemID: String,
override val title: String,
val thumbnail: String,
val artists: List<String>,
val series: List<String>,
val type: String,
val date: String,
val bookmark: Unit?,
val tags: List<Tag>,
val commentCount: Int,
val pageCount: Int
): ItemInfo {
override val source: String
get() = "hiyobi.io"
}
@Serializable
data class Manga(
val mangaId: Int,
val title: String,
val artist: List<String>,
val thumbnail: String,
val series: List<String>,
val type: String,
val date: String,
val bookmark: Unit?,
val tags: List<Tag>,
val commentCount: Int,
val pageCount: Int
)
@Serializable
data class QueryManga(
val nowPage: Int,
val maxPage: Int,
val manga: List<Manga>
)
@Serializable
data class SearchResultData(
val queryManga: QueryManga
)
@Serializable
data class SearchResult(
val data: SearchResultData
)
class Hiyobi_io(app: Application): Source(), DIAware {
override val di by closestDI(app)
private val logger = newLogger(LoggerFactory.default)
private val database: AppDatabase by instance()
private val bookmarkDao = database.bookmarkDao()
override val name = "hiyobi.io"
override val iconResID = R.drawable.hitomi
override val availableSortMode = emptyList<String>()
private val client: HttpClient by instance()
private suspend fun query(page: Int, tags: String): SearchResult {
val query = "{queryManga(page:$page,tags:$tags){nowPage maxPage manga{mangaId title artist thumbnail series type date bookmark tags{male female tag} commentCount pageCount}}}"
return client.get("https://api.hiyobi.io/api?query=$query")
}
private suspend fun totalCount(tags: String): Int {
val firstPageQuery = "{queryManga(page:1,tags:$tags){maxPage}}"
val maxPage = client.get<JsonObject>(
"https://api.hiyobi.io/api?query=$firstPageQuery"
)["data"]!!["queryManga"]!!["maxPage"]!!.jsonPrimitive.int
val lastPageQuery = "{queryManga(page:$maxPage,tags:$tags){manga{mangaId}}}"
val lastPageCount = client.get<JsonObject>(
"https://api.hiyobi.io/api?query=$lastPageQuery"
)["data"]!!["queryManga"]!!["manga"]!!.jsonArray.size
return (maxPage-1)*25+lastPageCount
}
override suspend fun search(
query: String,
range: IntRange,
sortMode: Int
): Pair<Channel<ItemInfo>, Int> = withContext(Dispatchers.IO) {
val channel = Channel<ItemInfo>()
val tags = parseQuery(query)
logger.info {
tags
}
CoroutineScope(Dispatchers.IO).launch {
(range.first/25+1 .. range.last/25+1).map { page ->
page to async { query(page, tags) }
}.forEach { (page, result) ->
result.await().data.queryManga.manga.forEachIndexed { index, manga ->
if ((page-1)*25+index in range) channel.send(transform(manga))
}
}
channel.close()
}
channel to totalCount(tags)
}
override suspend fun images(itemID: String): List<String> = withContext(Dispatchers.IO) {
val query = "{getManga(mangaId:$itemID){urls}}"
client.post<JsonObject>("https://api.hiyobi.io/api") {
contentType(ContentType.Application.Json)
body = mapOf("query" to query)
}["data"]!!["getManga"]!!["urls"]!!.jsonArray.map { "https://api.hiyobi.io/${it.content!!}" }
}
override suspend fun info(itemID: String): ItemInfo {
TODO("Not yet implemented")
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TagChip(tag: Tag, isFavorite: Boolean, onClick: ((Tag) -> Unit)? = null, onFavoriteClick: ((Tag) -> Unit)? = null) {
val icon = when {
tag.male != null -> Icons.Filled.Male
tag.female != null -> Icons.Filled.Female
else -> null
}
val (surfaceColor, textTint) = when {
isFavorite -> Pair(Orange500, Color.White)
else -> when {
tag.male != null -> Pair(Blue700, Color.White)
tag.female != null -> Pair(Pink600, Color.White)
else -> Pair(MaterialTheme.colors.background, MaterialTheme.colors.onBackground)
}
}
val starIcon = if (isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline
Surface(
modifier = Modifier.padding(2.dp),
onClick = { onClick?.invoke(tag) },
shape = RoundedCornerShape(16.dp),
color = surfaceColor,
elevation = 2.dp
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null)
Icon(
icon,
contentDescription = "Icon",
modifier = Modifier
.padding(4.dp)
.size(24.dp),
tint = Color.White
)
else
Box(Modifier.size(16.dp))
Text(
tag.tag,
color = textTint,
style = MaterialTheme.typography.body2
)
Icon(
starIcon,
contentDescription = "Favorites",
modifier = Modifier
.padding(8.dp)
.size(16.dp)
.clip(CircleShape)
.clickable { onFavoriteClick?.invoke(tag) },
tint = textTint
)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TagGroup(tags: List<Tag>) {
var isFolded by remember { mutableStateOf(true) }
val bookmarkedTags by bookmarkDao.getAll(name).observeAsState(emptyList())
val bookmarkedTagsInList = tags.filter { it.toString() in bookmarkedTags }
FlowRow(Modifier.padding(0.dp, 16.dp)) {
tags.sortedBy { if (bookmarkedTagsInList.contains(it)) 0 else 1 }.let { (if (isFolded) it.take(10) else it) }.forEach { tag ->
TagChip(
tag = tag,
isFavorite = bookmarkedTagsInList.contains(tag),
onFavoriteClick = {
val bookmarkTag = Bookmark(name, it.toString())
CoroutineScope(Dispatchers.IO).launch {
if (bookmarkedTagsInList.contains(it))
bookmarkDao.delete(bookmarkTag)
else
bookmarkDao.insert(bookmarkTag)
}
}
)
}
if (isFolded && tags.size > 10)
Surface(
modifier = Modifier.padding(2.dp),
color = MaterialTheme.colors.background,
shape = RoundedCornerShape(16.dp),
elevation = 2.dp,
onClick = { isFolded = false }
) {
Text(
"",
modifier = Modifier.padding(16.dp, 8.dp),
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body2
)
}
}
}
@OptIn(ExperimentalCoilApi::class)
@Composable
override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
itemInfo as HiyobiItemInfo
val bookmark by bookmarkDao.contains(itemInfo).observeAsState(false)
val painter = rememberImagePainter(itemInfo.thumbnail)
Column(
modifier = Modifier.clickable {
onEvent(SearchResultEvent(SearchResultEvent.Type.OPEN_READER, itemInfo.itemID, itemInfo))
}
) {
Row {
Image(
painter = painter,
contentDescription = null,
modifier = Modifier
.requiredWidth(150.dp)
.aspectRatio(
with(painter.intrinsicSize) { if (this == Size.Unspecified) 1f else width / height },
true
)
.padding(0.dp, 0.dp, 8.dp, 0.dp)
.align(Alignment.CenterVertically),
contentScale = ContentScale.FillWidth
)
Column {
Text(
itemInfo.title,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onSurface
)
val artistStringBuilder = StringBuilder()
with(itemInfo.artists) {
if (this.isNotEmpty())
artistStringBuilder.append(this.joinToString(", ") { it.wordCapitalize() })
}
if (artistStringBuilder.isNotEmpty())
Text(
artistStringBuilder.toString(),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
)
if (itemInfo.series.isNotEmpty())
Text(
stringResource(
id = R.string.galleryblock_series,
itemInfo.series.joinToString { it.wordCapitalize() }
),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
)
Text(
stringResource(id = R.string.galleryblock_type, itemInfo.type),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
)
key(itemInfo.tags) {
TagGroup(tags = itemInfo.tags)
}
}
}
Divider(
thickness = 1.dp,
modifier = Modifier.padding(0.dp, 8.dp, 0.dp, 0.dp)
)
Row(
modifier = Modifier.padding(8.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(itemInfo.itemID)
Text("${itemInfo.pageCount}P")
Icon(
if (bookmark) Icons.Default.Star else Icons.Default.StarOutline,
contentDescription = null,
tint = Orange500,
modifier = Modifier
.size(32.dp)
.clickable {
CoroutineScope(Dispatchers.IO).launch {
if (bookmark) bookmarkDao.delete(itemInfo)
else bookmarkDao.insert(itemInfo)
}
}
)
}
}
}
companion object {
private fun transform(manga: Manga) = HiyobiItemInfo(
manga.mangaId.toString(),
manga.title,
"https://api.hiyobi.io/${manga.thumbnail}",
manga.artist,
manga.series,
manga.type,
manga.date,
manga.bookmark,
manga.tags,
manga.commentCount,
manga.pageCount
)
fun parseQuery(query: String): String {
val queryBuilder = StringBuilder("[")
if (query.isNotBlank())
query.split(' ').filter { it.isNotBlank() }.forEach {
val tags = it.replace('_', ' ').split(':', limit = 2)
if (queryBuilder.length != 1) queryBuilder.append(',')
queryBuilder.append(
when {
tags.size == 1 -> "{tag:\"${tags[0]}\"}"
tags[0] == "male" -> "{male:1,tag:\"${tags[1]}\"}"
tags[0] == "female" -> "{female:1,tag:\"${tags[1]}\"}"
else -> "{tag:\"${tags[1]}\"}"
}
)
}
return queryBuilder.append(']').toString()
}
}
}
///*
// * Pupil, Hitomi.la viewer for Android
// * Copyright (C) 2021 tom5079
// *
// * This program is free software: you can redistribute it and/or modify
// * it under the terms of the GNU General Public License as published by
// * the Free Software Foundation, either version 3 of the License, or
// * (at your option) any later version.
// *
// * This program is distributed in the hope that it will be useful,
// * but WITHOUT ANY WARRANTY; without even the implied warranty of
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// * GNU General Public License for more details.
// *
// * You should have received a copy of the GNU General Public License
// * along with this program. If not, see <https://www.gnu.org/licenses/>.
// */
//
//package xyz.quaver.pupil.sources
//
//import android.app.Application
//import android.os.Parcelable
//import androidx.compose.foundation.Image
//import androidx.compose.foundation.clickable
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.shape.CircleShape
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.material.*
//import androidx.compose.material.icons.Icons
//import androidx.compose.material.icons.filled.Female
//import androidx.compose.material.icons.filled.Male
//import androidx.compose.material.icons.filled.Star
//import androidx.compose.material.icons.filled.StarOutline
//import androidx.compose.material.icons.outlined.StarOutline
//import androidx.compose.runtime.*
//import androidx.compose.runtime.livedata.observeAsState
//import androidx.compose.ui.Alignment
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.draw.clip
//import androidx.compose.ui.geometry.Size
//import androidx.compose.ui.graphics.Color
//import androidx.compose.ui.layout.ContentScale
//import androidx.compose.ui.res.stringResource
//import androidx.compose.ui.unit.dp
//import coil.annotation.ExperimentalCoilApi
//import coil.compose.rememberImagePainter
//import com.google.accompanist.flowlayout.FlowRow
//import io.ktor.client.*
//import io.ktor.client.request.*
//import io.ktor.http.*
//import kotlinx.coroutines.*
//import kotlinx.coroutines.channels.Channel
//import kotlinx.parcelize.Parcelize
//import kotlinx.serialization.Serializable
//import kotlinx.serialization.json.JsonObject
//import kotlinx.serialization.json.int
//import kotlinx.serialization.json.jsonArray
//import kotlinx.serialization.json.jsonPrimitive
//import org.kodein.di.DIAware
//import org.kodein.di.android.closestDI
//import org.kodein.di.instance
//import org.kodein.log.LoggerFactory
//import org.kodein.log.newLogger
//import xyz.quaver.pupil.R
//import xyz.quaver.pupil.db.AppDatabase
//import xyz.quaver.pupil.db.Bookmark
//import xyz.quaver.pupil.ui.theme.Blue700
//import xyz.quaver.pupil.ui.theme.Orange500
//import xyz.quaver.pupil.ui.theme.Pink600
//import xyz.quaver.pupil.util.content
//import xyz.quaver.pupil.util.get
//import xyz.quaver.pupil.util.wordCapitalize
//
//@Serializable
//@Parcelize
//data class Tag(
// val male: Int?,
// val female: Int?,
// val tag: String
//) : Parcelable {
// override fun toString(): String {
// val stringBuilder = StringBuilder()
//
// stringBuilder.append(when {
// male != null -> "male"
// female != null -> "female"
// else -> "tag"
// })
// stringBuilder.append(':')
// stringBuilder.append(tag)
//
// return stringBuilder.toString()
// }
//}
//
//@Serializable
//@Parcelize
//data class HiyobiItemInfo(
// override val itemID: String,
// override val title: String,
// val thumbnail: String,
// val artists: List<String>,
// val series: List<String>,
// val type: String,
// val date: String,
// val bookmark: Unit?,
// val tags: List<Tag>,
// val commentCount: Int,
// val pageCount: Int
//): ItemInfo {
// override val source: String
// get() = "hiyobi.io"
//}
//
//@Serializable
//data class Manga(
// val mangaId: Int,
// val title: String,
// val artist: List<String>,
// val thumbnail: String,
// val series: List<String>,
// val type: String,
// val date: String,
// val bookmark: Unit?,
// val tags: List<Tag>,
// val commentCount: Int,
// val pageCount: Int
//)
//
//@Serializable
//data class QueryManga(
// val nowPage: Int,
// val maxPage: Int,
// val manga: List<Manga>
//)
//
//@Serializable
//data class SearchResultData(
// val queryManga: QueryManga
//)
//
//@Serializable
//data class SearchResult(
// val data: SearchResultData
//)
//
//class Hiyobi_io(app: Application): Source(), DIAware {
// override val di by closestDI(app)
//
// private val logger = newLogger(LoggerFactory.default)
//
// private val database: AppDatabase by instance()
// private val bookmarkDao = database.bookmarkDao()
//
// override val name = "hiyobi.io"
// override val iconResID = R.drawable.hitomi
// override val availableSortMode = emptyList<String>()
//
// private val client: HttpClient by instance()
//
// private suspend fun query(page: Int, tags: String): SearchResult {
// val query = "{queryManga(page:$page,tags:$tags){nowPage maxPage manga{mangaId title artist thumbnail series type date bookmark tags{male female tag} commentCount pageCount}}}"
//
// return client.get("https://api.hiyobi.io/api?query=$query")
// }
//
// private suspend fun totalCount(tags: String): Int {
// val firstPageQuery = "{queryManga(page:1,tags:$tags){maxPage}}"
// val maxPage = client.get<JsonObject>(
// "https://api.hiyobi.io/api?query=$firstPageQuery"
// )["data"]!!["queryManga"]!!["maxPage"]!!.jsonPrimitive.int
//
// val lastPageQuery = "{queryManga(page:$maxPage,tags:$tags){manga{mangaId}}}"
// val lastPageCount = client.get<JsonObject>(
// "https://api.hiyobi.io/api?query=$lastPageQuery"
// )["data"]!!["queryManga"]!!["manga"]!!.jsonArray.size
//
// return (maxPage-1)*25+lastPageCount
// }
//
// override suspend fun search(query: String, page: Int, sortMode: Int): Pair<Channel<ItemInfo>, Int> = withContext(Dispatchers.IO) {
// val channel = Channel<ItemInfo>()
//
// val tags = parseQuery(query)
//
// logger.info {
// tags
// }
//
// CoroutineScope(Dispatchers.IO).launch {
// (range.first/25+1 .. range.last/25+1).map { page ->
// page to async { query(page, tags) }
// }.forEach { (page, result) ->
// result.await().data.queryManga.manga.forEachIndexed { index, manga ->
// if ((page-1)*25+index in range) channel.send(transform(manga))
// }
// }
//
// channel.close()
// }
//
// channel to totalCount(tags)
// }
//
// override suspend fun images(itemID: String): List<String> = withContext(Dispatchers.IO) {
// val query = "{getManga(mangaId:$itemID){urls}}"
//
// client.post<JsonObject>("https://api.hiyobi.io/api") {
// contentType(ContentType.Application.Json)
// body = mapOf("query" to query)
// }["data"]!!["getManga"]!!["urls"]!!.jsonArray.map { "https://api.hiyobi.io/${it.content!!}" }
// }
//
// override suspend fun info(itemID: String): ItemInfo {
// TODO("Not yet implemented")
// }
//
// @OptIn(ExperimentalMaterialApi::class)
// @Composable
// fun TagChip(tag: Tag, isFavorite: Boolean, onClick: ((Tag) -> Unit)? = null, onFavoriteClick: ((Tag) -> Unit)? = null) {
// val icon = when {
// tag.male != null -> Icons.Filled.Male
// tag.female != null -> Icons.Filled.Female
// else -> null
// }
//
// val (surfaceColor, textTint) = when {
// isFavorite -> Pair(Orange500, Color.White)
// else -> when {
// tag.male != null -> Pair(Blue700, Color.White)
// tag.female != null -> Pair(Pink600, Color.White)
// else -> Pair(MaterialTheme.colors.background, MaterialTheme.colors.onBackground)
// }
// }
//
// val starIcon = if (isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline
//
// Surface(
// modifier = Modifier.padding(2.dp),
// onClick = { onClick?.invoke(tag) },
// shape = RoundedCornerShape(16.dp),
// color = surfaceColor,
// elevation = 2.dp
// ) {
// Row(
// verticalAlignment = Alignment.CenterVertically
// ) {
// if (icon != null)
// Icon(
// icon,
// contentDescription = "Icon",
// modifier = Modifier
// .padding(4.dp)
// .size(24.dp),
// tint = Color.White
// )
// else
// Box(Modifier.size(16.dp))
//
// Text(
// tag.tag,
// color = textTint,
// style = MaterialTheme.typography.body2
// )
//
// Icon(
// starIcon,
// contentDescription = "Favorites",
// modifier = Modifier
// .padding(8.dp)
// .size(16.dp)
// .clip(CircleShape)
// .clickable { onFavoriteClick?.invoke(tag) },
// tint = textTint
// )
// }
// }
// }
//
// @OptIn(ExperimentalMaterialApi::class)
// @Composable
// fun TagGroup(tags: List<Tag>) {
// var isFolded by remember { mutableStateOf(true) }
// val bookmarkedTags by bookmarkDao.getAll(name).observeAsState(emptyList())
//
// val bookmarkedTagsInList = tags.filter { it.toString() in bookmarkedTags }
//
// FlowRow(Modifier.padding(0.dp, 16.dp)) {
// tags.sortedBy { if (bookmarkedTagsInList.contains(it)) 0 else 1 }.let { (if (isFolded) it.take(10) else it) }.forEach { tag ->
// TagChip(
// tag = tag,
// isFavorite = bookmarkedTagsInList.contains(tag),
// onFavoriteClick = {
// val bookmarkTag = Bookmark(name, it.toString())
//
// CoroutineScope(Dispatchers.IO).launch {
// if (bookmarkedTagsInList.contains(it))
// bookmarkDao.delete(bookmarkTag)
// else
// bookmarkDao.insert(bookmarkTag)
// }
// }
// )
// }
//
// if (isFolded && tags.size > 10)
// Surface(
// modifier = Modifier.padding(2.dp),
// color = MaterialTheme.colors.background,
// shape = RoundedCornerShape(16.dp),
// elevation = 2.dp,
// onClick = { isFolded = false }
// ) {
// Text(
// "…",
// modifier = Modifier.padding(16.dp, 8.dp),
// color = MaterialTheme.colors.onBackground,
// style = MaterialTheme.typography.body2
// )
// }
// }
// }
//
// @OptIn(ExperimentalCoilApi::class)
// @Composable
// override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
// itemInfo as HiyobiItemInfo
//
// val bookmark by bookmarkDao.contains(itemInfo).observeAsState(false)
//
// val painter = rememberImagePainter(itemInfo.thumbnail)
//
// Column(
// modifier = Modifier.clickable {
// onEvent(SearchResultEvent(SearchResultEvent.Type.OPEN_READER, itemInfo.itemID, itemInfo))
// }
// ) {
// Row {
// Image(
// painter = painter,
// contentDescription = null,
// modifier = Modifier
// .requiredWidth(150.dp)
// .aspectRatio(
// with(painter.intrinsicSize) { if (this == Size.Unspecified) 1f else width / height },
// true
// )
// .padding(0.dp, 0.dp, 8.dp, 0.dp)
// .align(Alignment.CenterVertically),
// contentScale = ContentScale.FillWidth
// )
//
// Column {
// Text(
// itemInfo.title,
// style = MaterialTheme.typography.h6,
// color = MaterialTheme.colors.onSurface
// )
//
// val artistStringBuilder = StringBuilder()
//
// with(itemInfo.artists) {
// if (this.isNotEmpty())
// artistStringBuilder.append(this.joinToString(", ") { it.wordCapitalize() })
// }
//
// if (artistStringBuilder.isNotEmpty())
// Text(
// artistStringBuilder.toString(),
// style = MaterialTheme.typography.subtitle1,
// color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
// )
//
// if (itemInfo.series.isNotEmpty())
// Text(
// stringResource(
// id = R.string.galleryblock_series,
// itemInfo.series.joinToString { it.wordCapitalize() }
// ),
// style = MaterialTheme.typography.body2,
// color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
// )
//
// Text(
// stringResource(id = R.string.galleryblock_type, itemInfo.type),
// style = MaterialTheme.typography.body2,
// color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
// )
//
// key(itemInfo.tags) {
// TagGroup(tags = itemInfo.tags)
// }
// }
// }
//
// Divider(
// thickness = 1.dp,
// modifier = Modifier.padding(0.dp, 8.dp, 0.dp, 0.dp)
// )
//
// Row(
// modifier = Modifier.padding(8.dp).fillMaxWidth(),
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.SpaceBetween
// ) {
// Text(itemInfo.itemID)
//
// Text("${itemInfo.pageCount}P")
//
// Icon(
// if (bookmark) Icons.Default.Star else Icons.Default.StarOutline,
// contentDescription = null,
// tint = Orange500,
// modifier = Modifier
// .size(32.dp)
// .clickable {
// CoroutineScope(Dispatchers.IO).launch {
// if (bookmark) bookmarkDao.delete(itemInfo)
// else bookmarkDao.insert(itemInfo)
// }
// }
// )
// }
// }
// }
//
// companion object {
// private fun transform(manga: Manga) = HiyobiItemInfo(
// manga.mangaId.toString(),
// manga.title,
// "https://api.hiyobi.io/${manga.thumbnail}",
// manga.artist,
// manga.series,
// manga.type,
// manga.date,
// manga.bookmark,
// manga.tags,
// manga.commentCount,
// manga.pageCount
// )
//
// fun parseQuery(query: String): String {
// val queryBuilder = StringBuilder("[")
//
// if (query.isNotBlank())
// query.split(' ').filter { it.isNotBlank() }.forEach {
// val tags = it.replace('_', ' ').split(':', limit = 2)
//
// if (queryBuilder.length != 1) queryBuilder.append(',')
//
// queryBuilder.append(
// when {
// tags.size == 1 -> "{tag:\"${tags[0]}\"}"
// tags[0] == "male" -> "{male:1,tag:\"${tags[1]}\"}"
// tags[0] == "female" -> "{female:1,tag:\"${tags[1]}\"}"
// else -> "{tag:\"${tags[1]}\"}"
// }
// )
// }
//
// return queryBuilder.append(']').toString()
// }
// }
//
//}

View File

@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.composable
package xyz.quaver.pupil.sources.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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 <T> ListSearchResult(searchResults: List<T>, 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)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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);

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String?>(null)
var imageCount by mutableStateOf(0)
private var images: List<String>? = null
val imageList = mutableStateListOf<Uri?>()
val progressList = mutableStateListOf<Float>()
@OptIn(ExperimentalCoroutinesApi::class)
fun load(urls: List<String>, 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<ImageSource?>() }
val states = remember { mutableStateListOf<SubSampledImageState>() }
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)
)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.composable
import android.app.Application
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.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<T>(app: Application) : AndroidViewModel(app) {
val searchResults = mutableStateListOf<T>()
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 <T> SearchBase(
model: SearchBaseViewModel<T> = viewModel(),
fabSubMenu: List<SubFabItem> = 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() }
)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.hitomi
import kotlinx.serialization.Serializable
@Serializable
data class HitomiSearchResult(
val itemID: String,
val title: String,
val thumbnail: String,
val artists: List<String>,
val series: List<String>,
val type: String,
val language: String,
val tags: List<String>
)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<HitomiSearchResult>(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
)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources.hitomi.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Female
import androidx.compose.material.icons.filled.Male
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarOutline
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import com.google.accompanist.flowlayout.FlowRow
import xyz.quaver.pupil.R
import xyz.quaver.pupil.sources.hitomi.HitomiSearchResult
import xyz.quaver.pupil.ui.theme.Blue700
import xyz.quaver.pupil.ui.theme.Orange500
import xyz.quaver.pupil.ui.theme.Pink600
private val languageMap = mapOf(
"indonesian" to "Bahasa Indonesia",
"catalan" to "català",
"cebuano" to "Cebuano",
"czech" to "Čeština",
"danish" to "Dansk",
"german" to "Deutsch",
"estonian" to "eesti",
"english" to "English",
"spanish" to "Español",
"esperanto" to "Esperanto",
"french" to "Français",
"italian" to "Italiano",
"latin" to "Latina",
"hungarian" to "magyar",
"dutch" to "Nederlands",
"norwegian" to "norsk",
"polish" to "polski",
"portuguese" to "Português",
"romanian" to "română",
"albanian" to "shqip",
"slovak" to "Slovenčina",
"finnish" to "Suomi",
"swedish" to "Svenska",
"tagalog" to "Tagalog",
"vietnamese" to "tiếng việt",
"turkish" to "Türkçe",
"greek" to "Ελληνικά",
"mongolian" to "Монгол",
"russian" to "Русский",
"ukrainian" to "Українська",
"hebrew" to "עברית",
"arabic" to "العربية",
"persian" to "فارسی",
"thai" to "ไทย",
"korean" to "한국어",
"chinese" to "中文",
"japanese" to "日本語"
)
private fun String.wordCapitalize() : String {
val result = ArrayList<String>()
for (word in this.split(" "))
result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() })
return result.joinToString(" ")
}
@Composable
fun DetailedSearchResult(
result: HitomiSearchResult,
bookmarks: Set<String>,
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<String>,
bookmarks: Set<String>,
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
)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> = emptyList()
override val iconResID: Int = R.drawable.manatoki
override suspend fun search(
query: String,
range: IntRange,
sortMode: Int
): Pair<Channel<ItemInfo>, Int> {
TODO("Not yet implemented")
}
override suspend fun images(itemID: String): List<String> = 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
)
}
}
///*
// * Pupil, Hitomi.la viewer for Android
// * Copyright (C) 2021 tom5079
// *
// * This program is free software: you can redistribute it and/or modify
// * it under the terms of the GNU General Public License as published by
// * the Free Software Foundation, either version 3 of the License, or
// * (at your option) any later version.
// *
// * This program is distributed in the hope that it will be useful,
// * but WITHOUT ANY WARRANTY; without even the implied warranty of
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// * GNU General Public License for more details.
// *
// * You should have received a copy of the GNU General Public License
// * along with this program. If not, see <https://www.gnu.org/licenses/>.
// */
//
//package xyz.quaver.pupil.sources.manatoki
//
//import android.app.Application
//import 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<String> = emptyList()
// override val iconResID: Int = R.drawable.manatoki
//
// override suspend fun search(
// query: String,
// range: IntRange,
// sortMode: Int
// ): Pair<Channel<ItemInfo>, Int> {
// TODO("Not yet implemented")
// }
//
// override suspend fun images(itemID: String): List<String> = 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
// )
// }
//
//}

View File

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

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<ImageSource?>() }
val states = remember { mutableStateListOf<SubSampledImageState>() }
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()
}
}
}

View File

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

View File

@@ -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<ItemInfo>()
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<String>()
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<String, History>(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
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Boolean>
private set
var error by mutableStateOf(false)
private set
var source by mutableStateOf<Source?>(null)
private set
var itemID by mutableStateOf<String?>(null)
private set
var title by mutableStateOf<String?>(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<String>? = null
val imageList = mutableStateListOf<Uri?>()
val progressList = mutableStateListOf<Float>()
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<ItemInfo>("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) }
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String>("download_folder"))
}.getOrElse {
Preferences["download_folder"] = defaultDownloadFolder.uri.toString()
defaultDownloadFolder
}
private var prevDownloadFolder: FileX? = null
private var downloadFolderMapInstance: MutableMap<String, String>? = null
private val downloadFolderMap: MutableMap<String, String>
@Synchronized
get() {
if (prevDownloadFolder != downloadFolder) {
prevDownloadFolder = downloadFolder
downloadFolderMapInstance = run {
val file = downloadFolder.getChild(".download")
val data = if (file.exists())
kotlin.runCatching {
file.readText()?.let<String, MutableMap<String, String>> { Json.decodeFromString(it) }
}.onFailure { file.delete() }.getOrNull()
else
null
data ?: run {
file.createNewFile()
mutableMapOf()
}
}
}
return downloadFolderMapInstance ?: mutableMapOf()
}
val downloads: Map<String, String>
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))
}
}
}
}

View File

@@ -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<String>()
@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, ItemInfo.() -> (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<MenuItem>.findMenu(itemID: Int): MenuItem? {
return firstOrNull { it.itemId == itemID }
}
fun <E> MutableLiveData<MutableList<E>>.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<SourceEntries>().toMap()[source]!!

View File

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