Update Kotatsu parsers lib
This commit is contained in:
275
.idea/other.xml
generated
275
.idea/other.xml
generated
@@ -4,279 +4,6 @@
|
||||
<option name="frameScreenshot" value="true" />
|
||||
</component>
|
||||
<component name="direct_access_persist.xml">
|
||||
<option name="deviceSelectionList">
|
||||
<list>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="b0q" />
|
||||
<option name="id" value="b0q" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S22 Ultra" />
|
||||
<option name="screenDensity" value="600" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3088" />
|
||||
<option name="selected" value="true" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="felix" />
|
||||
<option name="id" value="felix" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Fold" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2208" />
|
||||
<option name="screenY" value="1840" />
|
||||
<option name="selected" value="true" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts8uwifi" />
|
||||
<option name="id" value="gts8uwifi" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Tab S8 Ultra" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="1848" />
|
||||
<option name="screenY" value="2960" />
|
||||
<option name="selected" value="true" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="shiba" />
|
||||
<option name="id" value="shiba" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
<option name="selected" value="true" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="27" />
|
||||
<option name="brand" value="DOCOMO" />
|
||||
<option name="codename" value="F01L" />
|
||||
<option name="id" value="F01L" />
|
||||
<option name="manufacturer" value="FUJITSU" />
|
||||
<option name="name" value="F-01L" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1280" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="28" />
|
||||
<option name="brand" value="DOCOMO" />
|
||||
<option name="codename" value="SH-01L" />
|
||||
<option name="id" value="SH-01L" />
|
||||
<option name="manufacturer" value="SHARP" />
|
||||
<option name="name" value="AQUOS sense2 SH-01L" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2160" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="31" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a51" />
|
||||
<option name="id" value="a51" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy A51" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="akita" />
|
||||
<option name="id" value="akita" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="32" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="bluejay" />
|
||||
<option name="id" value="bluejay" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 6a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="29" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="crownqlteue" />
|
||||
<option name="id" value="crownqlteue" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Note9" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2220" />
|
||||
<option name="screenY" value="1080" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="dm3q" />
|
||||
<option name="id" value="dm3q" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S23 Ultra" />
|
||||
<option name="screenDensity" value="600" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3088" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="felix_camera" />
|
||||
<option name="id" value="felix_camera" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2208" />
|
||||
<option name="screenY" value="1840" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="husky" />
|
||||
<option name="id" value="husky" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8 Pro" />
|
||||
<option name="screenDensity" value="390" />
|
||||
<option name="screenX" value="1008" />
|
||||
<option name="screenY" value="2244" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="30" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="java" />
|
||||
<option name="id" value="java" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="G20" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="lynx" />
|
||||
<option name="id" value="lynx" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 7a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="31" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="oriole" />
|
||||
<option name="id" value="oriole" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 6" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="panther" />
|
||||
<option name="id" value="panther" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 7" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="31" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="q2q" />
|
||||
<option name="id" value="q2q" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Z Fold3" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1768" />
|
||||
<option name="screenY" value="2208" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="q5q" />
|
||||
<option name="id" value="q5q" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Z Fold5" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1812" />
|
||||
<option name="screenY" value="2176" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="30" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="r11" />
|
||||
<option name="id" value="r11" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Watch" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="384" />
|
||||
<option name="screenY" value="384" />
|
||||
<option name="type" value="WEAR_OS" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="30" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="redfin" />
|
||||
<option name="id" value="redfin" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 5" />
|
||||
<option name="screenDensity" value="440" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="shiba_beta" />
|
||||
<option name="id" value="shiba_beta" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tangorpro" />
|
||||
<option name="id" value="tangorpro" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Tablet" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="29" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="x1q" />
|
||||
<option name="id" value="x1q" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S20" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3200" />
|
||||
</PersistentDeviceSelectionData>
|
||||
</list>
|
||||
</option>
|
||||
<option name="selectedCloudProject" value="api-7108673381507456403-50668" />
|
||||
<option name="selectedCloudProject" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -173,7 +173,7 @@ dependencies {
|
||||
kapt("com.google.dagger:hilt-compiler:2.51.1")
|
||||
implementation("androidx.hilt:hilt-work:1.2.0")
|
||||
kapt("androidx.hilt:hilt-compiler:1.2.0")
|
||||
implementation("com.github.KotatsuApp:kotatsu-parsers:7d2f5696f5") {
|
||||
implementation("com.github.KotatsuApp:kotatsu-parsers:b404b44008") {
|
||||
exclude(group = "org.json", module = "json")
|
||||
}
|
||||
implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 4,
|
||||
"identityHash": "dbe1dcac0f49c5ae2ac88d88aa280081",
|
||||
"identityHash": "90b73386d5c61c2ddf46d6354ca2f1b6",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "manga",
|
||||
@@ -199,7 +199,7 @@
|
||||
},
|
||||
{
|
||||
"tableName": "sources",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `added_in` INTEGER NOT NULL, `used_at` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`source`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "source",
|
||||
@@ -218,6 +218,24 @@
|
||||
"columnName": "sort_key",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "addedIn",
|
||||
"columnName": "added_in",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUsedAt",
|
||||
"columnName": "used_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
@@ -851,7 +869,7 @@
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dbe1dcac0f49c5ae2ac88d88aa280081')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '90b73386d5c61c2ddf46d6354ca2f1b6')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
|
||||
@@ -86,6 +86,7 @@ import org.xtimms.shirizu.sections.feed.FeedScreen
|
||||
import org.xtimms.shirizu.sections.history.HistoryTab
|
||||
import org.xtimms.shirizu.sections.library.LibraryTab
|
||||
import org.xtimms.shirizu.sections.onboarding.OnboardingScreen
|
||||
import org.xtimms.shirizu.sections.profile.ProfileTab
|
||||
import org.xtimms.shirizu.sections.search.SearchTab
|
||||
import org.xtimms.shirizu.sections.settings.SettingsScreen
|
||||
import org.xtimms.shirizu.sections.shelf.ShelfTab
|
||||
@@ -301,7 +302,6 @@ class MainActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
object MainScreen : Screen() {
|
||||
|
||||
private val librarySearchEvent = Channel<String>()
|
||||
private val openTabEvent = Channel<Tab>()
|
||||
private val showBottomNavEvent = Channel<Boolean>()
|
||||
|
||||
@@ -312,7 +312,8 @@ object MainScreen : Screen() {
|
||||
// ShelfTab,
|
||||
// HistoryTab,
|
||||
ExploreTab(),
|
||||
SearchTab
|
||||
SearchTab,
|
||||
// ProfileTab
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -331,13 +332,6 @@ object MainScreen : Screen() {
|
||||
actions = {
|
||||
AppBarActions(
|
||||
persistentListOf(
|
||||
AppBar.Action(
|
||||
title = stringResource(R.string.suggestions),
|
||||
icon = Icons.Outlined.Creation,
|
||||
onClick = {
|
||||
navigator.push(SuggestionsScreen)
|
||||
},
|
||||
),
|
||||
AppBar.Action(
|
||||
title = stringResource(R.string.feed),
|
||||
icon = Icons.Outlined.RssFeed,
|
||||
@@ -425,6 +419,7 @@ object MainScreen : Screen() {
|
||||
// is Tab.History -> HistoryTab
|
||||
is Tab.Explore -> ExploreTab()
|
||||
is Tab.Search -> SearchTab
|
||||
// is Tab.Profile -> ProfileTab
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,5 +503,6 @@ object MainScreen : Screen() {
|
||||
// data object History : Tab
|
||||
data object Explore : Tab
|
||||
data object Search : Tab
|
||||
// data object Profile : Tab
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
interface ContentCache {
|
||||
|
||||
val isCachingEnabled: Boolean
|
||||
|
||||
suspend fun getDetails(source: MangaSource, url: String): Manga?
|
||||
|
||||
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
|
||||
|
||||
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
|
||||
|
||||
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
|
||||
|
||||
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
|
||||
|
||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
|
||||
|
||||
data class Key(
|
||||
val source: MangaSource,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
@@ -2,16 +2,19 @@ package org.xtimms.shirizu.core.cache
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.xtimms.shirizu.core.cache.MemoryContentCache.Key as CacheKey
|
||||
|
||||
class ExpiringLruCache<T>(
|
||||
val maxSize: Int,
|
||||
private val lifetime: Long,
|
||||
private val timeUnit: TimeUnit,
|
||||
) {
|
||||
) : Iterable<CacheKey> {
|
||||
|
||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
||||
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
|
||||
|
||||
operator fun get(key: ContentCache.Key): T? {
|
||||
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
|
||||
|
||||
operator fun get(key: CacheKey): T? {
|
||||
val value = cache[key] ?: return null
|
||||
if (value.isExpired) {
|
||||
cache.remove(key)
|
||||
@@ -19,7 +22,7 @@ class ExpiringLruCache<T>(
|
||||
return value.get()
|
||||
}
|
||||
|
||||
operator fun set(key: ContentCache.Key, value: T) {
|
||||
operator fun set(key: CacheKey, value: T) {
|
||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
||||
}
|
||||
|
||||
@@ -30,4 +33,8 @@ class ExpiringLruCache<T>(
|
||||
fun trimToSize(size: Int) {
|
||||
cache.trimToSize(size)
|
||||
}
|
||||
|
||||
fun remove(key: CacheKey) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,42 +6,54 @@ import android.content.res.Configuration
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.utils.system.isLowRamDevice
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
|
||||
@Singleton
|
||||
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
|
||||
|
||||
private val isLowRam = application.isLowRamDevice()
|
||||
|
||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
||||
private val pagesCache =
|
||||
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
||||
private val relatedMangaCache =
|
||||
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
|
||||
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
|
||||
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
|
||||
|
||||
override val isCachingEnabled: Boolean = true
|
||||
|
||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||
return detailsCache[Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
||||
detailsCache[ContentCache.Key(source, url)] = details
|
||||
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
||||
detailsCache[Key(source, url)] = details
|
||||
}
|
||||
|
||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
||||
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
||||
return pagesCache[Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
||||
pagesCache[ContentCache.Key(source, url)] = pages
|
||||
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
||||
pagesCache[Key(source, url)] = pages
|
||||
}
|
||||
|
||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
||||
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
||||
return relatedMangaCache[Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
||||
relatedMangaCache[ContentCache.Key(source, url)] = related
|
||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
||||
relatedMangaCache[Key(source, url)] = related
|
||||
}
|
||||
|
||||
fun clear(source: MangaSource) {
|
||||
clearCache(detailsCache, source)
|
||||
clearCache(pagesCache, source)
|
||||
clearCache(relatedMangaCache, source)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
@@ -67,4 +79,17 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
|
||||
else -> cache.trimToSize(cache.maxSize / 2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
||||
cache.forEach { key ->
|
||||
if (key.source == source) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Key(
|
||||
val source: MangaSource,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class StubContentCache : ContentCache {
|
||||
|
||||
override val isCachingEnabled: Boolean = false
|
||||
|
||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
|
||||
|
||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
|
||||
|
||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
|
||||
|
||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
|
||||
|
||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
|
||||
|
||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
|
||||
}
|
||||
@@ -54,7 +54,7 @@ import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun RowScope.ReadButton(
|
||||
info: HistoryInfo,
|
||||
info: HistoryInfo?,
|
||||
estimatedReadTime: String
|
||||
) {
|
||||
|
||||
@@ -63,13 +63,13 @@ fun RowScope.ReadButton(
|
||||
|
||||
val animatedCardContainerColor = animateColorAsState(
|
||||
label = "animatedCardContainerColor",
|
||||
targetValue = if (info.totalChapters == 0) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer,
|
||||
targetValue = if (info?.totalChapters == 0) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer,
|
||||
animationSpec = TweenSpec(500)
|
||||
).value
|
||||
|
||||
val animatedCardContentColor = animateColorAsState(
|
||||
label = "animatedCardContentColor",
|
||||
targetValue = if (info.totalChapters == 0) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
targetValue = if (info?.totalChapters == 0) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
animationSpec = TweenSpec(500)
|
||||
).value
|
||||
|
||||
@@ -105,9 +105,9 @@ fun RowScope.ReadButton(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
BackgroundProgress(
|
||||
if (info.totalChapters == 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
if (!info.isValid) 0.1f else 0.33f,
|
||||
info.history?.percent?.coerceIn(0f, 1f) ?: 0f
|
||||
if (info?.totalChapters == 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
if (info?.isValid == true) 0.1f else 0.33f,
|
||||
info?.history?.percent?.coerceIn(0f, 1f) ?: 0f
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -143,7 +143,8 @@ fun RowScope.ReadButton(
|
||||
animationSpec = infiniteRepeatable(tween(15000), RepeatMode.Restart)
|
||||
)
|
||||
val chaptersSubtitle = when {
|
||||
!info.isValid -> stringResource(R.string.loading_)
|
||||
info == null -> "null"
|
||||
info.isValid -> stringResource(R.string.loading_)
|
||||
info.currentChapter >= 0 -> when (infiniteTransition) {
|
||||
1 -> stringResource(
|
||||
R.string.chapter_d_of_d,
|
||||
@@ -162,7 +163,7 @@ fun RowScope.ReadButton(
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (info.history != null) {
|
||||
text = if (info?.history != null) {
|
||||
stringResource(R.string.continue_reading)
|
||||
} else {
|
||||
stringResource(R.string.read)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.outlined.ArrowDropDown
|
||||
import androidx.compose.material.icons.outlined.ArrowDropUp
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -30,28 +31,33 @@ fun SortChip(
|
||||
Box(modifier) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val arrowDrop = if (expanded) Icons.Outlined.ArrowDropUp else Icons.Outlined.ArrowDropDown
|
||||
|
||||
FilterChip(
|
||||
selected = true,
|
||||
onClick = { expanded = true },
|
||||
label = {
|
||||
Text(
|
||||
text = currentSortOption.label(LocalContext.current.resources),
|
||||
modifier = Modifier.animateContentSize(),
|
||||
)
|
||||
AnimatedContent(targetState = currentSortOption.label(LocalContext.current.resources), label = "Text") {
|
||||
Text(text = it)
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
AnimatedContent(targetState = currentSortOption.icon(), label = "Icon") {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Sort,
|
||||
imageVector = it,
|
||||
contentDescription = null, // decorative
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
AnimatedContent(targetState = arrowDrop, label = "Arrow drop") {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
imageVector = it,
|
||||
contentDescription = null, // decorative
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -2,10 +2,15 @@ package org.xtimms.shirizu.core.components
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowOutward
|
||||
import androidx.compose.material.icons.outlined.DateRange
|
||||
import androidx.compose.material.icons.outlined.SortByAlpha
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import org.xtimms.shirizu.R
|
||||
@@ -35,5 +40,12 @@ internal fun ColumnScope.SortDropdownMenuContent(
|
||||
|
||||
internal fun SortOption.label(resources: Resources): String = when (this) {
|
||||
SortOption.ALPHABETICAL -> resources.getString(R.string.sort_alphabetically)
|
||||
SortOption.PROGRESS -> resources.getString(R.string.progress)
|
||||
SortOption.DATE_ADDED -> resources.getString(R.string.sort_date_added)
|
||||
}
|
||||
|
||||
internal fun SortOption.icon(): ImageVector = when (this) {
|
||||
SortOption.ALPHABETICAL -> Icons.Outlined.SortByAlpha
|
||||
SortOption.PROGRESS -> Icons.Outlined.ArrowOutward
|
||||
SortOption.DATE_ADDED -> Icons.Outlined.DateRange
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.xtimms.shirizu.BuildConfig
|
||||
import org.xtimms.shirizu.core.database.entity.MangaSourceEntity
|
||||
import org.xtimms.shirizu.sections.explore.data.SourcesSortOrder
|
||||
|
||||
@@ -65,6 +66,9 @@ abstract class MangaSourcesDao {
|
||||
source = source,
|
||||
isEnabled = isEnabled,
|
||||
sortKey = getMaxSortKey() + 1,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
lastUsedAt = 0,
|
||||
isPinned = false,
|
||||
)
|
||||
upsert(entity)
|
||||
}
|
||||
|
||||
@@ -12,4 +12,7 @@ data class MangaSourceEntity(
|
||||
val source: String,
|
||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
|
||||
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.xtimms.shirizu.core.exceptions
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
class UnsupportedSourceException(
|
||||
message: String?,
|
||||
val manga: Manga?,
|
||||
) : IllegalArgumentException(message)
|
||||
@@ -3,6 +3,7 @@ package org.xtimms.shirizu.core.model
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.utils.system.iterator
|
||||
import java.text.DecimalFormat
|
||||
@@ -61,4 +62,4 @@ fun MangaChapter.formatNumber(): String? {
|
||||
}
|
||||
|
||||
val Manga.isLocal: Boolean
|
||||
get() = source == MangaSource.LOCAL
|
||||
get() = source == LocalMangaSource
|
||||
@@ -1,13 +1,72 @@
|
||||
package org.xtimms.shirizu.core.model
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.parser.external.ExternalMangaSource
|
||||
import org.xtimms.shirizu.utils.system.getDisplayName
|
||||
import org.xtimms.shirizu.utils.system.toLocale
|
||||
|
||||
fun MangaSource(name: String): MangaSource {
|
||||
MangaSource.entries.forEach {
|
||||
data object LocalMangaSource : MangaSource {
|
||||
override val name = "LOCAL"
|
||||
}
|
||||
|
||||
data object UnknownMangaSource : MangaSource {
|
||||
override val name = "UNKNOWN"
|
||||
}
|
||||
|
||||
fun MangaSource(name: String?): MangaSource {
|
||||
when (name ?: return UnknownMangaSource) {
|
||||
UnknownMangaSource.name -> return UnknownMangaSource
|
||||
|
||||
LocalMangaSource.name -> return LocalMangaSource
|
||||
}
|
||||
if (name.startsWith("content:")) {
|
||||
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
|
||||
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
|
||||
}
|
||||
MangaParserSource.entries.forEach {
|
||||
if (it.name == name) return it
|
||||
}
|
||||
return MangaSource.DUMMY
|
||||
return UnknownMangaSource
|
||||
}
|
||||
|
||||
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
|
||||
fun MangaSource.isNsfw(): Boolean = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.isNsfw()
|
||||
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||
else -> false
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
val ContentType.titleResId
|
||||
get() = when (this) {
|
||||
ContentType.MANGA -> R.string.content_type_manga
|
||||
ContentType.HENTAI -> R.string.hentai
|
||||
ContentType.COMICS -> R.string.comics
|
||||
ContentType.OTHER -> R.string.other_source
|
||||
}
|
||||
|
||||
fun MangaSource.getTitle(context: Context): String = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getTitle(context)
|
||||
is MangaParserSource -> title
|
||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||
is ExternalMangaSource -> resolveName(context)
|
||||
else -> context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getSummary(context)
|
||||
is MangaParserSource -> {
|
||||
val type = context.getString(contentType.titleResId)
|
||||
val locale = locale.toLocale().getDisplayName(context)
|
||||
context.getString(R.string.source_summary_pattern, type, locale)
|
||||
}
|
||||
|
||||
is ExternalMangaSource -> context.getString(R.string.external_source)
|
||||
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.xtimms.shirizu.core.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
data class MangaSourceInfo(
|
||||
val mangaSource: MangaSource,
|
||||
val isEnabled: Boolean,
|
||||
val isPinned: Boolean,
|
||||
) : MangaSource by mangaSource
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.xtimms.shirizu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import kotlinx.parcelize.Parceler
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.model.MangaSource
|
||||
|
||||
class MangaSourceParceler : Parceler<MangaSource> {
|
||||
|
||||
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
|
||||
|
||||
override fun MangaSource.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(name)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ object MangaTagParceler : Parceler<MangaTag> {
|
||||
override fun MangaTag.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(title)
|
||||
parcel.writeString(key)
|
||||
parcel.writeSerializable(source)
|
||||
parcel.writeString(source.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ data class ParcelableManga(
|
||||
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||
parcel.writeSerializable(state)
|
||||
parcel.writeString(author)
|
||||
parcel.writeSerializable(source)
|
||||
parcel.writeString(source.name)
|
||||
}
|
||||
|
||||
override fun create(parcel: Parcel) = ParcelableManga(
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
import org.xtimms.shirizu.BuildConfig
|
||||
import org.xtimms.shirizu.core.network.CommonHeaders
|
||||
import org.xtimms.shirizu.core.parser.MangaRepository
|
||||
import org.xtimms.shirizu.core.parser.RemoteMangaRepository
|
||||
import org.xtimms.shirizu.core.parser.ParserMangaRepository
|
||||
import java.net.IDN
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -26,7 +26,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
val request = chain.request()
|
||||
val source = request.tag(MangaSource::class.java)
|
||||
val repository = if (source != null) {
|
||||
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
|
||||
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("Http", "Request without source tag: ${request.url}")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.xtimms.shirizu.core.parser
|
||||
|
||||
import android.graphics.Canvas
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Rect
|
||||
import java.io.OutputStream
|
||||
import android.graphics.Bitmap as AndroidBitmap
|
||||
import android.graphics.Rect as AndroidRect
|
||||
|
||||
class BitmapWrapper private constructor(
|
||||
private val androidBitmap: AndroidBitmap
|
||||
) : Bitmap {
|
||||
|
||||
private val canvas by lazy { Canvas(androidBitmap) }
|
||||
|
||||
override val height: Int
|
||||
get() = androidBitmap.height
|
||||
|
||||
override val width: Int
|
||||
get() = androidBitmap.width
|
||||
|
||||
override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) {
|
||||
val androidSourceBitmap = (sourceBitmap as BitmapWrapper).androidBitmap
|
||||
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
|
||||
}
|
||||
|
||||
fun compressTo(output: OutputStream) {
|
||||
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
|
||||
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
|
||||
)
|
||||
|
||||
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
|
||||
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true)
|
||||
)
|
||||
|
||||
private fun Rect.toAndroidRect() = AndroidRect(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.xtimms.shirizu.core.parser
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.MutableLongSet
|
||||
import coil.request.CachePolicy
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainCoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xtimms.shirizu.BuildConfig
|
||||
import org.xtimms.shirizu.core.cache.MemoryContentCache
|
||||
import org.xtimms.shirizu.core.cache.SafeDeferred
|
||||
import org.xtimms.shirizu.utils.MultiMutex
|
||||
import org.xtimms.shirizu.utils.lang.processLifecycleScope
|
||||
|
||||
abstract class CachingMangaRepository(
|
||||
private val cache: MemoryContentCache,
|
||||
) : MangaRepository {
|
||||
|
||||
private val detailsMutex = MultiMutex<Long>()
|
||||
private val relatedMangaMutex = MultiMutex<Long>()
|
||||
private val pagesMutex = MultiMutex<Long>()
|
||||
|
||||
final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
|
||||
|
||||
final override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
|
||||
cache.getPages(source, chapter.url)?.let { return it }
|
||||
val pages = asyncSafe {
|
||||
getPagesImpl(chapter).distinctById()
|
||||
}
|
||||
cache.putPages(source, chapter.url, pages)
|
||||
pages
|
||||
}.await()
|
||||
|
||||
final override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
|
||||
cache.getRelatedManga(source, seed.url)?.let { return it }
|
||||
val related = asyncSafe {
|
||||
getRelatedMangaImpl(seed).filterNot { it.id == seed.id }
|
||||
}
|
||||
cache.putRelatedManga(source, seed.url, related)
|
||||
related
|
||||
}.await()
|
||||
|
||||
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
|
||||
if (cachePolicy.readEnabled) {
|
||||
cache.getDetails(source, manga.url)?.let { return it }
|
||||
}
|
||||
val details = asyncSafe {
|
||||
getDetailsImpl(manga)
|
||||
}
|
||||
if (cachePolicy.writeEnabled) {
|
||||
cache.putDetails(source, manga.url, details)
|
||||
}
|
||||
details
|
||||
}.await()
|
||||
|
||||
suspend fun peekDetails(manga: Manga): Manga? {
|
||||
return cache.getDetails(source, manga.url)
|
||||
}
|
||||
|
||||
fun invalidateCache() {
|
||||
cache.clear(source)
|
||||
}
|
||||
|
||||
protected abstract suspend fun getDetailsImpl(manga: Manga): Manga
|
||||
|
||||
protected abstract suspend fun getRelatedMangaImpl(seed: Manga): List<Manga>
|
||||
|
||||
protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage>
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
||||
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
||||
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
|
||||
dispatcher = Dispatchers.Default
|
||||
}
|
||||
return SafeDeferred(
|
||||
processLifecycleScope.async(dispatcher) {
|
||||
runCatchingCancellable { block() }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaPage>.distinctById(): List<MangaPage> {
|
||||
if (isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val result = ArrayList<MangaPage>(size)
|
||||
val set = MutableLongSet(size)
|
||||
for (page in this) {
|
||||
if (set.add(page.id)) {
|
||||
result.add(page)
|
||||
} else if (BuildConfig.DEBUG) {
|
||||
Log.w(null, "Duplicate page: $page")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.xtimms.shirizu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.xtimms.shirizu.core.exceptions.UnsupportedSourceException
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
override val states: Set<MangaState>
|
||||
get() = emptySet()
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = emptySet()
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = SortOrder.NEWEST
|
||||
set(value) = Unit
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = false
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = false
|
||||
override val isSearchSupported: Boolean
|
||||
get() = false
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = stub(null)
|
||||
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("This manga source is not supported", manga)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import javax.inject.Provider
|
||||
@Reusable
|
||||
class MangaDataRepository @Inject constructor(
|
||||
private val db: ShirizuDatabase,
|
||||
private val resolverProvider: Provider<MangaLinkResolver>,
|
||||
) {
|
||||
|
||||
suspend fun findMangaById(mangaId: Long): Manga? {
|
||||
@@ -23,6 +24,13 @@ class MangaDataRepository @Inject constructor(
|
||||
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga()
|
||||
}
|
||||
|
||||
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
||||
intent.manga != null -> intent.manga
|
||||
intent.mangaId != 0L -> findMangaById(intent.mangaId)
|
||||
intent.uri != null -> resolverProvider.get().resolve(intent.uri)
|
||||
else -> null
|
||||
}
|
||||
|
||||
suspend fun storeManga(manga: Manga) {
|
||||
db.withTransaction {
|
||||
val tags = manga.tags.toEntities()
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package org.xtimms.shirizu.core.parser
|
||||
|
||||
import android.net.Uri
|
||||
import coil.request.CachePolicy
|
||||
import dagger.Reusable
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.almostEquals
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
|
||||
import org.xtimms.shirizu.core.model.MangaSource
|
||||
import org.xtimms.shirizu.core.model.UnknownMangaSource
|
||||
import org.xtimms.shirizu.core.model.isNsfw
|
||||
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
|
||||
import org.xtimms.shirizu.utils.lang.ifNullOrEmpty
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class MangaLinkResolver @Inject constructor(
|
||||
private val repositoryFactory: MangaRepository.Factory,
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val dataRepository: MangaDataRepository,
|
||||
) {
|
||||
|
||||
suspend fun resolve(uri: Uri): Manga {
|
||||
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
|
||||
resolveAppLink(uri)
|
||||
} else {
|
||||
resolveExternalLink(uri)
|
||||
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
|
||||
}
|
||||
|
||||
private suspend fun resolveAppLink(uri: Uri): Manga? {
|
||||
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
|
||||
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
|
||||
val source = MangaSource(sourceName)
|
||||
require(source != UnknownMangaSource) { "Manga source $sourceName is not supported" }
|
||||
val repo = repositoryFactory.create(source)
|
||||
return repo.findExact(
|
||||
url = uri.getQueryParameter("url"),
|
||||
title = uri.getQueryParameter("name"),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun resolveExternalLink(uri: Uri): Manga? {
|
||||
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
|
||||
return it
|
||||
}
|
||||
val host = uri.host ?: return null
|
||||
val repo = sourcesRepository.allMangaSources.asSequence()
|
||||
.map { source ->
|
||||
repositoryFactory.create(source) as ParserMangaRepository
|
||||
}.find { repo ->
|
||||
host in repo.domains
|
||||
} ?: return null
|
||||
return repo.findExact(uri.toString().toRelativeUrl(host), null)
|
||||
}
|
||||
|
||||
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
|
||||
if (!title.isNullOrEmpty()) {
|
||||
val list = getList(0, MangaListFilter.Search(title))
|
||||
if (url != null) {
|
||||
list.find { it.url == url }?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
list.minByOrNull { it.title.levenshteinDistance(title) }
|
||||
?.takeIf { it.title.almostEquals(title, 0.2f) }
|
||||
?.let { return it }
|
||||
}
|
||||
val seed = getDetailsNoCache(
|
||||
getSeedManga(source, url ?: return null, title),
|
||||
)
|
||||
return runCatchingCancellable {
|
||||
val seedTitle = seed.title.ifEmpty {
|
||||
seed.altTitle
|
||||
}.ifNullOrEmpty {
|
||||
seed.author
|
||||
} ?: return@runCatchingCancellable null
|
||||
val seedList = getList(0, MangaListFilter.Search(seedTitle))
|
||||
seedList.first { x -> x.url == url }
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
|
||||
return if (this is ParserMangaRepository) {
|
||||
getDetails(manga, CachePolicy.READ_ONLY)
|
||||
} else {
|
||||
getDetails(manga)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
|
||||
id = run {
|
||||
var h = 1125899906842597L
|
||||
source.name.forEach { c ->
|
||||
h = 31 * h + c.code
|
||||
}
|
||||
url.forEach { c ->
|
||||
h = 31 * h + c.code
|
||||
}
|
||||
h
|
||||
},
|
||||
title = title.orEmpty(),
|
||||
altTitle = null,
|
||||
url = url,
|
||||
publicUrl = "",
|
||||
rating = 0.0f,
|
||||
isNsfw = source.isNsfw(),
|
||||
coverUrl = "",
|
||||
tags = emptySet(),
|
||||
state = null,
|
||||
author = null,
|
||||
largeCoverUrl = null,
|
||||
description = null,
|
||||
chapters = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.xtimms.shirizu.core.parser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
@@ -10,8 +11,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.asResponseBody
|
||||
import okio.Buffer
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
@@ -20,6 +26,7 @@ import org.xtimms.shirizu.core.network.MangaHttpClient
|
||||
import org.xtimms.shirizu.core.network.cookies.MutableCookieJar
|
||||
import org.xtimms.shirizu.core.prefs.SourceSettings
|
||||
import org.xtimms.shirizu.utils.system.configureForParser
|
||||
import org.xtimms.shirizu.utils.system.requireBody
|
||||
import org.xtimms.shirizu.utils.system.sanitizeHeaderValue
|
||||
import org.xtimms.shirizu.utils.system.toList
|
||||
import java.lang.ref.WeakReference
|
||||
@@ -72,6 +79,30 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
return LocaleListCompat.getAdjustedDefault().toList()
|
||||
}
|
||||
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap {
|
||||
return BitmapWrapper.create(width, height)
|
||||
}
|
||||
|
||||
override fun redrawImageResponse(
|
||||
response: Response,
|
||||
redraw: (image: Bitmap) -> Bitmap
|
||||
): Response {
|
||||
val image = response.requireBody().byteStream()
|
||||
|
||||
val opts = BitmapFactory.Options()
|
||||
opts.inMutable = true
|
||||
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
|
||||
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
|
||||
|
||||
val body = Buffer().also {
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder()
|
||||
.body(body)
|
||||
.build()
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView {
|
||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||
|
||||
@@ -2,8 +2,8 @@ package org.xtimms.shirizu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
|
||||
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||
fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||
return loaderContext.newParserInstance(source)
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
package org.xtimms.shirizu.core.parser
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.collection.ArrayMap
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.xtimms.shirizu.core.cache.ContentCache
|
||||
import org.xtimms.shirizu.core.cache.MemoryContentCache
|
||||
import org.xtimms.shirizu.core.model.LocalMangaSource
|
||||
import org.xtimms.shirizu.core.model.MangaSourceInfo
|
||||
import org.xtimms.shirizu.core.model.UnknownMangaSource
|
||||
import org.xtimms.shirizu.core.parser.external.ExternalMangaRepository
|
||||
import org.xtimms.shirizu.core.parser.external.ExternalMangaSource
|
||||
import org.xtimms.shirizu.core.parser.local.LocalMangaRepository
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.EnumMap
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -30,6 +37,8 @@ interface MangaRepository {
|
||||
|
||||
val contentRatings: Set<ContentRating>
|
||||
|
||||
var defaultSortOrder: SortOrder
|
||||
|
||||
val isMultipleTagsSupported: Boolean
|
||||
|
||||
val isTagsExclusionSupported: Boolean
|
||||
@@ -50,30 +59,58 @@ interface MangaRepository {
|
||||
|
||||
suspend fun getRelated(seed: Manga): List<Manga>
|
||||
|
||||
suspend fun find(manga: Manga): Manga? {
|
||||
val list = getList(0, MangaListFilter.Search(manga.title))
|
||||
return list.find { x -> x.id == manga.id }
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class Factory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val loaderContext: MangaLoaderContext,
|
||||
private val contentCache: ContentCache,
|
||||
private val contentCache: MemoryContentCache,
|
||||
) {
|
||||
|
||||
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
|
||||
private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>()
|
||||
|
||||
@AnyThread
|
||||
fun create(source: MangaSource): MangaRepository {
|
||||
if (source == MangaSource.LOCAL) {
|
||||
return localMangaRepository
|
||||
when (source) {
|
||||
is MangaSourceInfo -> return create(source.mangaSource)
|
||||
LocalMangaSource -> return localMangaRepository
|
||||
UnknownMangaSource -> return EmptyMangaRepository(source)
|
||||
}
|
||||
cache[source]?.get()?.let { return it }
|
||||
return synchronized(cache) {
|
||||
cache[source]?.get()?.let { return it }
|
||||
val repository = RemoteMangaRepository(
|
||||
val repository = createRepository(source)
|
||||
if (repository != null) {
|
||||
cache[source] = WeakReference(repository)
|
||||
repository
|
||||
} else {
|
||||
EmptyMangaRepository(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
|
||||
is MangaParserSource -> ParserMangaRepository(
|
||||
parser = MangaParser(source, loaderContext),
|
||||
cache = contentCache,
|
||||
)
|
||||
cache[source] = WeakReference(repository)
|
||||
repository
|
||||
|
||||
is ExternalMangaSource -> if (source.isAvailable(context)) {
|
||||
ExternalMangaRepository(
|
||||
contentResolver = context.contentResolver,
|
||||
source = source,
|
||||
cache = contentCache,
|
||||
)
|
||||
} else {
|
||||
EmptyMangaRepository(source)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.xtimms.shirizu.core.parser
|
||||
|
||||
import android.util.Log
|
||||
import coil.request.CachePolicy
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -27,17 +26,17 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.domain
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xtimms.shirizu.BuildConfig
|
||||
import org.xtimms.shirizu.core.cache.ContentCache
|
||||
import org.xtimms.shirizu.core.cache.MemoryContentCache
|
||||
import org.xtimms.shirizu.core.cache.SafeDeferred
|
||||
import org.xtimms.shirizu.core.prefs.SourceSettings
|
||||
import org.xtimms.shirizu.utils.lang.processLifecycleScope
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(InternalParsersApi::class)
|
||||
class RemoteMangaRepository(
|
||||
class ParserMangaRepository(
|
||||
private val parser: MangaParser,
|
||||
private val cache: ContentCache,
|
||||
) : MangaRepository, Interceptor {
|
||||
private val cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache), Interceptor {
|
||||
|
||||
override val source: MangaSource
|
||||
get() = parser.source
|
||||
@@ -51,6 +50,12 @@ class RemoteMangaRepository(
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = parser.availableContentRating
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = getConfig().defaultSortOrder ?: sortOrders.first()
|
||||
set(value) {
|
||||
getConfig().defaultSortOrder = value
|
||||
}
|
||||
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = parser.isMultipleTagsSupported
|
||||
|
||||
@@ -70,7 +75,7 @@ class RemoteMangaRepository(
|
||||
get() = parser.configKeyDomain.presetValues
|
||||
|
||||
val headers: Headers
|
||||
get() = parser.headers
|
||||
get() = parser.getRequestHeaders()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return if (parser is Interceptor) {
|
||||
@@ -84,16 +89,9 @@ class RemoteMangaRepository(
|
||||
return parser.getList(offset, filter)
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
cache.getPages(source, chapter.url)?.let { return it }
|
||||
val pages = asyncSafe {
|
||||
parser.getPages(chapter).distinctById()
|
||||
}
|
||||
cache.putPages(source, chapter.url, pages)
|
||||
return pages.await()
|
||||
}
|
||||
override suspend fun getPagesImpl(
|
||||
chapter: MangaChapter
|
||||
): List<MangaPage> = parser.getPages(chapter)
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page)
|
||||
|
||||
@@ -105,59 +103,19 @@ class RemoteMangaRepository(
|
||||
|
||||
suspend fun getFavicons(): Favicons = parser.getFavicons()
|
||||
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> {
|
||||
cache.getRelatedManga(source, seed.url)?.let { return it }
|
||||
val related = asyncSafe {
|
||||
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
|
||||
}
|
||||
cache.putRelatedManga(source, seed.url, related)
|
||||
return related.await()
|
||||
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
|
||||
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = parser.getDetails(manga)
|
||||
|
||||
fun getAvailableMirrors(): List<String> {
|
||||
return parser.configKeyDomain.presetValues.toList()
|
||||
}
|
||||
|
||||
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga {
|
||||
if (cachePolicy.readEnabled) {
|
||||
cache.getDetails(source, manga.url)?.let { return it }
|
||||
}
|
||||
val details = asyncSafe {
|
||||
parser.getDetails(manga)
|
||||
}
|
||||
if (cachePolicy.writeEnabled) {
|
||||
cache.putDetails(source, manga.url, details)
|
||||
}
|
||||
return details.await()
|
||||
fun isSlowdownEnabled(): Boolean {
|
||||
return getConfig().isSlowdownEnabled
|
||||
}
|
||||
|
||||
private fun getConfig() = parser.config as SourceSettings
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
||||
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
||||
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
|
||||
dispatcher = Dispatchers.Default
|
||||
}
|
||||
return SafeDeferred(
|
||||
processLifecycleScope.async(dispatcher) {
|
||||
runCatchingCancellable { block() }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaPage>.distinctById(): List<MangaPage> {
|
||||
if (isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val result = ArrayList<MangaPage>(size)
|
||||
val set = HashSet<Long>(size)
|
||||
for (page in this) {
|
||||
if (set.add(page.id)) {
|
||||
result.add(page)
|
||||
} else if (BuildConfig.DEBUG) {
|
||||
Log.w(null, "Duplicate page: $page")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
|
||||
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
|
||||
private fun Result<*>.isValidResult() = isSuccess && (getOrNull() as? Collection<*>)?.isEmpty() != true
|
||||
}
|
||||
264
app/src/main/java/org/xtimms/shirizu/core/parser/external/ExternalMangaRepository.kt
vendored
Normal file
264
app/src/main/java/org/xtimms/shirizu/core/parser/external/ExternalMangaRepository.kt
vendored
Normal file
@@ -0,0 +1,264 @@
|
||||
package org.xtimms.shirizu.core.parser.external
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
||||
import org.xtimms.shirizu.core.cache.MemoryContentCache
|
||||
import org.xtimms.shirizu.core.parser.CachingMangaRepository
|
||||
import org.xtimms.shirizu.utils.lang.ifNullOrEmpty
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class ExternalMangaRepository(
|
||||
private val contentResolver: ContentResolver,
|
||||
override val source: ExternalMangaSource,
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache) {
|
||||
|
||||
private val capabilities by lazy { queryCapabilities() }
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
|
||||
override val states: Set<MangaState>
|
||||
get() = capabilities?.availableStates.orEmpty()
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = capabilities?.availableContentRating.orEmpty()
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
|
||||
set(value) = Unit
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = capabilities?.isMultipleTagsSupported ?: true
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = capabilities?.isTagsExclusionSupported ?: false
|
||||
override val isSearchSupported: Boolean
|
||||
get() = capabilities?.isSearchSupported ?: true
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
||||
uri.appendQueryParameter("offset", offset.toString())
|
||||
when (filter) {
|
||||
is MangaListFilter.Advanced -> {
|
||||
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
|
||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
|
||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||
}
|
||||
|
||||
is MangaListFilter.Search -> {
|
||||
uri.appendQueryParameter("query", filter.query)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)?.use { cursor ->
|
||||
val result = ArrayList<Manga>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += cursor.getManga()
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = coroutineScope {
|
||||
val chapters = async { queryChapters(manga.url) }
|
||||
val details = queryDetails(manga.url)
|
||||
Manga(
|
||||
id = manga.id,
|
||||
title = details.title.ifBlank { manga.title },
|
||||
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
|
||||
url = details.url.ifEmpty { manga.url },
|
||||
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
|
||||
rating = maxOf(details.rating, manga.rating),
|
||||
isNsfw = details.isNsfw,
|
||||
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
|
||||
tags = details.tags + manga.tags,
|
||||
state = details.state ?: manga.state,
|
||||
author = details.author.ifNullOrEmpty { manga.author },
|
||||
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
description = details.description.ifNullOrEmpty { manga.description },
|
||||
chapters = chapters.await(),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(chapter.url)
|
||||
.build()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArrayList<MangaPage>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaPage(
|
||||
id = cursor.getLong(0),
|
||||
url = cursor.getString(1),
|
||||
preview = cursor.getStringOrNull(2),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = page.url
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/tags".toUri()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArraySet<MangaTag>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaTag(
|
||||
key = cursor.getString(0),
|
||||
title = cursor.getString(1),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = emptySet()
|
||||
|
||||
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
|
||||
|
||||
private suspend fun queryDetails(url: String): Manga = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
checkNotNull(
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getManga()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun queryChapters(url: String): List<MangaChapter>? = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArrayList<MangaChapter>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaChapter(
|
||||
id = cursor.getLong(0),
|
||||
name = cursor.getString(1),
|
||||
number = cursor.getFloat(2),
|
||||
volume = cursor.getInt(3),
|
||||
url = cursor.getString(4),
|
||||
scanlator = cursor.getStringOrNull(5),
|
||||
uploadDate = cursor.getLong(6),
|
||||
branch = cursor.getStringOrNull(7),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor.getManga() = Manga(
|
||||
id = getLong(0),
|
||||
title = getString(1),
|
||||
altTitle = getStringOrNull(2),
|
||||
url = getString(3),
|
||||
publicUrl = getString(4),
|
||||
rating = getFloat(5),
|
||||
isNsfw = getInt(6) > 1,
|
||||
coverUrl = getString(7),
|
||||
tags = getStringOrNull(8)?.split(':')?.mapNotNullToSet {
|
||||
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
|
||||
MangaTag(key = parts.first, title = parts.second, source = source)
|
||||
}.orEmpty(),
|
||||
state = getStringOrNull(9)?.let { MangaState.entries.find(it) },
|
||||
author = optString(10),
|
||||
largeCoverUrl = optString(11),
|
||||
description = optString(12),
|
||||
chapters = emptyList(),
|
||||
source = source,
|
||||
)
|
||||
|
||||
private fun Cursor.optString(columnIndex: Int): String? {
|
||||
return if (isNull(columnIndex)) {
|
||||
null
|
||||
} else {
|
||||
getString(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryCapabilities(): MangaSourceCapabilities? {
|
||||
val uri = "content://${source.authority}/capabilities".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
MangaSourceCapabilities(
|
||||
availableSortOrders = cursor.getStringOrNull(0)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
|
||||
SortOrder.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableStates = cursor.getStringOrNull(1)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
|
||||
MangaState.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableContentRating = cursor.getStringOrNull(2)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
|
||||
ContentRating.entries.find(it)
|
||||
}.orEmpty(),
|
||||
isMultipleTagsSupported = cursor.getInt(3) > 1,
|
||||
isTagsExclusionSupported = cursor.getInt(4) > 1,
|
||||
isSearchSupported = cursor.getInt(5) > 1,
|
||||
contentType = ContentType.entries.find(cursor.getString(6)) ?: ContentType.OTHER,
|
||||
defaultSortOrder = cursor.getStringOrNull(7)?.let {
|
||||
SortOrder.entries.find(it)
|
||||
} ?: SortOrder.ALPHABETICAL,
|
||||
sourceLocale = cursor.getStringOrNull(8)?.let { Locale(it) } ?: Locale.ROOT,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MangaSourceCapabilities(
|
||||
val availableSortOrders: Set<SortOrder>,
|
||||
val availableStates: Set<MangaState>,
|
||||
val availableContentRating: Set<ContentRating>,
|
||||
val isMultipleTagsSupported: Boolean,
|
||||
val isTagsExclusionSupported: Boolean,
|
||||
val isSearchSupported: Boolean,
|
||||
val contentType: ContentType,
|
||||
val defaultSortOrder: SortOrder,
|
||||
val sourceLocale: Locale,
|
||||
)
|
||||
}
|
||||
30
app/src/main/java/org/xtimms/shirizu/core/parser/external/ExternalMangaSource.kt
vendored
Normal file
30
app/src/main/java/org/xtimms/shirizu/core/parser/external/ExternalMangaSource.kt
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
package org.xtimms.shirizu.core.parser.external
|
||||
|
||||
import android.content.Context
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
data class ExternalMangaSource(
|
||||
val packageName: String,
|
||||
val authority: String,
|
||||
) : MangaSource {
|
||||
|
||||
override val name: String
|
||||
get() = "content:$packageName/$authority"
|
||||
|
||||
private var cachedName: String? = null
|
||||
|
||||
fun isAvailable(context: Context): Boolean {
|
||||
return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true
|
||||
}
|
||||
|
||||
fun resolveName(context: Context): String {
|
||||
cachedName?.let {
|
||||
return it
|
||||
}
|
||||
val pm = context.packageManager
|
||||
val info = pm.resolveContentProvider(authority, 0)
|
||||
return info?.loadLabel(pm)?.toString()?.also {
|
||||
cachedName = it
|
||||
} ?: authority
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
package org.xtimms.shirizu.core.parser.favicon
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ImageLoader
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.disk.DiskCache
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
@@ -15,7 +22,9 @@ import coil.network.HttpException
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -27,8 +36,10 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.xtimms.shirizu.core.cache.CacheDir
|
||||
import org.xtimms.shirizu.core.model.MangaSource
|
||||
import org.xtimms.shirizu.core.parser.EmptyMangaRepository
|
||||
import org.xtimms.shirizu.core.parser.MangaRepository
|
||||
import org.xtimms.shirizu.core.parser.RemoteMangaRepository
|
||||
import org.xtimms.shirizu.core.parser.ParserMangaRepository
|
||||
import org.xtimms.shirizu.core.parser.external.ExternalMangaRepository
|
||||
import org.xtimms.shirizu.utils.lang.writeAllCancellable
|
||||
import org.xtimms.shirizu.utils.withExtraCloseable
|
||||
import java.net.HttpURLConnection
|
||||
@@ -46,14 +57,27 @@ class FaviconFetcher(
|
||||
) : Fetcher {
|
||||
|
||||
private val diskCacheKey
|
||||
get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}"
|
||||
get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}"
|
||||
|
||||
private val fileSystem
|
||||
get() = checkNotNull(diskCache.value).fileSystem
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
getCached(options)?.let { return it }
|
||||
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
|
||||
return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
|
||||
is ParserMangaRepository -> fetchParserFavicon(repo)
|
||||
is ExternalMangaRepository -> fetchPluginIcon(repo)
|
||||
is EmptyMangaRepository -> DrawableResult(
|
||||
drawable = ColorDrawable(Color.WHITE),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
|
||||
val sizePx = maxOf(
|
||||
options.size.width.pxOrElse { FALLBACK_SIZE },
|
||||
options.size.height.pxOrElse { FALLBACK_SIZE },
|
||||
@@ -83,6 +107,20 @@ class FaviconFetcher(
|
||||
throwNSEE(lastError)
|
||||
}
|
||||
|
||||
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
|
||||
val source = repository.source
|
||||
val pm = options.context.packageManager
|
||||
val icon = runInterruptible(Dispatchers.IO) {
|
||||
val provider = pm.resolveContentProvider(source.authority, 0)
|
||||
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
|
||||
}
|
||||
return DrawableResult(
|
||||
drawable = icon.nonAdaptive(),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadIcon(url: String, source: MangaSource): Response {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
@@ -167,12 +205,20 @@ class FaviconFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Drawable.nonAdaptive() =
|
||||
if (this is AdaptiveIconDrawable) {
|
||||
LayerDrawable(arrayOf(background, foreground))
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
class Factory(
|
||||
context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
okHttpClientLazy: Lazy<OkHttpClient>,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) : Fetcher.Factory<Uri> {
|
||||
|
||||
private val okHttpClient by okHttpClientLazy
|
||||
private val diskCache = lazy {
|
||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||
DiskCache.Builder()
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xtimms.shirizu.core.model.LocalManga
|
||||
import org.xtimms.shirizu.core.model.LocalMangaSource
|
||||
import org.xtimms.shirizu.core.model.isLocal
|
||||
import org.xtimms.shirizu.core.parser.MangaRepository
|
||||
import org.xtimms.shirizu.core.parser.local.input.LocalMangaInput
|
||||
@@ -49,7 +50,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||
) : MangaRepository {
|
||||
|
||||
override val source = MangaSource.LOCAL
|
||||
override val source = LocalMangaSource
|
||||
private val locks = MultiMutex<Long>()
|
||||
private val localMappingCache = LocalMangaMappingCache()
|
||||
|
||||
@@ -60,6 +61,10 @@ class LocalMangaRepository @Inject constructor(
|
||||
override val states = emptySet<MangaState>()
|
||||
override val contentRatings = emptySet<ContentRating>()
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = SortOrder.NEWEST // TODO
|
||||
set(value) {}
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
if (offset > 0) {
|
||||
return emptyList()
|
||||
@@ -94,7 +99,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = when {
|
||||
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) {
|
||||
manga.source != LocalMangaSource -> requireNotNull(findSavedManga(manga)?.manga) {
|
||||
"Manga is not local or saved"
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
@@ -59,7 +60,7 @@ class MangaIndex(source: String?) {
|
||||
}
|
||||
|
||||
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
|
||||
val source = MangaSource.valueOf(json.getString("source"))
|
||||
val source = MangaParserSource.valueOf(json.getString("source"))
|
||||
Manga(
|
||||
id = json.getLong("id"),
|
||||
title = json.getString("title"),
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||
import org.xtimms.shirizu.core.model.LocalManga
|
||||
import org.xtimms.shirizu.core.model.LocalMangaSource
|
||||
import org.xtimms.shirizu.core.parser.local.MangaIndex
|
||||
import org.xtimms.shirizu.core.parser.local.hasCbzExtension
|
||||
import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput
|
||||
@@ -47,7 +48,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
|
||||
)
|
||||
val manga = info?.copy2(
|
||||
source = MangaSource.LOCAL,
|
||||
source = LocalMangaSource,
|
||||
url = mangaUri,
|
||||
coverUrl = cover,
|
||||
largeCoverUrl = cover,
|
||||
@@ -59,14 +60,14 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
// old downloads
|
||||
chapterFiles.values.elementAtOrNull(i)
|
||||
} ?: return@mapIndexedNotNull null
|
||||
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL)
|
||||
c.copy(url = file.toUri().toString(), source = LocalMangaSource)
|
||||
},
|
||||
) ?: Manga(
|
||||
id = root.absolutePath.longHashCode(),
|
||||
title = root.name.toHumanReadable(),
|
||||
url = mangaUri,
|
||||
publicUrl = mangaUri,
|
||||
source = MangaSource.LOCAL,
|
||||
source = LocalMangaSource,
|
||||
coverUrl = findFirstImageEntry().orEmpty(),
|
||||
chapters = chapterFiles.values.mapIndexed { i, f ->
|
||||
MangaChapter(
|
||||
@@ -74,7 +75,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
name = f.nameWithoutExtension.toHumanReadable(),
|
||||
number = 0f,
|
||||
volume = 0,
|
||||
source = MangaSource.LOCAL,
|
||||
source = LocalMangaSource,
|
||||
uploadDate = f.creationTime,
|
||||
url = f.toUri().toString(),
|
||||
scanlator = null,
|
||||
@@ -106,7 +107,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
.map {
|
||||
val pageUri = it.toUri().toString()
|
||||
MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL)
|
||||
MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
|
||||
}
|
||||
} else {
|
||||
ZipFile(file).use { zip ->
|
||||
@@ -121,7 +122,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
id = pageUri.longHashCode(),
|
||||
url = pageUri,
|
||||
preview = null,
|
||||
source = MangaSource.LOCAL,
|
||||
source = LocalMangaSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||
import org.xtimms.shirizu.core.model.LocalManga
|
||||
import org.xtimms.shirizu.core.model.LocalMangaSource
|
||||
import org.xtimms.shirizu.core.parser.local.MangaIndex
|
||||
import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput
|
||||
import org.xtimms.shirizu.utils.AlphanumComparator
|
||||
@@ -47,12 +48,12 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
||||
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
|
||||
)
|
||||
return@use info.copy2(
|
||||
source = MangaSource.LOCAL,
|
||||
source = LocalMangaSource,
|
||||
url = fileUri,
|
||||
coverUrl = cover,
|
||||
largeCoverUrl = cover,
|
||||
chapters = info.chapters?.map { c ->
|
||||
c.copy(url = fileUri, source = MangaSource.LOCAL)
|
||||
c.copy(url = fileUri, source = LocalMangaSource)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -70,7 +71,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
||||
title = title,
|
||||
url = fileUri,
|
||||
publicUrl = fileUri,
|
||||
source = MangaSource.LOCAL,
|
||||
source = LocalMangaSource,
|
||||
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
|
||||
chapters = chapters.sortedWith(AlphanumComparator())
|
||||
.mapIndexed { i, s ->
|
||||
@@ -79,7 +80,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
||||
name = s.ifEmpty { title },
|
||||
number = 0f,
|
||||
volume = 0,
|
||||
source = MangaSource.LOCAL,
|
||||
source = LocalMangaSource,
|
||||
uploadDate = 0L,
|
||||
url = uriBuilder.fragment(s).build().toString(),
|
||||
scanlator = null,
|
||||
@@ -135,7 +136,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
||||
id = entryUri.longHashCode(),
|
||||
url = entryUri,
|
||||
preview = null,
|
||||
source = MangaSource.LOCAL,
|
||||
source = LocalMangaSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.model.LocalMangaSource
|
||||
|
||||
class LocalMangaUtil(
|
||||
private val manga: Manga,
|
||||
) {
|
||||
|
||||
init {
|
||||
require(manga.source == MangaSource.LOCAL) {
|
||||
require(manga.source == LocalMangaSource) {
|
||||
"Expected LOCAL source but ${manga.source} found"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.xtimms.shirizu.App
|
||||
import org.xtimms.shirizu.ui.theme.SEED
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.network.doh.DoHProvider
|
||||
import org.xtimms.shirizu.sections.library.history.SortOption
|
||||
import org.xtimms.shirizu.ui.monet.PaletteStyle
|
||||
import org.xtimms.shirizu.utils.lang.processLifecycleScope
|
||||
import org.xtimms.shirizu.utils.system.LocaleLanguageCodeMap
|
||||
@@ -36,6 +37,8 @@ const val CONFIGURE = "configure"
|
||||
const val NOTIFICATION = "notification"
|
||||
const val READING_TIME = "reading_time"
|
||||
const val GRID_COLUMNS = "grid_columns"
|
||||
const val SORT_OPTION = "sort_option"
|
||||
|
||||
const val DOH = "doh"
|
||||
|
||||
const val SYSTEM_DEFAULT = 0
|
||||
|
||||
@@ -31,6 +31,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty)
|
||||
} as T
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
|
||||
is ConfigKey.UserAgent -> putString(key.key, value as String?)
|
||||
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
|
||||
is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xtimms.shirizu.core.model.getPreferredBranch
|
||||
import org.xtimms.shirizu.core.parser.MangaRepository
|
||||
import org.xtimms.shirizu.core.parser.RemoteMangaRepository
|
||||
import org.xtimms.shirizu.core.parser.ParserMangaRepository
|
||||
import org.xtimms.shirizu.core.tracker.model.MangaTracking
|
||||
import org.xtimms.shirizu.core.tracker.model.MangaUpdates
|
||||
import org.xtimms.shirizu.data.repository.HistoryRepository
|
||||
@@ -51,7 +51,7 @@ class Tracker @Inject constructor(
|
||||
): MangaUpdates = withMangaLock(track.manga.id) {
|
||||
val updates = runCatchingCancellable {
|
||||
val repo = mangaRepositoryFactory.create(track.manga.source)
|
||||
require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
|
||||
require(repo is ParserMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
|
||||
val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY)
|
||||
compare(track, manga, getBranch(manga))
|
||||
}.getOrElse { error ->
|
||||
|
||||
@@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
@@ -123,22 +122,6 @@ class MangaSearchRepository @Inject constructor(
|
||||
return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList()
|
||||
}
|
||||
|
||||
fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> {
|
||||
if (query.length < 3) {
|
||||
return emptyList()
|
||||
}
|
||||
val skipNsfw = !AppSettings.isNSFWEnabled()
|
||||
val sources = sourcesRepository.allMangaSources
|
||||
.filter { x ->
|
||||
(x.contentType != ContentType.HENTAI || !skipNsfw) && x.title.contains(query, ignoreCase = true)
|
||||
}
|
||||
return if (limit == 0) {
|
||||
sources
|
||||
} else {
|
||||
sources.take(limit)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSearchQuery(query: String) {
|
||||
recentSuggestions.saveRecentQuery(query, null)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
package org.xtimms.shirizu.data.repository
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.xtimms.shirizu.BuildConfig
|
||||
import org.xtimms.shirizu.core.database.ShirizuDatabase
|
||||
import org.xtimms.shirizu.core.database.dao.MangaSourcesDao
|
||||
import org.xtimms.shirizu.core.database.entity.MangaSourceEntity
|
||||
import org.xtimms.shirizu.core.model.MangaSource
|
||||
import org.xtimms.shirizu.core.model.MangaSourceInfo
|
||||
import org.xtimms.shirizu.core.model.getTitle
|
||||
import org.xtimms.shirizu.core.model.isNsfw
|
||||
import org.xtimms.shirizu.core.parser.external.ExternalMangaSource
|
||||
import org.xtimms.shirizu.core.prefs.AppSettings
|
||||
import org.xtimms.shirizu.core.prefs.KotatsuAppSettings
|
||||
import org.xtimms.shirizu.core.prefs.observeAsFlow
|
||||
@@ -25,24 +40,28 @@ import org.xtimms.shirizu.sections.explore.data.SourcesSortOrder
|
||||
import org.xtimms.shirizu.utils.ReversibleHandle
|
||||
import java.util.Collections
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Reusable
|
||||
@Singleton
|
||||
class MangaSourcesRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: ShirizuDatabase,
|
||||
private val settings: KotatsuAppSettings,
|
||||
) {
|
||||
|
||||
private val isNewSourcesAssimilated = AtomicBoolean(false)
|
||||
private val dao: MangaSourcesDao
|
||||
get() = db.getSourcesDao()
|
||||
|
||||
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
|
||||
remove(MangaSource.LOCAL)
|
||||
remove(MangaSource.DUMMY)
|
||||
private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
remove(MangaParserSource.DUMMY)
|
||||
}
|
||||
}
|
||||
|
||||
val allMangaSources: Set<MangaSource>
|
||||
val allMangaSources: Set<MangaParserSource>
|
||||
get() = Collections.unmodifiableSet(remoteSources)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
@@ -54,7 +73,8 @@ class MangaSourcesRepository @Inject constructor(
|
||||
return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled)
|
||||
}
|
||||
|
||||
fun observeDisabledSources(): Flow<List<MangaSource>> = combine(
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun observeDisabledSources(): Flow<List<MangaParserSource>> = combine(
|
||||
observeIsNsfwDisabled(),
|
||||
observeSortOrder(),
|
||||
) { skipNsfw, _ ->
|
||||
@@ -84,7 +104,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
|
||||
fun observeEnabledSources(): Flow<List<MangaParserSource>> = combine(
|
||||
observeIsNsfwDisabled(),
|
||||
observeSortOrder(),
|
||||
) { skipNsfw, order ->
|
||||
@@ -120,10 +140,13 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun assimilateNewSources(): Set<MangaSource> {
|
||||
suspend fun assimilateNewSources(): Boolean {
|
||||
if (isNewSourcesAssimilated.getAndSet(true)) {
|
||||
return false
|
||||
}
|
||||
val new = getNewSources()
|
||||
if (new.isEmpty()) {
|
||||
return emptySet()
|
||||
return false
|
||||
}
|
||||
var maxSortKey = dao.getMaxSortKey()
|
||||
val entities = new.map { x ->
|
||||
@@ -131,20 +154,20 @@ class MangaSourcesRepository @Inject constructor(
|
||||
source = x.name,
|
||||
isEnabled = false,
|
||||
sortKey = ++maxSortKey,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
lastUsedAt = 0,
|
||||
isPinned = false,
|
||||
)
|
||||
}
|
||||
dao.insertIfAbsent(entities)
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
new.removeAll { x -> x.isNsfw() }
|
||||
}
|
||||
return new
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun isSetupRequired(): Boolean {
|
||||
return dao.findAll().isEmpty()
|
||||
}
|
||||
|
||||
private suspend fun getNewSources(): MutableSet<MangaSource> {
|
||||
private suspend fun getNewSources(): MutableSet<out MangaSource> {
|
||||
val entities = dao.findAll()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
for (e in entities) {
|
||||
@@ -155,11 +178,11 @@ class MangaSourcesRepository @Inject constructor(
|
||||
|
||||
private fun List<MangaSourceEntity>.toSources(
|
||||
skipNsfwSources: Boolean,
|
||||
): List<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(size)
|
||||
): List<MangaParserSource> {
|
||||
val result = ArrayList<MangaParserSource>(size)
|
||||
for (entity in this) {
|
||||
val source = MangaSource(entity.source)
|
||||
if (skipNsfwSources && source.contentType == ContentType.HENTAI) {
|
||||
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||
if (skipNsfwSources && source.isNsfw()) {
|
||||
continue
|
||||
}
|
||||
if (source in remoteSources) {
|
||||
@@ -169,6 +192,41 @@ class MangaSourcesRepository @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private fun observeExternalSources(): Flow<List<ExternalMangaSource>> {
|
||||
val intent = Intent("app.kotatsu.parser.PROVIDE_MANGA")
|
||||
val pm = context.packageManager
|
||||
return callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
trySendBlocking(intent)
|
||||
}
|
||||
}
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_VERIFIED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
addDataScheme("package")
|
||||
},
|
||||
ContextCompat.RECEIVER_EXPORTED,
|
||||
)
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}.onStart {
|
||||
emit(null)
|
||||
}.map {
|
||||
pm.queryIntentContentProviders(intent, 0).map { resolveInfo ->
|
||||
ExternalMangaSource(
|
||||
packageName = resolveInfo.providerInfo.packageName,
|
||||
authority = resolveInfo.providerInfo.authority,
|
||||
)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
private fun observeIsNsfwDisabled() = MutableStateFlow(AppSettings.isNSFWEnabled()).asStateFlow()
|
||||
|
||||
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_NEW) {
|
||||
@@ -178,4 +236,6 @@ class MangaSourcesRepository @Inject constructor(
|
||||
private fun observeSortOrder() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_ORDER) {
|
||||
sourcesSortOrder
|
||||
}
|
||||
|
||||
private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
import org.xtimms.shirizu.core.database.entity.BookmarkEntity
|
||||
import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity
|
||||
@@ -83,6 +84,9 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
source = json.getString("source"),
|
||||
isEnabled = json.getBoolean("enabled"),
|
||||
sortKey = json.getInt("sort_key"),
|
||||
addedIn = json.getIntOrDefault("added_in", 0),
|
||||
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
||||
)
|
||||
|
||||
fun toMap(): Map<String, Any?> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.xtimms.shirizu.di
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.text.Html
|
||||
import androidx.work.WorkManager
|
||||
@@ -22,9 +21,6 @@ import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.xtimms.shirizu.BuildConfig
|
||||
import org.xtimms.shirizu.core.cache.CacheDir
|
||||
import org.xtimms.shirizu.core.cache.ContentCache
|
||||
import org.xtimms.shirizu.core.cache.MemoryContentCache
|
||||
import org.xtimms.shirizu.core.cache.StubContentCache
|
||||
import org.xtimms.shirizu.core.database.ShirizuDatabase
|
||||
import org.xtimms.shirizu.core.model.LocalManga
|
||||
import org.xtimms.shirizu.core.network.MangaHttpClient
|
||||
@@ -38,6 +34,7 @@ import org.xtimms.shirizu.sections.reader.thumbnails.MangaPageFetcher
|
||||
import org.xtimms.shirizu.utils.CoilImageGetter
|
||||
import org.xtimms.shirizu.utils.system.connectivityManager
|
||||
import org.xtimms.shirizu.utils.system.isLowRamDevice
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -70,7 +67,7 @@ interface ShirizuModule {
|
||||
@Singleton
|
||||
fun provideCoil(
|
||||
@ApplicationContext context: Context,
|
||||
@MangaHttpClient okHttpClient: OkHttpClient,
|
||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
imageProxyInterceptor: ImageProxyInterceptor,
|
||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||
@@ -81,37 +78,30 @@ interface ShirizuModule {
|
||||
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
||||
.build()
|
||||
}
|
||||
val okHttpClientLazy = lazy {
|
||||
okHttpClientProvider.get().newBuilder().cache(null).build()
|
||||
}
|
||||
return ImageLoader.Builder(context)
|
||||
.crossfade(500)
|
||||
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
|
||||
.okHttpClient { okHttpClientLazy.value }
|
||||
.interceptorDispatcher(Dispatchers.Default)
|
||||
.fetcherDispatcher(Dispatchers.IO)
|
||||
.decoderDispatcher(Dispatchers.Default)
|
||||
.transformationDispatcher(Dispatchers.Default)
|
||||
.diskCache(diskCacheFactory)
|
||||
.respectCacheHeaders(false)
|
||||
.networkObserverEnabled(false)
|
||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||
.allowRgb565(context.isLowRamDevice())
|
||||
.components(
|
||||
ComponentRegistry.Builder()
|
||||
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
|
||||
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
||||
.add(pageFetcherFactory)
|
||||
.add(imageProxyInterceptor)
|
||||
.build(),
|
||||
).build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideContentCache(
|
||||
application: Application,
|
||||
): ContentCache {
|
||||
return if (application.isLowRamDevice()) {
|
||||
StubContentCache()
|
||||
} else {
|
||||
MemoryContentCache(application)
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@LocalStorageChanges
|
||||
|
||||
@@ -32,11 +32,13 @@ import androidx.compose.ui.unit.dp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.ShirizuAsyncImage
|
||||
import org.xtimms.shirizu.core.components.MangaCover
|
||||
import org.xtimms.shirizu.core.model.getTitle
|
||||
import org.xtimms.shirizu.sections.details.data.ReadingTime
|
||||
import org.xtimms.shirizu.sections.details.model.HistoryInfo
|
||||
|
||||
@@ -45,13 +47,13 @@ fun ClassicDetailsInfoBox(
|
||||
imageUrl: String,
|
||||
favicon: Uri,
|
||||
title: String,
|
||||
altTitle: String,
|
||||
author: String,
|
||||
altTitle: String?,
|
||||
author: String?,
|
||||
isNsfw: Boolean,
|
||||
state: MangaState?,
|
||||
source: MangaSource,
|
||||
historyInfo: HistoryInfo,
|
||||
readingTime: ReadingTime,
|
||||
historyInfo: HistoryInfo?,
|
||||
readingTime: ReadingTime?,
|
||||
isTabletUi: Boolean,
|
||||
appBarPadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -133,8 +135,8 @@ fun MangaInfoLarge(
|
||||
imageUrl: String,
|
||||
favicon: Uri,
|
||||
title: String,
|
||||
altTitle: String,
|
||||
author: String,
|
||||
altTitle: String?,
|
||||
author: String?,
|
||||
source: MangaSource,
|
||||
state: MangaState?,
|
||||
historyInfo: HistoryInfo?,
|
||||
@@ -168,7 +170,7 @@ fun MangaInfoLarge(
|
||||
altTitle = altTitle,
|
||||
author = author,
|
||||
state = state,
|
||||
source = source.title,
|
||||
source = source.name,
|
||||
isInShelf = isInShelf,
|
||||
onAddToShelfClicked = onAddToShelfClicked,
|
||||
onSourceClicked = onSourceClicked,
|
||||
@@ -185,12 +187,12 @@ fun MangaInfoSmall(
|
||||
imageUrl: String,
|
||||
favicon: Uri,
|
||||
title: String,
|
||||
altTitle: String,
|
||||
author: String,
|
||||
altTitle: String?,
|
||||
author: String?,
|
||||
state: MangaState?,
|
||||
source: MangaSource,
|
||||
historyInfo: HistoryInfo,
|
||||
readingTime: ReadingTime,
|
||||
historyInfo: HistoryInfo?,
|
||||
readingTime: ReadingTime?,
|
||||
isInShelf: Boolean,
|
||||
onAddToShelfClicked: () -> Unit,
|
||||
onCoverClick: () -> Unit,
|
||||
@@ -225,7 +227,7 @@ fun MangaInfoSmall(
|
||||
altTitle = altTitle,
|
||||
author = author,
|
||||
state = state,
|
||||
source = source.title,
|
||||
source = source.name,
|
||||
isInShelf = isInShelf,
|
||||
onAddToShelfClicked = onAddToShelfClicked,
|
||||
onSourceClicked = onSourceClicked,
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
@@ -82,6 +83,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
@@ -108,7 +110,7 @@ fun MangaAndSourceTitlesLarge(
|
||||
title: String,
|
||||
altTitle: String,
|
||||
author: String,
|
||||
source: MangaSource,
|
||||
source: MangaParserSource,
|
||||
state: MangaState?,
|
||||
historyInfo: HistoryInfo,
|
||||
readingTime: ReadingTime?,
|
||||
@@ -153,7 +155,7 @@ fun MangaAndSourceTitlesSmall(
|
||||
altTitle: String,
|
||||
author: String,
|
||||
state: MangaState?,
|
||||
source: MangaSource,
|
||||
source: MangaParserSource,
|
||||
historyInfo: HistoryInfo,
|
||||
readingTime: ReadingTime?,
|
||||
isInShelf: Boolean,
|
||||
@@ -188,19 +190,16 @@ fun MangaAndSourceTitlesSmall(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalLayoutApi::class,
|
||||
ExperimentalMaterial3Api::class
|
||||
)
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DetailsContentInfo(
|
||||
favicon: Uri,
|
||||
title: String,
|
||||
altTitle: String,
|
||||
author: String,
|
||||
altTitle: String?,
|
||||
author: String?,
|
||||
state: MangaState?,
|
||||
source: String?,
|
||||
historyInfo: HistoryInfo,
|
||||
historyInfo: HistoryInfo?,
|
||||
readingTime: ReadingTime?,
|
||||
isInShelf: Boolean,
|
||||
onAddToShelfClicked: () -> Unit,
|
||||
@@ -224,7 +223,7 @@ fun DetailsContentInfo(
|
||||
maxLines = 3
|
||||
)
|
||||
|
||||
if (altTitle.isNotBlank()) {
|
||||
if (!altTitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = altTitle,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
@@ -235,7 +234,7 @@ fun DetailsContentInfo(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
if (author.isNotEmpty()) {
|
||||
if (!author.isNullOrBlank()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
@@ -294,7 +293,7 @@ fun DetailsContentInfo(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
InputChip(
|
||||
selected = false,
|
||||
onClick = { onAddToShelfClicked() },
|
||||
@@ -354,7 +353,7 @@ fun DetailsContentInfo(
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.width(56.dp),
|
||||
onClick = { onAddToShelfClicked() /*TODO*/ },
|
||||
onClick = { /*TODO*/ },
|
||||
shape = MaterialTheme.shapes.small,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
|
||||
) {
|
||||
@@ -579,14 +578,13 @@ private fun MangaSummary(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TagsChip(
|
||||
tag: MangaTag,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
SuggestionChip(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
|
||||
import org.xtimms.shirizu.core.ui.screens.LoadingScreen
|
||||
import org.xtimms.shirizu.utils.lang.AssistContentScreen
|
||||
import org.xtimms.shirizu.utils.lang.Screen
|
||||
@@ -26,13 +27,9 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
class DetailsScreen(
|
||||
private val manga: Manga,
|
||||
private val mangaId: Long,
|
||||
val fromSource: Boolean = false,
|
||||
) : Screen(), AssistContentScreen {
|
||||
|
||||
private var assistUrl: String? = null
|
||||
|
||||
override fun onProvideAssistUrl() = assistUrl
|
||||
) : Screen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
@@ -43,7 +40,7 @@ class DetailsScreen(
|
||||
|
||||
val screenModel =
|
||||
getScreenModel<DetailsScreenModel, DetailsScreenModel.Factory> { factory ->
|
||||
factory.create(context, manga, SnackbarHostState())
|
||||
factory.create(context, mangaId, SnackbarHostState())
|
||||
}
|
||||
|
||||
val state by screenModel.state.collectAsState()
|
||||
@@ -54,13 +51,13 @@ class DetailsScreen(
|
||||
}
|
||||
|
||||
val successState = state as DetailsScreenModel.State.Success
|
||||
val isOnlineSource = remember { successState.source != MangaSource.DUMMY && successState.source != MangaSource.LOCAL }
|
||||
|
||||
MangaScreen(
|
||||
state = successState,
|
||||
snackbarHostState = screenModel.snackbarHostState,
|
||||
isTabletUi = isTabletUi(),
|
||||
onBackClicked = navigator::pop,
|
||||
onMangaClicked = { },
|
||||
onWebViewClicked = {
|
||||
|
||||
},
|
||||
@@ -77,15 +74,4 @@ class DetailsScreen(
|
||||
onCoverClicked = { },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getMangaUrl(manga_: Manga?, parser_: MangaParser?): String? {
|
||||
val manga = manga_ ?: return null
|
||||
val source = parser_ ?: return null
|
||||
|
||||
return try {
|
||||
source.getDetails(manga).publicUrl
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,53 +3,40 @@ package org.xtimms.shirizu.sections.details
|
||||
import android.content.Context
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Immutable
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import cafe.adriel.voyager.hilt.ScreenModelFactory
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.base.viewmodel.BaseStateScreenModel
|
||||
import org.xtimms.shirizu.core.model.findById
|
||||
import org.xtimms.shirizu.core.model.getPreferredBranch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xtimms.shirizu.core.parser.MangaDataRepository
|
||||
import org.xtimms.shirizu.data.repository.BookmarksRepository
|
||||
import org.xtimms.shirizu.data.repository.FavouritesRepository
|
||||
import org.xtimms.shirizu.data.repository.HistoryRepository
|
||||
import org.xtimms.shirizu.sections.details.data.MangaDetails
|
||||
import org.xtimms.shirizu.sections.details.data.ReadingTime
|
||||
import org.xtimms.shirizu.sections.details.domain.BranchComparator
|
||||
import org.xtimms.shirizu.sections.details.domain.DetailsInteractor
|
||||
import org.xtimms.shirizu.sections.details.domain.DetailsLoadUseCase
|
||||
import org.xtimms.shirizu.sections.details.domain.ReadingTimeUseCase
|
||||
import org.xtimms.shirizu.sections.details.domain.RelatedMangaUseCase
|
||||
import org.xtimms.shirizu.sections.details.model.ChapterItem
|
||||
import org.xtimms.shirizu.sections.details.model.HistoryInfo
|
||||
import org.xtimms.shirizu.sections.details.model.MangaBranch
|
||||
import org.xtimms.shirizu.utils.lang.onEachWhile
|
||||
import org.xtimms.shirizu.utils.system.getDisplayMessage
|
||||
|
||||
class DetailsScreenModel @AssistedInject constructor(
|
||||
@Assisted val context: Context,
|
||||
@Assisted val manga: Manga,
|
||||
@Assisted val mangaId: Long,
|
||||
private val interactor: DetailsInteractor,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val bookmarksRepository: BookmarksRepository,
|
||||
private val favouritesRepository: FavouritesRepository,
|
||||
@@ -57,13 +44,16 @@ class DetailsScreenModel @AssistedInject constructor(
|
||||
private val readingTimeUseCase: ReadingTimeUseCase,
|
||||
private val relatedMangaUseCase: RelatedMangaUseCase,
|
||||
@Assisted val snackbarHostState: SnackbarHostState = SnackbarHostState(),
|
||||
) : BaseStateScreenModel<DetailsScreenModel.State>(State.Loading) {
|
||||
) : StateScreenModel<DetailsScreenModel.State>(State.Loading) {
|
||||
|
||||
private val successState: State.Success?
|
||||
get() = state.value as? State.Success
|
||||
|
||||
private val _events: Channel<Event> = Channel(Channel.UNLIMITED)
|
||||
val events: Flow<Event> = _events.receiveAsFlow()
|
||||
val details: MangaDetails?
|
||||
get() = successState?.details
|
||||
|
||||
val history = historyRepository.observeOne(mangaId)
|
||||
.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
private inline fun updateSuccessState(func: (State.Success) -> State.Success) {
|
||||
mutableState.update {
|
||||
@@ -74,142 +64,55 @@ class DetailsScreenModel @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingJob: Job
|
||||
|
||||
var details = MutableStateFlow(MangaDetails(manga, null, null, false))
|
||||
|
||||
private val mangaImpl = details.map { x -> x.toManga() }
|
||||
.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val history = historyRepository.observeOne(manga.id)
|
||||
.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val favouriteCategories = interactor.observeIsFavourite(manga.id)
|
||||
.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
|
||||
val remoteManga = MutableStateFlow<Manga?>(null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val newChaptersCount = details.flatMapLatest { d ->
|
||||
flowOf(0)
|
||||
}.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
||||
|
||||
private val chaptersQuery = MutableStateFlow("")
|
||||
val selectedBranch = MutableStateFlow<String?>(null)
|
||||
|
||||
val historyInfo: StateFlow<HistoryInfo> = combine(
|
||||
mangaImpl,
|
||||
selectedBranch,
|
||||
history,
|
||||
) { m, b, h ->
|
||||
HistoryInfo(m, b, h)
|
||||
}.stateIn(
|
||||
scope = screenModelScope + Dispatchers.Default,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = HistoryInfo(null, null, null),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val bookmarks = mangaImpl.flatMapLatest {
|
||||
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
|
||||
}.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val relatedManga: StateFlow<List<Manga>> = mangaImpl.mapLatest {
|
||||
if (it != null) {
|
||||
relatedMangaUseCase.invoke(it).orEmpty()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
|
||||
|
||||
val branches: StateFlow<List<MangaBranch>> = combine(
|
||||
details,
|
||||
selectedBranch,
|
||||
history,
|
||||
) { m, b, h ->
|
||||
val c = m.chapters
|
||||
if (c.isEmpty()) {
|
||||
return@combine emptyList()
|
||||
}
|
||||
val currentBranch = h?.let { m.allChapters.findById(it.chapterId) }?.branch
|
||||
c.map { x ->
|
||||
MangaBranch(
|
||||
name = x.key,
|
||||
count = x.value.size,
|
||||
isSelected = x.key == b,
|
||||
isCurrent = h != null && x.key == currentBranch,
|
||||
)
|
||||
}.sortedWith(BranchComparator())
|
||||
}.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val isChaptersEmpty: StateFlow<Boolean> = details.map {
|
||||
it.isLoaded && it.allChapters.isEmpty()
|
||||
}.stateIn(screenModelScope, SharingStarted.WhileSubscribed(), false)
|
||||
|
||||
val chapters = combine(
|
||||
combine(
|
||||
details,
|
||||
history,
|
||||
selectedBranch,
|
||||
newChaptersCount,
|
||||
bookmarks,
|
||||
) { manga, history, branch, news, bookmarks ->
|
||||
manga.mapChapters(
|
||||
history,
|
||||
news,
|
||||
branch,
|
||||
bookmarks,
|
||||
)
|
||||
},
|
||||
chaptersQuery,
|
||||
) { list, query ->
|
||||
list.filterSearch(query)
|
||||
}.stateIn(screenModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val readingTime = combine(
|
||||
details,
|
||||
selectedBranch,
|
||||
history,
|
||||
) { m, b, h ->
|
||||
readingTimeUseCase.invoke(m, b, h)
|
||||
}.stateIn(screenModelScope, SharingStarted.Lazily, null)
|
||||
|
||||
val selectedBranchValue: String?
|
||||
get() = selectedBranch.value
|
||||
private val selectedPositions: Array<Int> = arrayOf(-1, -1) // first and last selected index in list
|
||||
private val selectedChapterIds: HashSet<Long> = HashSet()
|
||||
|
||||
init {
|
||||
loadingJob = doLoad(manga.id)
|
||||
updateSuccessState { it.copy(isRefreshingData = false) }
|
||||
screenModelScope.launch(Dispatchers.IO) {
|
||||
detailsLoadUseCase.invoke(mangaId)
|
||||
.collectLatest { details ->
|
||||
updateSuccessState {
|
||||
it.copy(
|
||||
details = details
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun doLoad(mangaId: Long) = launchLoadingJob(Dispatchers.Default) {
|
||||
detailsLoadUseCase.invoke(mangaId)
|
||||
.onEachWhile {
|
||||
if (it.allChapters.isEmpty()) {
|
||||
return@onEachWhile false
|
||||
}
|
||||
val manga = it.toManga()
|
||||
// find default branch
|
||||
val hist = historyRepository.getOne(manga)
|
||||
selectedBranch.value = manga.getPreferredBranch(hist)
|
||||
true
|
||||
}.catch { error ->
|
||||
_events.send(Event.InternalError)
|
||||
snackbarHostState.showSnackbar(error.message ?: error.stackTraceToString())
|
||||
}.collect {
|
||||
details.value = it
|
||||
screenModelScope.launch(Dispatchers.IO) {
|
||||
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId))
|
||||
val details = MangaDetails(manga, null, null, false)
|
||||
|
||||
val needRefreshInfo = !details.isLoaded
|
||||
|
||||
mutableState.update {
|
||||
State.Success(
|
||||
manga = details.value.toManga(),
|
||||
source = details.value.toManga().source,
|
||||
readingTime = checkNotNull(readingTime.value),
|
||||
historyInfo = historyInfo.value,
|
||||
availableScanlators = setOf(),
|
||||
excludedScanlators = setOf(),
|
||||
isRefreshingData = false
|
||||
details = details
|
||||
)
|
||||
}
|
||||
|
||||
if (screenModelScope.isActive) {
|
||||
val fetchFromSourceTasks = listOf(
|
||||
async { if (needRefreshInfo) fetchMangaFromSource() },
|
||||
)
|
||||
fetchFromSourceTasks.awaitAll()
|
||||
}
|
||||
|
||||
updateSuccessState { it.copy(isRefreshingData = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMangaFromSource(manualFetch: Boolean = false) {
|
||||
val state = successState ?: return
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val networkManga = state.details.toManga()
|
||||
detailsLoadUseCase.getDetails(networkManga)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
screenModelScope.launch {
|
||||
snackbarHostState.showSnackbar(message = with(context) { e.getDisplayMessage(resources) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,12 +125,6 @@ class DetailsScreenModel @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFromHistory() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
historyRepository.delete(setOf(manga.id))
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
data object InternalError : Event
|
||||
}
|
||||
@@ -238,12 +135,7 @@ class DetailsScreenModel @AssistedInject constructor(
|
||||
|
||||
@Immutable
|
||||
data class Success(
|
||||
val manga: Manga,
|
||||
val source: MangaSource,
|
||||
val historyInfo: HistoryInfo,
|
||||
val readingTime: ReadingTime,
|
||||
val availableScanlators: Set<String>,
|
||||
val excludedScanlators: Set<String>,
|
||||
val details: MangaDetails,
|
||||
val isRefreshingData: Boolean = false,
|
||||
) : State
|
||||
}
|
||||
@@ -252,7 +144,7 @@ class DetailsScreenModel @AssistedInject constructor(
|
||||
interface Factory : ScreenModelFactory {
|
||||
fun create(
|
||||
context: Context,
|
||||
manga: Manga,
|
||||
mangaId: Long,
|
||||
snackbarHostState: SnackbarHostState
|
||||
): DetailsScreenModel
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.xtimms.shirizu.sections.details
|
||||
|
||||
import org.xtimms.shirizu.core.base.state.UiState
|
||||
import org.xtimms.shirizu.sections.details.data.MangaDetails
|
||||
|
||||
data class DetailsUiState(
|
||||
val details: MangaDetails? = null,
|
||||
override val isLoading: Boolean = false,
|
||||
override val message: String? = null,
|
||||
) : UiState() {
|
||||
override fun setLoading(value: Boolean) = copy(isLoading = value)
|
||||
override fun setMessage(value: String?) = copy(message = value)
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
package org.xtimms.shirizu.sections.details
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -19,13 +31,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.components.ClassicDetailsToolbar
|
||||
import org.xtimms.shirizu.core.components.MangaHorizontalItem
|
||||
import org.xtimms.shirizu.core.components.Scaffold
|
||||
import org.xtimms.shirizu.core.components.VerticalFastScroller
|
||||
import org.xtimms.shirizu.core.model.MangaHistory
|
||||
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
|
||||
import org.xtimms.shirizu.core.parser.favicon.faviconUri
|
||||
import org.xtimms.shirizu.core.prefs.AppSettings
|
||||
import org.xtimms.shirizu.sections.details.data.ReadingTime
|
||||
import org.xtimms.shirizu.sections.details.model.HistoryInfo
|
||||
import java.time.Instant
|
||||
@@ -36,6 +53,7 @@ fun MangaScreen(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
isTabletUi: Boolean,
|
||||
onBackClicked: () -> Unit,
|
||||
onMangaClicked: (Manga) -> Unit,
|
||||
onWebViewClicked: (() -> Unit)?,
|
||||
onWebViewLongClicked: (() -> Unit)?,
|
||||
onTrackingClicked: () -> Unit,
|
||||
@@ -56,6 +74,7 @@ fun MangaScreen(
|
||||
snackbarHostState = snackbarHostState,
|
||||
onBackClicked = onBackClicked,
|
||||
onTagSearch = onTagSearch,
|
||||
onMangaClicked = onMangaClicked,
|
||||
onRefresh = onRefresh,
|
||||
)
|
||||
}
|
||||
@@ -68,6 +87,7 @@ private fun MangaScreenSmallImpl(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onBackClicked: () -> Unit,
|
||||
onTagSearch: (String) -> Unit,
|
||||
onMangaClicked: (Manga) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val chapterListState = rememberLazyListState()
|
||||
@@ -91,7 +111,7 @@ private fun MangaScreenSmallImpl(
|
||||
label = "Top Bar Background",
|
||||
)
|
||||
ClassicDetailsToolbar(
|
||||
title = state.manga?.title ?: "",
|
||||
title = state.details.toManga().title,
|
||||
titleAlphaProvider = { animatedTitleAlpha },
|
||||
backgroundAlphaProvider = { animatedBgAlpha },
|
||||
navigateBack = { onBackClicked() },
|
||||
@@ -102,6 +122,8 @@ private fun MangaScreenSmallImpl(
|
||||
val topPadding = contentPadding.calculateTopPadding()
|
||||
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val relatedMangaListState = rememberLazyListState()
|
||||
|
||||
VerticalFastScroller(
|
||||
listState = chapterListState,
|
||||
topContentPadding = topPadding,
|
||||
@@ -121,16 +143,16 @@ private fun MangaScreenSmallImpl(
|
||||
contentType = DetailsScreenItem.INFO_BOX,
|
||||
) {
|
||||
ClassicDetailsInfoBox(
|
||||
imageUrl = state.manga.largeCoverUrl ?: state.manga.coverUrl,
|
||||
favicon = state.manga.source.faviconUri(),
|
||||
title = state.manga.title,
|
||||
altTitle = state.manga.altTitle ?: stringResource(id = R.string.unknown),
|
||||
author = state.manga.author ?: stringResource(id = R.string.unknown),
|
||||
isNsfw = state.manga.isNsfw,
|
||||
state = state.manga.state,
|
||||
source = state.manga.source,
|
||||
historyInfo = state.historyInfo,
|
||||
readingTime = state.readingTime,
|
||||
imageUrl = state.details.toManga().largeCoverUrl ?: state.details.toManga().coverUrl,
|
||||
favicon = state.details.toManga().source.faviconUri(),
|
||||
title = state.details.toManga().title,
|
||||
altTitle = state.details.toManga().altTitle,
|
||||
author = state.details.toManga().author,
|
||||
isNsfw = state.details.toManga().isNsfw,
|
||||
state = state.details.toManga().state,
|
||||
source = state.details.toManga().source,
|
||||
historyInfo = null,
|
||||
readingTime = null,
|
||||
isTabletUi = false,
|
||||
appBarPadding = topPadding,
|
||||
onCoverClick = { },
|
||||
@@ -147,12 +169,71 @@ private fun MangaScreenSmallImpl(
|
||||
) {
|
||||
ExpandableMangaDescription(
|
||||
defaultExpandState = false,
|
||||
description = state.manga?.description,
|
||||
tagsProvider = { state.manga?.tags },
|
||||
description = state.details.toManga().description,
|
||||
tagsProvider = { state.details.toManga().tags },
|
||||
onTagSearch = onTagSearch,
|
||||
onCopyTagToClipboard = { },
|
||||
)
|
||||
}
|
||||
|
||||
/*item {
|
||||
AnimatedVisibility(
|
||||
visible = state.relatedManga.isNotEmpty(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
|
||||
text = stringResource(id = R.string.related_manga),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.sizeIn(minHeight = 100.dp),
|
||||
state = relatedMangaListState,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = relatedMangaListState)
|
||||
) {
|
||||
items(
|
||||
items = state.relatedManga,
|
||||
key = { it.id },
|
||||
contentType = { it }
|
||||
) {
|
||||
MangaHorizontalItem(
|
||||
manga = it,
|
||||
onClick = { manga -> onMangaClicked(manga) },
|
||||
onLongClick = { })
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp),
|
||||
text = stringResource(id = R.string.chapters),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
|
||||
/*items(
|
||||
items = state.chapters
|
||||
) {
|
||||
ChapterListItem(
|
||||
title = it.chapter.name,
|
||||
date = it.chapter.uploadDate,
|
||||
scanlator = it.chapter.scanlator,
|
||||
read = !it.isUnread,
|
||||
bookmark = false,
|
||||
selected = false,
|
||||
onLongClick = { *//*TODO*//* },
|
||||
onClick = { *//*TODO*//* }
|
||||
)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.xtimms.shirizu.core.ShirizuAsyncImage
|
||||
@@ -38,7 +39,7 @@ fun ModernDetailsInfoBox(
|
||||
author: String,
|
||||
isNsfw: Boolean,
|
||||
state: MangaState?,
|
||||
source: MangaSource,
|
||||
source: MangaParserSource,
|
||||
historyInfo: HistoryInfo,
|
||||
readingTime: ReadingTime?,
|
||||
isTabletUi: Boolean,
|
||||
|
||||
@@ -32,7 +32,7 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
) {
|
||||
|
||||
operator fun invoke(mangaId: Long): Flow<MangaDetails> = channelFlow {
|
||||
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) {
|
||||
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) { // wrong method
|
||||
"Cannot resolve id $mangaId"
|
||||
}
|
||||
val local = if (!manga.isLocal) {
|
||||
@@ -52,7 +52,7 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getDetails(seed: Manga) = runCatchingCancellable {
|
||||
suspend fun getDetails(seed: Manga) = runCatchingCancellable {
|
||||
val repository = mangaRepositoryFactory.create(seed.source)
|
||||
repository.getDetails(seed)
|
||||
}.getOrThrow()
|
||||
|
||||
@@ -24,7 +24,7 @@ data class ExploreTab(
|
||||
get() {
|
||||
val image = Icons.Outlined.Explore
|
||||
return TabOptions(
|
||||
index = 3u,
|
||||
index = 1u,
|
||||
title = stringResource(R.string.nav_explore),
|
||||
icon = rememberVectorPainter(image),
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.components.FastScrollLazyColumn
|
||||
@@ -234,7 +235,7 @@ fun CatalogScreen(
|
||||
|
||||
@Composable
|
||||
fun SourceItem(
|
||||
source: MangaSource,
|
||||
source: MangaParserSource,
|
||||
onClickItem: (MangaSource) -> Unit,
|
||||
onLongClickItem: (MangaSource) -> Unit,
|
||||
onClickMenu: (MangaSource) -> Unit,
|
||||
|
||||
@@ -17,7 +17,9 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.model.getTitle
|
||||
import org.xtimms.shirizu.core.prefs.AppSettings
|
||||
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
|
||||
import org.xtimms.shirizu.sections.explore.sources.SourceUiModel
|
||||
@@ -34,7 +36,7 @@ class CatalogScreenModel @Inject constructor(
|
||||
val events = _events.receiveAsFlow()
|
||||
|
||||
init {
|
||||
val queryFilter: (String) -> ((MangaSource) -> Boolean) = { query ->
|
||||
val queryFilter: (String) -> ((MangaParserSource) -> Boolean) = { query ->
|
||||
filter@{ source ->
|
||||
if (query.isEmpty()) return@filter true
|
||||
query.split(",").any { _input ->
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.utils.LocaleHelper
|
||||
@@ -21,14 +22,14 @@ import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun BaseSourceItem(
|
||||
source: MangaSource,
|
||||
source: MangaParserSource,
|
||||
modifier: Modifier = Modifier,
|
||||
showTypeInContent: Boolean = true,
|
||||
onClickItem: () -> Unit = {},
|
||||
onLongClickItem: () -> Unit = {},
|
||||
icon: @Composable RowScope.(MangaSource) -> Unit = defaultIcon,
|
||||
action: @Composable RowScope.(MangaSource) -> Unit = {},
|
||||
content: @Composable RowScope.(MangaSource, String?) -> Unit = defaultContent,
|
||||
content: @Composable RowScope.(MangaParserSource, String?) -> Unit = defaultContent,
|
||||
) {
|
||||
fun getPrettyContentTypeName(type: ContentType?, context: Context): String {
|
||||
if (type == null) {
|
||||
@@ -60,7 +61,7 @@ private val defaultIcon: @Composable RowScope.(MangaSource) -> Unit = { source -
|
||||
SourceIcon(source = source)
|
||||
}
|
||||
|
||||
private val defaultContent: @Composable RowScope.(MangaSource, String?) -> Unit = { source, sourceLangString ->
|
||||
private val defaultContent: @Composable RowScope.(MangaParserSource, String?) -> Unit = { source, sourceLangString ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.components.ScrollbarLazyColumn
|
||||
@@ -105,7 +106,7 @@ fun SourceHeader(
|
||||
|
||||
@Composable
|
||||
fun SourceItem(
|
||||
source: MangaSource,
|
||||
source: MangaParserSource,
|
||||
onClickItem: (MangaSource) -> Unit,
|
||||
onLongClickItem: (MangaSource) -> Unit,
|
||||
onClickMenu: (MangaSource) -> Unit,
|
||||
@@ -172,6 +173,6 @@ private fun SourcePinButton(
|
||||
}
|
||||
|
||||
sealed interface SourceUiModel {
|
||||
data class Item(val source: MangaSource) : SourceUiModel
|
||||
data class Item(val source: MangaParserSource) : SourceUiModel
|
||||
data class Header(val language: String?) : SourceUiModel
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.xtimms.shirizu.sections.explore.sources
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -13,12 +15,17 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.model.MangaSourceInfo
|
||||
import org.xtimms.shirizu.core.model.getSummary
|
||||
import org.xtimms.shirizu.core.model.getTitle
|
||||
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
|
||||
import org.xtimms.shirizu.utils.LocaleHelper
|
||||
import javax.inject.Inject
|
||||
|
||||
class SourcesScreenModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val mangaSourcesRepository: MangaSourcesRepository,
|
||||
) : StateScreenModel<SourcesScreenModel.State>(State()) {
|
||||
|
||||
@@ -36,7 +43,7 @@ class SourcesScreenModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectEnabledSources(sources: List<MangaSource>) {
|
||||
private fun collectEnabledSources(sources: List<MangaParserSource>) {
|
||||
mutableState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
|
||||
@@ -73,12 +73,7 @@ class HistoryScreenModel @Inject constructor(
|
||||
val searchQuery = query ?: ""
|
||||
history.asSequence().map { it }
|
||||
.filter { it.manga.isNsfw == nsfw }
|
||||
.sortedByDescending {
|
||||
when (sort) {
|
||||
SortOption.DATE_ADDED -> it.history.updatedAt
|
||||
SortOption.ALPHABETICAL -> it.manga.title.lowercase()
|
||||
}.toString()
|
||||
}
|
||||
.sortedWith(MangaComparator(sort))
|
||||
.filter(queryFilter(searchQuery)).toList()
|
||||
.toImmutableList()
|
||||
}.collectLatest {
|
||||
@@ -222,10 +217,7 @@ class HistoryScreenModel @Inject constructor(
|
||||
val searchQuery: String? = null,
|
||||
val selection: PersistentList<Manga> = persistentListOf(),
|
||||
val showNsfw: Boolean = AppSettings.showNsfwInHistory(),
|
||||
val availableSorts: List<SortOption> = listOf(
|
||||
SortOption.DATE_ADDED,
|
||||
SortOption.ALPHABETICAL
|
||||
),
|
||||
val availableSorts: List<SortOption> = SortOption.entries,
|
||||
val sort: SortOption = SortOption.ALPHABETICAL,
|
||||
val list: PersistentList<HistoryItemModel> = persistentListOf(),
|
||||
val dialog: Dialog? = null,
|
||||
@@ -265,3 +257,13 @@ class HistoryScreenModel @Inject constructor(
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class MangaComparator(private val sort: SortOption) : Comparator<MangaWithHistory> {
|
||||
override fun compare(o1: MangaWithHistory, o2: MangaWithHistory): Int {
|
||||
return when (sort) {
|
||||
SortOption.DATE_ADDED -> o2.history.updatedAt.compareTo(o1.history.updatedAt)
|
||||
SortOption.ALPHABETICAL -> o1.manga.title.compareTo(o2.manga.title)
|
||||
SortOption.PROGRESS -> o2.history.percent.compareTo(o1.history.percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import org.xtimms.shirizu.core.components.DialogCheckBoxItem
|
||||
import org.xtimms.shirizu.core.components.Scaffold
|
||||
import org.xtimms.shirizu.core.components.LibraryBottomActionMenu
|
||||
import org.xtimms.shirizu.core.components.ShirizuDialog
|
||||
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
|
||||
import org.xtimms.shirizu.core.ui.screens.TabContent
|
||||
import org.xtimms.shirizu.sections.details.DetailsScreen
|
||||
|
||||
@@ -80,7 +81,7 @@ fun Screen.historyTab(): TabContent {
|
||||
onToggleEnableNsfw = { screenModel.filterNsfw(it) },
|
||||
onFilterChanged = { screenModel.search(it) },
|
||||
onSortSelected = { screenModel.sort(it) },
|
||||
onClick = { navigator.push(DetailsScreen(it.manga)) },
|
||||
onClick = { navigator.push(DetailsScreen(it.manga.id)) },
|
||||
onHistorySelected = screenModel::toggleSelection
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.xtimms.shirizu.sections.library.history
|
||||
|
||||
enum class SortOption {
|
||||
ALPHABETICAL,
|
||||
DATE_ADDED,
|
||||
enum class SortOption(id: Int) {
|
||||
ALPHABETICAL(0),
|
||||
DATE_ADDED(1),
|
||||
PROGRESS(2)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.components.Scaffold
|
||||
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
|
||||
import org.xtimms.shirizu.sections.details.DetailsScreen
|
||||
import org.xtimms.shirizu.utils.lang.Screen
|
||||
|
||||
@@ -75,7 +76,7 @@ data class MangaListScreen(private val source: MangaSource) : Screen() {
|
||||
columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation),
|
||||
snackbarHostState = snackbarHostState,
|
||||
contentPadding = paddingValues,
|
||||
onMangaClick = { navigator.push((DetailsScreen(it, true))) },
|
||||
onMangaClick = { navigator.push((DetailsScreen(it.id, true))) },
|
||||
onMangaLongClick = { manga -> },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.parser.MangaRepository
|
||||
|
||||
@@ -25,7 +26,7 @@ class MangaListScreenModel @AssistedInject constructor(
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) : StateScreenModel<MangaListScreenModel.State>(State()) {
|
||||
|
||||
val source = MangaSource.valueOf(sourceName)
|
||||
val source = MangaParserSource.valueOf(sourceName)
|
||||
private val repository = mangaRepositoryFactory.create(source)
|
||||
private val hasNextPage = MutableStateFlow(false)
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.components.AppBar
|
||||
@@ -21,7 +22,7 @@ import org.xtimms.shirizu.core.components.SearchToolbar
|
||||
fun BrowseSourceToolbar(
|
||||
searchQuery: String?,
|
||||
onSearchQueryChange: (String?) -> Unit,
|
||||
source: MangaSource?,
|
||||
source: MangaParserSource?,
|
||||
navigateUp: () -> Unit,
|
||||
onWebViewClick: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.xtimms.shirizu.sections.profile
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.ShirizuAsyncImage
|
||||
import org.xtimms.shirizu.core.components.PreferenceItem
|
||||
import org.xtimms.shirizu.core.components.PreferenceSubtitle
|
||||
import org.xtimms.shirizu.sections.shelf.ShelfCategory
|
||||
import org.xtimms.shirizu.sections.stats.ChaptersChart
|
||||
import org.xtimms.shirizu.sections.stats.TimeCard
|
||||
import org.xtimms.shirizu.sections.stats.categories.CategoriesChart
|
||||
import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||
import org.xtimms.shirizu.utils.composable.bodyWidth
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.bodyWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
AsyncImage(
|
||||
model = "https://avatars.githubusercontent.com/u/61558546?v=4",
|
||||
contentDescription = "profile",
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(100))
|
||||
.size(100.dp)
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(text = "Xtimms", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
item {
|
||||
Text(text = "My status", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
item {
|
||||
HorizontalDivider(modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp))
|
||||
}
|
||||
item {
|
||||
PreferenceSubtitle(text = stringResource(id = R.string.statistics))
|
||||
}
|
||||
item {
|
||||
TimeCard(
|
||||
modifier = Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
item {
|
||||
CategoriesChart(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
categories = listOf(
|
||||
ShelfCategory(1, "Test 1", 3),
|
||||
ShelfCategory(2, "Test 2", 4),
|
||||
ShelfCategory(3, "Test 3", 6),
|
||||
ShelfCategory(4, "Test 4", 7),
|
||||
ShelfCategory(5, "Test 5", 13),
|
||||
ShelfCategory(6, "Test 6", 12),
|
||||
)
|
||||
)
|
||||
}
|
||||
item {
|
||||
PreferenceSubtitle(text = stringResource(id = R.string.menu))
|
||||
}
|
||||
item {
|
||||
PreferenceItem(
|
||||
icon = Icons.Outlined.Settings,
|
||||
title = stringResource(id = R.string.settings)
|
||||
)
|
||||
}
|
||||
item {
|
||||
PreferenceItem(
|
||||
icon = Icons.AutoMirrored.Outlined.HelpOutline,
|
||||
title = stringResource(id = R.string.help_centre)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ProfileScreenPreview() {
|
||||
ShirizuTheme {
|
||||
ProfileScreen()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.xtimms.shirizu.sections.profile
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AccountCircle
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.ShirizuAsyncImage
|
||||
import org.xtimms.shirizu.core.components.Scaffold
|
||||
import org.xtimms.shirizu.utils.composable.bodyWidth
|
||||
import org.xtimms.shirizu.utils.lang.Tab
|
||||
|
||||
object ProfileTab : Tab {
|
||||
|
||||
private val snackbarHostState = SnackbarHostState()
|
||||
|
||||
override val options: TabOptions
|
||||
@Composable
|
||||
get() {
|
||||
val image = Icons.Outlined.AccountCircle
|
||||
return TabOptions(
|
||||
index = 3u,
|
||||
title = stringResource(R.string.profile),
|
||||
icon = rememberVectorPainter(image),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
Scaffold(
|
||||
snackbarHost = { snackbarHostState }
|
||||
) {
|
||||
ProfileScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,9 @@ import org.xtimms.shirizu.core.network.CommonHeaders
|
||||
import org.xtimms.shirizu.core.network.MangaHttpClient
|
||||
import org.xtimms.shirizu.core.network.interceptors.ImageProxyInterceptor
|
||||
import org.xtimms.shirizu.core.parser.MangaRepository
|
||||
import org.xtimms.shirizu.core.parser.RemoteMangaRepository
|
||||
import org.xtimms.shirizu.core.parser.ParserMangaRepository
|
||||
import org.xtimms.shirizu.core.parser.local.isFileUri
|
||||
import org.xtimms.shirizu.core.parser.local.isZipUri
|
||||
import org.xtimms.shirizu.core.prefs.AppSettings
|
||||
import org.xtimms.shirizu.sections.reader.pager.ReaderPage
|
||||
import org.xtimms.shirizu.utils.FileSize
|
||||
import org.xtimms.shirizu.utils.RetainedLifecycleCoroutineScope
|
||||
@@ -81,7 +80,7 @@ class PageLoader @Inject constructor(
|
||||
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
|
||||
|
||||
fun isPrefetchApplicable(): Boolean {
|
||||
return repository is RemoteMangaRepository
|
||||
return repository is ParserMangaRepository
|
||||
// && settings.isPagesPreloadEnabled
|
||||
&& !context.isPowerSaveMode()
|
||||
&& !isLowRam()
|
||||
|
||||
@@ -2,10 +2,13 @@ package org.xtimms.shirizu.sections.reader.pager
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.model.parcelable.MangaSourceParceler
|
||||
|
||||
@Parcelize
|
||||
@TypeParceler<MangaSource, MangaSourceParceler>
|
||||
data class ReaderPage(
|
||||
val id: Long,
|
||||
val url: String,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.xtimms.shirizu.sections.search
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -42,6 +40,7 @@ import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.components.MangaCarouselWithHeader
|
||||
import org.xtimms.shirizu.core.components.Scaffold
|
||||
import org.xtimms.shirizu.core.components.icons.Dice
|
||||
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
|
||||
import org.xtimms.shirizu.sections.details.DetailsScreen
|
||||
import org.xtimms.shirizu.sections.search.global.GlobalSearchScreen
|
||||
import org.xtimms.shirizu.sections.suggestions.SuggestionsScreen
|
||||
@@ -57,13 +56,13 @@ object SearchTab : Tab {
|
||||
get() {
|
||||
val image = Icons.Outlined.Search
|
||||
return TabOptions(
|
||||
index = 4u,
|
||||
index = 2u,
|
||||
title = stringResource(R.string.search),
|
||||
icon = rememberVectorPainter(image),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
@@ -135,7 +134,7 @@ object SearchTab : Tab {
|
||||
MangaCarouselWithHeader(
|
||||
items = state.list,
|
||||
title = stringResource(id = R.string.suggestions),
|
||||
onItemClick = { navigator.push(DetailsScreen(it)) },
|
||||
onItemClick = { navigator.push(DetailsScreen(it.id)) },
|
||||
onMoreClick = { navigator.push(SuggestionsScreen) },
|
||||
refreshing = state.isLoading,
|
||||
modifier = Modifier.animateItem(),
|
||||
|
||||
@@ -81,11 +81,6 @@ class GlobalSearchScreenModel @Inject constructor(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val sources = if (SearchSuggestionType.SOURCES in types) {
|
||||
repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val tags = tagsDeferred?.await()
|
||||
val mangaList = mangaDeferred?.await()
|
||||
@@ -93,11 +88,10 @@ class GlobalSearchScreenModel @Inject constructor(
|
||||
val hints = hintsDeferred?.await()
|
||||
val authors = authorsDeferred?.await()
|
||||
|
||||
buildList(queries.sizeOrZero() + sources.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
|
||||
buildList(queries.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
|
||||
if (!mangaList.isNullOrEmpty()) {
|
||||
add(SearchSuggestionItem.MangaList(mangaList))
|
||||
}
|
||||
sources?.mapTo(this) { SearchSuggestionItem.Source(it, it in enabledSources) }
|
||||
queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
|
||||
authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
|
||||
hints?.mapTo(this) { SearchSuggestionItem.Hint(it) }
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.xtimms.shirizu.sections.search.global.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.model.ListModel
|
||||
|
||||
@@ -44,7 +45,7 @@ sealed interface SearchSuggestionItem : ListModel {
|
||||
}
|
||||
|
||||
data class Source(
|
||||
val source: MangaSource,
|
||||
val source: MangaParserSource,
|
||||
val isEnabled: Boolean,
|
||||
) : SearchSuggestionItem {
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package org.xtimms.shirizu.sections.settings.sources
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
import androidx.compose.material.icons.outlined.NoAdultContent
|
||||
import androidx.compose.material.icons.outlined.SettingsApplications
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.components.PreferenceItem
|
||||
import org.xtimms.shirizu.core.components.PreferenceSwitch
|
||||
import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
|
||||
import org.xtimms.shirizu.core.prefs.AppSettings
|
||||
import org.xtimms.shirizu.core.prefs.NSFW
|
||||
|
||||
const val SOURCES_DESTINATION = "sources"
|
||||
|
||||
@Composable
|
||||
fun SourcesView(
|
||||
viewModel: SourcesSettingsViewModel = hiltViewModel(),
|
||||
navigateBack: () -> Unit,
|
||||
navigateToSourcesCatalog: () -> Unit,
|
||||
navigateToSourcesManagement: () -> Unit,
|
||||
) {
|
||||
|
||||
val context = LocalContext.current
|
||||
val availableSourcesCount = viewModel.availableSourcesCount.collectAsState(-1).value
|
||||
val enabledSourcesCount = viewModel.enabledSourcesCount.collectAsState(-1).value
|
||||
val state by viewModel.viewStateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
var isNSFWEnabled by remember {
|
||||
mutableStateOf(AppSettings.isNSFWEnabled())
|
||||
}
|
||||
|
||||
ScaffoldWithTopAppBar(
|
||||
title = stringResource(R.string.manga_sources),
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
item {
|
||||
PreferenceItem(
|
||||
title = stringResource(id = R.string.manage_sources),
|
||||
description = if (enabledSourcesCount >= 0) {
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.items,
|
||||
enabledSourcesCount,
|
||||
enabledSourcesCount
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
icon = Icons.Outlined.SettingsApplications,
|
||||
onClick = { navigateToSourcesManagement() }
|
||||
)
|
||||
}
|
||||
item {
|
||||
PreferenceItem(
|
||||
title = stringResource(id = R.string.sources_catalog),
|
||||
description = if (availableSourcesCount >= 0) {
|
||||
stringResource(R.string.available_d, availableSourcesCount)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
icon = Icons.Outlined.Apps,
|
||||
onClick = { navigateToSourcesCatalog() }
|
||||
)
|
||||
}
|
||||
item {
|
||||
PreferenceSwitch(
|
||||
title = stringResource(id = R.string.disable_nsfw),
|
||||
description = stringResource(id = R.string.disable_nsfw_desc),
|
||||
icon = Icons.Outlined.NoAdultContent,
|
||||
isChecked = isNSFWEnabled
|
||||
) {
|
||||
isNSFWEnabled = !isNSFWEnabled
|
||||
AppSettings.updateValue(NSFW, isNSFWEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.ShirizuAsyncImage
|
||||
import org.xtimms.shirizu.core.parser.favicon.faviconUri
|
||||
@@ -23,7 +24,7 @@ import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||
|
||||
@Composable
|
||||
fun SourceCatalogItem(
|
||||
source: MangaSource,
|
||||
source: MangaParserSource,
|
||||
) {
|
||||
|
||||
Row(
|
||||
@@ -53,6 +54,6 @@ fun SourceCatalogItem(
|
||||
@Composable
|
||||
fun SourceCatalogItemPreview() {
|
||||
ShirizuTheme {
|
||||
SourceCatalogItem(source = MangaSource.MANGADEX)
|
||||
SourceCatalogItem(source = MangaParserSource.MANGADEX)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package org.xtimms.shirizu.sections.settings.sources.catalog
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.SearchOff
|
||||
import androidx.room.InvalidationTracker
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.ViewModelLifecycle
|
||||
import dagger.hilt.android.lifecycle.RetainedLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.database.TABLE_SOURCES
|
||||
import org.xtimms.shirizu.core.database.ShirizuDatabase
|
||||
import org.xtimms.shirizu.core.database.removeObserverAsync
|
||||
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
|
||||
import org.xtimms.shirizu.utils.lang.lifecycleScope
|
||||
|
||||
class SourcesCatalogListProducer @AssistedInject constructor(
|
||||
@Assisted private val locale: String?,
|
||||
@Assisted private val contentType: ContentType,
|
||||
@Assisted lifecycle: ViewModelLifecycle,
|
||||
private val repository: MangaSourcesRepository,
|
||||
private val database: ShirizuDatabase,
|
||||
) : InvalidationTracker.Observer(TABLE_SOURCES), RetainedLifecycle.OnClearedListener {
|
||||
|
||||
private val scope = lifecycle.lifecycleScope
|
||||
|
||||
private var query: String? = null
|
||||
val list = MutableStateFlow(emptyList<SourceCatalogItemModel>())
|
||||
|
||||
private var job = scope.launch(Dispatchers.Default) {
|
||||
list.value = buildList()
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
database.invalidationTracker.addObserver(this@SourcesCatalogListProducer)
|
||||
}
|
||||
lifecycle.addOnClearedListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
database.invalidationTracker.removeObserverAsync(this)
|
||||
}
|
||||
|
||||
override fun onInvalidated(tables: Set<String>) {
|
||||
val prevJob = job
|
||||
job = scope.launch(Dispatchers.Default) {
|
||||
prevJob.cancelAndJoin()
|
||||
list.update { buildList() }
|
||||
}
|
||||
}
|
||||
|
||||
fun setQuery(value: String?) {
|
||||
this.query = value
|
||||
onInvalidated(emptySet())
|
||||
}
|
||||
|
||||
private suspend fun buildList(): List<SourceCatalogItemModel> {
|
||||
val sources = repository.getDisabledSources().toMutableList()
|
||||
when (val q = query) {
|
||||
null -> sources.retainAll { it.contentType == contentType && it.locale == locale }
|
||||
"" -> return emptyList()
|
||||
else -> sources.retainAll { it.title.contains(q, ignoreCase = true) }
|
||||
}
|
||||
sources.sortBy { it.title }
|
||||
return sources.map {
|
||||
SourceCatalogItemModel(
|
||||
source = it,
|
||||
showSummary = query != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(
|
||||
locale: String?,
|
||||
contentType: ContentType,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
): SourcesCatalogListProducer
|
||||
}
|
||||
}
|
||||
@@ -57,11 +57,11 @@ fun SourcesCatalogPager(
|
||||
items(
|
||||
items = sources,
|
||||
) { item ->
|
||||
item.items.forEach { source ->
|
||||
/*item.items.forEach { source ->
|
||||
SourceCatalogItem(
|
||||
source = source.source,
|
||||
)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package org.xtimms.shirizu.sections.settings.sources.catalog
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.ImageLoader
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.components.ScaffoldWithClassicTopAppBar
|
||||
|
||||
const val CATALOG_DESTINATION = "catalog"
|
||||
|
||||
@Composable
|
||||
fun SourcesCatalogView(
|
||||
coil: ImageLoader,
|
||||
sourcesCatalogViewModel: SourcesCatalogViewModel = hiltViewModel(),
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
|
||||
val categories by sourcesCatalogViewModel.content.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
ScaffoldWithClassicTopAppBar(
|
||||
title = stringResource(R.string.sources_catalog),
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
val pagerState = rememberPagerState(0) { categories.size }
|
||||
val scope = rememberCoroutineScope()
|
||||
if (categories.isNotEmpty()) {
|
||||
SourcesCatalogTabs(
|
||||
categories = categories,
|
||||
pagerState = pagerState,
|
||||
) { scope.launch { pagerState.animateScrollToPage(it) } }
|
||||
}
|
||||
|
||||
SourcesCatalogPager(
|
||||
coil = coil,
|
||||
state = pagerState,
|
||||
contentPadding = padding,
|
||||
searchQuery = null,
|
||||
getSourcesForPage = { categories }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package org.xtimms.shirizu.sections.settings.sources.catalog
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.base.viewmodel.KotatsuBaseViewModel
|
||||
import org.xtimms.shirizu.core.prefs.AppSettings
|
||||
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
|
||||
import org.xtimms.shirizu.utils.ReversibleAction
|
||||
import org.xtimms.shirizu.utils.lang.MutableEventFlow
|
||||
import org.xtimms.shirizu.utils.lang.call
|
||||
import java.util.EnumMap
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SourcesCatalogViewModel @Inject constructor(
|
||||
private val repository: MangaSourcesRepository,
|
||||
private val listProducerFactory: SourcesCatalogListProducer.Factory,
|
||||
) : KotatsuBaseViewModel() {
|
||||
|
||||
private val lifecycle = RetainedLifecycleImpl()
|
||||
private var searchQuery: String? = null
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val locales = repository.allMangaSources.mapToSet { it.locale }
|
||||
val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales })
|
||||
|
||||
private val listProducers = locale.map { lc ->
|
||||
createListProducers(lc)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value))
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val content: StateFlow<List<SourceCatalogPage>> = listProducers.flatMapLatest {
|
||||
val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } }
|
||||
combine<SourceCatalogPage, List<SourceCatalogPage>>(flows, Array<SourceCatalogPage>::toList)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
lifecycle.dispatchOnCleared()
|
||||
}
|
||||
|
||||
fun performSearch(query: String?) {
|
||||
searchQuery = query
|
||||
listProducers.value.forEach { (_, v) -> v.setQuery(query) }
|
||||
}
|
||||
|
||||
fun setLocale(value: String?) {
|
||||
locale.value = value
|
||||
}
|
||||
|
||||
fun addSource(source: MangaSource) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val rollback = repository.setSourceEnabled(source, true)
|
||||
onActionDone.call(ReversibleAction(R.string.source_enabled, rollback))
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun createListProducers(lc: String?): Map<ContentType, SourcesCatalogListProducer> {
|
||||
val types = EnumSet.allOf(ContentType::class.java)
|
||||
if (AppSettings.isNSFWEnabled()) {
|
||||
types.remove(ContentType.HENTAI)
|
||||
}
|
||||
return types.associateWithTo(EnumMap(ContentType::class.java)) { type ->
|
||||
listProducerFactory.create(lc, type, lifecycle).also {
|
||||
it.setQuery(searchQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.components.Scaffold
|
||||
import org.xtimms.shirizu.core.components.LibraryBottomActionMenu
|
||||
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
|
||||
import org.xtimms.shirizu.core.ui.screens.EmptyScreen
|
||||
import org.xtimms.shirizu.core.ui.screens.LoadingScreen
|
||||
import org.xtimms.shirizu.sections.details.DetailsScreen
|
||||
@@ -85,7 +86,7 @@ object ShelfTab : Tab, NoLiftingAppBarScreen {
|
||||
currentPage = { screenModel.activeCategoryIndex },
|
||||
hasActiveFilters = state.hasActiveFilters,
|
||||
onChangeCurrentPage = { },
|
||||
onMangaClicked = { navigator.push(DetailsScreen(it)) },
|
||||
onMangaClicked = { navigator.push(DetailsScreen(it.id)) },
|
||||
onToggleSelection = { },
|
||||
onToggleRangeSelection = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.ui.theme.colorMax
|
||||
import org.xtimms.shirizu.ui.theme.colorMin
|
||||
@@ -158,12 +159,12 @@ private fun PreviewChart() {
|
||||
ChaptersChart(
|
||||
modifier = Modifier.size(100.dp),
|
||||
chapters = listOf(
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY)
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY)
|
||||
),
|
||||
chartPadding = PaddingValues(vertical = 16.dp)
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.ui.theme.colorMax
|
||||
import org.xtimms.shirizu.ui.theme.colorMin
|
||||
@@ -98,12 +99,12 @@ fun MinMaxReadCard(
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(),
|
||||
chapters = listOf(
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY)
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
|
||||
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,13 +14,28 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.sections.shelf.ShelfCategory
|
||||
import org.xtimms.shirizu.utils.material.HarmonizedColorPalette
|
||||
import org.xtimms.shirizu.utils.material.combineColors
|
||||
import org.xtimms.shirizu.utils.material.harmonize
|
||||
import org.xtimms.shirizu.utils.material.harmonizeWithColor
|
||||
import org.xtimms.shirizu.utils.material.toPalette
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class TagUsage(
|
||||
val name: String,
|
||||
val mangaCount: BigDecimal,
|
||||
var color: HarmonizedColorPalette? = null
|
||||
)
|
||||
|
||||
var baseColors = listOf(
|
||||
Color(0xFFF86BAE),
|
||||
@@ -35,10 +50,13 @@ var baseColors = listOf(
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CategoriesChart(
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
categories: List<ShelfCategory>
|
||||
) {
|
||||
|
||||
val isNightMode = isSystemInDarkTheme()
|
||||
val labelWithoutTag = stringResource(R.string.progress)
|
||||
val maxDisplay = 7
|
||||
|
||||
val colors = baseColors.map {
|
||||
toPalette(
|
||||
@@ -48,9 +66,61 @@ fun CategoriesChart(
|
||||
),
|
||||
)
|
||||
}
|
||||
val restColor = toPalette(
|
||||
color = harmonize(
|
||||
designColor = Color(0xFF222222),
|
||||
sourceColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
).copy(
|
||||
main = if (isNightMode) Color(0xFFF0F0F0) else Color(0xFF222222),
|
||||
onSurface = if (isNightMode) Color(0xFF1A1A1A) else Color(0xFFF4F4F4)
|
||||
)
|
||||
val stubColor = toPalette(
|
||||
color = harmonize(
|
||||
designColor = Color(0xFFCCCCCC),
|
||||
sourceColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
).copy(
|
||||
main = if (isNightMode) MaterialTheme.colorScheme.surfaceVariant else Color(0xFFCCCCCC),
|
||||
)
|
||||
|
||||
var offsetColor = 0
|
||||
|
||||
val tags by remember {
|
||||
var result = categories
|
||||
.groupBy { it.title.trim() }
|
||||
.map { tag ->
|
||||
TagUsage(
|
||||
tag.key,
|
||||
tag.value.map { it.mangaCount.toBigDecimal() }.reduce { acc, next -> acc + next },
|
||||
)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
.reversed()
|
||||
.toList()
|
||||
|
||||
// Set colors
|
||||
result.subList(0, result.size.coerceAtMost(maxDisplay)).forEachIndexed { index, tagUsage ->
|
||||
tagUsage.color = colors.getOrNull(index - offsetColor) ?: colors.last()
|
||||
}
|
||||
|
||||
// Combine rest tags to one
|
||||
if (result.size > maxDisplay) {
|
||||
result = result.slice(0..<maxDisplay) + TagUsage(
|
||||
name = labelWithoutTag,
|
||||
mangaCount = result
|
||||
.slice(maxDisplay until result.size)
|
||||
.map { it.mangaCount }
|
||||
.reduce { acc, next -> acc + next },
|
||||
color = restColor,
|
||||
)
|
||||
}
|
||||
|
||||
mutableStateOf(result)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = combineColors(
|
||||
@@ -59,26 +129,22 @@ fun CategoriesChart(
|
||||
angle = 0.3f,
|
||||
),
|
||||
)
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
DonutChart(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
|
||||
.size(64.dp),
|
||||
items = emptyList(),
|
||||
items = tags,
|
||||
)
|
||||
FlowRow(Modifier.padding(4.dp, 4.dp)) {
|
||||
|
||||
tags.forEach { tag ->
|
||||
TagAmount(
|
||||
modifier = Modifier.padding(4.dp, 4.dp),
|
||||
value = tag.name,
|
||||
palette = tag.color,
|
||||
amount = tag.mangaCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,11 +16,12 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.shirizu.sections.shelf.ShelfCategory
|
||||
import java.math.RoundingMode
|
||||
|
||||
@Composable
|
||||
fun DonutChart(
|
||||
modifier: Modifier = Modifier,
|
||||
items: List<ShelfCategory>,
|
||||
items: List<TagUsage>,
|
||||
chartPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
val localDensity = LocalDensity.current
|
||||
@@ -42,7 +43,7 @@ fun DonutChart(
|
||||
val heightWithPaddings = height - topOffset - bottomOffset
|
||||
val widthWithPaddings = width - startOffset - endOffset
|
||||
|
||||
val total = items.map { it.id }.reduce { acc, next -> acc + next }
|
||||
val total = items.map { it.mangaCount }.reduce { acc, next -> acc + next }
|
||||
var offset = 0f
|
||||
|
||||
val gap = 0f
|
||||
@@ -52,11 +53,37 @@ fun DonutChart(
|
||||
val minSweepAngle = 28f
|
||||
val offsetAngle = -90f
|
||||
|
||||
var itemAngles = items.map {
|
||||
it.mangaCount
|
||||
.divide(total, 5, RoundingMode.HALF_DOWN)
|
||||
.multiply(360.toBigDecimal())
|
||||
.toFloat()
|
||||
}
|
||||
|
||||
val shareAngle = itemAngles
|
||||
.filter { it < minSweepAngle }
|
||||
.map { minSweepAngle - it }
|
||||
.fold(0f) { acc, next -> acc + next }
|
||||
val splitItems = itemAngles.filter { it > minSweepAngle }.toMutableList()
|
||||
|
||||
itemAngles = itemAngles.map { angle ->
|
||||
if (angle < minSweepAngle) {
|
||||
return@map minSweepAngle
|
||||
}
|
||||
|
||||
if (angle > minSweepAngle) {
|
||||
return@map angle - shareAngle / splitItems.size
|
||||
}
|
||||
|
||||
angle
|
||||
}
|
||||
|
||||
items.forEachIndexed { index, tag ->
|
||||
val sweepAngle = itemAngles[index]
|
||||
drawArc(
|
||||
Color.Black,
|
||||
tag.color?.main ?: Color.Black,
|
||||
startAngle = offset + halfGap + offsetAngle,
|
||||
sweepAngle = 36 - gap,
|
||||
sweepAngle = sweepAngle - gap,
|
||||
useCenter = false,
|
||||
topLeft = Offset(startOffset + halfStrokeWidth, topOffset + halfStrokeWidth),
|
||||
size = Size(widthWithPaddings - strokeWidth, heightWithPaddings - strokeWidth),
|
||||
@@ -66,7 +93,7 @@ fun DonutChart(
|
||||
),
|
||||
)
|
||||
|
||||
offset += 50
|
||||
offset += sweepAngle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ fun TagAmount(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "56",
|
||||
text = amount.toString(),
|
||||
softWrap = false,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.W900),
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.components.MangaGridItem
|
||||
import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
|
||||
import org.xtimms.shirizu.core.components.icons.Creation
|
||||
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
|
||||
import org.xtimms.shirizu.core.ui.screens.EmptyScreen
|
||||
import org.xtimms.shirizu.core.ui.screens.LoadingScreen
|
||||
import org.xtimms.shirizu.sections.details.DetailsScreen
|
||||
@@ -84,7 +85,7 @@ object SuggestionsScreen : Screen() {
|
||||
SuggestionsScreenContent(
|
||||
suggestions = it,
|
||||
contentPadding = padding,
|
||||
onClick = { suggestion -> navigator.push(DetailsScreen(suggestion.manga)) }
|
||||
onClick = { suggestion -> navigator.push(DetailsScreen(suggestion.manga.id)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package org.xtimms.shirizu.utils
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
class MultiMutex<T : Any> : Set<T> {
|
||||
|
||||
@@ -40,4 +43,17 @@ class MultiMutex<T : Any> : Set<T> {
|
||||
delegates.remove(element)?.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
suspend inline fun <R> withLock(element: T, block: () -> R): R {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
return try {
|
||||
lock(element)
|
||||
block()
|
||||
} finally {
|
||||
unlock(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.IOException
|
||||
import org.json.JSONObject
|
||||
@@ -72,3 +73,5 @@ private fun Char.isValidForHeaderValue(): Boolean {
|
||||
// from okhttp3.Headers$Companion.checkValue
|
||||
return this == '\t' || this in '\u0020'..'\u007e'
|
||||
}
|
||||
|
||||
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }
|
||||
@@ -1,12 +1,22 @@
|
||||
package org.xtimms.shirizu.utils.system
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.xtimms.shirizu.R
|
||||
import java.util.Locale
|
||||
|
||||
fun String.toLocale() = Locale(this)
|
||||
|
||||
fun Locale?.getDisplayName(context: Context): String = when (this) {
|
||||
null -> context.getString(R.string.multi_lang)
|
||||
Locale.ROOT -> context.getString(R.string.various_languages)
|
||||
else -> getDisplayLanguage(this).toTitleCase(this)
|
||||
}
|
||||
|
||||
fun LocaleListCompat.toList(): List<Locale> = List(size()) { i -> getOrThrow(i) }
|
||||
|
||||
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
|
||||
|
||||
@@ -356,9 +356,16 @@
|
||||
<string name="shelves">Shelves</string>
|
||||
<string name="history_cleared">History cleared</string>
|
||||
<string name="search_by_reading_history">Search by reading history</string>
|
||||
<string name="sort_alphabetically">A-Z</string>
|
||||
<string name="sort_alphabetically">Name</string>
|
||||
<string name="sort_date_added">Date added</string>
|
||||
<string name="show_nsfw">Show NSFW</string>
|
||||
<string name="action_save">Save</string>
|
||||
<string name="action_mark_as_completed">Mark as completed</string>
|
||||
<string name="sort_alphabetically_reversed">Z-A</string>
|
||||
<string name="profile">Profile</string>
|
||||
<string name="menu">Menu</string>
|
||||
<string name="help_centre">Help centre</string>
|
||||
<string name="source_summary_pattern">%1$s, %2$s</string>
|
||||
<string name="external_source">External/plugin</string>
|
||||
<string name="various_languages">Various languages</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user