WIP
This commit is contained in:
2
.idea/deploymentTargetDropDown.xml
generated
2
.idea/deploymentTargetDropDown.xml
generated
@@ -12,6 +12,6 @@
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</targetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2021-12-18T04:42:03.889339Z" />
|
||||
<timeTargetWasSelectedWithDropDown value="2021-12-18T09:43:21.798655Z" />
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -30,6 +30,7 @@
|
||||
<entry key="../../../../layout/compose-model-1639538998660.xml" value="0.30277777777777776" />
|
||||
<entry key="../../../../layout/compose-model-1639625734547.xml" value="0.1" />
|
||||
<entry key="../../../../layout/compose-model-1639629588722.xml" value="0.3472222222222222" />
|
||||
<entry key="../../../../layout/compose-model-1639809297022.xml" value="0.1" />
|
||||
<entry key="../../../../layout/custom_preview.xml" value="0.518974358974359" />
|
||||
<entry key="app/src/main/res/drawable/avd_star.xml" value="0.2722222222222222" />
|
||||
<entry key="app/src/main/res/drawable/close.xml" value="0.31614583333333335" />
|
||||
|
||||
@@ -83,7 +83,7 @@ dependencies {
|
||||
implementation("androidx.compose.runtime:runtime-livedata:1.0.5")
|
||||
implementation("androidx.compose.ui:ui-util:1.0.5")
|
||||
implementation("androidx.activity:activity-compose:1.4.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.4.0-beta02")
|
||||
implementation("androidx.navigation:navigation-compose:2.4.0-rc01")
|
||||
|
||||
implementation("com.google.accompanist:accompanist-flowlayout:0.20.3")
|
||||
implementation("com.google.accompanist:accompanist-appcompat-theme:0.20.3")
|
||||
|
||||
@@ -46,166 +46,6 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".ui.ReaderActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/galleries"
|
||||
android:scheme="http" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/manga"
|
||||
android:scheme="http" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/doujinshi"
|
||||
android:scheme="http" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/cg"
|
||||
android:scheme="http" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/reader"
|
||||
android:scheme="http" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/galleries"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/manga"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/doujinshi"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/cg"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hitomi.la"
|
||||
android:pathPrefix="/reader"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hiyobi.me"
|
||||
android:scheme="http"
|
||||
android:pathPrefix="/reader" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="hiyobi.me"
|
||||
android:pathPrefix="/reader"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="e-hentai.org"
|
||||
android:pathPrefix="/g"
|
||||
android:scheme="http" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="e-hentai.org"
|
||||
android:pathPrefix="/g"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
|
||||
@@ -43,15 +43,11 @@ import io.ktor.client.features.json.serializer.*
|
||||
import okhttp3.Protocol
|
||||
import org.kodein.di.*
|
||||
import org.kodein.di.android.x.androidXModule
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.pupil.db.databaseModule
|
||||
import xyz.quaver.pupil.sources.sourceModule
|
||||
import xyz.quaver.pupil.util.*
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class Pupil : Application(), DIAware {
|
||||
|
||||
@@ -60,7 +56,6 @@ class Pupil : Application(), DIAware {
|
||||
import(databaseModule)
|
||||
import(sourceModule)
|
||||
|
||||
bind { singleton { DownloadManager(applicationContext) } }
|
||||
bind { singleton { NetworkCache(applicationContext) } }
|
||||
|
||||
bind { singleton {
|
||||
|
||||
@@ -2,7 +2,6 @@ package xyz.quaver.pupil.db
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.*
|
||||
import xyz.quaver.pupil.sources.ItemInfo
|
||||
|
||||
@Entity(primaryKeys = ["source", "itemID"])
|
||||
data class Bookmark(
|
||||
@@ -21,19 +20,15 @@ interface BookmarkDao {
|
||||
|
||||
@Query("SELECT EXISTS(SELECT * FROM bookmark WHERE source = :source AND itemID = :itemID)")
|
||||
fun contains(source: String, itemID: String): LiveData<Boolean>
|
||||
|
||||
fun contains(bookmark: Bookmark) = contains(bookmark.source, bookmark.itemID)
|
||||
fun contains(itemInfo: ItemInfo) = contains(itemInfo.source, itemInfo.itemID)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(bookmark: Bookmark)
|
||||
|
||||
suspend fun insert(source: String, itemID: String) = insert(Bookmark(source, itemID))
|
||||
suspend fun insert(itemInfo: ItemInfo) = insert(Bookmark(itemInfo.source, itemInfo.itemID))
|
||||
|
||||
@Delete
|
||||
suspend fun delete(bookmark: Bookmark)
|
||||
|
||||
suspend fun delete(source: String, itemID: String) = delete(Bookmark(source, itemID))
|
||||
suspend fun delete(itemInfo: ItemInfo) = delete(Bookmark(itemInfo.source, itemInfo.itemID))
|
||||
}
|
||||
@@ -19,40 +19,23 @@
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import androidx.navigation.NavController
|
||||
import org.kodein.di.*
|
||||
import xyz.quaver.pupil.sources.manatoki.Manatoki
|
||||
|
||||
interface ItemInfo : Parcelable {
|
||||
val source: String
|
||||
val itemID: String
|
||||
val title: String
|
||||
}
|
||||
|
||||
data class SearchResultEvent(val type: Type, val itemID: String, val payload: Parcelable? = null) {
|
||||
enum class Type {
|
||||
OPEN_READER,
|
||||
OPEN_DETAILS,
|
||||
NEW_QUERY
|
||||
}
|
||||
}
|
||||
import xyz.quaver.pupil.sources.hitomi.Hitomi
|
||||
|
||||
abstract class Source {
|
||||
abstract val name: String
|
||||
abstract val iconResID: Int
|
||||
abstract val availableSortMode: List<String>
|
||||
|
||||
abstract suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int>
|
||||
abstract suspend fun images(itemID: String): List<String>
|
||||
abstract suspend fun info(itemID: String): ItemInfo
|
||||
|
||||
@Composable
|
||||
open fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit = { }) { }
|
||||
open fun MainScreen(navController: NavController) { }
|
||||
|
||||
open fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = { }
|
||||
@Composable
|
||||
open fun Search(navController: NavController) { }
|
||||
|
||||
@Composable
|
||||
open fun Reader(navController: NavController) { }
|
||||
}
|
||||
|
||||
typealias SourceEntry = Pair<String, Source>
|
||||
@@ -62,12 +45,12 @@ val sourceModule = DI.Module(name = "source") {
|
||||
|
||||
listOf<(Application) -> (Source)>(
|
||||
{ Hitomi(it) },
|
||||
{ Hiyobi_io(it) },
|
||||
{ Manatoki(it) }
|
||||
//{ Hiyobi_io(it) },
|
||||
//{ Manatoki(it) }
|
||||
).forEach { source ->
|
||||
inSet { singleton { source(instance()).let { it.name to it } } }
|
||||
}
|
||||
|
||||
bind { singleton { History(di) } }
|
||||
//bind { singleton { History(di) } }
|
||||
// inSet { singleton { Downloads(di).let { it.name to it as Source } } }
|
||||
}
|
||||
@@ -18,20 +18,6 @@
|
||||
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.instance
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.io.util.getChild
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.util.DownloadManager
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
/*
|
||||
class Downloads(override val di: DI) : Source(), DIAware {
|
||||
|
||||
|
||||
@@ -18,53 +18,43 @@
|
||||
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.direct
|
||||
import xyz.quaver.pupil.util.database
|
||||
|
||||
class History(override val di: DI) : Source(), DIAware {
|
||||
|
||||
private val historyDao = direct.database().historyDao()
|
||||
|
||||
override val name: String
|
||||
get() = "history"
|
||||
override val iconResID: Int
|
||||
get() = 0 //TODO
|
||||
override val availableSortMode: List<String> = emptyList()
|
||||
|
||||
private val history = direct.database().historyDao()
|
||||
|
||||
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
|
||||
val channel = Channel<ItemInfo>()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
|
||||
|
||||
channel.close()
|
||||
}
|
||||
|
||||
throw NotImplementedError("")
|
||||
//return Pair(channel, histories.map.size)
|
||||
}
|
||||
|
||||
override suspend fun images(itemID: String): List<String> {
|
||||
throw NotImplementedError("")
|
||||
}
|
||||
|
||||
override suspend fun info(itemID: String): ItemInfo {
|
||||
throw NotImplementedError("")
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
//
|
||||
//class History(override val di: DI) : Source(), DIAware {
|
||||
// private val historyDao = direct.database().historyDao()
|
||||
//
|
||||
// override val name: String
|
||||
// get() = "history"
|
||||
// override val iconResID: Int
|
||||
// get() = 0 //TODO
|
||||
// override val availableSortMode: List<String> = emptyList()
|
||||
//
|
||||
// private val history = direct.database().historyDao()
|
||||
//
|
||||
// override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
|
||||
// val channel = Channel<ItemInfo>()
|
||||
//
|
||||
// CoroutineScope(Dispatchers.IO).launch {
|
||||
//
|
||||
//
|
||||
// channel.close()
|
||||
// }
|
||||
//
|
||||
// throw NotImplementedError("")
|
||||
// //return Pair(channel, histories.map.size)
|
||||
// }
|
||||
//
|
||||
// override suspend fun images(itemID: String): List<String> {
|
||||
// throw NotImplementedError("")
|
||||
// }
|
||||
//
|
||||
// override suspend fun info(itemID: String): ItemInfo {
|
||||
// throw NotImplementedError("")
|
||||
// }
|
||||
//
|
||||
//
|
||||
// @Composable
|
||||
// override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
|
||||
//
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,469 +1,465 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Female
|
||||
import androidx.compose.material.icons.filled.Male
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarOutline
|
||||
import androidx.compose.material.icons.outlined.StarOutline
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.rememberImagePainter
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.di.instance
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.db.AppDatabase
|
||||
import xyz.quaver.pupil.db.Bookmark
|
||||
import xyz.quaver.pupil.ui.theme.Blue700
|
||||
import xyz.quaver.pupil.ui.theme.Orange500
|
||||
import xyz.quaver.pupil.ui.theme.Pink600
|
||||
import xyz.quaver.pupil.util.content
|
||||
import xyz.quaver.pupil.util.get
|
||||
import xyz.quaver.pupil.util.wordCapitalize
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class Tag(
|
||||
val male: Int?,
|
||||
val female: Int?,
|
||||
val tag: String
|
||||
) : Parcelable {
|
||||
override fun toString(): String {
|
||||
val stringBuilder = StringBuilder()
|
||||
|
||||
stringBuilder.append(when {
|
||||
male != null -> "male"
|
||||
female != null -> "female"
|
||||
else -> "tag"
|
||||
})
|
||||
stringBuilder.append(':')
|
||||
stringBuilder.append(tag)
|
||||
|
||||
return stringBuilder.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class HiyobiItemInfo(
|
||||
override val itemID: String,
|
||||
override val title: String,
|
||||
val thumbnail: String,
|
||||
val artists: List<String>,
|
||||
val series: List<String>,
|
||||
val type: String,
|
||||
val date: String,
|
||||
val bookmark: Unit?,
|
||||
val tags: List<Tag>,
|
||||
val commentCount: Int,
|
||||
val pageCount: Int
|
||||
): ItemInfo {
|
||||
override val source: String
|
||||
get() = "hiyobi.io"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Manga(
|
||||
val mangaId: Int,
|
||||
val title: String,
|
||||
val artist: List<String>,
|
||||
val thumbnail: String,
|
||||
val series: List<String>,
|
||||
val type: String,
|
||||
val date: String,
|
||||
val bookmark: Unit?,
|
||||
val tags: List<Tag>,
|
||||
val commentCount: Int,
|
||||
val pageCount: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QueryManga(
|
||||
val nowPage: Int,
|
||||
val maxPage: Int,
|
||||
val manga: List<Manga>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResultData(
|
||||
val queryManga: QueryManga
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResult(
|
||||
val data: SearchResultData
|
||||
)
|
||||
|
||||
class Hiyobi_io(app: Application): Source(), DIAware {
|
||||
override val di by closestDI(app)
|
||||
|
||||
private val logger = newLogger(LoggerFactory.default)
|
||||
|
||||
private val database: AppDatabase by instance()
|
||||
private val bookmarkDao = database.bookmarkDao()
|
||||
|
||||
override val name = "hiyobi.io"
|
||||
override val iconResID = R.drawable.hitomi
|
||||
override val availableSortMode = emptyList<String>()
|
||||
|
||||
private val client: HttpClient by instance()
|
||||
|
||||
private suspend fun query(page: Int, tags: String): SearchResult {
|
||||
val query = "{queryManga(page:$page,tags:$tags){nowPage maxPage manga{mangaId title artist thumbnail series type date bookmark tags{male female tag} commentCount pageCount}}}"
|
||||
|
||||
return client.get("https://api.hiyobi.io/api?query=$query")
|
||||
}
|
||||
|
||||
private suspend fun totalCount(tags: String): Int {
|
||||
val firstPageQuery = "{queryManga(page:1,tags:$tags){maxPage}}"
|
||||
val maxPage = client.get<JsonObject>(
|
||||
"https://api.hiyobi.io/api?query=$firstPageQuery"
|
||||
)["data"]!!["queryManga"]!!["maxPage"]!!.jsonPrimitive.int
|
||||
|
||||
val lastPageQuery = "{queryManga(page:$maxPage,tags:$tags){manga{mangaId}}}"
|
||||
val lastPageCount = client.get<JsonObject>(
|
||||
"https://api.hiyobi.io/api?query=$lastPageQuery"
|
||||
)["data"]!!["queryManga"]!!["manga"]!!.jsonArray.size
|
||||
|
||||
return (maxPage-1)*25+lastPageCount
|
||||
}
|
||||
|
||||
override suspend fun search(
|
||||
query: String,
|
||||
range: IntRange,
|
||||
sortMode: Int
|
||||
): Pair<Channel<ItemInfo>, Int> = withContext(Dispatchers.IO) {
|
||||
val channel = Channel<ItemInfo>()
|
||||
|
||||
val tags = parseQuery(query)
|
||||
|
||||
logger.info {
|
||||
tags
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
(range.first/25+1 .. range.last/25+1).map { page ->
|
||||
page to async { query(page, tags) }
|
||||
}.forEach { (page, result) ->
|
||||
result.await().data.queryManga.manga.forEachIndexed { index, manga ->
|
||||
if ((page-1)*25+index in range) channel.send(transform(manga))
|
||||
}
|
||||
}
|
||||
|
||||
channel.close()
|
||||
}
|
||||
|
||||
channel to totalCount(tags)
|
||||
}
|
||||
|
||||
override suspend fun images(itemID: String): List<String> = withContext(Dispatchers.IO) {
|
||||
val query = "{getManga(mangaId:$itemID){urls}}"
|
||||
|
||||
client.post<JsonObject>("https://api.hiyobi.io/api") {
|
||||
contentType(ContentType.Application.Json)
|
||||
body = mapOf("query" to query)
|
||||
}["data"]!!["getManga"]!!["urls"]!!.jsonArray.map { "https://api.hiyobi.io/${it.content!!}" }
|
||||
}
|
||||
|
||||
override suspend fun info(itemID: String): ItemInfo {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun TagChip(tag: Tag, isFavorite: Boolean, onClick: ((Tag) -> Unit)? = null, onFavoriteClick: ((Tag) -> Unit)? = null) {
|
||||
val icon = when {
|
||||
tag.male != null -> Icons.Filled.Male
|
||||
tag.female != null -> Icons.Filled.Female
|
||||
else -> null
|
||||
}
|
||||
|
||||
val (surfaceColor, textTint) = when {
|
||||
isFavorite -> Pair(Orange500, Color.White)
|
||||
else -> when {
|
||||
tag.male != null -> Pair(Blue700, Color.White)
|
||||
tag.female != null -> Pair(Pink600, Color.White)
|
||||
else -> Pair(MaterialTheme.colors.background, MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
val starIcon = if (isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
onClick = { onClick?.invoke(tag) },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = surfaceColor,
|
||||
elevation = 2.dp
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (icon != null)
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = "Icon",
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(24.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
else
|
||||
Box(Modifier.size(16.dp))
|
||||
|
||||
Text(
|
||||
tag.tag,
|
||||
color = textTint,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
|
||||
Icon(
|
||||
starIcon,
|
||||
contentDescription = "Favorites",
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { onFavoriteClick?.invoke(tag) },
|
||||
tint = textTint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun TagGroup(tags: List<Tag>) {
|
||||
var isFolded by remember { mutableStateOf(true) }
|
||||
val bookmarkedTags by bookmarkDao.getAll(name).observeAsState(emptyList())
|
||||
|
||||
val bookmarkedTagsInList = tags.filter { it.toString() in bookmarkedTags }
|
||||
|
||||
FlowRow(Modifier.padding(0.dp, 16.dp)) {
|
||||
tags.sortedBy { if (bookmarkedTagsInList.contains(it)) 0 else 1 }.let { (if (isFolded) it.take(10) else it) }.forEach { tag ->
|
||||
TagChip(
|
||||
tag = tag,
|
||||
isFavorite = bookmarkedTagsInList.contains(tag),
|
||||
onFavoriteClick = {
|
||||
val bookmarkTag = Bookmark(name, it.toString())
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
if (bookmarkedTagsInList.contains(it))
|
||||
bookmarkDao.delete(bookmarkTag)
|
||||
else
|
||||
bookmarkDao.insert(bookmarkTag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isFolded && tags.size > 10)
|
||||
Surface(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
color = MaterialTheme.colors.background,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
elevation = 2.dp,
|
||||
onClick = { isFolded = false }
|
||||
) {
|
||||
Text(
|
||||
"…",
|
||||
modifier = Modifier.padding(16.dp, 8.dp),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
|
||||
itemInfo as HiyobiItemInfo
|
||||
|
||||
val bookmark by bookmarkDao.contains(itemInfo).observeAsState(false)
|
||||
|
||||
val painter = rememberImagePainter(itemInfo.thumbnail)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.clickable {
|
||||
onEvent(SearchResultEvent(SearchResultEvent.Type.OPEN_READER, itemInfo.itemID, itemInfo))
|
||||
}
|
||||
) {
|
||||
Row {
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.requiredWidth(150.dp)
|
||||
.aspectRatio(
|
||||
with(painter.intrinsicSize) { if (this == Size.Unspecified) 1f else width / height },
|
||||
true
|
||||
)
|
||||
.padding(0.dp, 0.dp, 8.dp, 0.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
itemInfo.title,
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onSurface
|
||||
)
|
||||
|
||||
val artistStringBuilder = StringBuilder()
|
||||
|
||||
with(itemInfo.artists) {
|
||||
if (this.isNotEmpty())
|
||||
artistStringBuilder.append(this.joinToString(", ") { it.wordCapitalize() })
|
||||
}
|
||||
|
||||
if (artistStringBuilder.isNotEmpty())
|
||||
Text(
|
||||
artistStringBuilder.toString(),
|
||||
style = MaterialTheme.typography.subtitle1,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
|
||||
)
|
||||
|
||||
if (itemInfo.series.isNotEmpty())
|
||||
Text(
|
||||
stringResource(
|
||||
id = R.string.galleryblock_series,
|
||||
itemInfo.series.joinToString { it.wordCapitalize() }
|
||||
),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(id = R.string.galleryblock_type, itemInfo.type),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
|
||||
)
|
||||
|
||||
key(itemInfo.tags) {
|
||||
TagGroup(tags = itemInfo.tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier.padding(0.dp, 8.dp, 0.dp, 0.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(8.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(itemInfo.itemID)
|
||||
|
||||
Text("${itemInfo.pageCount}P")
|
||||
|
||||
Icon(
|
||||
if (bookmark) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
contentDescription = null,
|
||||
tint = Orange500,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clickable {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
if (bookmark) bookmarkDao.delete(itemInfo)
|
||||
else bookmarkDao.insert(itemInfo)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun transform(manga: Manga) = HiyobiItemInfo(
|
||||
manga.mangaId.toString(),
|
||||
manga.title,
|
||||
"https://api.hiyobi.io/${manga.thumbnail}",
|
||||
manga.artist,
|
||||
manga.series,
|
||||
manga.type,
|
||||
manga.date,
|
||||
manga.bookmark,
|
||||
manga.tags,
|
||||
manga.commentCount,
|
||||
manga.pageCount
|
||||
)
|
||||
|
||||
fun parseQuery(query: String): String {
|
||||
val queryBuilder = StringBuilder("[")
|
||||
|
||||
if (query.isNotBlank())
|
||||
query.split(' ').filter { it.isNotBlank() }.forEach {
|
||||
val tags = it.replace('_', ' ').split(':', limit = 2)
|
||||
|
||||
if (queryBuilder.length != 1) queryBuilder.append(',')
|
||||
|
||||
queryBuilder.append(
|
||||
when {
|
||||
tags.size == 1 -> "{tag:\"${tags[0]}\"}"
|
||||
tags[0] == "male" -> "{male:1,tag:\"${tags[1]}\"}"
|
||||
tags[0] == "female" -> "{female:1,tag:\"${tags[1]}\"}"
|
||||
else -> "{tag:\"${tags[1]}\"}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return queryBuilder.append(']').toString()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
///*
|
||||
// * Pupil, Hitomi.la viewer for Android
|
||||
// * Copyright (C) 2021 tom5079
|
||||
// *
|
||||
// * This program is free software: you can redistribute it and/or modify
|
||||
// * it under the terms of the GNU General Public License as published by
|
||||
// * the Free Software Foundation, either version 3 of the License, or
|
||||
// * (at your option) any later version.
|
||||
// *
|
||||
// * This program is distributed in the hope that it will be useful,
|
||||
// * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// * GNU General Public License for more details.
|
||||
// *
|
||||
// * You should have received a copy of the GNU General Public License
|
||||
// * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
// */
|
||||
//
|
||||
//package xyz.quaver.pupil.sources
|
||||
//
|
||||
//import android.app.Application
|
||||
//import android.os.Parcelable
|
||||
//import androidx.compose.foundation.Image
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.shape.CircleShape
|
||||
//import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.material.icons.Icons
|
||||
//import androidx.compose.material.icons.filled.Female
|
||||
//import androidx.compose.material.icons.filled.Male
|
||||
//import androidx.compose.material.icons.filled.Star
|
||||
//import androidx.compose.material.icons.filled.StarOutline
|
||||
//import androidx.compose.material.icons.outlined.StarOutline
|
||||
//import androidx.compose.runtime.*
|
||||
//import androidx.compose.runtime.livedata.observeAsState
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.draw.clip
|
||||
//import androidx.compose.ui.geometry.Size
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.layout.ContentScale
|
||||
//import androidx.compose.ui.res.stringResource
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import coil.annotation.ExperimentalCoilApi
|
||||
//import coil.compose.rememberImagePainter
|
||||
//import com.google.accompanist.flowlayout.FlowRow
|
||||
//import io.ktor.client.*
|
||||
//import io.ktor.client.request.*
|
||||
//import io.ktor.http.*
|
||||
//import kotlinx.coroutines.*
|
||||
//import kotlinx.coroutines.channels.Channel
|
||||
//import kotlinx.parcelize.Parcelize
|
||||
//import kotlinx.serialization.Serializable
|
||||
//import kotlinx.serialization.json.JsonObject
|
||||
//import kotlinx.serialization.json.int
|
||||
//import kotlinx.serialization.json.jsonArray
|
||||
//import kotlinx.serialization.json.jsonPrimitive
|
||||
//import org.kodein.di.DIAware
|
||||
//import org.kodein.di.android.closestDI
|
||||
//import org.kodein.di.instance
|
||||
//import org.kodein.log.LoggerFactory
|
||||
//import org.kodein.log.newLogger
|
||||
//import xyz.quaver.pupil.R
|
||||
//import xyz.quaver.pupil.db.AppDatabase
|
||||
//import xyz.quaver.pupil.db.Bookmark
|
||||
//import xyz.quaver.pupil.ui.theme.Blue700
|
||||
//import xyz.quaver.pupil.ui.theme.Orange500
|
||||
//import xyz.quaver.pupil.ui.theme.Pink600
|
||||
//import xyz.quaver.pupil.util.content
|
||||
//import xyz.quaver.pupil.util.get
|
||||
//import xyz.quaver.pupil.util.wordCapitalize
|
||||
//
|
||||
//@Serializable
|
||||
//@Parcelize
|
||||
//data class Tag(
|
||||
// val male: Int?,
|
||||
// val female: Int?,
|
||||
// val tag: String
|
||||
//) : Parcelable {
|
||||
// override fun toString(): String {
|
||||
// val stringBuilder = StringBuilder()
|
||||
//
|
||||
// stringBuilder.append(when {
|
||||
// male != null -> "male"
|
||||
// female != null -> "female"
|
||||
// else -> "tag"
|
||||
// })
|
||||
// stringBuilder.append(':')
|
||||
// stringBuilder.append(tag)
|
||||
//
|
||||
// return stringBuilder.toString()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Serializable
|
||||
//@Parcelize
|
||||
//data class HiyobiItemInfo(
|
||||
// override val itemID: String,
|
||||
// override val title: String,
|
||||
// val thumbnail: String,
|
||||
// val artists: List<String>,
|
||||
// val series: List<String>,
|
||||
// val type: String,
|
||||
// val date: String,
|
||||
// val bookmark: Unit?,
|
||||
// val tags: List<Tag>,
|
||||
// val commentCount: Int,
|
||||
// val pageCount: Int
|
||||
//): ItemInfo {
|
||||
// override val source: String
|
||||
// get() = "hiyobi.io"
|
||||
//}
|
||||
//
|
||||
//@Serializable
|
||||
//data class Manga(
|
||||
// val mangaId: Int,
|
||||
// val title: String,
|
||||
// val artist: List<String>,
|
||||
// val thumbnail: String,
|
||||
// val series: List<String>,
|
||||
// val type: String,
|
||||
// val date: String,
|
||||
// val bookmark: Unit?,
|
||||
// val tags: List<Tag>,
|
||||
// val commentCount: Int,
|
||||
// val pageCount: Int
|
||||
//)
|
||||
//
|
||||
//@Serializable
|
||||
//data class QueryManga(
|
||||
// val nowPage: Int,
|
||||
// val maxPage: Int,
|
||||
// val manga: List<Manga>
|
||||
//)
|
||||
//
|
||||
//@Serializable
|
||||
//data class SearchResultData(
|
||||
// val queryManga: QueryManga
|
||||
//)
|
||||
//
|
||||
//@Serializable
|
||||
//data class SearchResult(
|
||||
// val data: SearchResultData
|
||||
//)
|
||||
//
|
||||
//class Hiyobi_io(app: Application): Source(), DIAware {
|
||||
// override val di by closestDI(app)
|
||||
//
|
||||
// private val logger = newLogger(LoggerFactory.default)
|
||||
//
|
||||
// private val database: AppDatabase by instance()
|
||||
// private val bookmarkDao = database.bookmarkDao()
|
||||
//
|
||||
// override val name = "hiyobi.io"
|
||||
// override val iconResID = R.drawable.hitomi
|
||||
// override val availableSortMode = emptyList<String>()
|
||||
//
|
||||
// private val client: HttpClient by instance()
|
||||
//
|
||||
// private suspend fun query(page: Int, tags: String): SearchResult {
|
||||
// val query = "{queryManga(page:$page,tags:$tags){nowPage maxPage manga{mangaId title artist thumbnail series type date bookmark tags{male female tag} commentCount pageCount}}}"
|
||||
//
|
||||
// return client.get("https://api.hiyobi.io/api?query=$query")
|
||||
// }
|
||||
//
|
||||
// private suspend fun totalCount(tags: String): Int {
|
||||
// val firstPageQuery = "{queryManga(page:1,tags:$tags){maxPage}}"
|
||||
// val maxPage = client.get<JsonObject>(
|
||||
// "https://api.hiyobi.io/api?query=$firstPageQuery"
|
||||
// )["data"]!!["queryManga"]!!["maxPage"]!!.jsonPrimitive.int
|
||||
//
|
||||
// val lastPageQuery = "{queryManga(page:$maxPage,tags:$tags){manga{mangaId}}}"
|
||||
// val lastPageCount = client.get<JsonObject>(
|
||||
// "https://api.hiyobi.io/api?query=$lastPageQuery"
|
||||
// )["data"]!!["queryManga"]!!["manga"]!!.jsonArray.size
|
||||
//
|
||||
// return (maxPage-1)*25+lastPageCount
|
||||
// }
|
||||
//
|
||||
// override suspend fun search(query: String, page: Int, sortMode: Int): Pair<Channel<ItemInfo>, Int> = withContext(Dispatchers.IO) {
|
||||
// val channel = Channel<ItemInfo>()
|
||||
//
|
||||
// val tags = parseQuery(query)
|
||||
//
|
||||
// logger.info {
|
||||
// tags
|
||||
// }
|
||||
//
|
||||
// CoroutineScope(Dispatchers.IO).launch {
|
||||
// (range.first/25+1 .. range.last/25+1).map { page ->
|
||||
// page to async { query(page, tags) }
|
||||
// }.forEach { (page, result) ->
|
||||
// result.await().data.queryManga.manga.forEachIndexed { index, manga ->
|
||||
// if ((page-1)*25+index in range) channel.send(transform(manga))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// channel.close()
|
||||
// }
|
||||
//
|
||||
// channel to totalCount(tags)
|
||||
// }
|
||||
//
|
||||
// override suspend fun images(itemID: String): List<String> = withContext(Dispatchers.IO) {
|
||||
// val query = "{getManga(mangaId:$itemID){urls}}"
|
||||
//
|
||||
// client.post<JsonObject>("https://api.hiyobi.io/api") {
|
||||
// contentType(ContentType.Application.Json)
|
||||
// body = mapOf("query" to query)
|
||||
// }["data"]!!["getManga"]!!["urls"]!!.jsonArray.map { "https://api.hiyobi.io/${it.content!!}" }
|
||||
// }
|
||||
//
|
||||
// override suspend fun info(itemID: String): ItemInfo {
|
||||
// TODO("Not yet implemented")
|
||||
// }
|
||||
//
|
||||
// @OptIn(ExperimentalMaterialApi::class)
|
||||
// @Composable
|
||||
// fun TagChip(tag: Tag, isFavorite: Boolean, onClick: ((Tag) -> Unit)? = null, onFavoriteClick: ((Tag) -> Unit)? = null) {
|
||||
// val icon = when {
|
||||
// tag.male != null -> Icons.Filled.Male
|
||||
// tag.female != null -> Icons.Filled.Female
|
||||
// else -> null
|
||||
// }
|
||||
//
|
||||
// val (surfaceColor, textTint) = when {
|
||||
// isFavorite -> Pair(Orange500, Color.White)
|
||||
// else -> when {
|
||||
// tag.male != null -> Pair(Blue700, Color.White)
|
||||
// tag.female != null -> Pair(Pink600, Color.White)
|
||||
// else -> Pair(MaterialTheme.colors.background, MaterialTheme.colors.onBackground)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// val starIcon = if (isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline
|
||||
//
|
||||
// Surface(
|
||||
// modifier = Modifier.padding(2.dp),
|
||||
// onClick = { onClick?.invoke(tag) },
|
||||
// shape = RoundedCornerShape(16.dp),
|
||||
// color = surfaceColor,
|
||||
// elevation = 2.dp
|
||||
// ) {
|
||||
// Row(
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// if (icon != null)
|
||||
// Icon(
|
||||
// icon,
|
||||
// contentDescription = "Icon",
|
||||
// modifier = Modifier
|
||||
// .padding(4.dp)
|
||||
// .size(24.dp),
|
||||
// tint = Color.White
|
||||
// )
|
||||
// else
|
||||
// Box(Modifier.size(16.dp))
|
||||
//
|
||||
// Text(
|
||||
// tag.tag,
|
||||
// color = textTint,
|
||||
// style = MaterialTheme.typography.body2
|
||||
// )
|
||||
//
|
||||
// Icon(
|
||||
// starIcon,
|
||||
// contentDescription = "Favorites",
|
||||
// modifier = Modifier
|
||||
// .padding(8.dp)
|
||||
// .size(16.dp)
|
||||
// .clip(CircleShape)
|
||||
// .clickable { onFavoriteClick?.invoke(tag) },
|
||||
// tint = textTint
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @OptIn(ExperimentalMaterialApi::class)
|
||||
// @Composable
|
||||
// fun TagGroup(tags: List<Tag>) {
|
||||
// var isFolded by remember { mutableStateOf(true) }
|
||||
// val bookmarkedTags by bookmarkDao.getAll(name).observeAsState(emptyList())
|
||||
//
|
||||
// val bookmarkedTagsInList = tags.filter { it.toString() in bookmarkedTags }
|
||||
//
|
||||
// FlowRow(Modifier.padding(0.dp, 16.dp)) {
|
||||
// tags.sortedBy { if (bookmarkedTagsInList.contains(it)) 0 else 1 }.let { (if (isFolded) it.take(10) else it) }.forEach { tag ->
|
||||
// TagChip(
|
||||
// tag = tag,
|
||||
// isFavorite = bookmarkedTagsInList.contains(tag),
|
||||
// onFavoriteClick = {
|
||||
// val bookmarkTag = Bookmark(name, it.toString())
|
||||
//
|
||||
// CoroutineScope(Dispatchers.IO).launch {
|
||||
// if (bookmarkedTagsInList.contains(it))
|
||||
// bookmarkDao.delete(bookmarkTag)
|
||||
// else
|
||||
// bookmarkDao.insert(bookmarkTag)
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// if (isFolded && tags.size > 10)
|
||||
// Surface(
|
||||
// modifier = Modifier.padding(2.dp),
|
||||
// color = MaterialTheme.colors.background,
|
||||
// shape = RoundedCornerShape(16.dp),
|
||||
// elevation = 2.dp,
|
||||
// onClick = { isFolded = false }
|
||||
// ) {
|
||||
// Text(
|
||||
// "…",
|
||||
// modifier = Modifier.padding(16.dp, 8.dp),
|
||||
// color = MaterialTheme.colors.onBackground,
|
||||
// style = MaterialTheme.typography.body2
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @OptIn(ExperimentalCoilApi::class)
|
||||
// @Composable
|
||||
// override fun SearchResult(itemInfo: ItemInfo, onEvent: (SearchResultEvent) -> Unit) {
|
||||
// itemInfo as HiyobiItemInfo
|
||||
//
|
||||
// val bookmark by bookmarkDao.contains(itemInfo).observeAsState(false)
|
||||
//
|
||||
// val painter = rememberImagePainter(itemInfo.thumbnail)
|
||||
//
|
||||
// Column(
|
||||
// modifier = Modifier.clickable {
|
||||
// onEvent(SearchResultEvent(SearchResultEvent.Type.OPEN_READER, itemInfo.itemID, itemInfo))
|
||||
// }
|
||||
// ) {
|
||||
// Row {
|
||||
// Image(
|
||||
// painter = painter,
|
||||
// contentDescription = null,
|
||||
// modifier = Modifier
|
||||
// .requiredWidth(150.dp)
|
||||
// .aspectRatio(
|
||||
// with(painter.intrinsicSize) { if (this == Size.Unspecified) 1f else width / height },
|
||||
// true
|
||||
// )
|
||||
// .padding(0.dp, 0.dp, 8.dp, 0.dp)
|
||||
// .align(Alignment.CenterVertically),
|
||||
// contentScale = ContentScale.FillWidth
|
||||
// )
|
||||
//
|
||||
// Column {
|
||||
// Text(
|
||||
// itemInfo.title,
|
||||
// style = MaterialTheme.typography.h6,
|
||||
// color = MaterialTheme.colors.onSurface
|
||||
// )
|
||||
//
|
||||
// val artistStringBuilder = StringBuilder()
|
||||
//
|
||||
// with(itemInfo.artists) {
|
||||
// if (this.isNotEmpty())
|
||||
// artistStringBuilder.append(this.joinToString(", ") { it.wordCapitalize() })
|
||||
// }
|
||||
//
|
||||
// if (artistStringBuilder.isNotEmpty())
|
||||
// Text(
|
||||
// artistStringBuilder.toString(),
|
||||
// style = MaterialTheme.typography.subtitle1,
|
||||
// color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
|
||||
// )
|
||||
//
|
||||
// if (itemInfo.series.isNotEmpty())
|
||||
// Text(
|
||||
// stringResource(
|
||||
// id = R.string.galleryblock_series,
|
||||
// itemInfo.series.joinToString { it.wordCapitalize() }
|
||||
// ),
|
||||
// style = MaterialTheme.typography.body2,
|
||||
// color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
|
||||
// )
|
||||
//
|
||||
// Text(
|
||||
// stringResource(id = R.string.galleryblock_type, itemInfo.type),
|
||||
// style = MaterialTheme.typography.body2,
|
||||
// color = MaterialTheme.colors.onSurface.copy(alpha = 0.6F)
|
||||
// )
|
||||
//
|
||||
// key(itemInfo.tags) {
|
||||
// TagGroup(tags = itemInfo.tags)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Divider(
|
||||
// thickness = 1.dp,
|
||||
// modifier = Modifier.padding(0.dp, 8.dp, 0.dp, 0.dp)
|
||||
// )
|
||||
//
|
||||
// Row(
|
||||
// modifier = Modifier.padding(8.dp).fillMaxWidth(),
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// horizontalArrangement = Arrangement.SpaceBetween
|
||||
// ) {
|
||||
// Text(itemInfo.itemID)
|
||||
//
|
||||
// Text("${itemInfo.pageCount}P")
|
||||
//
|
||||
// Icon(
|
||||
// if (bookmark) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
// contentDescription = null,
|
||||
// tint = Orange500,
|
||||
// modifier = Modifier
|
||||
// .size(32.dp)
|
||||
// .clickable {
|
||||
// CoroutineScope(Dispatchers.IO).launch {
|
||||
// if (bookmark) bookmarkDao.delete(itemInfo)
|
||||
// else bookmarkDao.insert(itemInfo)
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// companion object {
|
||||
// private fun transform(manga: Manga) = HiyobiItemInfo(
|
||||
// manga.mangaId.toString(),
|
||||
// manga.title,
|
||||
// "https://api.hiyobi.io/${manga.thumbnail}",
|
||||
// manga.artist,
|
||||
// manga.series,
|
||||
// manga.type,
|
||||
// manga.date,
|
||||
// manga.bookmark,
|
||||
// manga.tags,
|
||||
// manga.commentCount,
|
||||
// manga.pageCount
|
||||
// )
|
||||
//
|
||||
// fun parseQuery(query: String): String {
|
||||
// val queryBuilder = StringBuilder("[")
|
||||
//
|
||||
// if (query.isNotBlank())
|
||||
// query.split(' ').filter { it.isNotBlank() }.forEach {
|
||||
// val tags = it.replace('_', ' ').split(':', limit = 2)
|
||||
//
|
||||
// if (queryBuilder.length != 1) queryBuilder.append(',')
|
||||
//
|
||||
// queryBuilder.append(
|
||||
// when {
|
||||
// tags.size == 1 -> "{tag:\"${tags[0]}\"}"
|
||||
// tags[0] == "male" -> "{male:1,tag:\"${tags[1]}\"}"
|
||||
// tags[0] == "female" -> "{female:1,tag:\"${tags[1]}\"}"
|
||||
// else -> "{tag:\"${tags[1]}\"}"
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// return queryBuilder.append(']').toString()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,24 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.composable
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -16,11 +32,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Modifier.Companion.any
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -29,7 +43,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastAll
|
||||
|
||||
enum class FloatingActionButtonState(private val isExpanded: Boolean) {
|
||||
COLLAPSED(false), EXPANDED(true);
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
160
app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt
Normal file
160
app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +1,101 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources.manatoki
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.jsoup.Jsoup
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.sources.ItemInfo
|
||||
import xyz.quaver.pupil.sources.Source
|
||||
|
||||
@Parcelize
|
||||
class ManatokiItemInfo(
|
||||
override val itemID: String,
|
||||
override val title: String
|
||||
) : ItemInfo {
|
||||
override val source: String = "manatoki.net"
|
||||
}
|
||||
|
||||
class Manatoki(app: Application) : Source(), DIAware {
|
||||
override val di by closestDI(app)
|
||||
|
||||
private val logger = newLogger(LoggerFactory.default)
|
||||
|
||||
override val name = "manatoki.net"
|
||||
override val availableSortMode: List<String> = emptyList()
|
||||
override val iconResID: Int = R.drawable.manatoki
|
||||
|
||||
override suspend fun search(
|
||||
query: String,
|
||||
range: IntRange,
|
||||
sortMode: Int
|
||||
): Pair<Channel<ItemInfo>, Int> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun images(itemID: String): List<String> = coroutineScope {
|
||||
val jsoup = withContext(Dispatchers.IO) {
|
||||
Jsoup.connect("https://manatoki116.net/comic/$itemID").get()
|
||||
}
|
||||
|
||||
val htmlData = jsoup
|
||||
.selectFirst(".view-padding > script")!!
|
||||
.data()
|
||||
.splitToSequence('\n')
|
||||
.fold(StringBuilder()) { sb, line ->
|
||||
if (!line.startsWith("html_data")) return@fold sb
|
||||
|
||||
line.drop(12).dropLast(2).split('.').forEach {
|
||||
if (it.isNotBlank()) sb.appendCodePoint(it.toInt(16))
|
||||
}
|
||||
sb
|
||||
}.toString()
|
||||
|
||||
Jsoup.parse(htmlData)
|
||||
.select("img[^data-]:not([style])")
|
||||
.map {
|
||||
it.attributes()
|
||||
.first { it.key.startsWith("data-") }
|
||||
.value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun info(itemID: String): ItemInfo = coroutineScope {
|
||||
val jsoup = withContext(Dispatchers.IO) {
|
||||
Jsoup.connect("https://manatoki116.net/comic/$itemID").get()
|
||||
}
|
||||
|
||||
val title = jsoup.selectFirst(".toon-title")!!.ownText()
|
||||
|
||||
ManatokiItemInfo(
|
||||
itemID,
|
||||
title
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
///*
|
||||
// * Pupil, Hitomi.la viewer for Android
|
||||
// * Copyright (C) 2021 tom5079
|
||||
// *
|
||||
// * This program is free software: you can redistribute it and/or modify
|
||||
// * it under the terms of the GNU General Public License as published by
|
||||
// * the Free Software Foundation, either version 3 of the License, or
|
||||
// * (at your option) any later version.
|
||||
// *
|
||||
// * This program is distributed in the hope that it will be useful,
|
||||
// * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// * GNU General Public License for more details.
|
||||
// *
|
||||
// * You should have received a copy of the GNU General Public License
|
||||
// * along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
// */
|
||||
//
|
||||
//package xyz.quaver.pupil.sources.manatoki
|
||||
//
|
||||
//import android.app.Application
|
||||
//import kotlinx.coroutines.Dispatchers
|
||||
//import kotlinx.coroutines.channels.Channel
|
||||
//import kotlinx.coroutines.coroutineScope
|
||||
//import kotlinx.coroutines.withContext
|
||||
//import kotlinx.parcelize.Parcelize
|
||||
//import org.jsoup.Jsoup
|
||||
//import org.kodein.di.DIAware
|
||||
//import org.kodein.di.android.closestDI
|
||||
//import org.kodein.log.LoggerFactory
|
||||
//import org.kodein.log.newLogger
|
||||
//import xyz.quaver.pupil.R
|
||||
//import xyz.quaver.pupil.sources.ItemInfo
|
||||
//import xyz.quaver.pupil.sources.Source
|
||||
//
|
||||
//@Parcelize
|
||||
//class ManatokiItemInfo(
|
||||
// override val itemID: String,
|
||||
// override val title: String
|
||||
//) : ItemInfo {
|
||||
// override val source: String = "manatoki.net"
|
||||
//}
|
||||
//
|
||||
//class Manatoki(app: Application) : Source(), DIAware {
|
||||
// override val di by closestDI(app)
|
||||
//
|
||||
// private val logger = newLogger(LoggerFactory.default)
|
||||
//
|
||||
// override val name = "manatoki.net"
|
||||
// override val availableSortMode: List<String> = emptyList()
|
||||
// override val iconResID: Int = R.drawable.manatoki
|
||||
//
|
||||
// override suspend fun search(
|
||||
// query: String,
|
||||
// range: IntRange,
|
||||
// sortMode: Int
|
||||
// ): Pair<Channel<ItemInfo>, Int> {
|
||||
// TODO("Not yet implemented")
|
||||
// }
|
||||
//
|
||||
// override suspend fun images(itemID: String): List<String> = coroutineScope {
|
||||
// val jsoup = withContext(Dispatchers.IO) {
|
||||
// Jsoup.connect("https://manatoki116.net/comic/$itemID").get()
|
||||
// }
|
||||
//
|
||||
// val htmlData = jsoup
|
||||
// .selectFirst(".view-padding > script")!!
|
||||
// .data()
|
||||
// .splitToSequence('\n')
|
||||
// .fold(StringBuilder()) { sb, line ->
|
||||
// if (!line.startsWith("html_data")) return@fold sb
|
||||
//
|
||||
// line.drop(12).dropLast(2).split('.').forEach {
|
||||
// if (it.isNotBlank()) sb.appendCodePoint(it.toInt(16))
|
||||
// }
|
||||
// sb
|
||||
// }.toString()
|
||||
//
|
||||
// Jsoup.parse(htmlData)
|
||||
// .select("img[^data-]:not([style])")
|
||||
// .map {
|
||||
// it.attributes()
|
||||
// .first { it.key.startsWith("data-") }
|
||||
// .value
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override suspend fun info(itemID: String): ItemInfo = coroutineScope {
|
||||
// val jsoup = withContext(Dispatchers.IO) {
|
||||
// Jsoup.connect("https://manatoki116.net/comic/$itemID").get()
|
||||
// }
|
||||
//
|
||||
// val title = jsoup.selectFirst(".toon-title")!!.ownText()
|
||||
//
|
||||
// ManatokiItemInfo(
|
||||
// itemID,
|
||||
// title
|
||||
// )
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -18,60 +18,23 @@
|
||||
|
||||
package xyz.quaver.pupil.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import kotlinx.coroutines.*
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.di.direct
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.pupil.*
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.sources.SearchResultEvent
|
||||
import xyz.quaver.pupil.ui.composable.*
|
||||
import xyz.quaver.pupil.ui.dialog.OpenWithItemIDDialog
|
||||
import xyz.quaver.pupil.ui.dialog.SourceSelectDialog
|
||||
import xyz.quaver.pupil.ui.theme.PupilTheme
|
||||
import xyz.quaver.pupil.ui.viewmodel.MainViewModel
|
||||
import xyz.quaver.pupil.util.*
|
||||
import kotlin.math.*
|
||||
import xyz.quaver.pupil.util.source
|
||||
|
||||
private enum class NavigationIconState {
|
||||
MENU,
|
||||
ARROW
|
||||
}
|
||||
|
||||
class MainActivity : ComponentActivity(), DIAware {
|
||||
override val di by closestDI()
|
||||
@@ -86,251 +49,22 @@ class MainActivity : ComponentActivity(), DIAware {
|
||||
|
||||
setContent {
|
||||
PupilTheme {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val navController = rememberNavController()
|
||||
|
||||
val maxPage by model.maxPage.collectAsState(0)
|
||||
|
||||
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
|
||||
|
||||
val prevPageAvailable by derivedStateOf {
|
||||
model.currentPage > 1
|
||||
}
|
||||
|
||||
val nextPageAvailable by derivedStateOf {
|
||||
model.currentPage <= maxPage
|
||||
}
|
||||
|
||||
var overscroll: Float? by remember { mutableStateOf(null) }
|
||||
|
||||
var isFabExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) }
|
||||
var isFabVisible by remember { mutableStateOf(true) }
|
||||
|
||||
val searchBarHeight = LocalDensity.current.run { 56.dp.roundToPx() }
|
||||
var searchBarOffset by remember { mutableStateOf(0) }
|
||||
|
||||
val navigationIcon = remember { DrawerArrowDrawable(this) }
|
||||
var navigationIconState by remember { mutableStateOf(NavigationIconState.MENU) }
|
||||
val navigationIconTransition = updateTransition(navigationIconState, label = "navigationIconTransition")
|
||||
val navigationIconProgress by navigationIconTransition.animateFloat(
|
||||
label = "navigationIconProgress"
|
||||
) { state ->
|
||||
when (state) {
|
||||
NavigationIconState.MENU -> 0f
|
||||
NavigationIconState.ARROW -> 1f
|
||||
}
|
||||
}
|
||||
|
||||
val onSearchResultEvent: (SearchResultEvent) -> Unit = { event ->
|
||||
when (event.type) {
|
||||
SearchResultEvent.Type.OPEN_READER -> {
|
||||
startActivity(
|
||||
Intent(
|
||||
this@MainActivity,
|
||||
ReaderActivity::class.java
|
||||
).apply {
|
||||
putExtra("source", model.source.name)
|
||||
putExtra("id", event.itemID)
|
||||
putExtra("payload", event.payload)
|
||||
})
|
||||
}
|
||||
else -> TODO("")
|
||||
}
|
||||
}
|
||||
|
||||
var sourceSelectDialog by remember { mutableStateOf(false) }
|
||||
var openWithItemIDDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(navigationIconProgress) {
|
||||
navigationIcon.progress = navigationIconProgress
|
||||
}
|
||||
|
||||
if (sourceSelectDialog)
|
||||
SourceSelectDialog(
|
||||
currentSource = model.source.name,
|
||||
onDismissRequest = { sourceSelectDialog = false }
|
||||
) { source ->
|
||||
sourceSelectDialog = false
|
||||
model.setSourceAndReset(source.name)
|
||||
NavHost(navController, startDestination = "main/{source}") {
|
||||
composable("main/{source}") {
|
||||
direct.source(it.arguments?.getString("source") ?: "hitomi.la")
|
||||
.MainScreen(navController)
|
||||
}
|
||||
|
||||
if (openWithItemIDDialog)
|
||||
OpenWithItemIDDialog {
|
||||
openWithItemIDDialog = false
|
||||
|
||||
it?.let {
|
||||
onSearchResultEvent(SearchResultEvent(
|
||||
SearchResultEvent.Type.OPEN_READER,
|
||||
it
|
||||
))
|
||||
}
|
||||
composable("search/{source}") {
|
||||
direct.source(it.arguments?.getString("source") ?: "hitomi.la")
|
||||
.Search(navController)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
MultipleFloatingActionButton(
|
||||
listOf(
|
||||
SubFabItem(
|
||||
Icons.Default.Block,
|
||||
stringResource(R.string.main_fab_cancel)
|
||||
),
|
||||
SubFabItem(
|
||||
painterResource(R.drawable.ic_jump),
|
||||
stringResource(R.string.main_jump_title)
|
||||
),
|
||||
SubFabItem(
|
||||
Icons.Default.Shuffle,
|
||||
stringResource(R.string.main_fab_random)
|
||||
),
|
||||
SubFabItem(
|
||||
painterResource(R.drawable.numeric),
|
||||
stringResource(R.string.main_open_gallery_by_id)
|
||||
) {
|
||||
openWithItemIDDialog = true
|
||||
}
|
||||
),
|
||||
visible = isFabVisible,
|
||||
targetState = isFabExpanded,
|
||||
onStateChanged = {
|
||||
isFabExpanded = it
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.offset(0.dp, overscroll?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } } ?: 0.dp)
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val overscrollSnapshot = overscroll
|
||||
|
||||
if (overscrollSnapshot == null || overscrollSnapshot == 0f) {
|
||||
searchBarOffset =
|
||||
(searchBarOffset + available.y.roundToInt()).coerceIn(
|
||||
-searchBarHeight,
|
||||
0
|
||||
)
|
||||
|
||||
isFabVisible = available.y > 0f
|
||||
|
||||
return Offset.Zero
|
||||
} else {
|
||||
val newOverscroll =
|
||||
if (overscrollSnapshot > 0f && available.y < 0f)
|
||||
max(overscrollSnapshot + available.y, 0f)
|
||||
else if (overscrollSnapshot < 0f && available.y > 0f)
|
||||
min(overscrollSnapshot + available.y, 0f)
|
||||
else
|
||||
overscrollSnapshot
|
||||
|
||||
return Offset(0f, newOverscroll - overscrollSnapshot).also {
|
||||
overscroll = newOverscroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
if (available.y == 0f || source == NestedScrollSource.Fling) return Offset.Zero
|
||||
|
||||
return overscroll?.let {
|
||||
val newOverscroll = (it + available.y).coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)
|
||||
|
||||
Offset(0f, newOverscroll - it).also {
|
||||
overscroll = newOverscroll
|
||||
}
|
||||
} ?: Offset.Zero
|
||||
}
|
||||
}).pointerInput(Unit) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var pointer = down.id
|
||||
overscroll = 0f
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val dragEvent = event.changes.fastFirstOrNull { it.id == pointer }!!
|
||||
|
||||
if (dragEvent.changedToUpIgnoreConsumed()) {
|
||||
val otherDown = event.changes.fastFirstOrNull { it.pressed }
|
||||
if (otherDown == null) {
|
||||
dragEvent.consumePositionChange()
|
||||
overscroll = null
|
||||
break
|
||||
}
|
||||
else
|
||||
pointer = otherDown.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp, 56.dp, 0.dp, 0.dp)
|
||||
) {
|
||||
items(model.searchResults, key = { it.itemID }) { itemInfo ->
|
||||
ProgressCard(
|
||||
progress = 0.5f
|
||||
) {
|
||||
model.source.SearchResult(itemInfo = itemInfo, onEvent = onSearchResultEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.loading)
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
|
||||
FloatingSearchBar(
|
||||
modifier = Modifier.offset(0.dp, LocalDensity.current.run { searchBarOffset.toDp() }),
|
||||
query = model.query,
|
||||
onQueryChange = { model.query = it },
|
||||
navigationIcon = {
|
||||
Icon(
|
||||
painter = rememberDrawablePainter(navigationIcon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false)
|
||||
) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
Image(
|
||||
painterResource(model.source.iconResID),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable {
|
||||
sourceSelectDialog = true
|
||||
}
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Sort,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
},
|
||||
onTextFieldFocused = { navigationIconState = NavigationIconState.ARROW },
|
||||
onTextFieldUnfocused = { navigationIconState = NavigationIconState.MENU; model.resetAndQuery() }
|
||||
)
|
||||
composable("reader/{source}/{itemID}") {
|
||||
direct.source(it.arguments?.getString("source") ?: "hitomi.la")
|
||||
.Reader(navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,26 +19,17 @@
|
||||
package xyz.quaver.pupil.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.android.x.closestDI
|
||||
import org.kodein.di.direct
|
||||
import org.kodein.di.instance
|
||||
import org.kodein.log.LoggerFactory
|
||||
import org.kodein.log.newLogger
|
||||
import xyz.quaver.pupil.proto.settingsDataStore
|
||||
import xyz.quaver.pupil.sources.History
|
||||
import xyz.quaver.pupil.sources.ItemInfo
|
||||
import xyz.quaver.pupil.sources.Source
|
||||
import xyz.quaver.pupil.util.source
|
||||
import kotlin.math.ceil
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
|
||||
@@ -46,138 +37,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
|
||||
|
||||
private val logger = newLogger(LoggerFactory.default)
|
||||
|
||||
val searchResults = mutableStateListOf<ItemInfo>()
|
||||
|
||||
private val resultsPerPage = app.settingsDataStore.data.map {
|
||||
it.resultsPerPage
|
||||
}
|
||||
|
||||
var loading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var queryJob: Job? = null
|
||||
private var suggestionJob: Job? = null
|
||||
|
||||
var query by mutableStateOf("")
|
||||
private val queryStack = mutableListOf<String>()
|
||||
|
||||
private val defaultSourceFactory: (String) -> Source = {
|
||||
direct.source(it)
|
||||
}
|
||||
private var sourceFactory: (String) -> Source = defaultSourceFactory
|
||||
var source by mutableStateOf(sourceFactory("hitomi.la"))
|
||||
private set
|
||||
|
||||
var sortModeIndex by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
var currentPage by mutableStateOf(1)
|
||||
|
||||
var totalItems by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
val maxPage by derivedStateOf {
|
||||
resultsPerPage.map {
|
||||
ceil(totalItems / it.toDouble()).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSourceAndReset(sourceName: String) {
|
||||
source = sourceFactory(sourceName)
|
||||
sortModeIndex = 0
|
||||
|
||||
query = ""
|
||||
resetAndQuery()
|
||||
}
|
||||
|
||||
fun resetAndQuery() {
|
||||
queryStack.add(query)
|
||||
currentPage = 1
|
||||
|
||||
query()
|
||||
}
|
||||
|
||||
fun setModeAndReset(mode: MainMode) {
|
||||
sourceFactory = when (mode) {
|
||||
MainMode.SEARCH, MainMode.DOWNLOADS -> defaultSourceFactory
|
||||
MainMode.HISTORY -> { { direct.instance<String, History>(arg = it) } }
|
||||
else -> return
|
||||
}
|
||||
|
||||
setSourceAndReset(
|
||||
when {
|
||||
mode == MainMode.DOWNLOADS -> "downloads"
|
||||
//source.value is Downloads -> "hitomi.la"
|
||||
else -> source.name
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun query() {
|
||||
suggestionJob?.cancel()
|
||||
queryJob?.cancel()
|
||||
|
||||
loading = true
|
||||
searchResults.clear()
|
||||
|
||||
queryJob = viewModelScope.launch {
|
||||
val resultsPerPage = resultsPerPage.first()
|
||||
|
||||
logger.info {
|
||||
resultsPerPage.toString()
|
||||
}
|
||||
|
||||
val (channel, count) = source.search(
|
||||
query,
|
||||
(currentPage - 1) * resultsPerPage until currentPage * resultsPerPage,
|
||||
sortModeIndex
|
||||
)
|
||||
|
||||
totalItems = count
|
||||
|
||||
for (result in channel) {
|
||||
yield()
|
||||
searchResults.add(result)
|
||||
}
|
||||
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun random(callback: (ItemInfo) -> Unit) {
|
||||
if (totalItems == 0)
|
||||
return
|
||||
|
||||
val random = Random.Default.nextInt(totalItems)
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
source.search(
|
||||
query,
|
||||
random .. random,
|
||||
sortModeIndex
|
||||
).first.receive()
|
||||
}.let(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if backpress is consumed, false otherwise
|
||||
*/
|
||||
fun onBackPressed(): Boolean {
|
||||
if (queryStack.removeLastOrNull() == null || queryStack.isEmpty())
|
||||
return false
|
||||
|
||||
query = queryStack.removeLast()
|
||||
resetAndQuery()
|
||||
return true
|
||||
}
|
||||
|
||||
enum class MainMode {
|
||||
SEARCH,
|
||||
HISTORY,
|
||||
DOWNLOADS,
|
||||
FAVORITES
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,18 +18,15 @@
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.toAndroidRect
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.serialization.json.*
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.DirectDIAware
|
||||
@@ -40,71 +37,7 @@ import xyz.quaver.graphics.subsampledimage.newBitmapRegionDecoder
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.io.util.inputStream
|
||||
import xyz.quaver.pupil.db.AppDatabase
|
||||
import xyz.quaver.pupil.sources.ItemInfo
|
||||
import xyz.quaver.pupil.sources.SourceEntries
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun String.wordCapitalize() : String {
|
||||
val result = ArrayList<String>()
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
for (word in this.split(" "))
|
||||
result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() })
|
||||
|
||||
return result.joinToString(" ")
|
||||
}
|
||||
|
||||
private val suffix = listOf(
|
||||
"B",
|
||||
"kB",
|
||||
"MB",
|
||||
"GB",
|
||||
"TB" //really?
|
||||
)
|
||||
|
||||
fun byteToString(byte: Long, precision : Int = 1) : String {
|
||||
var size = byte.toDouble(); var suffixIndex = 0
|
||||
|
||||
while (size >= 1024) {
|
||||
size /= 1024
|
||||
suffixIndex++
|
||||
}
|
||||
|
||||
return "%.${precision}f ${suffix[suffixIndex]}".format(size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert android generated ID to requestCode
|
||||
* to prevent java.lang.IllegalArgumentException: Can only use lower 16 bits for requestCode
|
||||
*
|
||||
* https://stackoverflow.com/questions/38072322/generate-16-bit-unique-ids-in-android-for-startactivityforresult
|
||||
*/
|
||||
fun Int.normalizeID() = this.and(0xFFFF)
|
||||
|
||||
val formatMap = mapOf<String, ItemInfo.() -> (String)>(
|
||||
"-id-" to { itemID },
|
||||
"-title-" to { title },
|
||||
// TODO
|
||||
)
|
||||
/**
|
||||
* Formats download folder name with given Metadata
|
||||
*/
|
||||
fun ItemInfo.formatDownloadFolder(format: String = Preferences["download_folder_name", "[-id-] -title-"]): String =
|
||||
format.let {
|
||||
formatMap.entries.fold(it) { str, (k, v) ->
|
||||
str.replace(k, v.invoke(this), true)
|
||||
}
|
||||
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
|
||||
|
||||
fun String.ellipsize(n: Int): String =
|
||||
if (this.length > n)
|
||||
this.slice(0 until n) + "…"
|
||||
else
|
||||
this
|
||||
|
||||
operator fun JsonElement.get(index: Int) =
|
||||
this.jsonArray[index]
|
||||
@@ -115,27 +48,6 @@ operator fun JsonElement.get(tag: String) =
|
||||
val JsonElement.content
|
||||
get() = this.jsonPrimitive.contentOrNull
|
||||
|
||||
fun List<MenuItem>.findMenu(itemID: Int): MenuItem? {
|
||||
return firstOrNull { it.itemId == itemID }
|
||||
}
|
||||
|
||||
fun <E> MutableLiveData<MutableList<E>>.notify() {
|
||||
this.value = this.value
|
||||
}
|
||||
|
||||
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long, bytesJustCopied: Int) -> Unit): Long {
|
||||
var bytesCopied: Long = 0
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytes = read(buffer)
|
||||
while (bytes >= 0) {
|
||||
out.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
onCopy(bytesCopied, bytes)
|
||||
bytes = read(buffer)
|
||||
}
|
||||
return bytesCopied
|
||||
}
|
||||
|
||||
fun DIAware.source(source: String) = lazy { direct.source(source) }
|
||||
fun DirectDIAware.source(source: String) = instance<SourceEntries>().toMap()[source]!!
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ option java_package = "xyz.quaver.pupil.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
message Settings {
|
||||
optional int32 results_per_page = 1 [default = 25];
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user