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