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> </deviceKey>
</Target> </Target>
</targetSelectedWithDropDown> </targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2021-12-18T04:42:03.889339Z" /> <timeTargetWasSelectedWithDropDown value="2021-12-18T09:43:21.798655Z" />
</component> </component>
</project> </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-1639538998660.xml" value="0.30277777777777776" />
<entry key="../../../../layout/compose-model-1639625734547.xml" value="0.1" /> <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-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="../../../../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/avd_star.xml" value="0.2722222222222222" />
<entry key="app/src/main/res/drawable/close.xml" value="0.31614583333333335" /> <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.runtime:runtime-livedata:1.0.5")
implementation("androidx.compose.ui:ui-util:1.0.5") implementation("androidx.compose.ui:ui-util:1.0.5")
implementation("androidx.activity:activity-compose:1.4.0") 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-flowlayout:0.20.3")
implementation("com.google.accompanist:accompanist-appcompat-theme:0.20.3") implementation("com.google.accompanist:accompanist-appcompat-theme:0.20.3")

View File

@@ -46,166 +46,6 @@
</intent-filter> </intent-filter>
</receiver> </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 <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize" android:configChanges="keyboardHidden|orientation|screenSize"

View File

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

View File

@@ -2,7 +2,6 @@ package xyz.quaver.pupil.db
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.room.* import androidx.room.*
import xyz.quaver.pupil.sources.ItemInfo
@Entity(primaryKeys = ["source", "itemID"]) @Entity(primaryKeys = ["source", "itemID"])
data class Bookmark( data class Bookmark(
@@ -21,19 +20,15 @@ interface BookmarkDao {
@Query("SELECT EXISTS(SELECT * FROM bookmark WHERE source = :source AND itemID = :itemID)") @Query("SELECT EXISTS(SELECT * FROM bookmark WHERE source = :source AND itemID = :itemID)")
fun contains(source: String, itemID: String): LiveData<Boolean> fun contains(source: String, itemID: String): LiveData<Boolean>
fun contains(bookmark: Bookmark) = contains(bookmark.source, bookmark.itemID) fun contains(bookmark: Bookmark) = contains(bookmark.source, bookmark.itemID)
fun contains(itemInfo: ItemInfo) = contains(itemInfo.source, itemInfo.itemID)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(bookmark: Bookmark) suspend fun insert(bookmark: Bookmark)
suspend fun insert(source: String, itemID: String) = insert(Bookmark(source, itemID)) suspend fun insert(source: String, itemID: String) = insert(Bookmark(source, itemID))
suspend fun insert(itemInfo: ItemInfo) = insert(Bookmark(itemInfo.source, itemInfo.itemID))
@Delete @Delete
suspend fun delete(bookmark: Bookmark) suspend fun delete(bookmark: Bookmark)
suspend fun delete(source: String, itemID: String) = delete(Bookmark(source, itemID)) 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 package xyz.quaver.pupil.sources
import android.app.Application import android.app.Application
import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.ktor.http.* import androidx.navigation.NavController
import kotlinx.coroutines.channels.Channel
import org.kodein.di.* import org.kodein.di.*
import xyz.quaver.pupil.sources.manatoki.Manatoki import xyz.quaver.pupil.sources.hitomi.Hitomi
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
}
}
abstract class Source { abstract class Source {
abstract val name: String abstract val name: String
abstract val iconResID: Int 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 @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> typealias SourceEntry = Pair<String, Source>
@@ -62,12 +45,12 @@ val sourceModule = DI.Module(name = "source") {
listOf<(Application) -> (Source)>( listOf<(Application) -> (Source)>(
{ Hitomi(it) }, { Hitomi(it) },
{ Hiyobi_io(it) }, //{ Hiyobi_io(it) },
{ Manatoki(it) } //{ Manatoki(it) }
).forEach { source -> ).forEach { source ->
inSet { singleton { source(instance()).let { it.name to it } } } 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 } } } // inSet { singleton { Downloads(di).let { it.name to it as Source } } }
} }

View File

@@ -18,20 +18,6 @@
package xyz.quaver.pupil.sources 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 { class Downloads(override val di: DI) : Source(), DIAware {

View File

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

View File

@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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.background
import androidx.compose.foundation.clickable 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.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -16,11 +32,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier.Companion.any
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector 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.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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) { enum class FloatingActionButtonState(private val isExpanded: Boolean) {
COLLAPSED(false), EXPANDED(true); 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 // * Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079 // * Copyright (C) 2021 tom5079
* // *
* This program is free software: you can redistribute it and/or modify // * 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 // * it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or // * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. // * (at your option) any later version.
* // *
* This program is distributed in the hope that it will be useful, // * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of // * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details. // * GNU General Public License for more details.
* // *
* You should have received a copy of the GNU General Public License // * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. // * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ // */
//
package xyz.quaver.pupil.sources.manatoki //package xyz.quaver.pupil.sources.manatoki
//
import android.app.Application //import android.app.Application
import kotlinx.coroutines.Dispatchers //import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel //import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope //import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext //import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize //import kotlinx.parcelize.Parcelize
import org.jsoup.Jsoup //import org.jsoup.Jsoup
import org.kodein.di.DIAware //import org.kodein.di.DIAware
import org.kodein.di.android.closestDI //import org.kodein.di.android.closestDI
import org.kodein.log.LoggerFactory //import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger //import org.kodein.log.newLogger
import xyz.quaver.pupil.R //import xyz.quaver.pupil.R
import xyz.quaver.pupil.sources.ItemInfo //import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.Source //import xyz.quaver.pupil.sources.Source
//
@Parcelize //@Parcelize
class ManatokiItemInfo( //class ManatokiItemInfo(
override val itemID: String, // override val itemID: String,
override val title: String // override val title: String
) : ItemInfo { //) : ItemInfo {
override val source: String = "manatoki.net" // override val source: String = "manatoki.net"
} //}
//
class Manatoki(app: Application) : Source(), DIAware { //class Manatoki(app: Application) : Source(), DIAware {
override val di by closestDI(app) // override val di by closestDI(app)
//
private val logger = newLogger(LoggerFactory.default) // private val logger = newLogger(LoggerFactory.default)
//
override val name = "manatoki.net" // override val name = "manatoki.net"
override val availableSortMode: List<String> = emptyList() // override val availableSortMode: List<String> = emptyList()
override val iconResID: Int = R.drawable.manatoki // override val iconResID: Int = R.drawable.manatoki
//
override suspend fun search( // override suspend fun search(
query: String, // query: String,
range: IntRange, // range: IntRange,
sortMode: Int // sortMode: Int
): Pair<Channel<ItemInfo>, Int> { // ): Pair<Channel<ItemInfo>, Int> {
TODO("Not yet implemented") // TODO("Not yet implemented")
} // }
//
override suspend fun images(itemID: String): List<String> = coroutineScope { // override suspend fun images(itemID: String): List<String> = coroutineScope {
val jsoup = withContext(Dispatchers.IO) { // val jsoup = withContext(Dispatchers.IO) {
Jsoup.connect("https://manatoki116.net/comic/$itemID").get() // Jsoup.connect("https://manatoki116.net/comic/$itemID").get()
} // }
//
val htmlData = jsoup // val htmlData = jsoup
.selectFirst(".view-padding > script")!! // .selectFirst(".view-padding > script")!!
.data() // .data()
.splitToSequence('\n') // .splitToSequence('\n')
.fold(StringBuilder()) { sb, line -> // .fold(StringBuilder()) { sb, line ->
if (!line.startsWith("html_data")) return@fold sb // if (!line.startsWith("html_data")) return@fold sb
//
line.drop(12).dropLast(2).split('.').forEach { // line.drop(12).dropLast(2).split('.').forEach {
if (it.isNotBlank()) sb.appendCodePoint(it.toInt(16)) // if (it.isNotBlank()) sb.appendCodePoint(it.toInt(16))
} // }
sb // sb
}.toString() // }.toString()
//
Jsoup.parse(htmlData) // Jsoup.parse(htmlData)
.select("img[^data-]:not([style])") // .select("img[^data-]:not([style])")
.map { // .map {
it.attributes() // it.attributes()
.first { it.key.startsWith("data-") } // .first { it.key.startsWith("data-") }
.value // .value
} // }
} // }
//
override suspend fun info(itemID: String): ItemInfo = coroutineScope { // override suspend fun info(itemID: String): ItemInfo = coroutineScope {
val jsoup = withContext(Dispatchers.IO) { // val jsoup = withContext(Dispatchers.IO) {
Jsoup.connect("https://manatoki116.net/comic/$itemID").get() // Jsoup.connect("https://manatoki116.net/comic/$itemID").get()
} // }
//
val title = jsoup.selectFirst(".toon-title")!!.ownText() // val title = jsoup.selectFirst(".toon-title")!!.ownText()
//
ManatokiItemInfo( // ManatokiItemInfo(
itemID, // itemID,
title // title
) // )
} // }
//
} //}

View File

@@ -18,60 +18,23 @@
package xyz.quaver.pupil.ui package xyz.quaver.pupil.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateFloat import androidx.navigation.compose.NavHost
import androidx.compose.animation.core.updateTransition import androidx.navigation.compose.composable
import androidx.compose.foundation.* import androidx.navigation.compose.rememberNavController
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 org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.android.closestDI import org.kodein.di.android.closestDI
import org.kodein.di.direct
import org.kodein.log.LoggerFactory import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger 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.theme.PupilTheme
import xyz.quaver.pupil.ui.viewmodel.MainViewModel import xyz.quaver.pupil.ui.viewmodel.MainViewModel
import xyz.quaver.pupil.util.* import xyz.quaver.pupil.util.source
import kotlin.math.*
private enum class NavigationIconState {
MENU,
ARROW
}
class MainActivity : ComponentActivity(), DIAware { class MainActivity : ComponentActivity(), DIAware {
override val di by closestDI() override val di by closestDI()
@@ -86,251 +49,22 @@ class MainActivity : ComponentActivity(), DIAware {
setContent { setContent {
PupilTheme { PupilTheme {
val focusManager = LocalFocusManager.current val navController = rememberNavController()
val maxPage by model.maxPage.collectAsState(0) NavHost(navController, startDestination = "main/{source}") {
composable("main/{source}") {
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() } direct.source(it.arguments?.getString("source") ?: "hitomi.la")
.MainScreen(navController)
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)
} }
if (openWithItemIDDialog) composable("search/{source}") {
OpenWithItemIDDialog { direct.source(it.arguments?.getString("source") ?: "hitomi.la")
openWithItemIDDialog = false .Search(navController)
it?.let {
onSearchResultEvent(SearchResultEvent(
SearchResultEvent.Type.OPEN_READER,
it
))
}
} }
Scaffold( composable("reader/{source}/{itemID}") {
floatingActionButton = { direct.source(it.arguments?.getString("source") ?: "hitomi.la")
MultipleFloatingActionButton( .Reader(navController)
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() }
)
} }
} }
} }

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 package xyz.quaver.pupil.ui.viewmodel
import android.app.Application 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.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.DIAware
import org.kodein.di.android.x.closestDI import org.kodein.di.android.x.closestDI
import org.kodein.di.direct import org.kodein.di.direct
import org.kodein.di.instance
import org.kodein.log.LoggerFactory import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger 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.sources.Source
import xyz.quaver.pupil.util.source import xyz.quaver.pupil.util.source
import kotlin.math.ceil
import kotlin.random.Random
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
class MainViewModel(app: Application) : AndroidViewModel(app), DIAware { class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
@@ -46,138 +37,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
private val logger = newLogger(LoggerFactory.default) 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 = { private val defaultSourceFactory: (String) -> Source = {
direct.source(it) direct.source(it)
} }
private var sourceFactory: (String) -> Source = defaultSourceFactory private var sourceFactory: (String) -> Source = defaultSourceFactory
var source by mutableStateOf(sourceFactory("hitomi.la")) 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 package xyz.quaver.pupil.util
import android.annotation.SuppressLint
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.view.MenuItem
import android.view.View 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.Rect
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toAndroidRect import androidx.compose.ui.graphics.toAndroidRect
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.DirectDIAware import org.kodein.di.DirectDIAware
@@ -40,71 +37,7 @@ import xyz.quaver.graphics.subsampledimage.newBitmapRegionDecoder
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.inputStream import xyz.quaver.io.util.inputStream
import xyz.quaver.pupil.db.AppDatabase import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.SourceEntries 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) = operator fun JsonElement.get(index: Int) =
this.jsonArray[index] this.jsonArray[index]
@@ -115,27 +48,6 @@ operator fun JsonElement.get(tag: String) =
val JsonElement.content val JsonElement.content
get() = this.jsonPrimitive.contentOrNull 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 DIAware.source(source: String) = lazy { direct.source(source) }
fun DirectDIAware.source(source: String) = instance<SourceEntries>().toMap()[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; option java_multiple_files = true;
message Settings { message Settings {
optional int32 results_per_page = 1 [default = 25];
} }